从“删除后重生”到认证绕过:PHP 常驻内存实验与 Cacti remote_agent 命令执行链路复盘

目录

背景与复盘范围

[案例一:PHP 文件删除后为何重新出现](#案例一:PHP 文件删除后为何重新出现)

[1. 先从脚本行为拆解问题](#1. 先从脚本行为拆解问题)

[2. while 循环让删除动作失去终点](#2. while 循环让删除动作失去终点)

[3. 删除文件后,代码为什么还能继续运行](#3. 删除文件后,代码为什么还能继续运行)

[4. 清除方法取决于代码所在的位置](#4. 清除方法取决于代码所在的位置)

[5. while 死循环为何绕过普通垃圾回收](#5. while 死循环为何绕过普通垃圾回收)

[6. 从 CPU 异常判断线索,但不能只看一个指标](#6. 从 CPU 异常判断线索,但不能只看一个指标)

[7. 文件权限隐藏并不能替代进程清理](#7. 文件权限隐藏并不能替代进程清理)

[8. 对"不死码"和"内存码"的边界判断](#8. 对“不死码”和“内存码”的边界判断)

[案例二:Cacti remote_agent.php 的认证链路复盘](#案例二:Cacti remote_agent.php 的认证链路复盘)

[1. 先确认框架与实验范围](#1. 先确认框架与实验范围)

[2. 漏洞入口:无需身份认证访问远程代理文件](#2. 漏洞入口:无需身份认证访问远程代理文件)

[3. 版本确认与复现实验准备](#3. 版本确认与复现实验准备)

[4. 在源码目录中找到入口文件](#4. 在源码目录中找到入口文件)

[5. 第一层判断:获取客户端 IP](#5. 第一层判断:获取客户端 IP)

[6. gethostbyaddr() 与数据库主机名比对](#6. gethostbyaddr() 与数据库主机名比对)

[7. 通过数据库查询验证假设](#7. 通过数据库查询验证假设)

[8. get_client_addr() 如何选择请求头](#8. get_client_addr() 如何选择请求头)

[9. 地址数组与合法性检查](#9. 地址数组与合法性检查)

[10. 认证绕过的完整推理](#10. 认证绕过的完整推理)

[11. 从 action 分发进入数据查询函数](#11. 从 action 分发进入数据查询函数)

[12. poller_item 查询与 action 常量](#12. poller_item 查询与 action 常量)

[13. 分隔符为何改变命令边界](#13. 分隔符为何改变命令边界)

[14. 最终参数关系与代码回溯](#14. 最终参数关系与代码回溯)

[15. 不能跳过的验证顺序](#15. 不能跳过的验证顺序)

[16. 两个案例中失败结果的作用](#16. 两个案例中失败结果的作用)

[17. 从防御角度回看代码边界](#17. 从防御角度回看代码边界)

讲师经验与方法总结

复盘结论


摘要:本文根据一份安全技术授课转录稿重构而成,按原始讲解顺序复盘两个授权实验:先通过 PHP 脚本观察"删除后重生"的常驻执行现象,再转向 Cacti remote_agent.php 的源码审计,逐步追踪客户端地址获取、X-Forwarded-For 信任、主机名校验、action 分发、数据库查询和命令执行函数之间的关系。文章保留了实验中的失败现象、代码定位、参数判断和讲师对方法边界的评价,重点呈现如何从运行结果回推进程状态,再从函数调用链定位漏洞成因。

背景与复盘范围

本次复盘包含两个连续案例。第一部分讨论 PHP 环境中的"不死码"现象,核心问题是:一个 PHP 文件被删除后,为什么仍然会在磁盘上重新出现;删除、关闭浏览器和重启 PHP-FPM 分别会对结果产生什么影响。第二部分转向 Cacti 监控框架的授权实验,目标是在本地靶场中理解 remote_agent.php 的请求路径,并确认从认证函数到命令执行函数之间的参数传递关系。

原始课程反复强调,涉及命令执行的内容只应放在授权的源码审计、竞赛环境或漏洞复现靶场中验证。本文保持这一边界,示例中的目录、服务和请求均指向实验环境;不把实验过程延伸为针对未知系统的操作建议。

案例一:PHP 文件删除后为何重新出现

1. 先从脚本行为拆解问题

实验代码首先设置了忽略用户连接的选项,使得请求端即使中断连接,脚本也不会随之停止。脚本最大执行时间被设置为不超时,因此后续的循环能够长期运行。这里的重点并不是单个配置项,而是两个条件叠加后,PHP 请求不再受浏览器连接生命周期约束。

图 01:截图展示了实验页面和脚本运行的初始界面,用来确认后续观察发生在同一授权环境中。

代码随后调用 unlink(__FILE__)__FILE__ 代表当前 PHP 文件名,因此这一步会把正在执行的脚本从磁盘删除。删除动作发生在脚本已经被 PHP 解释器加载之后,因而"文件是否存在"和"代码是否还在执行"成为两个需要分别验证的问题。

图 02:截图突出显示删除当前 PHP 文件的代码位置,验证删除目标是脚本自身。

脚本还定义了一个新的文件名,例如 test.php,并准备了要写入的代码内容。只有当请求参数中的 md5 值与预设值匹配时,后续逻辑才会被触发。讲解中提到,哈希值并不是固定不可变的秘密,实验者可以自己选择口令,再计算对应的 MD5 值作为触发条件。

图 03:截图展示了参数校验和待写入代码变量,说明脚本需要先满足触发条件。

2. while 循环让删除动作失去终点

两个变量准备完成后,脚本进入一个 while 死循环。循环体反复把代码写入 test.php,并在每轮之间暂停一段时间。讲解中的示例使用约 5 秒的间隔,所以第一次生成文件后,删除动作不会让现象永久结束,下一轮仍会重新写入。

图 04:截图展示循环体、写入文件名和写入内容,说明文件生成是循环行为而不是一次性落盘。

写入文件的时间戳也被讨论。若不额外修改时间,ls -lls -t 会把刚生成的文件显示为最新文件。原稿指出,Windows 环境没有直接对应的 touch 命令,修改时间的伪装本身也不能解决根本问题:有经验的排查者仍然能观察到同一个文件被删除后持续出现。

每次生成后,实验者尝试删除 test.php。删除成功只能证明当前磁盘文件被移除,不能证明循环已经停止。因为 while 循环所在的 PHP 进程仍然运行,下一次迭代仍可能重新创建同名文件。

图 05:截图对应循环中的等待与再次写入过程,用来观察文件在时间上的重复出现。

3. 删除文件后,代码为什么还能继续运行

PHP 进程执行脚本时,会先把代码装载到内存。即使脚本随后执行 unlink,已经装载的指令仍然属于当前 PHP 进程。只要进程没有结束,循环状态就不会因为磁盘文件被删除而自动消失。

图 06:截图展示删除动作与后续循环代码同时存在,说明磁盘文件和进程内代码不是同一份状态。

实验先在浏览器中发起请求,再关闭浏览器窗口。随后删除重新生成的 test.php,等待约 5 秒观察文件是否恢复。第一次观察没有得到稳定的恢复结果,表面上像是"不死码"失效。

这个失败结果促使实验者回到执行链路本身排查:浏览器关闭可能同时终止承载脚本的请求进程。如果进程结束,内存中的循环当然不会继续,磁盘上的文件也就不会再次出现。因此,失败并不能否定循环机制,只能说明实验触发方式与预期的常驻进程条件不一致。

图 07:截图记录第一次删除后的目录状态,文件没有按预期立即重现,成为后续分析进程生命周期的依据。

随后再次观察下方目录位置,发现文件又被生成。这个结果说明浏览器窗口关闭并不一定终止承载脚本的 PHP 进程;只要进程仍在,之前已经进入内存的 while 循环就会继续执行。

图 08:截图展示删除后同名 PHP 文件重新出现,验证常驻循环仍在执行。

4. 清除方法取决于代码所在的位置

从上述现象可以推导出"不死码"的基本原理:脚本第一次执行后,代码进入 PHP 进程内存;unlink 只处理磁盘上的原文件;循环继续运行时,写入操作会再次把文件落盘。因此删除文件不是清除进程的办法。

图 09:截图给出再次生成的文件列表,作为"磁盘删除不等于进程终止"的直接证据。

清除动作应当针对承载代码的 PHP 进程。讲解中使用重启 PHP-FPM 的方式清空进程内存,重启完成后,原先驻留在内存中的循环随进程结束而消失。

图 10:截图展示服务重启操作及其结果,说明清除对象是进程而不是单个文件。

内存写入具有易失性。系统或服务重启后,进程内存重新初始化,未落盘的数据不会保留。这也是讲解中认为内存型代码相对容易通过重启清除的原因。

5. while 死循环为何绕过普通垃圾回收

正常 PHP 脚本执行到末尾后,函数栈和临时对象可以被回收,进程不会因为单个请求长期占用内存。当前示例不同之处在于存在 while 死循环,脚本始终没有"执行完"的时刻,相关代码会持续留在活动进程中。

图 11:截图展示讲解中对脚本执行生命周期的对比,重点是死循环阻止请求自然结束。

只要循环仍然活跃,删除后的文件就可能按间隔重新写入。要结束这种状态,可以清空 PHP-FPM 进程池,或者直接终止对应的 PHP 进程。两种方式的共同点都是让保存循环状态的进程退出。

图 12:截图展示死循环代码,说明垃圾回收无法在脚本尚未结束时回收这段逻辑。

6. 从 CPU 异常判断线索,但不能只看一个指标

讲解随后把现象与 CPU 占用联系起来。死循环会持续消耗处理器资源,长期运行时间较长、CPU 占用偏高,可能提示存在类似常驻代码。但 CPU 高并不是唯一结论,也可能来自挖矿程序或其他计算密集型任务。

图 13:截图展示系统性能观察界面,说明排查需要把 CPU 指标与文件反复出现结合起来。

原稿还讨论了 timesleep 的关系:进程在休眠期间不持续消耗 CPU,因此实际 CPU 时间与墙上时钟时间可能不一致。讲解者认为,在这个实验里不必过度依赖时间字段,优先观察"CPU 偏高"和"文件删除后再次出现"两个现象的组合更直接。

7. 文件权限隐藏并不能替代进程清理

另一种被提到的处理方式是把文件设置为不可读,试图让外部请求无法访问。讲解者明确指出,这只能改变文件权限,不能终止已经驻留在进程内存中的循环;如果排查者拥有 root 权限,仍然可以恢复读取权限。

图 14:截图展示权限修改思路,说明隐藏或去掉读取权限并不能解决进程级问题。

因此,权限操作最多是短暂降低可见性,不能作为最终清除方案。更可靠的动作仍然是停止或重启 PHP-FPM,再核对磁盘中是否还有残留文件。

8. 对"不死码"和"内存码"的边界判断

讲解中还区分了"不死码"和真正意义上的内存代码。示例脚本会不断把内容写回磁盘,因此可以在目录中看到 test.php 的反复出现。只要代码落盘,就能通过文件监控、时间戳和内容扫描发现它。

图 15:截图展示扩展版本的脚本,用来对比简单循环与更复杂实现。

真正不落盘的内存代码不会在目录中留下同样直观的文件痕迹;而本文实验中的脚本虽然利用了内存中的循环状态,却持续落盘生成文件。讲解者因此认为,把这种实现直接称为"内存码"并不严谨,更准确的描述是"删除后会重新写回的常驻脚本"。

图 16:截图展示扩展实现的关键代码,帮助定位其仍然依赖磁盘写入这一事实。

从面试和实战角度,讲解者给出的判断是:这类实现的关键并不复杂,核心只是让 while 循环长期占用 PHP 进程,再通过重启 PHP-FPM 清除。学习重点应放在区分磁盘文件、进程内存和服务生命周期,而不是把一个十年前就出现的技巧包装成复杂技术。

案例二:Cacti remote_agent.php 的认证链路复盘

1. 先确认框架与实验范围

第二个案例转向 Cacti。讲解中把它与 Zabbix、Prometheus 放在同一类监控软件中,用于观察服务器的 CPU、风扇、负载和内存等指标。实验对象是一个开源监控框架,复盘聚焦其 remote_agent.php 文件和相关源码。

图 17:截图展示 Cacti 项目页面,确认分析对象和代码来源。

项目标识、仓库信息和更新状态被用来确认这是一个持续维护的开源项目。讲解者同时提醒,项目的热度、收藏数或是否带有流行标签,并不能替代代码审计本身。

图 18:截图展示项目主页、标识和维护信息,作为实验对象的背景记录。

2. 漏洞入口:无需身份认证访问远程代理文件

复盘从一个明确的限制条件开始:remote_agent.php 可以被访问,但入口本身没有按管理员、普通用户或游客区分权限。表面上"无需身份认证"意味着任何请求都能到达文件,真正的安全边界则在文件内部的远程客户端校验函数。

图 19:截图展示漏洞说明中的入口文件和访问条件。

进入文件后,代码首先调用远程客户端认证逻辑,相关函数负责确认请求是否来自被授权的客户端。如果这个函数返回失败,流程会直接 exit;只有绕过它,后面的 action 分发和数据查询才有意义。

图 20:截图展示认证调用和失败退出路径,说明后续分析必须先解决客户端校验。

3. 版本确认与复现实验准备

实验环境使用 docker compose 安装,讲解中还提到可以使用 Vulhub 复现相关漏洞。源码和容器化环境的价值在于:研究者可以在隔离靶场中查看完整代码、修改参数并观察输出,而不会把测试请求发送到未知目标。

图 21:截图展示部署方式和版本信息,确认实验依赖可重复搭建。

影响范围被记录为 Cacti 1.2.171.2.22。实验者还回忆曾经遇到 1.2.20 的实际站点,这个版本正好落在范围内,因此漏洞披露后能够迅速联想到需要检查的站点。这里保留的是版本判断与补丁时效性的经验,不把具体站点作为复现对象。

图 22:截图展示版本列表和受影响区间,说明为什么实验选择特定版本。

部署完成后,先下载源码并解压,在目录中定位 remote_agent.php。这个过程看似基础,却决定了后续追踪是否能落到真实实现,而不是只停留在漏洞描述。

图 23:截图展示源码下载页面和复现准备。

图 24:截图展示靶场说明,明确后续验证在授权实验环境内进行。

图 25:截图展示容器复现目录,确认环境可以由源码和配置共同还原。

4. 在源码目录中找到入口文件

解压完成后,先检查根目录和管理目录,再定位 remote_agent.php。讲解中多次回到这个文件,是因为它既包含入口逻辑,也包含远程客户端校验和 action 分发。

图 26:截图展示源码目录树,突出 remote_agent.php 所在位置。

入口文件虽然允许未认证访问,但并不是一进入就执行危险操作。代码先获取客户端 IP,再依据黑名单、主机名和数据库中的客户端记录进行判断。任何一个检查失败,都会返回 false 或直接退出。

图 27:截图展示校验函数的完整结构,便于按执行顺序拆分后续判断。

5. 第一层判断:获取客户端 IP

函数首先调用 get_client_addr()。如果没有拿到地址,就立即返回 false,调用方接着退出。因此,分析第一步不是猜 payload,而是确认这个函数到底从哪里读取客户端地址。

图 28:截图展示获取客户端地址的调用和失败分支。

如果拿到地址,代码还会把它与配置中的黑名单进行比较。命中黑名单时,函数返回无效客户端;未命中才会继续执行主机名解析和数据库比对。

图 29:截图展示黑名单检查和无效客户端处理。

为了确认黑名单和默认值来自哪里,实验者尝试在当前文件中查找配置变量,发现部分定义属于 PHP 底层或全局配置,于是转向全局搜索。

图 30:截图展示在全局源码中搜索地址配置的过程。

分析到这里形成了两个必要条件:第一,请求必须让应用成功获取客户端地址;第二,地址必须通过黑名单和合法性校验。只有满足这两个条件,代码才会继续调用 gethostbyaddr()

6. gethostbyaddr() 与数据库主机名比对

gethostbyaddr() 的作用是把 IP 转换为主机名。实验中使用内网回环地址 127.0.0.1,预期结果是 localhost。随后代码从数据库的 poller 表读取主机名,并比较数据库值、解析结果以及客户端名称是否一致。

图 31:截图展示 gethostbyaddr() 处理流程,说明回环地址可能得到 localhost

当两侧名称相等时,函数记录日志并继续;不相等时则进入失败路径。由于校验函数没有在每个分支都调用 exit,需要继续跟踪最终返回点,才能确认请求是否真正通过。

图 32:截图展示主机名比较和成功分支。

图 33:截图展示认证函数汇合后的返回路径,说明两个校验点必须同时满足。

源码注释和分析文字把这个逻辑概括为:数据库中 poller 表的 hostname 默认是 localhost,只要客户端地址能被解析成相同名称,验证就有机会通过。

图 34:截图展示认证条件的实现细节。

图 35:截图展示对比字段和默认值说明。

7. 通过数据库查询验证假设

接下来直接查看数据库查询位置。代码从 poller 表取出主机名,再把查询结果和处理后的客户端地址放到同一判断中。此时分析不再停留在函数名推测,而是把"地址---主机名---数据库记录"三者放到一条数据流里。

图 36:截图展示数据库查询语句和查询结果使用位置。

图 37:截图展示比较字段,确认认证成功依赖名称和地址的一致性。

如果客户端地址是 127.0.0.1,解析结果为 localhost,数据库默认主机名也为 localhost,三个条件就能对齐。这个推理暂时仍需要验证一个前提:请求头是否能够影响 get_client_addr() 返回值。

图 38:截图展示回环地址、主机名和认证结果的关联。

8. get_client_addr() 如何选择请求头

继续追踪 get_client_addr(),可以看到函数先构造一个请求头名称数组,然后逐个检查服务器变量。数组的第一个候选项是 X-Forwarded-For

图 39:截图展示函数准备检查的请求头列表。

当服务器变量中存在 X-Forwarded-For 时,代码会读取其值,并按逗号分割多个地址。随后通过内部循环逐个解析,直到找到一个合法 IP。

图 40:截图展示请求头读取和地址拆分逻辑。

这里的关键限制是:如果应用直接信任客户端提供的 X-Forwarded-For,而前置代理没有覆盖或清理该字段,那么请求者就可能影响应用看到的"客户端地址"。这不是浏览器能力,而是服务端信任边界配置问题。

图 41:截图展示从服务器变量读取请求头的代码。

实验先在页面中打印服务器变量,确认默认请求没有 X-Forwarded-For。随后在授权浏览器环境中启用请求头修改插件,准备加入测试值。

图 42:截图展示调试输出,确认默认请求未携带目标请求头。

图 43:截图展示用于实验的请求头修改界面,作用仅限于本地靶场验证。

第一次填写时没有得到预期结果,原因是请求头名称书写不符合服务器变量映射规则。调整为带下划线的服务器变量形式后,再次刷新页面。

图 44:截图记录第一次刷新结果,目标字段没有出现在输出中。

图 45:截图展示修正后的请求头配置。

刷新后,服务器变量中出现了测试地址,证明应用确实能够从该请求头获得值。

图 46:截图展示请求头成功进入应用的调试输出。

9. 地址数组与合法性检查

拿到请求头值后,函数按逗号分割。单个普通 IP 不包含逗号,因此数组只有一个元素;多个地址时,函数才会依次处理每一项。

图 47:截图展示请求头读取和数组初始化。

图 48:截图展示地址拆分和循环入口。

如果只有一个地址,拆分结果仍然是单元素数组,循环不会"绕过"任何验证,只是把这个地址继续送入 FILTER_VALIDATE_IP 等合法性检查。

图 49:截图展示单元素数组和后续校验。

分析中还提到十六进制、八进制等非标准写法可能带来解析差异,但本实验使用的是明确的 127.0.0.1,它属于合法 IPv4 表示,不会因为格式异常被过滤。

图 50:截图展示对函数行为的辅助分析,重点是合法 IP 仍会继续进入认证链路。

通过合法性检查后,函数把地址赋值给 client_addr,并使用两层 break 跳出请求头循环和地址循环。最终返回的地址就是实验请求头中的回环地址。

图 51:截图展示双层循环退出和客户端地址赋值。

为了进一步确认单地址和多地址行为,实验者在代码中直接构造服务器变量并打印处理结果。

图 52:截图展示用于验证地址处理逻辑的测试代码。

图 53:截图展示从服务器变量取值的过程。

随后把原函数中的判断完整复制到小型测试脚本中,再次打印结果。

图 54:截图展示独立验证脚本的条件判断。

输出结果显示,单个地址经过逗号分割后仍保留为一个元素,随后被识别为合法地址。

图 55:截图展示单地址验证结果。

当测试值改为回环地址后,函数把它写入 client_addr,最终返回 127.0.0.1。这一步完成了从请求头到应用内部变量的证据链。

图 56:截图展示最终返回的客户端地址。

10. 认证绕过的完整推理

回到主流程,伪造的 127.0.0.1 首先通过 IP 合法性检查;随后 gethostbyaddr() 将其转换为 localhost;数据库中的 hostname 默认也是 localhost;名称比较因此返回 true。认证绕过的根因不是某一个字符串,而是应用把可由请求者影响的头部当成了可信客户端地址。

图 57:截图展示请求头地址到主机名的转换关系。

图 58:截图展示数据库默认值和解析结果的对应关系。

图 59:截图展示地址解析函数返回 localhost 的结果。

如果主机名和客户端地址在后续处理后无法对齐,流程会走到失败分支;实验中通过回环地址让两者对齐,于是成功避开该分支。

图 60:截图展示认证不匹配时的控制流。

代码还包含一个处理主机名点号的函数:有点号时按点拆分,没有点号时原样返回。localhost 不包含点,因此函数返回值仍然是 localhost

图 61:截图展示主机名处理函数的条件判断。

图 62:截图展示单标签主机名经过处理后的结果。

最终,数据库查询到的 hostname 和处理后的客户端名称均为 localhost,认证函数返回 true,代码进入下一个 switch 分发点。

图 63:截图展示认证成功的最终判断。

讲解者将问题归纳为两点:一是 X-Forwarded-For 被直接用于获取客户端 IP,二是该值可被请求影响。地址经过 gethostbyaddr() 后变成 localhost,再与数据库默认值比较,最终完成绕过。

图 64:截图展示信任边界缺陷的核心代码。

图 65:截图展示数据库默认主机名和比较结果。

11. 从 action 分发进入数据查询函数

认证绕过后,程序根据 GET 参数 action 进入不同 case。不同动作对应不同业务流程,只有其中一个分支会继续调用与命令执行相关的函数。因此,分析不能只证明认证成功,还要继续确认用户可控的 action 是否能到达目标分支。

图 66:截图展示通过认证后进入请求处理的代码。

图 67:截图展示各个 case 分支及其对应动作。

逐个查看分支后,命令执行相关逻辑位于一个特定动作中。由于 action 来自 GET 参数,实验请求可以在授权环境中选择该分支;其他动作不会触发同样的代码路径。

图 68:截图展示目标 case 所在位置。

图 69:截图展示 action 参数到目标函数的调用关系。

进入目标函数后,代码接收 local_data_idshost_idpoller_id 三个参数。它们都通过请求参数传入,但其中部分参数经过数字过滤,因此需要分别判断类型和过滤器,而不能简单地把所有参数当成字符串。

图 70:截图展示目标函数在主流程中的位置。

图 71:截图展示三个请求参数的接收位置。

函数签名显示 local_data_ids 带有复数形式,代码按数组处理;host_idpoller_id 则是单值参数。三者都能由请求提供,但过滤器会限制一部分输入格式。

图 72:截图展示三个参数的类型和命名。

源码进一步调用 filter_input 或等价的数字过滤逻辑。数字参数可以通过过滤,非数字内容会被拒绝或转换,因此实验 payload 必须遵循参数类型,而不是任意拼接。

图 73:截图展示数字过滤和参数读取。

12. poller_item 查询与 action 常量

目标函数会在 poller_item 表中查询记录。查询条件由 local_data_idhost_id 组成,前者在循环中逐个取出数组元素,后者作为固定条件参与查询。

图 74:截图展示查询语句和目标数据表。

图 75:截图展示数组元素逐个参与查询的过程。

数据库示例中,local_data_id 可以是 1、2、3、4host_id 都为 1,每条记录带有一个 action 值。循环拿到记录后,代码检查它的 action 是否等于代表命令执行的常量。

图 76:截图展示实验数据库表格,说明数组 ID 如何映射到动作字段。

命令执行分支中的关键调用是 proc_open。它会把拼接后的命令交给系统执行。前面的固定字符串和管道参数由程序控制,真正需要继续追踪的是 poller_id 是否能影响命令字符串。

图 77:截图展示执行函数及其参数组成。

代码追踪结果表明,poller_id 来自用户请求,并被拼接进最终命令。到这里,命令执行链路已经闭合:认证函数通过后,用户可控的 action 进入目标函数,数字参数查到 action=2 的记录,随后 poller_id 进入 proc_open

13. 分隔符为何改变命令边界

讲解中用 Linux 命令行的基本规则解释分号的作用:在授权实验中,如果一条命令字符串前面已经存在固定参数,分号可以结束前一条命令,再让后续文本作为新的命令片段解析。这里的关键不是某一条具体命令,而是字符串拼接缺少严格的参数化和边界控制。

图 78:截图展示可控参数在命令字符串中的位置。

为验证这一点,实验使用本地终端演示连续执行两个无害命令的方式,并观察分号如何分隔命令。所有验证都在靶场容器中完成,输出只用于确认代码路径。

图 79:截图展示本地实验终端和命令分隔验证结果。

因此,最终风险来自多个条件叠加:未认证入口、可伪造的客户端地址、可控的 action、可查询到命令执行动作的 ID,以及未被安全封装的命令参数。单独看其中一个条件未必能直接执行命令,组合后才形成完整链路。

14. 最终参数关系与代码回溯

复盘最后把实验参数归纳为:local_data_ids=1host_id=1poller_id 承担最终命令参数。两个 ID 之所以都取 1,是因为默认安装数据中,local_data_id=1host_id=1 对应的记录,其 action 值为 2

图 80:截图展示最终请求参数的组织方式。

程序先遍历 local_data_ids,再以 host_id 查询 poller_item。查到记录后取出 action,当值为 2 时进入命令执行 case

图 81:截图展示循环、查询和动作读取的顺序。

常量定义位置进一步证明数字 2 对应命令执行动作。这个确认很重要,因为如果只凭截图猜测数字含义,容易把普通数据查询分支误判为危险分支。

图 82:截图展示全局常量定义和动作映射。

action=2 的记录被选中后,代码把用户可控的 poller_id 送入执行函数。至此,从请求头认证绕过到数据库动作映射,再到命令执行参数的每一步都已在源码中找到对应位置。

图 83:截图展示最终执行分支,完成从参数到函数调用的代码回溯。

15. 不能跳过的验证顺序

课程最后没有把结论停留在"某个参数可控,所以可以执行"的层面,而是回到整条代码路径再做一次顺序复核。这个顺序应当从最外层开始:请求先抵达 remote_agent.php;认证函数必须返回成功;action 必须落到特定的 case;目标函数必须接收到符合过滤器要求的三个参数;local_data_idshost_id 的组合还必须从 poller_item 查询到对应动作;最后才轮到 poller_idproc_open 相关代码中发挥作用。

这也解释了为什么不能任意替换请求参数。local_data_ids 是数组型输入,代码会把其中每个 ID 拆出来遍历;host_id 是单值条件;二者用于确定查询记录。即使 poller_id 可控,若前两个 ID 没有查到动作值为 2 的记录,程序也不会进入执行分支。反过来,即使数据库存在该记录,如果认证函数停在 false,整个 switch 根本没有机会执行。

原稿把默认记录的组合写得很明确:在安装后的数据中,local_data_id=1host_id=1 的查询结果带有 action=2。这里的"1"不是任意挑选的占位数字,而是为了在表中命中这条默认记录。函数查询后把记录当作数组使用,取出其中的 action 字段,再交由后续 case 判断。全局常量定义把数字 2 映射为命令执行动作,至此数字、数据库字段和分支名称才建立起可验证的对应关系。

这类回溯还有一个实际价值:它能排除"看见 proc_open 就立即断言漏洞可利用"的误判。源码中出现危险函数只是线索,仍需确认调用点是否可到达、拼接的参数是否可控、过滤条件是否能通过、前置认证是否能够影响。课程中对每一步截图、打印服务器变量和单独构造测试脚本的做法,正是在补全这些证据。

16. 两个案例中失败结果的作用

第一个案例里,关闭浏览器后文件未按预期恢复,看起来像是脚本失效;但这个现象没有被忽略,而是被当作验证条件不足的信号。排查者继续观察进程和目录状态,才把"浏览器连接""PHP 请求进程""磁盘文件"三个对象区分开。若没有这个失败步骤,结论很容易被简化成"删除失败所以文件会回来",却无法解释为什么某次删除后没有立即恢复。

第二个案例也有类似过程。直接打印服务器变量时,预期的 X-Forwarded-For 并不存在;初次配置请求头后,输出依旧没有显示目标字段。原因不是漏洞推理被推翻,而是请求头名称与服务器端变量的形式没有对应。修改后再次刷新,变量才出现。这个小插曲说明源码审计不能脱离运行时验证:函数虽然写着读取某个键,真正进入 $_SERVER 的键名仍然取决于 Web 服务器如何映射 HTTP 头。

两段失败过程共同说明,复盘的重点不是证明每次操作都成功,而是记录失败把排查范围缩小到了哪里。关闭浏览器后不恢复,提示需要检查进程是否结束;请求头未出现,提示需要检查变量映射和配置。这样的结果会直接决定下一步该查看服务状态、目录状态还是调试输出,不能被压缩成一句"调整后成功"。

17. 从防御角度回看代码边界

虽然课程以漏洞成因分析为主,但两个案例都给出了清晰的防御落点。对于常驻 PHP 脚本,先确认文件是否反复出现,再结合 top 或任务管理器观察 CPU,并把清除动作落到 PHP-FPM 进程和服务重启上。单纯删除某个文件或撤销读取权限都没有处理循环实际所在的内存状态。

对于 Cacti 的认证链路,风险根源是把请求方可以影响的 X-Forwarded-For 作为可信客户端地址。若应用需要识别真实来源,代理层与应用层应明确约定哪些头部可信,不能由外部请求直接决定认证依据。随后,命令构造部分还应避免把可控参数直接拼入传给 proc_open 的字符串;本案例中,过滤数字 ID 只能约束部分参数,不能替代完整的命令边界控制。

这些结论完全来自案例中的代码路径:地址来源决定认证结果,数据库记录决定执行分支,poller_id 决定最终命令字符串的一部分。把每一层责任分开,才能避免只修复最表面的入口访问问题,却保留后续信任和拼接缺陷。

讲师经验与方法总结

这次复盘体现了两种相同的排查方法。第一种方法用于 PHP 常驻脚本:先记录文件删除、重新出现和浏览器关闭后的差异,再把差异归因到进程生命周期,最后用重启 PHP-FPM 验证"内存中的循环已被清除"。第二种方法用于 Cacti:先从入口限制开始,沿着函数调用、过滤器、数据库查询和 switch 分支逐层跟踪,直到找到真正影响执行结果的参数。

讲师特别强调,不能把"文件消失"直接等同于"代码停止",也不能把"认证函数存在"直接等同于"认证安全"。前者需要区分磁盘和进程内存,后者需要确认认证所依赖的数据是否来自可信来源。

在学习建议上,原稿反复要求动手搭建 Vulhub 或 Docker 环境,亲自跟读源码,再用 AI 辅助解释函数,而不是只看博客结论。遇到 action 为什么变成 2、某个 ID 为什么能查到特定记录这类问题,应当回到数据库和常量定义处验证。只有把调用链走通,才能判断分析是否成立。

复盘中的命令执行内容仅用于授权靶场和源码审计。对外发布时,真正需要保留的是漏洞成因、验证证据和修复边界,而不是把实验 payload 当成面向未知目标的操作手册。

复盘结论

第一个案例的结论是:删除 PHP 文件只能删除磁盘副本,无法自动终止已经加载代码的进程;while 死循环让脚本持续写回文件,重启 PHP-FPM 才能清空承载循环的进程状态。由于示例仍然落盘生成文件,把它称为严格意义上的"内存码"并不准确。

第二个案例的结论是:Cacti remote_agent.php 的风险链路由多个薄弱点组成。入口未认证访问本身只是起点,真正的认证绕过来自对 X-Forwarded-For 的信任;绕过后,用户可控 action 进入特定函数,默认数据库记录把两个数字 ID 映射到命令执行动作,最终使 poller_id 进入 proc_open。只有按这个顺序逐段验证,才能解释漏洞为何成立。

相关推荐
Black蜡笔小新1 小时前
明厨亮灶落地指南:EasyCVR如何让后厨监控如何合规上云?
安全·音视频·easycvr
ai小陈2 小时前
GPU服务器租用安全配置实战:SSH密钥与端口最小化开放
服务器·人工智能·深度学习·安全·ai·gpu算力
ccino .2 小时前
Docker 容器删除卡在 Removal In Progress / Dead 解决
服务器·安全·web安全·网络安全
三掌柜6662 小时前
22秒攻击窗口下的防御重构:AI Threat Defense 与 Agent 安全护栏实践
人工智能·安全
马立杰2 小时前
mysql中Illegal mix of collations for operation “UNION”错误的解决方法
数据库·sql·网络安全
xixiaoyunya2 小时前
软件开发中的代码备份策略:Git 之外的本地防线
安全·备份·代码
JaguarJack3 小时前
PHP 8.6 只是个小版本,三十项弃用却已在为 PHP 9.0 铺路
后端·php·服务端
BingoGo3 小时前
PHP 8.6 只是个小版本,三十项弃用却已在为 PHP 9.0 铺路
后端·php