平时刷CTF,我最喜欢先拿Web方向的题目热身。Web_php_include 这题在我记忆里地位很稳:它不算难,但在“文件包含”这个知识点上卡得刚刚好——题目把源码直接甩到你脸上,include($page) 一眼就能看到,偏偏又在参数上盖了一层过滤,逼着你去想怎么绕过 strstr 的检测。对新手来说,这一关能把伪协议、过滤绕过、Base64解码串成一条完整链路;对老手来说,它也是个不错的查漏补缺素材。这篇文章就把我实打实的尝试过程写出来,不绕弯子。
1. 题面源码一眼抓出关键代码:include与那行if判断
1.1 题目入口的静态展示
访问题目后,页面没有复杂的后台管理、没有登录框,直接就给你看了一段PHP源码。这是非常友好的“开卷考试”式出题。核心逻辑整理出来是这样:
<?php show_source(__FILE__); $page = $_GET['page']; if (strstr($page, "php://")) { echo "Hacker!!!"; } else { include($page); } ?>也许实际版本里输出格式、分隔符略有不同,但不影响判断。第一眼扫过去,大家的目光都会落在include($page)上——这是标准的动态文件包含;而strstr($page, "php://")则是一个针对伪协议的过滤判断。
这里有个特别值得新手注意的点:题目特意调用了show_source(__FILE__),把当前文件源码直接输出。这意味着它不想让你在“猜源码”上浪费时间,考点被主动暴露出来了。很多CTF题目会藏一手,这题反而不藏,它考的就是你懂不懂include的危险性,以及能不能把被过滤的那条路径绕过去。
1.2 为什么include参数这么敏感
动态文件包含是PHP老代码里非常经典的高危写法。include会把指定的文件内容当作PHP脚本的一部分来解析:文件里是普通文本,就输出文本;文件里带了<?php ?>标签,标签内的代码就会被执行。所以一旦参数可控,攻击者至少能做两件事:
- 读取服务器上的任意文件内容;
- 通过包含包含恶意代码的文件,直接拿到命令执行权限。
而本题还没有完全“裸奔”,它在include前做了判断:参数里只要出现小写的php://,就直接打印Hacker!!!。这属于典型的“半拦不拦”滤波——它只堵了最显眼的路径,却没有把其他路径都封死。
1.3 第一次试探:直接传flag.php会发生什么
按照CTF选手的直觉,第一反应通常是传page=flag.php。但这道题大概率不会让你直接得手,原因可能有两个:
第一,flag文件不一定在当前位置,或者文件名不是flag.php,所以include出去只是报个Warning,页面没有任何有效输出。第二,就算你传的路径没猜错,include一个flag.php,如果文件内容是这样的:
<?php $flag = "flag{xxxx}"; ?>include之后只是给$flag赋了个值,页面并不会输出这个变量,你照样什么都看不到。
所以直接盲猜路径和文件名,效率很低。在文件包含类的题目里,第一步该做的永远是信息收集——先把目标环境里的源码读出来,看清目录结构,再决定下一步怎么走。
2. 第一次被拦的完整心理过程:php://filter为什么会被顶回来
2.1 我最初的想法与回显
看到include($page),我的第一反应就是 PHP 伪协议三板斧:php://filter读源码、php://input写代码、data://执行命令。标准读取 payload 长这样:
index.php?page=php://filter/read=convert.base64-encode/resource=index.php说起来很顺,但实际一提交,页面马上回了两个字:Hacker!!!。当时我就知道,这道题把php://这个字符串的精确匹配堵住了。strstr在参数里找到了小写连续的php://,于是直接执行了echo "Hacker!!!",根本没有走到include那一步。
这种“明明知道正确答案但不让你写”的感觉,在CTF里其实很常见。但也别急着觉得题目赖皮,过滤函数本身往往就是解题的突破口,你得先搞清楚它是怎么工作的。
2.2 strstr函数的小脾气:区分大小写是绕过的关键
strstr($haystack, $needle)在PHP里的行为是严格区分大小写的。也就是说,它找的是连续的小写php://这三个字符加两个斜杠。只要参数里没有这个连续小写串,strstr的返回值就是false。
那问题就变成了:能不能让include最终收到一个合法的php://伪协议,但同时字符串里又不包含小写的php://?
答案在于PHP本身的协议解析机制。PHP 的流包装器(stream wrapper)名称不区分大小写,php://、PHP://、Php://在实际解析时都会被当作同一个伪协议来处理。于是,只要把 payload 里的php://改成PHP://:
index.php?page=PHP://filter/read=convert.base64-encode/resource=index.php此时strstr($page, "php://")找不到小写原串,返回false,于是代码正常走到include($page);而include去加载这个路径时,PHP又会把PHP://当作php://来解析,从而顺利触发伪协议。
2.3 顺手验证:strstr和stristr的本质差别
为了确认这个判断没错,我后来又试了PhP://、pHp://各种大小写混写,结果都能过。归根结底,就是一句话:strstr是区分大小写的,stristr才是忽略大小写的版本。
如果出题人在这一行写的是stristr($page, "php://"),那大小写绕过就彻底失效了,因为stristr('PHP://', 'php://')会返回真,照样被拦。所以碰到文件包含题目,第一件事永远是看它用的到底是哪种过滤函数,它区分不区分大小写,决定了你后面能不能走大小写绕过这条近路。
3. 绕过思路落地的关键认知:把php://换成PHP://之后发生了什么
3.1 第一次成功的请求长这样
我实际发出的请求是:
curl "http://目标站/index.php?page=PHP://filter/read=convert.base64-encode/resource=index.php"页面不再返回Hacker!!!,而是输出了一段很长的Base64字符串。这段字符串开头是PD9waHA之类,懂的人一眼就能认出来,PD9waHA就是<?php的Base64编码结果。Base64能解码,说明读取成功了,只是内容被打包成了编码形式。
这里要特别提醒一点:虽然PHP://filter绕过了strstr,但真正的读取动作要交给PHP内部的流解析。如果你改完大小写之后没输出,别怀疑绕过思路,先检查是不是resource=后面的文件名写错了,或者过滤器名拼写不对。
3.2 php://filter的结构拆解
php://filter这个伪协议,本质上是一个给文件读写加过滤器的封装层。它最常见的三段式写法是:
php://filter/read=过滤器/resource=目标文件read=:设置读取时经过的过滤器,比如convert.base64-encode就是在读取内容时先做一次Base64编码再返回;resource=:指定实际读取的目标,可以是相对路径、绝对路径;- 过滤器还可以用
|串起来,比如read=string.rot13|convert.base64-encode,先ROT13再Base64,属于进阶玩法。
在这个payload里,核心是read=convert.base64-encode。include到这段封装后,拿到的其实已经是被Base64编码过的字符串,里面没有任何<?php标签,所以PHP不会把它当代码执行,而是原样输出。这一步直接解决了“include文件时PHP代码会被执行、看不到源码”的难题。
3.3 解码那串Base64
拿到Base64之后,我习惯用本地命令解码:
echo 'PD9waHA...' | base64 -d内容长的时候,推荐先存到文件里再解:
echo 'PD9waHA...' > /tmp/encoded.txt base64 -d /tmp/encoded.txt当然用Python也方便:
import base64 print(base64.b64decode("PD9waHA...").decode())解出来就是index.php的原始源码。看到的内容和题目贴出来的几乎一样,这就确认了一件事:伪协议读取确实打通了,接下来要做的就是在源码和目录结构里找flag的藏身之处。
4. 拿flag的完整链路复现:从读index.php到读flag
4.1 读哪些文件,按什么顺序
我拿到index.php源码后,先整体扫了一遍,确认了题目逻辑和考法,也确认了环境里应该有专门的flag文件,逻辑上没有出现在当前文件里。那么下一步就是猜flag文件的位置。CTF环境里常见的习惯是当前目录放flag.php或者flag,也可能在根目录/flag。
所以我按这个顺序试:
flag.phpflag/flag
正常的做法是每个都用PHP://filter/read=convert.base64-encode/resource=套一遍,看哪个返回的Base64能解出有意义的内容。我这边是flag.php一次命中。
4.2 第二次读取与解码
请求:
index.php?page=PHP://filter/read=convert.base64-encode/resource=flag.php返回Base64,解码后内容是典型的:
<?php $flag = "flag{模拟flag内容}"; ?>到了这一步,题目就结束了。flag拿到了,这道题也就算通关。真正值得留意的不是“读flag.php”这最后一步,而是整个过程中对过滤规律的理解:为什么不能直接php://,为什么改成PHP://就行,为什么读取时非要用convert.base64-encode。
4.3 为什么include直接读不到明文
这里再补充一个新手容易卡住的点。如果直接include("flag.php"),而且flag.php里的代码是<?php $flag = "flag{...}"; ?>,PHP解析器会把这段代码执行掉,给$flag赋值,但页面不会输出任何东西。也就是说,你的请求大概率是成功的,但结果不可见。
用convert.base64-encode过滤器之后,文件内容在交给include之前就变成了纯Base64文本,没有PHP标签,include不会执行它,只会把编码文本输出到页面。于是源码内容就暴露了。这个思路可以迁移到任何PHP源码读取场景,是最通用的信息收集手段。
5. 复盘与扩展:data://直接执行命令,以及这类题目的通用打法
5.1 另一条路:data://伪协议直接RCE
题目只过滤了php://,那data://是不是就能用?答案是可以,前提是目标环境的allow_url_include开关处于开启状态(很多CTF靶场确实开着)。我试了这样一个payload:
index.php?page=data://text/plain,%3C%3Fphp%20system(%27ls%27)%3B%3F%3E%3C%3Fphp是<?php的URL编码,system('ls')会在目标服务器上执行ls命令,%3B%3F%3E是;?>. 由于这个字符串里没有连续的php://,strstr检测直接放行,include就加载了一个内容为PHP代码的数据流,命令顺利执行。
这个思路比读文件更直接,等于直接从文件包含升级到了代码执行。但它的前提更苛刻:一旦allow_url_include=Off,data://和php://input这类需要开启远程包含的wrapper就会失效。相比之下,php://filter读取本地文件不需要依赖这个开关,通用性更强。所以我这题的第一解法是filter,而不是data,道理就在这。
5.2 如果过滤函数换了,需要立刻换思路
这道题的精髓在于“过滤只拦了最标准的路径”。如果把题目做点变形,过滤规则变了,招式也得跟着变。常见的变体有:
| 过滤方式 | 特征 | 可能有效的思路 |
|---|---|---|
strstr($page, "php://") | 区分大小写 | 改为PHP://等大小写混写 |
stristr($page, "php://") | 不区分大小写 | 大小写绕不动,考虑data://或其他wrapper |
正则/php:\/\//i | 不区分大小写 | 大小写绕不动,需要找其他协议或编码利用 |
| 正则 `/php:// | data://` 都过滤 | 两个入口都封 |
每次改题,判断的重点永远是:它到底过滤了什么、用什么函数过滤、过滤发生在解码前还是解码后。只要把这几个问题问清楚,绕过路径基本就浮出水面了。
5.3 从防御者视角看:这类漏洞该怎么修
刷题不能只为了拿flag,还得想想真实开发中怎么挡住这种攻击。修复动态文件包含,最稳妥的办法是用白名单:
$allowed = ['home', 'about', 'contact']; if (!in_array($page, $allowed, true)) { exit('invalid page'); }如果确实需要动态拼接路径,也要做路径校验:
$base = '/var/www/html/pages/'; $real = realpath($base . $page); if ($real === false || strpos($real, $base) !== 0) { exit('invalid path'); } include $real;realpath会解析掉../和符号链接,再用strpos确认最终路径没有跳出限定目录,这比单纯替换../靠谱得多。同时,生产环境里务必确认allow_url_include=Off,这个开关能拦掉很大一类远程包含攻击;即便本地包含,也要避免直接把用户输入塞进include。这些点答题时不一定用得上,但做代码审计和写防护方案时价值很高。
5.4 以后再遇到include题目,我会按这个顺序处理
复盘了这道题之后,我把处理文件包含类题目的步骤固定成了五步,分享出来,新手可以直接照做:
- 打开页面先看有没有源码泄露,有源码就先读源码,找include/require的位置;
- 确认过滤规则:是
strstr还是stristr,是正则还是替换,是否区分大小写; - 先上
php://filter读源码,注意先用大小写变体试探,确认伪协议是否可用; - 从源码里找目录结构、文件名线索,再读取真正的flag文件;
- 如果目标是拿shell或验证RCE,再试
data://、php://input等执行类wrapper。
这套顺序看起来简单,但能避免一个非常常见的低级错误:上来就蒙flag文件路径,蒙不到就卡住。在CTF里,信息收集永远比瞎猜值钱。文件包含题尤其如此,先读源码,路径和藏身点就都有了方向,剩下的只是一步步验证而已。