Braid信息安全博客 - Web安全|代码审计|安全开发|Java|php|python

【PHP代码审计】 Zabbix 2.2.x, 3.0.x SQL注射漏洞

0x01 背景

昨晚zabbix这个高危漏洞又在朋友圈炸开锅了,这篇是根据POC对zabbix3.0.3的源码进行了粗浅的分析。
注入产生的流程:
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
&timestamp=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

<?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

<?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

/**
* 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

/**
* 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

require_once dirname(__FILE__).'/include/page_footer.php';

我们再跟进page_footer.php,发现调用了CProfile类的flush方法如下:

//判断CProfle类是否被修改过,刚刚调用了update修改了~
if (CProfile::isModified()) {
DBstart();
$result = CProfile::flush();
DBend($result);
}

我们跟进flush方法:

<?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方法中注入参数做强制整形转换即可

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,我们看下这个函数:

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/

本文由HackBraid整理总结,原文链接:http://www.cnbraid.com/2016/zabbix303.html,如需转载请联系作者。