☰
PHP命令执行与代码执行漏洞:原理、攻击链路与防御实践
2026/10/22 5:27:27 网站建设 项目流程

1. 命令执行与代码执行:先从最基础的区分说起

做安全测试或者代码审计时,我经常看到有人把命令执行和代码执行混为一谈,实际上这是两类不同层面的漏洞。命令执行(Command Execution)指的是攻击者借助Web应用把恶意命令注入到操作系统层面,由系统直接执行,比如执行whoami、cat /etc/passwd这类系统命令;而代码执行(Code Execution)则指攻击者把恶意PHP代码注入到应用逻辑中,由PHP解释器执行,比如通过eval()执行一段剪贴进来的变量代码。两者最终的“执行者”不同,一个是操作系统shell,一个是PHP引擎,危害等级都很高,但利用和防御的思路有本质差异。

先说个我常用来帮助新人理解这两者差异的类比。命令执行就好比你让助理去楼下打印文件,结果你在文件名里加了“& 顺便把垃圾桶倒了”,助理拿着这张单子不仅打印了文件,还执行了倒垃圾这个额外操作;代码执行则更像是你把一段“自动处理流程”写在一张便签上交给助理,助理把它当成正式制度执行了下去。前者祸从外部命令注入,后者祸从内部逻辑被篡改。

这篇文章的目标读者很明确:正在做PHP代码审计的开发者、负责WAF规则维护的安全工程师、对应用安全感兴趣的后端同学。我会从底层原理讲起,再拆解真实攻击链,最后给出一套可以在生产环境落地的多层防御方案,每个环节都尽可能附上实操过程和踩坑记录。无论你是第一次接触这类漏洞,还是已经在和RCE对抗,应该都能从中找到有用的东西。

2. 底层原理剖析:为什么这些函数如此危险

2.1 命令执行函数的本质:PHP如何把输入交给系统

如果我们剥开PHP的外壳看本质,命令执行漏洞产生的原因只有一个:用户可控的数据流,被拼接到了系统命令的字符串中,然后交shell解释执行。底层调用链通常是这样的:

用户输入 → PHP变量 → 字符串拼接 → system()/exec() → /bin/sh -c "拼接后的命令" → 系统执行

核心的敏感函数我都整理过一遍,常见的主要有system()、exec()、shell_exec()、passthru()、popen()、proc_open(),以及更底层的pcntl_exec()。其中system()和passthru()会直接把命令的原始输出返回给浏览器,比较适合攻击者快速验证;exec()只返回最后一行输出;shell_exec()需要用echo包裹才能看到结果。各个函数在输出处理上略有差异,但本质都是向shell传递了一条待执行的命令串。

这里必须多提一句:system()这类函数在底层调用的是/bin/sh -c,所以它不只是“执行一条命令”,而是“把整段字符串交给shell解析”。学过shell的人都明白,;、|、&、>、<这些符号在shell眼里都是指令分隔和重定向操作。只要用户输入被拼接到命令字符串里,绕过空格过滤、绕过关键字过滤都是可以做到的。攻击者常用的命令拼接方式,比如127.0.0.1;cat /etc/passwd、127.0.0.1|whoami、127.0.0.1&&id,所有花式打法本质上就一个目的:让shell在解析字符串时把用户注入的部分当作新命令来处理。

2.2 代码执行函数的引擎差异:eval、assert 与 preg_replace

代码执行类漏洞的函数家族更庞大。eval()是最典型的,它把参数字符串当作PHP代码执行,这个是字面意义的“代码注入”,危险程度不用多说。assert()在PHP 5时代可以用来实现代码执行,因为assert()的参数如果是字符串,系统会把它当作PHP代码来执行,这个特性在PHP 7.2之后已经不能执行代码了,但在老项目中依然会有利用场景。还有一个非常隐蔽的点是preg_replace()的/e修饰符,当正则表达式使用/e时,替换字符串会先被当作PHP代码执行,再把执行结果拿去替换,这是曾经非常经典的代码执行入口,不过在PHP 5.5.0 以后/e修饰符已经被标记为废弃,PHP 7.0 彻底移除。如果我还负责维护极老版本的项目,审计时依然会留意这个。

