Zabbix 2.2.x, 3.0.x SQL注射漏洞
- 发表于
- Vulndb
0x01 背景
昨晚zabbix这个高危漏洞又在朋友圈炸开锅了,这篇是根据POC对zabbix3.0.3的源码进行了粗浅的分析。
注入产生的流程:
1 2 |
jsrpc.php:182→CScreenBuilder::getScreen()→CScreenBase::calculateTime()→CProfile::update() →page_footer.php:40→CProfile::flush()→CProfile::insertDB()→DBexecute() |
0x02 漏洞分析
根据网上提供的POC:
jsrpc.php?type=9
&method=screen.get
×tamp=1471403798083
&pageFile=history.php
&profileIdx=web.item.graph
&profileIdx2=1+or+updatexml(1,md5(0x11),1)+or+1=1)%23
&updateProfile=true
&period=3600
&stime=20160817050632
&resourcetype=17
我们可以找到漏洞文件jsrpc.php,为了方便阅读,去掉了跟本次漏洞无关的代码:
/zabbix-3.0.3/frontends/php/jsrpc.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
<?php $requestType = getRequest('type', PAGE_TYPE_JSON); if ($requestType == PAGE_TYPE_JSON) { $http_request = new CHttpRequest(); $json = new CJson(); $data = $json->decode($http_request->body(), true); } else { //将url的参数赋给$data $data = $_REQUEST; } $page['title'] = 'RPC'; $page['file'] = 'jsrpc.php'; $page['type'] = detect_page_type($requestType); require_once dirname(__FILE__).'/include/page_header.php'; if (!is_array($data) || !isset($data['method']) || ($requestType == PAGE_TYPE_JSON && (!isset($data['params']) || !is_array($data['params'])))) { fatal_error('Wrong RPC call to JS RPC!'); } $result = []; //判断参数中method值screen.get然后进入到相应的case里 switch ($data['method']) { ... case 'screen.get': $result = ''; $screenBase = CScreenBuilder::getScreen($data); if ($screenBase !== null) { $screen = $screenBase->get(); if ($data['mode'] == SCREEN_MODE_JS) { $result = $screen; } else { if (is_object($screen)) { $result = $screen->toString(); } } } break; ... require_once dirname(__FILE__).'/include/page_footer.php'; |
然后我们看到是调用了CScreenBuilder类的getScreen($data)方法来处理$data数据,我们跟进,发现首先调用了个构造方法初始化数据
/zabbix-3.0.3/frontends/php/include/classes/screens/CScreenBuilder.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
<?php /** * Init screen data. * * @param array $options * @param boolean $options['isFlickerfree'] * @param string $options['pageFile'] * @param int $options['mode'] * @param int $options['timestamp'] * @param int $options['hostid'] * @param int $options['period'] * @param int $options['stime'] * @param string $options['profileIdx'] * @param int $options['profileIdx2'] * @param boolean $options['updateProfile'] * @param array $options['screen'] */ public function __construct(array $options = []) { $this->isFlickerfree = isset($options['isFlickerfree']) ? $options['isFlickerfree'] : true; $this->mode = isset($options['mode']) ? $options['mode'] : SCREEN_MODE_SLIDESHOW; $this->timestamp = !empty($options['timestamp']) ? $options['timestamp'] : time(); $this->hostid = !empty($options['hostid']) ? $options['hostid'] : null; // get page file if (!empty($options['pageFile'])) { $this->pageFile = $options['pageFile']; } else { global $page; $this->pageFile = $page['file']; } // get screen if (!empty($options['screen'])) { $this->screen = $options['screen']; } elseif (array_key_exists('screenid', $options) && $options['screenid'] > 0) { $this->screen = API::Screen()->get([ 'screenids' => $options['screenid'], 'output' => API_OUTPUT_EXTEND, 'selectScreenItems' => API_OUTPUT_EXTEND, 'editable' => ($this->mode == SCREEN_MODE_EDIT) ]); if (!empty($this->screen)) { $this->screen = reset($this->screen); } else { access_deny(); } } // calculate time $this->profileIdx = !empty($options['profileIdx']) ? $options['profileIdx'] : ''; $this->profileIdx2 = !empty($options['profileIdx2']) ? $options['profileIdx2'] : null; $this->updateProfile = isset($options['updateProfile']) ? $options['updateProfile'] : true; $this->timeline = CScreenBase::calculateTime([ 'profileIdx' => $this->profileIdx, 'profileIdx2' => $this->profileIdx2, 'updateProfile' => $this->updateProfile, 'period' => !empty($options['period']) ? $options['period'] : null, 'stime' => !empty($options['stime']) ? $options['stime'] : null ]); } |
这里就有对profileIdx2参数的操作了,但是还是没有insert注入语句,我们看到最后调用了CScreenBase类的calculateTime方法并把profileIdx2传进去了,跟进
/zabbix-3.0.3/frontends/php/include/classes/screens/CScreenBase.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
/** * Insert javascript flicker-free screen data. * * @static * * @param array $options * @param string $options['profileIdx'] * @param int $options['profileIdx2'] * @param boolean $options['updateProfile'] * @param int $options['period'] * @param string $options['stime'] * * @return array */ public static function calculateTime(array $options = []) { if (!array_key_exists('updateProfile', $options)) { $options['updateProfile'] = true; } if (empty($options['profileIdx2'])) { $options['profileIdx2'] = 0; } // Show only latest data without update is set only period. if (!empty($options['period']) && empty($options['stime'])) { $options['updateProfile'] = false; $options['profileIdx'] = ''; } // period if (empty($options['period'])) { $options['period'] = !empty($options['profileIdx']) ? CProfile::get($options['profileIdx'].'.period', ZBX_PERIOD_DEFAULT, $options['profileIdx2']) : ZBX_PERIOD_DEFAULT; } else { if ($options['period'] < ZBX_MIN_PERIOD) { show_error_message(_n('Minimum time period to display is %1$s minute.', 'Minimum time period to display is %1$s minutes.', (int) ZBX_MIN_PERIOD / SEC_PER_MIN )); $options['period'] = ZBX_MIN_PERIOD; } elseif ($options['period'] > ZBX_MAX_PERIOD) { show_error_message(_n('Maximum time period to display is %1$s day.', 'Maximum time period to display is %1$s days.', (int) ZBX_MAX_PERIOD / SEC_PER_DAY )); $options['period'] = ZBX_MAX_PERIOD; } } if ($options['updateProfile'] && !empty($options['profileIdx'])) { CProfile::update($options['profileIdx'].'.period', $options['period'], PROFILE_TYPE_INT, $options['profileIdx2']); } ... |
这里再次引入了一个类CProfile并调用update方法将profileIdx2带入到更新操作里了,没有insert语句没关系,我们先跟进CProfile类的update函数。
/zabbix-3.0.3/frontends/php/include/classes/user/CProfile.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
/** * Update favorite values in DB profiles table. * * @param string $idx max length is 96 * @param mixed $value max length 255 for string * @param int $type * @param int $idx2 */ public static function update($idx, $value, $type, $idx2 = 0) { if (is_null(self::$profiles)) { self::init(); } if (!self::checkValueType($value, $type)) { return; } $profile = [ 'idx' => $idx, 'value' => $value, 'type' => $type, 'idx2' => $idx2 ]; $current = self::get($idx, null, $idx2); if (is_null($current)) { if (!isset(self::$insert[$idx])) { self::$insert[$idx] = []; } self::$insert[$idx][$idx2] = $profile; } else { if ($current != $value) { if (!isset(self::$update[$idx])) { self::$update[$idx] = []; } self::$update[$idx][$idx2] = $profile; } } if (!isset(self::$profiles[$idx])) { self::$profiles[$idx] = []; } self::$profiles[$idx][$idx2] = $value; } |
发现这里只是对$profiles变量做了更新操作,当然profileIdx2参数也赋值进去了,这里是没有insert注入操作的。我们再回到最开始的jsrpc.php,这里最后引入了page_footer.php
1 |
require_once dirname(__FILE__).'/include/page_footer.php'; |
我们再跟进page_footer.php,发现调用了CProfile类的flush方法如下:
1 2 3 4 5 6 |
//判断CProfle类是否被修改过,刚刚调用了update修改了~ if (CProfile::isModified()) { DBstart(); $result = CProfile::flush(); DBend($result); } |
我们跟进flush方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
<?php public static function flush() { $result = false; if (self::$profiles !== null && self::$userDetails['userid'] > 0 && self::isModified()) { $result = true; foreach (self::$insert as $idx => $profile) { foreach ($profile as $idx2 => $data) { //执行insert语句 $result &= self::insertDB($idx, $data['value'], $data['type'], $idx2); } } ksort(self::$update); foreach (self::$update as $idx => $profile) { ksort($profile); foreach ($profile as $idx2 => $data) { $result &= self::updateDB($idx, $data['value'], $data['type'], $idx2); } } } return $result; } ... private static function insertDB($idx, $value, $type, $idx2) { $value_type = self::getFieldByType($type); $values = [ 'profileid' => get_dbid('profiles', 'profileid'), 'userid' => self::$userDetails['userid'], 'idx' => zbx_dbstr($idx), $value_type => zbx_dbstr($value), 'type' => $type, 'idx2' => $idx2 ]; //执行insert语句,造成注入 return DBexecute('INSERT INTO profiles ('.implode(', ', array_keys($values)).') VALUES ('.implode(', ', $values).')'); } |
发现执行了insert语句造成注入漏洞,至此粗浅的漏洞分析完了。
0x03 漏洞证明
@寂寞的瘦子 表弟搭建了zabbix3.0.3的测试环境,使用了XPATH注入,证明如下:
0x04 漏洞修复
暴力的修补方法是对CProfile类的flush方法中注入参数做强制整形转换即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public static function flush() { $result = false; if (self::$profiles !== null && self::$userDetails['userid'] > 0 && self::isModified()) { $result = true; foreach (self::$insert as $idx => $profile) { foreach ($profile as $idx2 => $data) { //这里用intval($idx2)替换原来的$idx2 $result &= self::insertDB($idx, $data['value'], $data['type'], intval($idx2)); } } ksort(self::$update); foreach (self::$update as $idx => $profile) { ksort($profile); foreach ($profile as $idx2 => $data) { $result &= self::updateDB($idx, $data['value'], $data['type'], $idx2); } } } return $result; } |
跟佳佳沟通了下,发现一个更好的修复方法就是使用zabbix自己的过滤函数zbx_dbstr,我们看下这个函数:
1 2 3 4 5 6 7 8 9 10 |
case ZBX_DB_MYSQL: if (is_array($var)) { foreach ($var as $vnum => $value) { //使用mysqli_real_escape_string对单引号等特殊字符进行转义 $var[$vnum] = "'".mysqli_real_escape_string($DB['DB'], $value)."'"; } return $var; } //加上单引号保护,万无一失 return "'".mysqli_real_escape_string($DB['DB'], $var)."'"; |
使用mysqli_real_escape_string对单引号等特殊字符进行转义,并且加上单引号保护保证万无一失~
所以这里更优的修复方法是将上面intval函数换为zbx_dbstr。
漏洞来源:http://seclists.org/fulldisclosure/2016/Aug/82
相关漏洞:https://www.exploit-db.com/exploits/40237/
原文连接
的情况下转载,若非则不得使用我方内容。