[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.php、hint.php、flag.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 题目里include、require、file_get_contents、fopen这类函数是最容易出问题的点它们一旦参数可控就可能导致文件读取、文件包含甚至 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; }这个函数做了四步检查检查$page是否存在且为字符串这个没什么好绕的传字符串即可。检查$page是否直接在白名单里。白名单只有source.php和hint.php。把$page截取到第一个?之前再看截取结果是否在白名单里。先把$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能过第二步检查那直接传?filesource.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 到拿到 Flag4.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 /?filesource.php?../../../../../../ffffllllaaaagggg HTTP/1.1 Host: target结果页面空白。原因可能是路径穿越层数不对或者?被中间件截断导致 include 时只加载了source.php。然后我把?改成 URL 编码的%3F并增加穿越层数GET /?filesource.php%3F../../../../../../../../../ffffllllaaaagggg HTTP/1.1 Host: target这次页面返回了类似于 base64 或直接从文本文件输出的内容。为什么有效因为%3F在第一次$_GET解析后可能还没有被还原为?但在 urldecode