还有一个很容易忽略的入口是create_function(),早年很多开发者用它来动态创建匿名函数,第一参数是参数列表,第二参数是函数体,函数体本身就是一段字符串,用户输入一旦可以控制第二参数,就直接获得代码执行。这个函数在PHP 7.2.0 被废弃,PHP 8.0.0 被移除。接着是call_user_func()和call_user_func_array(),这两个是回调函数,本身不算漏洞,但它们经常被利用来触发其他危险函数的调用,打个比方:普通函数是明锁,回调类函数是万能钥匙,本身不伤害人,但配合危险函数就变致命。

3. 攻击面分析:有价值的入口都在哪里

3.1 输入源分类:从GET、POST到请求头与文件内容

做攻防分析时,很多人把注意力全放在参数值上,只盯着?cmd=whoami这种明显特征。实际上从审计角度,所有外部可控的数据流都可能是攻击入口。GET和POST参数是最常见的,无需多解释。Cookie中的字段也是可控输入,而且很多开发者觉得Cookie里只是一些标识信息,不参与后端逻辑,忘了对相关值做过滤。HTTP头中的User-Agent、Referer、X-Forwarded-For同样可控,如果后端代码把这些头信息写入日志,而日志文件又被某个命令执行函数引用,就能构造一个“日志投毒”场景。

我之前给一个老系统做代码审计时就遇到过一个隐蔽案例:系统通过exec()调用一个外部的压缩工具处理用户上传的文件名,文件名的来源不是表单参数,而是JSON请求体里的filename字段。开发者只对GET参数做了过滤,完全没防JSON体,攻击者构造filename=test.tar.gz;cat /etc/passwd就直接打穿了。这类案例带来的教训是:凡是进入系统命令拼接点的数据流,都必须视为不可信输入,和它来自GET、POST、Cookie、Header还是文件内容没有关系。

3.2 触发点场景:哪些业务功能容易成为重灾区

根据我在多个项目里的审计经验,有几类业务功能出现命令执行和代码执行漏洞的概率明显高于其他功能。第一类是“在线执行系统命令”类功能,典型的有服务器管理后台的ping工具、端口扫描器、WebShell类管理工具,这类功能本身就是执行命令的,防护重心应该放在权限控制和命令白名单上。第二类是调用外部程序处理数据的功能,比如调用ImageMagick处理图片、调用FFmpeg处理视频、调用压缩工具解压文件,这类功能往往会把文件名、URL、参数拼接到命令里。第三类是动态模板渲染和动态代码生成功能,比如某些CMS允许自定义模板标签、某些报表系统允许拼接表达式,这些地方容易走向eval()或create_function()。

还有一个很多人容易忽视的场景是定时任务和消息队列消费者。后台脚本对输入参数的信任度往往比Web前端更低,比如某个队列任务从消息体中取出文件名直接交给shell_exec(),业务人员觉得“这是内部消息,不会有外部输入”,但消息队列本身可能因为未鉴权被外部写入恶意消息。攻击链瞬间被打通。对企业来说,所有自动化处理链路的入口点都应该纳入攻击面管理清单。

3.3 绕过手法盘点:编码、注释符与大小写混淆

谈攻防不能只谈“有洞就打”,真正的对抗在于如何绕过开发者设置的过滤规则。最常见的绕过手法是编码绕过,攻击者利用URL编码、双重URL编码、Base64编码或十六进制编码来绕过关键字匹配。比如WAF检测到参数里含有cat /etc/passwd,攻击者把cat编码成%63at,或者把整条命令base64编码后交给后端echo '...' | base64 -d | bash。这时候字符串特征完全变了,正则匹配很容易失效。

还有一类是注释符绕过。Linux shell支持#注释,Windows shell不支持,但PHP里的#是注释符。如果开发者拼接到命令串时在中间插入了过滤逻辑,攻击者可能利用注释符截断后续内容。举个经典例子,把输入拼接到ping -c 1 <input>,直接传入127.0.0.1 #就能让后面拼接的任何内容失效。还有大小写混合,如果过滤正则区分大小写,Cat就可能绕过,当然shell命令对大小写敏感,但有些命令本身支持别名,或者可以通过管道间接构造等效指令。这些绕手法在真实环境中都有体现,防御的时候一定要假设“正则总有可能被绕过”这个前提来设计安全方案。

4. 攻防实战:从构造Payload到加固系统的完整链路

4.1 命令执行实例演示:从探测到利用的整个路径

我拿模拟项目X来演示一条典型命令执行攻击链。假设系统有一个“在线Ping”功能,后端代码大概是这样的:

<?php $ip = $_GET['ip']; system("ping -c 1 " . $ip); ?>

