做CTF题最怕的不是知识点有多冷门,而是明明网上搜得到一堆答案,你却不知道这些答案为什么能串起来。最近复盘了一道名为“ZJCTF,不过如此”的赛题,整体玩下来确实有股“不过如此”的错觉,但它背后藏的PHP双引号解析、伪协议读取、preg_replace代码执行三个点,放在任何PHP审计任务里都有用。这篇就当作一份踩坑笔记和个人解题复盘,把每一步的为什么、怎么做、以及我踩过的坑都摊开讲。
这道题最吸引我的地方在于,它没有把考点放在偏门的PHP函数上,而是把三个非常常见、甚至写过几年业务代码的人都容易忽略的PHP特性串成一条链。入口是一个文件包含点,中间靠伪协议读源码拿线索,最后通过preg_replace的/e修饰符执行任意代码。整个过程有点像拆积木:只要你知道双引号字符串会在运行时对变量做替换,你会知道为什么payload里要多写一层转义;只要你知道php://filter可以把源码变成base64,你会知道为什么读include的文件时页面不会变白;只要你知道preg_replace加了/e之后会把替换文本当代码算,你才会真正理解那句“别在代码里用/e”到底有多疼。
我习惯把这种题拆成两个层次去学:第一个层次是能解出来,拿到flag就算完;第二个层次是想明白它为什么要这样绕,以及如果把题目里的某个小细节改掉,利用链会不会断。这篇复盘不会只给最终payload,还会把每一步的推理过程都写出来,适合有一定PHP基础、想系统整理PHP流协议和正则执行漏洞的读者,也适合那些准备把CTF技巧迁移到代码审计工作上的人。
1. 核心思路拆解:三个考点为什么会串成一条利用链
1.1 题目入口不是RCE,而是“文件内容相等”判断
很多人在拿到这类题的第一反应是去找哪个参数可以控制文件名,然后直接拼接路径去读文件。但这题的入口反而藏在一个看似平平无奇的字符串相等判断里。题面大概率会是这样的一种形态:先判断某个参数经过file_get_contents读取后的内容是否等于一个固定字符串,比如“I have a dream”,如果相等,继续处理下一个文件参数。
这个设计的巧妙之处在于,它把PHP伪协议变成了唯一可行的入口。你没法直接传一个本地路径,因为本地路径里通常不会恰好存着那个固定的内容。所以你必须思考一个问题:能不能让file_get_contents从一个可控制内容的地方读取数据?这就自然引出php://input和data://两种流协议。前者把HTTP请求体当作数据来源,后者把URL里拼接的字符串当作数据源。只要掌握了这两个协议,第一关就能过。
1.2 文件包含点需要加密读取,才能绕开“直接执行”
过了相等判断后,代码大概率会调用include或require去加载另一个文件参数。这时候你自然会想到读flag文件,但这里有个很常见的坑:直接include一个PHP文件时,文件里的PHP代码会被执行,而不是以源码形式显示。所以如果你提交file=flag.php,浏览器很可能只看到空白或者一段正常业务输出,源码里的变量赋值和判断逻辑根本看不见。
解决办法就是把伪协议和过滤器组合起来:php://filter/read=convert.base64-encode/resource=目标文件。过滤器会在PHP解析之前把文件内容做一次base64编码,include的时候加载进去的是编码后的一串文本,不会触发PHP执行,照样能看到完整源码。这一步是整个题目的拐点,因为后面的preg_replace利用代码就藏在这些源码里。
1.3 preg_replace的/e修饰符负责把“文本替换”升级成“代码执行”
当你在源码里看到preg_replace,尤其是带着/e修饰符的那一行时,这道题就从文件读取变成了远程代码执行。要知道preg_replace本身只是一个字符串替换工具,它的第三个参数是替换文本。但一旦正则表达式末尾带上了/e,PHP会把替换文本当作PHP代码先eval一遍,再返回执行后的字符串。这意味着谁能控制替换文本,谁就控制了服务器。
这里最容易被双引号坑到的点就是:如果源码里把替换文本写在双引号字符串中,双引号会在PHP解析这一行代码时先把变量展开。所以你经常会看到类似"system('$_GET[cmd]')"的写法,表面上它只是个普通字符串,实际上它会把外部参数直接拼进替换文本,最后被/e送上执行。双引号、伪协议、preg_replace,三个知识点到这里就完美闭环了。
2. 环境搭建与工具清单:先把复现靶场跑起来
2.1 本地复现需要哪个版本的PHP
preg_replace的/e修饰符在PHP 7.0被标记为废弃,到了PHP 8.0直接移除。如果我在全新环境里复现,第一反应就是检查PHP版本。Windows下自带的高版本PHP大概率跑不了,最简单的方案是用Docker拉一个PHP 7.3或PHP 7.2的容器,里面把Apache和mod_php配好,再丢一个最基本的题面文件进去。
我习惯于用docker-compose起一个带Apache的PHP环境,镜像名可以用php:7.3-apache。这样做的另一个好处是,容器里默认PHP配置比较干净,不会因为系统里装了奇怪的扩展导致流协议不可用。需要特别注意两个配置项:allow_url_include和allow_url_fopen。虽然php://input不依赖远程URL开关,但data://包装器受allow_url_fopen影响,如果后面想用data://协议绕过,那得保证配置是On。调试时最好把display_errors也打开,这样preg_replace执行报错或者双引号解析出问题,页面会直接告诉你哪里坏了。
2.2 调试时我会盯住的几个工具
不少人一到这类题就只知道开浏览器,其实很多问题用简单工具就能定位。第一步是抓请求,我一般直接看浏览器的开发者工具,确认请求方法和请求体是否真的发送成功。用php://input时,请求体必须是原始字符串,不能用application/x-www-form-urlencoded的格式提交,否则PHP端拿到的内容是解析后的键值对,不是完整的“I have a dream”。
第二步是准备一个能解码base64的终端或小脚本。用伪协议读出来的源码是一长串base64,直接在浏览器里看会很乱,我习惯把它复制到文件中,然后echo 内容 | base64 -d。有些环境下文件里可能混入不可见字符,所以解码后最好再用file命令看一眼文件类型,确保不是二进制乱码。
第三步是准备一份可以快速构造POST请求的工具,curl就够用了。不要嫌命令行麻烦,等你需要在“I have a dream”后面精确控制换行符的时候,你会发现curl比任何图形化工具都可靠。我通常会把完整请求写成一个脚本文件,方便反复尝试,因为每次微调payload都要重发一次,手工在浏览器里点来点去效率太低。
有一个细节容易忽略:题面如果用了file_get_contents($text, 'r'),很多解法直接传php://input。但curl发送请求体时,如果文件末尾换行符没处理好,读到的内容会变成“I have a dream\n”,和硬编码的字符串差了一个字节,这题就直接卡死。所以我在调试时经常用printf而不是echo来确认实际发送的字节数,确保payload里没有多余的换行。
3. 前置知识一:PHP双引号字符串的插值陷阱
3.1 双引号里到底能塞什么变量
学过PHP基础的人都知道双引号字符串里能直接写变量,比如"hello $name"会输出hello加变量值。但真正到了审计场景,没几个人会去留意它和单引号的本质区别。双引号和heredoc结构会做变量插值,单引号不会,就这么一条规则,却会在preg_replace的第三个参数上酿成大事。
先看几个最常见的写法。"$name"是最简单的直接变量,"{$name}"是花括号语法,适合在变量名后面紧跟其他字符时使用。对于数组,"$_GET['cmd']"和"$_GET[cmd]"都是合法的,双引号内可以直接解析带键的数组变量。这看起来好像是PHP给你提供的一个便利,但对于代码审计来说,只要这个双引号字符串出现在一个函数调用里,而这个函数又会被当成代码执行,那这个便利就是致命风险。
3.2 为什么正则替换代码里老是看到反斜杠
凡是写preg_replace的人都知道,在正则表达式和替换字符串中,反斜杠都有特殊意义。在PHP双引号字符串里,单个反斜杠会被当成转义符,所以你要表示正则的一个反斜杠时,经常要写成\\。这会造成一个很麻烦的分层解析问题:PHP先解析双引号字符串的转义,然后preg_replace再去解析正则语法,如果带/e修饰符,还会多一次代码解析。
我见过很多新手在这一步直接把payload搞乱。问题出在他们不清楚双引号字符串里的$_GET[...]会被优先展开。如果你在替换文本里写的是system('cat /flag'),这本身没有问题,但如果整个替换文本外层的引号已经把你的代码包起来,那么在进入preg_replace之前,双引号会把这一层引号里的内容变成一个新的字符串。等/e真正执行的时候,这个字符串又被当作PHP代码解析一次。套娃式的解析就是这道题的灵魂。
3.3 利用插值把外部参数直接塞进替换文本
现在我们把双引号插值和preg_replace结合在一起想。假设题目源码里写的是:
preg_replace("/.*/e", "system('$_GET[cmd]')", $str);这个双引号字符串在源代码解析时就会被展开。如果你提交cmd=cat flag.php,那么双引号字符串实际变成system('cat flag.php')。而preg_replace拿到这段文本之后,因为正则带/e,它会把system('cat flag.php')执行一遍。最终你通过一个看起来只是传参的操作,触发了系统命令执行。
这里还有一层很隐蔽的优先顺序问题。双引号插值发生在字符串变量生成的那一刻,而/e执行则发生在preg_replace调用时,这两个时间点之间有先后之分。如果你不理解这个顺序,你可能会试图把$_GET[cmd]直接写进payload里,希望它在/e阶段被解析,但它早在双引号解析阶段就被拿掉了。理解这一点,比背payload重要得多。
4. 前置知识二:PHP伪协议的“读”与“写”
4.1 php://filter读源码的正确姿势
伪协议在PHP里算是一个隐藏的高级功能,很多人写业务代码时根本不会用到。但在文件读取类漏洞里,php://filter是最经典的选择。它的标准用法是:
php://filter/read=convert.base64-encode/resource=目标文件这条语句的作用是让文件流在读取时先经过convert.base64-encode过滤器,再输出。正因为有了这个过滤器,include一个PHP文件时,文件内容不再作为PHP代码解析,而是payback一长串base64文本。对CTF题目来说,这就是从一个黑盒变成白盒的转折点。
我还遇到过一种情况,就是过滤器写得不完整,比如漏掉了/read=convert.base64-encode/resource=中间某段。伪协议解析很严格,少一个/就会报路径错误,页面直接变成警告或空白。排查时可以先尝试最简单的一个过滤器,确认能读到文件内容,再增加新的过滤器链,这样定位问题会快很多。
4.2 php://input与data://的请求体玩法
如果题面要求file_get_contents的结果必须等于某个固定字符串,最直接的办法就是让数据来自你完全控制的请求体。php://input会返回POST请求的原始body,所以只要把body写成目标字符串,file_get_contents(php://input)就会返回这个字符串。这种玩法不依赖服务器上的真实文件,也不受allow_url_include影响,因为是本地流协议。
data://包装器则是把数据直接写在URL里,例如data://text/plain,I have a dream。它要求allow_url_fopen开启,而且空格等特殊字符要做URL编码。相比php://input,data://的优点是可以在GET请求里完成,缺点是对URL长度有限制,而且在某些PHP配置下可能被禁用。如果这道题服务器允许file_get_contents走data://,那会简单很多,但我在实际做题时更习惯用php://input,因为它对环境的依赖更少。
4.3 读取被include的文件时,文件名怎么构造
很多人在这一步会犯一个低级错误:直接把目标文件写进file参数,读出来却是空的。原因很简单,include本身是一个执行操作,它会把PHP文件当作程序运行,而不是当作文本输出。如果你强行include一个包含PHP代码的文件,你看到的只会是这段代码执行后的结果,通常是一片空白或者一个错误提示。
所以一定要在文件名前拼上伪协议。构造出的file参数一般是php://filter/convert.base64-encode/resource=flag.php。有些题目会对文件参数做关键词过滤,比如不能出现“flag”字样,那就要想办法绕过,常见的思路是URL编码、大小写混合或者换用其他伪协议做二次拼接。但万变不离其宗,核心目标都是让文件内容以编码或纯文本形式暴露出来,而不是被执行。
5. preg_replace进阶:/e修饰符与代码执行
5.1 preg_replace到底是怎么工作的
先看一下preg_replace的基本原型:preg_replace($pattern, $replacement, $subject)。它的作用是在subject里按pattern做匹配,把匹配到的部分替换成replacement。听起来很简单,但正则引擎在替换前还会对replacement做一些特殊处理,比如\1或$1这类反向引用,会用匹配到的子串替换掉。
如果是普通模式,替换完成就结束了。一旦正则表达式末尾带了/e,PHP会在完成反向引用替换之后,把整个replacement字符串当作PHP代码执行,然后把返回的字符串作为最终替换结果。这个设计在很早以前的PHP里是用来方便做动态替换的,比如你想把匹配到的数字转成大写,就可以在replacement里写一个函数调用。但它完全没有考虑一件事:replacement可能是用户可控的。
5.2 双引号解析 + /e = 一个隐藏的注入点
把前面的知识点拼起来,你会看到一条清晰的攻击路径。源码中如果有:
preg_replace("/.*/e", "system('$_GET[cmd]')", $input);从人类的角度看,这个函数调用的本意是想在正则执行后执行system函数,并且把cmd参数的值作为命令。但双引号字符串里的$_GET[cmd]在PHP生成这个字符串时就会被替换成你提交的参数。如果你提交一个精心构造的命令,那这段字符串就变成了真正的PHP代码,接着被/e执行。这相当于一个本来应该是黑盒的参数,瞬间变成了PHP命令注入点。
还有一个容易混淆的点:如果题目里的replacement是用单引号包裹的,比如'system($_GET[cmd])',双引号插值就不会发生。那你就需要通过别的方式去控制replacement,比如匹配捕获组里的内容触发代码执行。所以在做题时,先看清楚源码里的引号类型,再决定利用路径,这个顺序不能反。
5.3 常见限制和绕过方案
在真实题目里,服务端多半会做一些限制。最常见的是禁用危险函数,比如php.ini里把system禁了,或者用disable_functions把exec、shell_exec、passthru都拉黑。这时候你的payload就会从系统命令转向纯PHP函数,比如readfile($_GET[file])或者file_get_contents,至少能先读文件。如果file_get_contents也被禁,还可以考虑include的路径穿越或者遍历目录函数,只要思路是让服务器帮我们处理,解决方案总能找到。
另一个常见限制是引号转义问题。因为双引号字符串本身已经有一层引号,你的payload里如果出现引号,很可能会被PHP的转义逻辑弄坏。我常用的做法是尽量使用无引号形式,比如在双引号字符变量里用$_GET[cmd]而不是$_GET['cmd'],因为前者在双引号里可以直接解析,不需要多余的引号配对。如果必须用字符串,那就先通过URL编码或者在payload里用chr函数拼接,避免跟源码里的引号吵架。
6. 完整解题过程模拟:从读源码到拿flag
6.1 第一步:用php://input过掉字符串相等判断
我假设题面源码和我前面描述的类似,入口是一个text参数和file参数。先把text设为php://input,然后在请求体里直接放入I have a dream,用POST方式发送。这里要注意请求体不能带多余换行,所以推荐用下面这种写法:
printf 'I have a dream' | curl -X POST --data-binary @- 'http://靶机/?text=php://input&file=php://filter/convert.base64-encode/resource=next.php'如果你用的是curl的-d参数,它会默认把请求体当成表单解析,导致php://input读到的内容变成I+have+a+dream这种URL编码串,和原字符串不一致。所以必须用--data-binary来发送原始body,这个细节能救命。
等页面返回之后,你会看到一段base64文本,把它拿出来解码,得到next.php的源码。源码里就会出现preg_replace的利用点,通常是整个题目的第二关。到这一步,伪协议的知识已经用完,接下来进入代码执行阶段。
6.2 第二步:从源码里找到preg_replace使用点
解码后的源码可能会长这样:
<?php $str = $_GET['str']; $cmd = $_GET['cmd']; preg_replace("/.*/e", "system('$cmd')", $str);看到这个结构,就要立刻意识到双引号和/e两个特性同时在发生作用。当我们提交cmd=cat flag.php时,双引号字符串会先被解析成system('cat flag.php'),再被/e执行。这里最核心的动作是:你不需要去控制str,因为str只是被匹配的主题,最终执行的是cmd参数里的值。
如果源码里不是简单的system调用,而是套了一层别的函数,那就按实际的函数去构造payload。关键思路不变:通过双引号插值控制preg_replace的第三个参数,让/e帮我们执行任意PHP代码。这也就是“双引号、伪协议、preg_replace”三个知识点真正的交汇点。
6.3 第三步:拼出最终执行链
我习惯先用一个无害payload验证执行能力,比如提交cmd=phpinfo,如果页面输出了PHP信息,说明代码执行成功,再换成读取flag的命令。这样能避免一次性构造复杂payload时,前端传参错误和后端执行错误混在一起,难以定位问题。
拿到flag后别急着走,再回头想一想:如果把题目中preg_replace的replacement从双引号改成单引号,这条链还成立吗?答案是不成立,因为单引号不会解析$cmd。如果把PHP版本换成8.0,/e直接不存在,也不会成立。所以这道题本质上是在考察你对PHP运行时字符串解析顺序的理解,而不是单纯让你背一个漏洞利用模板。
7. 常见问题与排查实录
7.1 一看就会、一跑就废的坑
我在这道题上踩过好几个坑,第一个是php://input读取到的内容总是多了一个换行。后来我发现是复制粘贴payload时,命令行末尾残留了换行符,导致file_get_contents返回的字符串变成了“I have a dream\n”。排查方法很简单:先用xxd看一下请求体的十六进制,确认最后一个字节不是0x0a。
第二个坑是data://协议在本地跑通了,到靶机上却不行。原因是这两台机器PHP配置不一样,一台开了allow_url_fopen,另一台没有。所以如果题面没有明确提示不允许php://input,我永远优先用php://input,它对配置的依赖最小,报错也相对明确。
第三个坑是preg_replace /e执行的时候,经常会因为PHP版本太新而报错。如果你用的环境是PHP 7.4或者8.0,就算源码正确,也会直接看到“preg_replace(): The /e modifier is no longer supported”的致命错误。这不是payload写错了,是环境版本问题,换到PHP 7.3以下即可。
7.2 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| php://input读不到完整内容 | 请求体被当成表单解析 | 改用--data-binary发送原始body |
| 字符串相等判断失败 | 请求体中多了一个换行 | 用xxd检查请求体最后几个字节 |
| include后页面空白 | 文件内的PHP代码被直接执行 | 文件名前拼接php://filter的base64过滤器 |
| php://filter报路径错误 | 过滤器路径少写了/read前缀 | 检查完整的伪协议字符串 |
| preg_replace报/e被禁用 | PHP版本过高 | 改用PHP 7.3或更低版本环境 |
| payload中引号失效 | 双引号字符串转义解析冲突 | 改用无引号数组写法或chr拼接 |
前阵子和朋友闲聊时说,CTF题目越来越像一个知识体检中心,它不会给你一张说明书,而是把好几个知识点揉在一起,看你能不能顺着逻辑自己走通。这道“不过如此”的题目,名字起得很谦虚,实际每一步都有它存在的理由。如果你能独立把双引号、伪协议、preg_replace这条链走通,以后再看到类似的正则替换漏洞,第一眼就会条件反射式地想到:这个替换文本是不是用户可控的?这个正则有没有带/e?这个字符串值是在什么时候被展开的?
我个人在实际做题中最大的收获,不是会了一个payload,而是养成了一种拆分思维。拿到任何一个PHP文件读取点,我会先问一层:它是include还是file_get_contents?如果是include,能不能用伪协议把源码逼出来?再往下看,如果代码里出现正则替换,我会问:第三个参数是单引号还是双引号?如果是双引号,有没有变量插值?如果有/e,替换文本会不会被当成代码执行?这些问题就像把一棵树从头摸到尾,每次顺着分支走,都能得到一条清晰的利用路径,也才能在解题时真正做到“不过如此”。