HCTF 2018 WarmUp:PHP代码审计与文件包含绕过实战分析
2026/9/9 19:37:07 网站建设 项目流程

[HCTF 2018]WarmUp 是我印象很深的一道 Web 热身题,它不考什么偏门漏洞,核心就落在 PHP 代码审计和文件包含这两件事上。很多新手打完这道题会觉得“哦,原来就是看源码、绕过滤、读文件”,但真到自己上手时,又常常卡在不知道从哪里开始读代码、不知道那些过滤为什么要这么绕。这篇文章我就把这题的完整思路从头捋一遍,包括信息收集、源码审计、过滤绕过、最终 payload 构造,以及我在实战中踩过的一些坑,希望能帮你把这类题目的底子打好。

1. 题目整体分析与环境探测

1.1 题目给我们的第一信号:页面源码和 source.php

拿到题目环境,第一眼看到的页面通常很简单,可能就一张图片、一行文字,甚至一个空白页。HCTF 2018 WarmUp 的入口页面也不例外,表面上什么都没有,但 F12 打开浏览器开发者工具,或者直接查看网页源代码,就能看到一段 PHP 代码的入口提示。

这道题考古起来,有一个非常经典的入口写法:页面源码里直接暴露了source.php这个文件,甚至会把一堆 PHP 代码直接渲染出来。我当时第一次看到这个页面时,第一反应是“这题是不是出错了?源码直接给我了?”后来发现这就是热身题的风格——先让你看到源码,然后看你能不能读明白。

所以我把信息收集的第一步定为:永远先看页面源码。不是看 HTML 结构,而是看有没有注释、隐藏链接、PHP 标签、JS 中夹带的文件路径。很多 Web 题的第一步都是通过源码泄露来给一个关键文件名,WarmUp 这个系列尤其明显。

1.2 信息收集的三个层次:可见源码、提示文件、响应头

拿到一个 Web 题,我习惯按三个层次去做信息收集:

  • 第一层:肉眼可见的内容。包括页面正文、页面源码中的注释、隐藏 input、<a>标签的 href。
  • 第二层:通过源码推导出的文件。比如source.phphint.phpflag.php,这类文件往往不会直接出现在页面正文,但源码里会有线索,或者你需要通过目录扫描、路径猜测去发现。
  • 第三层:HTTP 响应头、Cookie、JS 文件、请求参数中泄露的信息。有些题目会在响应头里塞 flag 的一部分,或者在 Cookie 中提示下一步路径,WarmUp 虽然没有这么花哨,但多检查一层总没错。

在这道题里,访问source.php之后,你会看到完整的源码,里面还有hint.php的引用。如果你按照提示去访问hint.php,就会得到关于 flag 文件名的提示。这一步的价值在于:它把你的攻击目标从“找 flag”变成了“读取某个具体文件”,大大降低了盲目性。

1.3 为什么“WarmUp”这类题目最喜欢藏源码

这里多说一句,为什么热身题都爱把源码放在source.php里?因为 Web 方向的新手最先要建立的思路就是“一切攻击都有迹可循”,而源码就是最大的痕迹。文件包含、SQL 注入、模板注入、反序列化,这些漏洞的利用大多建立在正确解析了服务端逻辑的前提下。源码能看到,就相当于把出题人的底牌亮给你了,剩下的就是拼你对 PHP 函数行为的理解。

所以遇到 WarmUp 这类题,别急着扫描目录、跑字典,先老老实实把源码读透。这一步做好了,后面的利用就是水到渠成。

2. PHP 代码审计与漏洞定位

2.1 审计入口:永远先看 include / require / file_put_contents

拿到 PHP 源码后,我的审计顺序是固定的:先找文件操作函数,再找参数来源,最后看过滤逻辑。因为在 CTF 的 PHP 题目里,includerequirefile_get_contentsfopen这类函数是最容易出问题的点,它们一旦参数可控,就可能导致文件读取、文件包含甚至 RCE。

WarmUp 这题的源码里,核心就是include $_REQUEST['file'];这一句。$_REQUEST意味着参数可以通过 GET、POST、Cookie 三种方式传递,而include直接用了这个参数,这是一个标准的文件包含漏洞点。但再往下看,它并没有直接让你随心所欲,而是经过了checkFile函数的检查。

2.2 核心源码拆解:checkFile 的检查逻辑

这里我把当年题目里流传最广的 checkFile 版本整理出来,细节上可能和原题容器有出入,但核心逻辑一致:

<?php show_source(__FILE__); class emmm { public static function checkFile(&$page) { $whitelist = ["source" => "source.php", "hint" => "hint.php"]; if (!isset($page) || !is_string($page)) { return false; } 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; } return false; } } if (!empty($_REQUEST['file']) && is_string($_REQUEST['file']) && emmm::checkFile($_REQUEST['file']) ) { include $_REQUEST['file']; } else { echo "no no no"; }

这个函数做了四步检查:

  1. 检查$page是否存在且为字符串,这个没什么好绕的,传字符串即可。
  2. 检查$page是否直接在白名单里。白名单只有source.phphint.php
  3. $page截取到第一个?之前,再看截取结果是否在白名单里。
  4. 先把$page做一次urldecode,再截取到第一个?之前,看是否在白名单里。

看到这里,你可能会想:这个白名单只有两个文件,想直接 includeflag.php肯定过不了第一步,那第二步和第三步有什么用?