用户传ip=127.0.0.1时命令是ping -c 1 127.0.0.1,这是正常行为。现在攻击者构造:

ip=127.0.0.1;id

拼出来的命令就是ping -c 1 127.0.0.1;id,shell先执行ping,再执行id,命令成功注入。第一步通常用id或whoami做无害探测,原因在于这类命令执行成功与否不影响业务稳定性,同时能快速确认命令注入点是否有效。接着确认系统类型后,就可以考虑读取文件、查看网络配置、外带数据等后续操作。

在真实攻防演练中,攻击者还会利用bash的${IFS}变量代替空格来绕过空格过滤,或者把命令写入临时文件再执行,或者利用通配符*匹配来绕过文件路径过滤,后面这些手段每一招都能绕开一批不够完善的过滤逻辑,防御方必须心里有数。安全测试阶段我的习惯是每一个探测请求都要观察三点:响应内容变化、响应时间变化、服务器端日志中是否有命令执行记录,三者综合判断才能确认漏洞是否真实存在,避免把WAF拦截造成的超时误判为命令执行成功。

4.2 代码执行实例演示:多层混淆与回调函数利用链

代码执行的经典链路上,我拿一个存在call_user_func()的场景来演示。假设系统根据某个参数动态调用函数:

<?php $action = $_GET['action']; call_user_func($action); ?>

直接传action=phpinfo就能执行phpinfo(),这是快速探测。如果想执行系统命令,可以借助PHP的assert()在旧版本中的字符串执行特性:action=assert配合另一个参数传入phpinfo()或system('id'),这种利用方式绕过了“只能调用无参数函数”的限制。

代码执行和命令执行还有一个不同点:代码执行payload可以进行复杂混淆,因为PHP语法本身很灵活。常见的混淆手法包括字符串拼接、函数名动态化、用chr()和hex2bin()编码数据、用注释拆分关键字。比如sys'tem这种写法对某些正则过滤就有效,因为PHP解析时会先拼接字符串再去寻找对应函数。我测评过不少开源WAF规则集,实话讲,对普通字符串匹配类规则,稍微做两层编码就能绕过,真正有效的是行为检测——判断执行链路上是否同时出现用户可控数据、危险函数、系统调用这三个要素。

4.3 针对目标系统的分层加固:从代码到运行环境的纵深防御

单点防御风险极大,我始终坚持三层防护的思路。第一层是代码层,最低要求是危险函数的参数必须经过白名单校验,而不是黑名单过滤。所谓白名单,就是明确“允许的值集合”,比如Ping功能只允许IP地址格式,可以用filter_var($ip, FILTER_VALIDATE_IP)做强制校验;或者按照业务约束只允许域名格式,那就用正则强制匹配域名结构。相比之下,黑名单过滤“屏蔽掉分号和管道符”的思路在理论上就无法彻底,因为shell语法中还有很多符号和关键字可以用来拼接命令。

第二层是运行环境加固,关键动作包括:disable_functions配置中禁用system、exec、shell_exec、passthru、proc_open、popen、pcntl_exec等危险函数;open_basedir把PHP可访问的目录范围限制到业务目录内;关闭不必要的动态执行扩展;以低权限用户运行PHP-FPM进程,确保即使被攻破也只能在受限权限内操作。这层防御的意义在于:即使代码层校验被绕过了,攻击者拿到的也是一支打不响的枪。

第三层是检测响应层,核心思路是让攻击行为可以被感知。我建议在日志中记录命令执行类函数的所有调用参数,同时为关键操作建立审计日志到独立的日志索引。对WAF规则,至少覆盖URL解码后和Base64解码后的双重检测,避免简单编码绕过。用安全组或云防火墙限制Web服务器对外主动连接,可以阻止攻击者外带数据——外带数据是很多攻击链里必不可少的一步,切断这个出口就能大幅提升攻击成本。

5. 常见问题与排查技巧实录

5.1 命令注入点排查案例:明明有过滤为什么还被绕过

有一回我给一个老项目排查命令执行告警,业务方坚持说他们已经加了过滤,代码在调用exec()之前把分号和竖线都替换掉了。我沿着数据流看了一遍,发现他们确实做了替换,但顺序上有个致命漏洞:先拼接到命令字符串,后做替换过滤。这么说大家应该就能理解了,命令执行发生在拼接完成后的shell解释阶段,如果拼接时用户输入已经被替换干净了,后面自然不会出问题;但他们的代码是先把所有输入替换好,拼接到命令串时用的是经过替换的字符串,看起来没问题,实际上在某些构造下仍然能绕过。后来我进一步分析,发现过滤逻辑只处理了参数中的分号和管道符等少量符号,但完全没有覆盖换行符和$()命令替换写法。果不其然,构造ip=127.0.0.1$(whoami)后命令正常执行了。

