1. 项目概述:一次关于PHP文件包含漏洞的深度实战复盘
最近在复盘一些经典的Web安全挑战题,特别是攻防世界(CTF)平台上的“warmup”这道题,它可以说是PHP文件包含漏洞(File Inclusion Vulnerability)的一个绝佳教学案例。这道题的精妙之处在于,它没有使用常规的本地文件包含(LFI)或远程文件包含(RFI)那种“直给”的方式,而是设置了一个看似安全的“白名单”检查机制。很多新手,甚至一些有一定经验的朋友,初次接触时都可能卡住,因为它要求我们不仅要理解文件包含的原理,更要深入理解服务器在处理请求时,代码逻辑、文件系统与Web服务器配置三者之间微妙的交互关系。这不仅仅是解一道题,更是理解现代Web应用中一种常见安全设计缺陷及其绕过思路的实战演练。
简单来说,这个项目就是深入剖析“warmup”题目中如何利用PHP文件包含漏洞,并成功绕过其白名单限制,最终读取到目标文件(比如flag)的过程。我们将从漏洞原理、代码审计、环境交互、路径构造等多个维度,一步步拆解攻击链。无论你是正在学习Web安全的新手,希望巩固文件包含漏洞知识的中级选手,还是想了解一些不常见的绕过技巧的安全爱好者,这篇复盘都能给你带来清晰的思路和可直接复现的实操经验。接下来,我们就抛开那些抽象的理论,直接进入“案发现场”,看看这道题到底是怎么被“玩转”的。
2. 漏洞核心原理与“warmup”场景还原
在开始拆解具体技巧前,我们必须把地基打牢。PHP文件包含漏洞,核心源于include、require、include_once、require_once这四个函数。当这些函数包含的文件路径来自于用户可控的输入(如$_GET、$_POST、$_COOKIE等),且未经过严格过滤时,攻击者就有可能操纵这个路径,让服务器去执行或读取非预期的文件。
2.1 文件包含漏洞的两种基本形态
- 本地文件包含(LFI):包含服务器本地的文件。例如,
include($_GET[‘file’]);,如果传入?file=../../etc/passwd,就可能读取到系统敏感文件。 - 远程文件包含(RFI):包含远程服务器上的文件。这需要
php.ini中allow_url_include设置为On(默认是Off),风险极高,可以导致直接执行远程恶意代码。
“warmup”这道题,通常属于LFI的范畴,但它的难点不在于简单的目录遍历(../),而在于它有一个白名单校验。题目源码(模拟)的关键部分往往如下所示:
<?php highlight_file(__FILE__); class emmm { public static function checkFile(&$page) { // 白名单列表 $whitelist = ["source.php", "hint.php"]; // 检查$page是否在白名单内 if (! isset($page) || !is_string($page)) { echo "you can't see it"; return false; } // 关键检查:$page 必须是以白名单内某个文件开头的字符串 if (in_array($page, $whitelist)) { return true; } $_page = mb_substr($page, 0, mb_strpos($page . '?', '?')); if (in_array($_page, $whitelist)) { return true; } $_page = urldecode($page); $_page = mb_substr($_page, 0, mb_strpos($_page . '?', '?')); if (in_array($_page, $whitelist)) { return true; } echo "you can't see it"; return false; } } if (! empty($_REQUEST['file']) // 检查file参数是否存在且非空 && is_string($_REQUEST['file']) // 检查是否为字符串 && emmm::checkFile($_REQUEST['file']) // 调用白名单检查函数 ) { include $_REQUEST['file']; // 包含文件 } else { echo "<br><img src=\"https://i.loli.net/2018/11/01/5bdb0d93dc794.jpg\" />"; } ?>2.2 “warmup”场景的独特设定
一眼看去,这个检查似乎很严格:$page参数必须直接是source.php或hint.php,或者是一个以它们开头、后面跟了?的字符串。这看起来万无一失,因为即使你传入source.php/../../../../etc/passwd,经过mb_substr($page, 0, mb_strpos($page . '?', '?'))处理,截取第一个?之前的部分,得到的是source.php,依然在白名单内,检查通过。但是,真正的漏洞就隐藏在“检查通过”之后的那行代码:include $_REQUEST[‘file’];。
这里存在一个检查点与执行点分离的经典问题。checkFile函数检查的是处理后的$_page,而最终执行include使用的是原始的$_REQUEST[‘file’]。只要我们能构造一个payload,让它通过前者的校验,同时又能让后者解析成我们想要的路径,漏洞就产生了。
关键理解:
include函数在解析文件路径时,其行为会受到服务器环境(如PHP版本、操作系统)和Web服务器(如Apache、Nginx)配置的深刻影响。尤其是在处理包含路径中的特殊字符(如/、?、#)时,不同组件的解析差异就是我们绕过的突破口。
3. 白名单绕过的核心技巧:路径解析差异的利用
要让原始的$_REQUEST[‘file’]被include函数解析成其他路径,我们需要利用Web服务器和PHP在解析URL和文件路径时的特性。主要有以下几种思路,而“warmup”通常考察的是第一种。
3.1 利用?进行路径截断(经典解法)
这是本题最直接的解法。观察checkFile函数,它用mb_strpos($page . ‘?’, ‘?’)来寻找第一个问号的位置。如果我们传入source.php?../../../../../etc/passwd,函数逻辑如下:
$page值为source.php?../../../../../etc/passwd。$page . ‘?’变成source.php?../../../../../etc/passwd?。mb_strpos(…, ‘?’)找到第一个问号的位置,在source.php后面。mb_substr($page, 0, 上述位置)截取出source.php。source.php在白名单中,checkFile返回true。
此时,原始的参数$_REQUEST[‘file’]仍然是source.php?../../../../../etc/passwd。当执行include ‘source.php?../../../../../etc/passwd’;时,PHP的include函数会如何理解这个字符串?
PHP的include机制:include会把传入的字符串当作一个文件路径。当路径中包含?时,PHP会默认将?及其之后的部分视为查询字符串(query string),而不是路径的一部分。在尝试打开文件时,PHP会忽略?后面的所有内容。也就是说,对于include ‘source.php?../../../../../etc/passwd’;,PHP实际尝试打开的文件就是source.php。
那岂不是没用?别急,这里需要引入第二个角色:Web服务器(以Apache为例)。
当浏览器发起一个请求http://target.com/index.php?file=source.php?../../../../../etc/passwd时,这个URL会被Web服务器(如Apache)首先接收并解析。Apache的解析规则是:最后一个?之后的内容是HTTP请求的查询字符串(QUERY_STRING),而?之前的部分是请求的路径(PATH_INFO或实际文件路径)。但是,在这个URL中出现了两个?,这通常是不规范的。Apache的默认模块mod_cgi或mod_php在处理时,可能会将第一个?之后的所有内容整体作为查询字符串传递给PHP。然而,这里存在一个关键点:在将路径映射到实际文件系统时,Apache可能会对URL中的..进行目录回溯解析。
更常见的利用方式是结合路径..和伪协议php://filter。但“warmup”题目通常设计为直接包含flag文件。假设通过信息收集(比如包含hint.php)我们得知flag文件在/ffffllllaaaagggg(一个虚构的根目录文件)。我们的Payload可以构造为:?file=source.php?/../../../../ffffllllaaaagggg
- 对
checkFile函数:它截取第一个?之前的部分,得到source.php,通过检查。 - 对PHP的
include:它接收到的是source.php?/../../../../ffffllllaaaagggg。PHP会尝试打开名为source.php的文件,并将?/../../../../ffffllllaaaagggg视为一个无效的查询字符串而忽略吗?不一定。在某些环境下,特别是当路径中包含多个/时,PHP的文件系统函数在解析时,可能会因为?的存在而产生非预期的行为。但更通用和可靠的理解是,我们需要让?后面的路径在某个环节被解析。
实际上,更精确的利用依赖于PHP在特定配置下对include路径中?的模糊处理,以及路径中/的数量。一个经过验证的有效Payload是:?file=source.php?../../../../../ffffllllaaaagggg。在某些PHP版本和服务器配置下,include会错误地将整个字符串作为路径,而?被当作合法文件名的一部分(虽然不存在),但利用../成功跳出了当前目录。另一种可能是,Web服务器在将请求传递给PHP前,对URL进行了一些重写或解码,导致了路径解析的歧义。
3.2 利用#(锚点)进行截断
#在URL中表示网页锚点,其后的内容不会发送到服务器。但在PHP代码中,如果直接将#写入include的字符串,例如include ‘source.php#../../../../etc/passwd’;,PHP的include函数会怎么处理?在Linux系统下,#是合法的文件名字符。PHP会尝试寻找一个名为source.php#../../../../etc/passwd的文件,这显然不存在。因此,纯#截断在文件包含中通常无效,除非结合其他技巧(如NULL字节截断,但已在PHP高版本修复)。
3.3 利用编码与双重编码
这是更高级的绕过方式,常用于应对更复杂的过滤。checkFile函数中有一行$_page = urldecode($page);,说明它对输入进行了一次URL解码。这本身是一个安全措施,旨在防止编码绕过。但如果我们进行双重编码呢?
- 我们想传入
source.php?../../../../etc/passwd。 - 服务器收到后,会默认进行一次URL解码。如果我们直接传,
?会被解码为?。 - 但是,如果我们把
?编码两次:%253f(%25是%的编码,3f是?的编码)。 - 服务器第一次解码:
%253f->%3f(因为%25被解码成了%)。 checkFile函数执行urldecode:对%3f进行解码,得到?。此时它截取判断,可能因为?已经出现而通过检查(取决于代码逻辑,在warmup原题中,它是在urldecode后再次截取判断,所以依然可能通过)。- 而最终
include使用的$_REQUEST[‘file’],在PHP获取时已经经过服务器第一次解码,即source.php%3f../../../../etc/passwd。在某些特定环境或配置下,PHP的include可能将%3f仍当作字面字符%3f处理,从而无法正确识别?的截断作用,导致整个字符串被当作路径,使得../生效。或者,在另一些场景下,文件系统调用层面对编码的解析差异可能导致漏洞。
在“warmup”原题中,通常不需要用到双重编码这么复杂,第一种?截断就足够了。但了解这种思路对于应对更严格的过滤非常重要。
实操心得:在实战测试中,
?截断的成功率高度依赖于目标服务器的具体环境(PHP版本、Web服务器类型及配置、操作系统)。因此,在CTF中,这通常是一个“已知可用”的考点。在真实渗透测试中,则需要多种Payload进行尝试,并仔细观察错误信息。一个常见的测试序列是:先尝试简单的../遍历,再尝试?截断,接着尝试....//或..;/等变形,最后尝试编码绕过。
4. 完整攻击链实操与步骤拆解
现在,我们假设在一个模拟的“warmup”靶场环境中,进行完整的攻击演示。目标:读取服务器上的/flag文件。
4.1 信息收集与初步探测
- 访问目标:打开题目提供的URL,例如
http://xxx.xxx.xxx.xxx:port/。 - 查看页面源码:通常首页会给出提示,或者直接展示部分源码(因为题目有
highlight_file(__FILE__);)。我们能看到类似前面给出的PHP代码。 - 尝试白名单文件:根据代码中的白名单
[“source.php”, “hint.php”],我们首先尝试访问:http://xxx.xxx.xxx.xxx:port/?file=source.php-> 可能会显示当前页面的源码(即index.php的源码,因为include了自身)。http://xxx.xxx.xxx.xxx:port/?file=hint.php->这是关键一步!很多CTF题目会在hint.php中给出flag的路径提示。访问后,页面可能会显示一行字,比如:flag not here, but maybe in /flag或者look at the source code of /ffffllllaaaagggg。我们假设提示是flag is in /ffffllllaaaagggg。
4.2 构造绕过Payload
我们已经知道:
- 检查逻辑:截取第一个
?之前的内容,检查是否在白名单内。 - 目标文件:
/ffffllllaaaagggg(位于网站根目录)。 - 当前文件:我们位于
index.php。
我们需要构造一个file参数,使得:
- 截取第一个
?之前的部分是source.php或hint.php。 - 最终
include整个参数时,能跳转到/ffffllllaaaagggg。
根据第3节的分析,我们使用?截断技术。Payload构造如下:?file=source.php?/../../../../../ffffllllaaaagggg
路径回溯解释:假设index.php位于/var/www/html/。
source.php?这部分让检查通过。/../../../../../这部分是目录回溯。从/var/www/html/开始:../->/var/www/../../->/var/../../../->/../../../../-> 已经到根目录/,继续../仍然停留在/。
- 最终路径:根目录
/+ffffllllaaaagggg=/ffffllllaaaagggg。
为什么是5个../?这是一个经验值。在不确定具体目录深度时,通常会使用多个../以确保能回溯到根目录。使用4个、5个、6个都是常见的做法。
4.3 发起攻击并获取Flag
将构造好的Payload拼接到URL后:http://xxx.xxx.xxx.xxx:port/?file=source.php?/../../../../../ffffllllaaaagggg
访问这个URL。如果漏洞存在且环境配置允许这种绕过,服务器将执行include ‘source.php?/../../../../../ffffllllaaaagggg’;。根据我们之前的分析,在特定环境下,这个操作会成功包含并输出/ffffllllaaaagggg文件的内容,也就是我们想要的flag。
4.4 其他Payload变种尝试
如果上述Payload不成功,可以尝试以下变种,这体现了实战中的试探过程:
- 减少
../数量:?file=source.php?/../../ffffllllaaaagggg(假设目录较浅)。 - 使用
hint.php作为前缀:?file=hint.php?/../../../../../ffffllllaaaagggg。 - 去掉
?后的/:?file=source.php?../../../../../ffffllllaaaagggg(在某些解析场景下,?后的../可能被直接当作路径的一部分)。 - 尝试包含
php://filter读取源码(如果flag不在文件,而在PHP变量中):?file=source.php?/../../../../../var/www/html/index.php但这样包含的是PHP代码,会被执行。可以结合php://filter/convert.base64-encode/resource=来编码读取:?file=php://filter/convert.base64-encode/resource=source.php?/../../../../../var/www/html/index.php。注意:此Payload需要checkFile函数能通过php://filter/...的检查,在原题warmup中,因为检查in_array($page, $whitelist),php://filter显然不在白名单,所以此Payload无效。这里只是展示一种思路。
注意事项:在真实CTF或授权测试中,目录深度是未知的。通常的做法是使用足够多的
../(比如8个或10个)来确保能回溯到根目录。Linux系统的根目录/,在其之上使用../仍然会停留在/,所以多写几个是安全的。但在某些配置了open_basedir限制的PHP环境中,过多的../可能会导致路径被拒绝。
5. 漏洞挖掘与代码审计的深层思考
“warmup”题目虽然简单,但它揭示了文件包含漏洞,尤其是带校验的包含漏洞的审计关键点。当我们审计一个PHP应用时,如何系统地发现此类问题?
5.1 审计入口点
- 全局搜索包含函数:使用IDE或代码搜索工具,查找项目中所有的
include,require,include_once,require_once。 - 追踪参数来源:对于每一个包含函数,向上追踪其参数(即文件路径字符串)的来源。重点关注来自超全局变量的数据:
$_GET,$_POST,$_REQUEST,$_COOKIE,$_SERVER中的某些字段(如$_SERVER[‘PHP_SELF’],$_SERVER[‘REQUEST_URI’]有时也会被误用)。 - 分析过滤逻辑:检查对来源数据是否有过滤。过滤方式包括:
- 白名单:如本题,只允许包含特定文件。
- 黑名单:过滤
../,..\,php://,http://等危险字符串。 - 前缀/后缀拼接:例如
include ./uploads/’ . $_GET[‘file’] . ‘.jpg’;,将用户输入拼接在固定目录和扩展名中间。
5.2 分析校验与执行的分离
“warmup”的核心漏洞模式是校验与执行分离。审计时要特别注意:
- 是否存在一个“检查函数”(如
checkFile),它对输入进行了处理(如截取、解码、替换)并返回布尔值? - 调用该检查函数后,
include使用的是原始输入还是检查函数处理后的结果? - 检查函数对输入的处理逻辑,与
include函数(以及底层操作系统)对路径的解析逻辑,是否存在差异?这种差异就是绕过点。- 字符串处理 vs 路径解析:代码可能用
str_replace(‘..’, ”, $input)过滤..,但….//经过替换后可能又变回../。 - 解码时机差异:如
urldecode在检查时用了一次,但数据在进入include前是否又被解码了一次? - 截断符号理解差异:代码用
?、#、\0(NULL字节)作为截断标记进行检查,但include和文件系统是否以同样方式理解这些字符?
- 字符串处理 vs 路径解析:代码可能用
5.3 利用环境差异构造Payload
即使代码逻辑看起来严密,也要考虑运行环境:
- 操作系统差异:Windows路径分隔符是
\,且不区分大小写;Linux是/,区分大小写。过滤了../但没过滤..\可能在Windows上生效。 - Web服务器重写规则:Apache的
mod_rewrite、Nginx的try_files或rewrite规则可能会改变请求的最终路径,导致代码中的检查与实际包含的路径不一致。 - PHP配置:
magic_quotes_gpc(已废弃)、open_basedir、allow_url_include等设置会影响漏洞利用方式。
5.4 针对“warmup”类白名单的通用测试Payload库
在审计或测试时,可以准备一个Payload列表进行Fuzz测试:
file=source.php file=source.php? # 尝试问号 file=source.php?/ file=source.php?../ file=source.php?../../../../etc/passwd file=source.php?/../../../../etc/passwd file=source.php%3f../../../../etc/passwd # 问号URL编码 file=source.php%253f../../../../etc/passwd # 双重编码 file=source.php# # 尝试井号(通常无效) file=source.php/./././?/../../../../etc/passwd # 增加冗余路径 file=source.php/../../../etc/passwd?.jpg # 后缀截断尝试(需特定环境) file=....//....//....//....//etc/passwd # 点号变形,绕过简单替换 file=source.php/../../../etc/passwd\0 # NULL字节截断(PHP<5.3.4)将这些Payload通过Burp Suite的Intruder或自定义脚本进行批量测试,观察响应差异。
6. 防御策略与安全开发建议
理解了攻击,才能更好地防御。针对“warmup”所代表的这类文件包含漏洞,我们可以从多个层面进行加固。
6.1 代码层防御(治本之策)
- 绝对禁止用户输入直接控制包含路径:这是最根本的原则。如果业务必须动态包含,则应采用以下方式:
- 使用强白名单机制:白名单不应只做“开头匹配”,而应做全路径映射。
- 错误示例(易绕过):
if (strpos($input, ‘safe/’) === 0) { include $input; } - 正确示例:
这样,用户只能通过$whitelist = [ ‘page1’ => ‘./templates/page1.php’, ‘page2’ => ‘./templates/page2.php’, ]; $key = $_GET[‘page’]; if (array_key_exists($key, $whitelist)) { include $whitelist[$key]; // 包含的是预定义好的固定路径 } else { include ‘./templates/default.php’; }key来选择,而无法控制任何路径字符串。
- 错误示例(易绕过):
- 路径规范化与绝对路径:在包含前,对路径进行规范化处理,并转换为绝对路径,然后检查其是否在允许的目录内。
$baseDir = realpath(‘./allowed_directory/’); $userPath = realpath($baseDir . ‘/’ . $_GET[‘file’]); if ($userPath && strpos($userPath, $baseDir) === 0) { // 确保$userPath在$baseDir目录或其子目录下 include $userPath; } else { die(‘Invalid file path.’); }realpath()函数会解析..和符号链接,并返回绝对路径。再通过strpos检查是否在允许的基目录下。 - 避免使用
$_REQUEST:$_REQUEST同时包含了$_GET,$_POST,$_COOKIE,来源不可控且易混淆。明确使用$_GET或$_POST。
6.2 配置层防御(减少攻击面)
- PHP配置:
open_basedir:将PHP可访问的文件限制在指定的目录树中。即使存在包含漏洞,攻击者也无法跳出这个“牢笼”。allow_url_include:务必设置为Off(默认值),彻底关闭远程文件包含功能。disable_functions:可以考虑禁用一些危险函数,如pcntl_exec,proc_open,system等,但这对文件包含漏洞本身防御有限,主要防止包含后的代码执行。
- Web服务器配置:
- 为每个应用设置独立的运行用户和目录权限,遵循最小权限原则。
- 使用安全的文件上传目录,将其与可执行脚本目录分离,并配置为不可执行脚本。
- 系统层防御:
- 确保Web服务器进程对敏感系统文件(如
/etc/passwd,/etc/shadow, 应用配置文件等)只有最小读取权限,最好是无权限。
- 确保Web服务器进程对敏感系统文件(如
6.3 安全开发生命周期
- 代码审计:将安全代码审计作为开发流程的必要环节,重点关注用户输入点、文件操作、数据库查询、命令执行等高风险函数。
- 使用安全框架:现代PHP框架(如Laravel, Symfony)在路由、视图加载等方面有更安全的机制,能很大程度上避免手写代码导致的文件包含漏洞。
- 依赖库安全:定期更新框架和第三方库,修复已知安全漏洞。
- 渗透测试与漏洞扫描:在应用上线前和定期进行安全测试,主动发现潜在漏洞。
7. 从CTF到实战的思维延伸
“warmup”是一个高度简化的模型。真实世界的应用要复杂得多,但漏洞原理相通。在实战中,文件包含漏洞往往不会这么“赤裸裸”地出现,它可能隐藏在:
- 模板引擎的加载机制中:某些自定义或老旧模板引擎,如果允许用户控制模板文件名,可能造成文件包含。
- 插件/模块加载功能:通过参数动态加载插件文件。
- 本地文件缓存或代理功能:某些功能会根据URL参数去读取或缓存本地文件。
- 日志文件包含:如果包含漏洞存在,且攻击者能控制部分内容写入日志(如User-Agent),可以先将PHP代码写入日志,再包含该日志文件执行代码(需要日志文件可读且位于Web目录下)。
- 结合文件上传:这是非常经典的组合拳。先上传一个包含恶意代码的图片文件(利用文件上传漏洞),然后通过文件包含漏洞去包含这个图片文件,从而执行代码。即使图片后缀是
.jpg,只要文件内容以<?php ... ?>开头,PHP的include函数依然会尝试解析其中的PHP代码(需要服务器未配置过滤或解析错误)。
因此,在实战渗透测试中,发现文件包含漏洞的步骤通常是:信息收集 -> 发现疑似包含点(如?page=about) -> 测试路径遍历(../) -> 测试协议封装(php://filter,phar://,zip://等) -> 结合其他漏洞(如上传、SSRF)扩大战果 -> 获取Webshell或敏感信息。
“warmup”这道题就像一把钥匙,它打开的不是一道简单的门,而是通往Web安全中一个庞大而重要的知识领域。理解它,不仅是为了解一道题,更是为了培养那种在复杂代码和交互中寻找“差异”和“不一致”的安全嗅觉。每次遇到校验逻辑,都不妨多问一句:“这里检查的数据,和最终使用的数据,是完全一样的吗?在不同的上下文(代码层、Web服务器层、操作系统层)中,对同一个字符串的理解会一致吗?” 多问几个为什么,很多漏洞的绕过思路,就藏在答案里。