用处就在?的截断逻辑上。

2.3 漏洞点确认:白名单绕过 + include 参数可控

关键在于第二步:mb_substr($page, 0, mb_strpos($page . '?', '?'))会把$page字符串从开头截到第一个?出现的位置。如果我用source.php?作为开头,那么截取到的结果就是source.php,它正好在白名单里,于是checkFile返回 true。

与此同时,include使用的是未经截断的原始$_REQUEST['file'],也就是source.php?后面还有一串东西。这就造成了检查内容和包含内容不一致:检查的是source.php,包含的是完整字符串。这种“审计函数和最终执行函数处理同一参数的方式不同”的漏洞,在代码审计里非常典型,叫“检查与使用不一致”(Trick in check vs use)。

确认了这个点,整个题目的核心思路就清晰了:我们只要构造一个以source.php?hint.php?开头、后面拼接目标文件路径的参数,就能通过白名单检查,同时让 include 去读取我们想要的文件。

3. 过滤绕过与利用原理解析

3.1 为什么需要 URL 编码和解码

有同学会问:既然source.php?/flag能过第二步检查,那直接传?file=source.php?/flag不就行了吗?

理论上可以,但实际做题时要注意服务器、中间件、PHP 版本对?的解析差异。有些情况下,?会被当成 URL 的查询字符串分隔符,导致后面的内容被截断;有时候又会被 PHP 的$_GET解析器提前处理。所以原题里更稳的做法是使用 URL 编码,用%3F表示?

看第四步检查就明白了:urldecode($page)之后会先把%3F解码成?,再做一次截断检查。所以我传source.php%3F/flag后,第四步检查截取出来的还是source.php,白名单照样通过。而include拿到参数后,如果 PHP 在文件包含解析路径时能正确处理%3F或者再次解码,就能把路径指向目标文件。

这就是为什么这道题在 payload 里经常能看到%3F或更复杂的双层编码%253F——为了绕过不同层的检查,同时保证 include 时得到我们想要的路径。

3.2 路径穿越:从白名单文件跳到任意文件

既然参数可控,下一步就是怎么从source.php?/xxx跳到根目录或上一级目录去读取 flag 文件。

假设hint.php告诉我们 flag 文件名是ffffllllaaaagggg,它在 Web 根目录或更高层目录下,那我们需要用相对路径去定位它。常见的做法是使用../一层一层往上跳,比如:

source.php?/../../../../../../ffffllllaaaagggg

这里source.php?相当于一个占位前缀,后面的../../../../../../ffffllllaaaagggg才是真正要读取的目标。路径穿越的层数取决于当前文件所在目录到目标文件的相对距离,通常从 3 层开始往上试,一直到能读到内容为止。

3.3 include 与相对路径的行为差异

这里有个技术细节要讲清楚:include在处理包含路径时,如果是相对路径,会结合当前工作目录(cwd)去定位。而在 Web 环境中,cwd 往往是入口脚本所在的目录。WarmUp 的入口就在根目录,flag 文件也在根目录附近,所以用../回到根目录就能命中。

如果你在本地复现或者做题时发现路径穿越层数不对,多半是因为你请求的 URL 有不同的路由层级,或者include之前已经被其他代码改变了 cwd。这种问题没有捷径,只能一个个层数去试,这也是后面我要讲“脚本化试错”的原因。

3.4 mb_substr 和 mb_strpos 的字符编码陷阱

这个版本用的是mb_开头的多字节字符串函数,为什么要用多字节版本?因为这些函数在处理中文字符、特殊字符时更安全,不会因为字节截断导致乱码或绕过。但反过来,mb_strpos的行为在 PHP 7 和 PHP 8 之间也有细微差异,如果某个 read flag 的 payload 在本地 PHP 8 上失败,但题目容器是 PHP 5/7,原理就可能是多字节函数的返回值不同。

遇到这类问题时,不要死磕一个 payload,换个编码方式、换个分隔符,往往就通了。?不行就试%3F,再不行就试%253F,本质都是利用截断逻辑的差异。

4. 实战:从构造 Payload 到拿到 Flag

4.1 先看 hint.php 再决定目标文件

我先访问了hint.php,页面里提示 flag 文件名是ffffllllaaaagggg,没有.php后缀。这个信息非常关键,说明我们不能用php://filter去读,也不该用常规的flag.php路径,而要把目标确定为ffffllllaaaagggg

有的版本里,flag 文件名可能是flag.php,也可能直接叫flag。但做题套路是一样的:先找到提示,再构造路径。

4.2 最终 Payload 的推导过程

基于前面分析,构造 payload 的思路是:

  • 前缀必须是白名单文件,我用source.php?
  • 拼接路径穿越,目标是ffffllllaaaagggg
  • 先用直接带?的形式试。

第一个尝试:

GET /?file=source.php?../../../../../../ffffllllaaaagggg HTTP/1.1 Host: target

结果页面空白。原因可能是路径穿越层数不对,或者?被中间件截断,导致 include 时只加载了source.php

然后我把?改成 URL 编码的%3F,并增加穿越层数:

GET /?file=source.php%3F../../../../../../../../../ffffllllaaaagggg HTTP/1.1 Host: target

这次页面返回了类似于 base64 或直接从文本文件输出的内容。为什么有效?因为%3F在第一次$_GET解析后可能还没有被还原为?,但在 `urldecode

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询