这个案例的排查思路值得记录:先确认告警的特征字段,再沿调用链反向追踪输入流,最后对比过滤规则覆盖面和实际攻击向量。提醒所有做防御的朋友,排查这类问题时不要只听代码负责人说“我们过滤了”,一定要自己拿payload实测一遍,用数据说话。我当时把过滤函数单独拎出来跑了一个测试脚本,把几十种常见绕过向量逐条输入,一次性看到哪些能穿过去,这个过程不超过半小时,比口头扯皮高效太多了。

5.2 白名单校验的边界与怪癖:file_exists与命令注入的意外串扰

在构建白名单校验的时候,我发现一个很隐蔽的坑:有些开发者用file_exists()判断“用户输入的文件是否存在”来过滤非法路径,这个操作本身没有问题,但如果file_exists()的参数是用户可控的字符串,它实际上会触碰到文件系统,并且某些环境下会暴露文件是否存在的信息。假如用户输入file_exists('/etc/passwd'),页面返回结果差异就可能让攻击者获得系统文件探测能力。更麻烦的是,file_exists()的返回值不能用来确认“这个路径可以安全地拼接到命令里”,两者没有等价关系。

另外还要注意escapeshellarg()和escapeshellcmd()这对兄弟函数,很多人把它们当成命令注入的银弹,实际不是。escapeshellarg()用于给单个参数加上单引号包裹,并转义参数内部的单引号,适合处理单个参数值;escapeshellcmd()转义shell中的特殊字符,但它不足以完全防止注入,而且Windows环境下这两个函数的行为和Linux不同。我自己实测过一个场景:在Windows上用escapeshellarg()处理路径参数,它会把反斜杠处理得很奇怪,导致原本正常的功能直接异常。所以最佳实践是:能不用系统命令就别用,必须用的时候优先用escapeshellarg()包裹参数、白名单校验路径、尽量传数组参数而不是字符串拼接。

5.3 日志取证与应急响应:如何在报警后快速定位攻击路径

安全事件发生后,第一件事不是急着修复漏洞,而是先把攻击路径完整还原出来。日志取证的核心有三点:请求日志找攻击入口,命令调用日志找执行点,文件变更日志找落地内容。我在应急响应中见过很多团队只保留了Web访问日志,没有记录系统命令调用参数,导致排查到一半就断了。所以我在防御方案里一直强调,必须在PHP层就记录命令执行函数的调用参数,可以写一个简单的日志中间件,把system()、exec()等的入参、来源IP、UA、时间全部记下来,这会成为攻击分析的关键证据。

还原攻击路径时,按时间线串起请求日志找到第一个恶意请求,看当时的参数是否触发了代码执行,顺着执行结果追踪后续操作,检查/tmp目录是否存在可疑文件,检查计划任务是否新增了任务,检查Web目录是否有新增脚本。这套流程我建议每个团队在平时至少演练一次,真到出事那天才不会手忙脚乱。我也踩过两次坑之后才养成了“日志先保留、系统后修复”的操作习惯,先打包日志文件再动应用配置,避免修复过程中的日志写入把攻击痕迹冲掉。

6. 构建全链路防御体系的实际落地流程

6.1 第一步:全面体检,建立危险函数清单与调用点台账

落地防御体系的第一步是全面体检,摸清家底。我习惯用代码审计工具自动扫描加人工复核,先跑一遍正则规则找出所有危险函数调用点,再逐个去确认每个调用点是否处理了外部输入。最终输出一份“危险函数调用点台账”,包含文件路径、函数名称、参数来源、是否可控、当前防护措施这几个字段。台账的价值不只是给自己看,而是让整个团队对“我们有多少个暴露面”达成共识。

扫描规则我按函数危险等级分了优先级:第一优先是eval()、assert()、preg_replace()带/e、create_function(),这几个直接关联代码执行;第二优先是system()、exec()、shell_exec()、passthru()、proc_open()、popen(),关联命令执行;第三优先是call_user_func()、call_user_func_array()、array_map()、usort()这类回调型函数。递归调用、动态类名调用、反序列化入口也需要额外关注,它们能把攻击链引到上述危险函数上。

6.2 第二步:代码加固,从根源切断注入可能

代码加固阶段要分两步走。第一步是重构高危调用点,把命令执行函数换成更安全的实现方式。能处理为“参数数组”形式就优先使用,例如PHP的proc_open()支持传数组形式参数,这时候shell不会解析拼接,注入风险大幅下降;能用PHP原生库就绝不调外部命令,比如图片处理优先用GD库或Imagick扩展,而不是调系统ImageMagick命令;能上白名单就必须上白名单,比如用filter_var()做IP校验、用正则强制匹配域名格式、用realpath()校验文件路径必须在允许目录内。

第二步是加固所有不可绕过的高危调用点,套一层统一的输入校验逻辑。这里我借用过一个很实用的“输入净化”函数,把所有外部输入先走一遍白名单规则,规则不通过就直接拒绝请求并记录审计日志。要注意顺序必须严格:先校验,再拼接,再执行。校验失败时的错误信息不要暴露具体过滤规则,只返回通用错误提示,避免攻击者根据回显调整payload,这些细节在真实业务里大有讲究。

6.3 第三步:运行环境加固,让漏洞失效的最大化努力

PHP配置文件里我推荐的加固项包括:

disable_functions = system,exec,shell_exec,passthru,proc_open,popen,pcntl_exec open_basedir = /var/www/html:/tmp/safe_dir expose_php = Off display_errors = Off

文件上传目录要设置open_basedir禁止执行权限,Web目录下禁止存放.php后缀的可执行文件。如果业务本来就不需要某些扩展,比如phpinfo()相关的调试扩展,能关就关。以低权限用户运行PHP-FPM,配合Linux的systemd服务配置,让PHP进程的文件系统访问边界尽量收缩到运行目录。部署了这些之后,哪怕某天某个调用点确实存在命令注入,攻击者也会发现所有危险函数都已经被禁用、文件系统被限制在业务目录内、运行用户权限很低,利用难度直接上升一个数量级。

6.4 第四步:建立监控与应急SOP,做到攻防可见可控

最后一步是监控与应急机制。从技术到管理的闭环才是完整防御。我建议至少覆盖这几项监控:对禁用函数的调用行为进行监控,方法是通过自定义PHP扩展或修改过的原生函数包装器记录异常调用;对命令执行函数的参数日志做关键字告警;对Web访问日志中的命令执行特征做离线分析;对文件和计划任务变更做完整性校验。安全组规则要限制Web服务器只能访问必要的端口和域名,禁止主动外连不必要的外部地址。

应急SOP至少包含五步:发现告警、日志取证、隔离节点、漏洞修复、复盘总结。第一次做应急演练时我们用了大概两个小时,后面熟练了压到了40分钟。这套SOP要和监控体系配合起来,才能真正做到“早发现、快处置、能复原”。在演练过程中我坚持用真实的攻击流量去测试监控效果,而不是假设监控规则一定有效,这样才能发现规则盲区并持续优化。

7. 几条值得长期坚持的实战心得

先分享一个老生常谈但怎么强调都不为过的原则:过滤永远做白名单而不是黑名单。黑名单逻辑是“我知道哪些是坏的,所以屏蔽它们”,白名单逻辑是“我只接受我知道是好的,其余全部拒绝”。防御永远无法穷举攻击手段,但业务合法输入是有限的。守住合法集合,攻击面自然就收缩了。

再分享一个关于安全评审时机的经验。我一直主张在功能设计阶段就让安全介入,而不是等代码写完了再做安全测试。如果产品经理和架构师在评审阶段就问一句“这个功能需要执行系统命令吗”,很多不必要的命令执行调用点压根就不会出现。能不用系统命令就不用系统命令,这是成本最低的防御手段。等到代码写完了再加固,往往是重新改造一处业务流程,代价完全不同。

最后提醒一句实战中大量看到的现象:很多人以为装了WAF就可以高枕无忧,实际上WAF只是防线的一环。WAF规则需要持续的维护对抗新型绕过手法,而业务代码本身的安全质量才是决定整体安全水位的关键。把WAF作为唯一的防御依赖,迟早会出问题。最稳妥的方案永远是代码层白名单校验、运行环境加固、日志监控和应急响应联动起来,构建的是全链路防御体系,对抗整个攻击链的每个环节。这条路我走了很多年,每一次攻防演练都在验证一个朴素道理:安全不是某个环节的特效,而是所有环节叠加后的综合水位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询