墨者学院的“PHP代码分析溯源”系列,是不少做代码审计入门的师傅反复刷的题。第4题表面上和前面几题差不多,都是把源码丢给你,让你自己找到问题、打通利用链、把结果交上去。但如果你还按上一题的惯性,上来就搜eval、assert、shell_exec,大概率会卡在原地。这题真正想考察的是完整的分析能力:从入口点开始,顺着参数把整条调用链捋清楚,判断每个可疑点是否真的可控、真的能到达危险函数,最后再把漏洞变成可用的通道。所以这篇东西我不打算写“第4题的标准答案”,而是把一套能反复用的审计流程拆开讲,顺带说说我在墨者这类平台上踩过的坑。
1. 拿到题目包后,先搞清楚“溯源”二字指什么
1.1 两种常见的题目形态
先说一个很多人容易忽略的事:题目叫“代码分析溯源”,但不同考点的侧重点完全不一样。第一种是“给一段源码,分析出漏洞并利用”,重点是找可利用点,目标通常是拿到flag或者后台权限;第二种是“给你一个被入侵后的环境或日志,倒推攻击者做了什么”,这种更像真实应急响应,要你在代码里找后门、找webshell、甚至通过日志还原攻击链。墨者这套题从命名来看偏向前者,也就是“源码审计+利用”,但题面里可能还会藏着一条暗线,比如让你通过漏洞拿到数据库配置后,再去数据库里溯源某条操作记录。
所以我拿到题目后的第一件事不是打开代码,而是先把题目描述、提交格式、环境信息完全读一遍,把“解题目标”写下来。很多新手在这里栽过跟头。题目明明要求提交某个“溯源结果”,他却以为只要getshell就行,结果拿到shell也不清楚该去哪个目录找答案,白白浪费环境时间。溯源题还有一个特点:出题人会把真正要你找的“结论”藏在代码、配置、数据库或日志中的某一层,如果你只是验证了一个RCE就收工,往往会漏掉下一步。
1.2 本地环境准备:先能跑起来,再谈分析
解析这类题,我建议先搭一个本地调试环境,不要直接在题目网页上试来试去。在线环境有IP限制、并发干扰,payload太长很容易被影响,而且每个操作都会留下日志,你也不想让题目环境一团乱。
本地准备清单:
- PHP集成环境:phpstudy或者直接用Docker拉一个
php:7.4-apache镜像。版本尽量和题目环境一致。墨者这类老题目,很多源码是PHP 5.x/7.x时代写的,你拿PHP 8跑,可能因为函数移除直接跑不起来,那分析就无从谈起。 - 代码编辑器:VSCode或PhpStorm。我习惯用PhpStorm,因为它的
Find Usages和Go to Declaration在追调用链时非常有用,能直接看一个函数在哪些地方被调用,双向跳转很顺手。 - 抓包工具:Burp Suite,用来改请求、看响应差异、对比各种payload的输出。
- 数据库环境:如果源码里带了SQL文件,本地建好库和表,很多逻辑需要真实数据才能走到。
环境装好后,第一步是访问一下首页,确认能不能正常打开。如果打开就报错或者白屏,优先看config.php里的数据库配置、PHP版本、路径设置,别急着审代码。跑不起来的代码,很多逻辑你是看不全的。常见的坑是数据库密码错误、时区配置导致PHP 8直接报警告,以及缺少某个扩展,比如mysqli、gd、curl。这类问题在本地版本和题目版本不一致时特别容易出现。
2. 整体梳理:把审计变成一次有目的的排查
2.1 先画文件地图,再读业务
拿到源码后,不要一头扎进去逐行读。我习惯先做一张文件地图:目录里有哪些文件,哪些是入口,哪些是公共引用,哪些是配置文件。用命令列一下:
find . -type f -name "*.php" | xargs wc -l | sort -rn | head -30这个命令能很快告诉你哪些文件代码量最大。真正复杂的业务逻辑通常集中在少数几个文件里,而入口文件一般都很短。比如典型的MVC结构,入口index.php可能只有十几行,业务逻辑都在controllers和models目录下;传统小应用则相反,可能十几个页面文件各管一段,公共函数都塞在common.php里。
文件地图画完之后,优先级就很清楚:
- 配置文件:看是否存在硬编码、可写风险、数据库口令;
- 入口与路由:看哪些页面是无条件加载的,哪些是要登录的;
- 公共函数文件:看过滤函数和权限校验都做了什么;
- 处理上传、下载、搜索、登录等功能的业务文件:这些是漏洞高发地。
在墨者这类溯源题里,有时还会故意在某个目录里放一个不起眼的文件,比如include/conn.php、upload/shell.php。它们往往不是正常业务的一部分,文件名也能看出端倪。画地图时,如果发现某个文件路径和整体结构格格不入,请单独标记,这很可能就是出题人留下的“题眼”。
2.2 全局搜索的顺序与优先级
有了地图之后,再做一次定向搜索。搜索不是乱搜,顺序很重要。我一般分成三轮。
第一轮,搜“危险终点”,也就是直接执行代码、命令、查询数据库、读取文件的函数:
grep -rn -E "eval|assert|system|exec|shell_exec|passthru|popen|proc_open|pcntl_exec|unserialize|include|require|preg_replace|create_function|call_user_func" --include="*.php" .第二轮,搜“输入起点”:
grep -rn -E '\$_GET|\$_POST|\$_REQUEST|\$_COOKIE|\$_FILES|getParameter|getHeader' --include="*.php" .第三轮,把这两份结果交叉比对,重点看“输入起点出现在危险函数所在文件的上一行或同一函数体内”的情况。这个交集越小,题目往往越简单;交集很大,说明这个应用对用户输入做了很多处理,你需要继续往下钻。
这里插一句工具问题。你可能听说过Seay源代码审计系统、RIPS、Fortify等自动化工具,它们确实能扫出大量疑似点。但CTF题目总会故意设置一些绕开简单正则的写法,比如函数名拼接、用变量动态调用。所以我只把工具结果当线索,真正判断还得靠人。手动审计的核心能力是理解上下文,工具替代不了。
2.3 容易被忽略的高价值位置
有几个位置,我做题时一定会额外花时间看,因为它们经常是出题人埋点的首选:
- 上传点与包含点的组合。如果某个文件上传只校验了MIME或后缀,再把上传文件路径交给include,那基本就是一条完整的利用链。
- 日志写入与日志包含。很多小应用会把IP、UA、错误信息写入日志文件,如果日志路径可控、内容部分可控,配合文件包含就能执行代码。
- 配置文件中的
parse_str和extract。这两个函数能把数组展开成变量,如果参数可控,就可能造成变量覆盖,进而覆盖掉权限判断、跳转地址等关键值。 - 下载或预览功能里的路径处理。
download.php?file=xx直接拼文件路径,是文件读取/包含漏洞的重灾区。 - 序列化数据落地。比如把用户提交的序列化字符串直接存库或直接
unserialize,也是常见考点。
我把它记为“上传、下载、日志、配置、序列化”五类位置。每拿到一个新项目,我会先把这五类位置全部标出,再结合输入起点逐一定级。碰到可疑点,先别急着试,把上下文代码完整复制到一个临时文件里,把用户可控的变量标出来,再判断能不能走到危险函数。
3. 核心高危点的排查思路
3.1 文件包含类:参数能不能原样到达include
文件包含的原理不展开说了,关键要看一步:include或require的参数是否来自用户输入,以及到达之前经过什么处理。
我以一个类似常见小应用的代码片段举例:
<?php if (isset($_GET['page'])) { $page = $_GET['page']; include($page . '.php'); } ?>如果看到这种写法,第一反应就是这里存在本地文件包含,而且下面有开发者以为“很安全”的处理——强制加上了.php后缀。那我们就得考虑绕过方式:
- 用
php://filter伪协议读取源码,比如php://filter/convert.base64-encode/resource=config,注意resource=config不带.php后缀,在某些拼接场景下正好能用; - 用绝对路径尝试包含系统文件,拼接
.php后大多不成立,但别急着否定,有些环境对文件名处理很粗糙; - 如果
allow_url_include=On,可以尝试data://协议,把base64编码的PHP代码传进去直接执行。
实际审计中,一旦看到include的文件名可控,先看这一行前后的上下文,比联想N种绕过姿势更有效。比如有些地方在include之前会写$page = str_replace('../', '', $page),但可能没过滤绝对路径;有些地方会拼.php,那我们就可以用php://filter读取同目录下的源码,或者用data://协议传入含量接执行,前提是PHP配置允许。
这类题的动态验证也很有讲究。你可以先用一个不存在的页面名访问,看报错信息里是否完整暴露了拼接后的路径。如果能看到,基本就能确认前面没有加后缀、没有加目录前缀,漏洞条件非常干净。如果页面会把文件内容原样输出,那大概率还存在文件读取漏洞,可以直接读源码。
在溯源题里,文件包含的价值不只是RCE,它还能帮你读取config.php这类敏感文件,拿到数据库口令后再去访问后台甚至数据库,这样整个利用链就完整了。所以排查文件包含时,既要评估“能否命令执行”,也要评估“能读到什么敏感信息”。
3.2 SQL注入类:看拼接、转义和输出点
SQL注入在溯源题里非常常见。审计时,先搜SQL语句的位置,再看参数是怎么进去的。我一般把SQL注入分成两类来看:一类是数字型和字符串拼接型,一类是经过弱过滤的二次注入。
对于最简单的拼接型,代码大概长这样:
$sql = "SELECT * FROM users WHERE id=" . $_GET['id'];这基本能直接注入。如果是字符串型,开发可能写了addslashes或mysql_real_escape_string,那就要看有没有宽字节注入的可能,以及数据库连接字符集是否是GBK等。现在很多题目环境都是PHP 7+,宽字节注入的场景比以前少了,但也要注意。
更隐蔽的是“过滤后的二次注入”。比如一个函数先把单引号替换成\',但只执行了一次,之后这个字符串又被拼进后续SQL查询,就可能出现转义被消费掉的情况。我在墨者这类题目上见过不少类似的设置:先存储,后拼接,结果存储层的转义在拼接层被绕过了。
另外,判断注入不需要一上来就上sqlmap。先手动确认:
- 输入一个引号,看是否报错;
- 输入
1 and 1=1与1 and 1=2,看返回差异; - 看页面是否有输出点,比如查询结果会显示用户名,那就可以考虑联合查询;
- 没有输出点就用报错注入或盲注。
溯源题有一个细节值得注意:题目可能不是让你直接读出数据,而是让你通过SQL注入把恶意PHP代码写入数据库,再通过某个文件包含把数据库内容执行成代码。这种情况我在审计时一定会把“存在SQL写入点”和“存在数据库内容回显或包含点”连起来看,因为这是一套完整的利用链。你在代码里看到INSERT或UPDATE语句时,别光想着读数据,也想想这个数据之后去了哪里。
3.3 命令执行与代码执行:最直接的入口
命令执行类漏洞审计起来最省事,但也是最容易看走眼的。危险函数就那么几个,只要用户参数能原样到达,基本就稳了。
举个例子,很多小工具都有“ping检测”功能:
$ip = $_POST['ip']; system("ping -c 3 " . $ip);这里就能直接拼命令。审计时要重点注意开发人员做的过滤,比如只过滤了;和|,但没过滤$()、反引号、换行符;比如只过滤了空格,但可以用${IFS}绕过;再比如用正则会过滤cat、ls等关键字,那可以用c""at、c\at、编码等方式绕过。这些都是老生常谈,但在实际做审计时,我会把这些过滤函数从头到尾读一遍,看它到底把哪些字符列入黑名单,哪些字符漏掉了。
代码执行类则要格外关注几个函数和写法:
eval和assert:如果参数来自用户输入,直接就是RCE;preg_replace的/e修饰符:PHP 7以下可以用,替换内容会执行;create_function:通过字符串创建匿名函数,参数可控时等同于代码执行;call_user_func、call_user_func_array:第一个参数是回调函数名,如果可控,可以直接调用任意函数,比如call_user_func('system', 'whoami');- 变量函数:比如
$action = $_GET['a']; $action();,这也是一种隐性的代码执行。
在做题时,一旦命中了这些函数,我建议立即在PhpStorm中打开该文件,然后Find Usages看这个函数是从哪调进来的。如果是从表单、URL参数直接进来,那么下一步就直接构造最简单的payload验证,不要再继续往后审其他文件。因为这是性价比最高的路径。
3.4 反序列化与变量覆盖:绕远路但经常是正解
墨者这类题目中,反序列化是大概率出现的考法,因为PHP序列化漏洞利用讲究链条和魔术方法,比单纯的危险函数更有“分析”的味道,也契合“代码分析溯源”这个标题。
先看一个最基础的触发点:
$data = $_COOKIE['user']; $user = unserialize(base64_decode($data));如果项目里存在一个__destruct或__wakeup方法,并且里面调用了可执行方法,我们就可以通过构造序列化对象来触发。这个链条就叫POP链,也就是面向属性编程。它不要求一个函数同时包含危险函数和用户输入,只要串联起多个类即可。
审计反序列化的时候,真正难的不是写poc,而是确定每个魔术方法的调用条件和属性类型。我一般按以下顺序做:
- 全局搜魔术方法:
__destruct、__wakeup、__toString、__call; - 在方法体里找危险动作:文件操作、命令执行、调用外部方法;
- 从触发点反向推:我的输入怎么变成目标类的属性;
- 写一个本地的生成器脚本,把对象序列化后放进输入点测试。
变量覆盖也类似。extract($_POST)在早年CMS里非常流行。比如下面这种写法:
extract($_POST); if ($is_admin) { echo "welcome"; }如果变量$is_admin原本是false,但通过POST提交is_admin=1,直接就被覆盖成1,权限判断形同虚设。parse_str也类似,它会把字符串解析成变量,如果输入可控,同样可以覆盖关键变量。
这类漏洞的排查技巧:在代码里搜$_GET、$_POST、$_REQUEST之后,重点找有没有一个extract或parse_str包裹这些超全局变量。如果看到extract($_POST),直接把它标记为“高优先级的变量覆盖点”,再往下看哪里用到了可能被覆盖的变量。
4. 动态验证与一条完整利用链的落地
4.1 静态分析之后,必须做动态验证
静态审计得出的结论只能算“疑似”,能不能用,还得看动态表现。我一般拿到题目环境后不会立刻上大招,而是先做几个温和的验证请求。
比如,假设我在本地分析时发现某个参数进入了一个文件包含点,我会这么做:
- 先正常访问一次,记录正常返回内容;
- 再访问一个确定不存在的文件名,观察报错和响应差异;
- 再用
php://filter读取当前文件自身,看是否能拿到base64编码源码; - 如果读不到,考虑路径是否要加前缀、有没有后缀、是否过滤伪协议。
这四步走完,基本就能确认这个点能不能读文件、能不能包含远程。整个过程不要依赖自动化工具,Burp的重放功能就够了。我特别喜欢把同一请求的响应放到一个临时笔记里对比,肉眼判断比看工具输出快得多。
在做动态验证之前,要确认本地PHP版本和题目环境一致。如果条件不允许,至少要确认几个关键配置:allow_url_include、display_errors、magic_quotes_gpc。这些配置直接决定了payload能不能通。拿allow_url_include举例,很多早期的PHP文件包含RCE需要远程包含,如果环境是默认配置Off,那你就只能走php://input或者本地文件包含加日志投毒。
4.2 从回显差异判断漏洞类型
做题时,payload发过去之后,要学会看回显。我总结了一批典型的回显特征,遇到这些情况几乎可以直接对号入座:
- 页面出现完整的PHP报错信息,比如
Warning: include(foo.php): failed to open stream:,说明文件包含点基本确认; - 页面出现
Fatal error: Call to undefined function或class not found,说明代码执行类函数被触发了; - 页面输出数据库查询的错误信息,比如
You have an error in your SQL syntax,说明SQL拼接点存在; - 页面空白或500,但响应体长度发生了变化,多半是代码走到了某个分支,需要结合本地代码继续分析;
- 回显完全一样,那可能需要转盲注或盲打,比如时间盲注、外带请求。
看回显是动态验证的核心能力。很多人老是问“为什么我的payload没反应”,其实多半不是payload写错了,而是根本没看懂前后两个响应的差异。我一般先点开对比工具,左边是正常请求,右边是测试请求,先把响应头、状态码、长度都过一遍,再去看正文。有些时候,题目环境出于防护会把错误显示关掉,但状态码和长度会变,这本身就能给你线索。
以命令执行举例,如果你怀疑一个system函数能打,先别急着反弹shell。先用最无害的whoami测试,看回显是否出现当前用户;如果没回显,可以用ping配合时间差来确认,比如ping -c 5 127.0.0.1,再把-c 5改成-c 10,观察响应时间差异。确认命令能执行之后,再考虑写文件、读取flag或者进一步收集信息。我个人的习惯是,能读文件就先读文件,尽量避免反弹shell,省得被平台盯着。
4.3 拿到权限以后的“溯源”动作
回到“溯源”两个字。很多题目拿到代码执行权限并不代表结束,它还要你继续在系统里找到“攻击源头”。比如,源码里可能有一个后台管理员登录功能,你通过SQL注入读取到管理员表里的用户名和密码哈希,然后需要在后台进一步操作,或者找到一条日志记录,里面藏着题目要求的flag。
我自己的经验是把这类题当成一次“轻量级应急响应”来做:
- 拿到shell或代码执行后,先看当前目录和Web根目录,列目录看有没有特殊文件;
- 看数据库配置,找连接账号和库名,尝试连接数据库;
- 看日志目录,找访问日志或者错误日志,溯源攻击路径;
- 全局搜文件名疑似flag的文件,比如
flag.txt、key.php、隐藏目录里的文件。
记住,溯源题的最终目标不一定是权限,而是“结论”。可能是某个IP、某个参数、某个文件名,甚至是一串被加密的数据。所以拿到权限后的每一步操作都要有目的,不要漫无目的地翻目录。翻遍整个服务器找flag是最低效的方式。你要学会从题目描述里推断它需要什么,再按图索骥。
5. 常见问题与避坑实录
5.1 卡点速查表
把我在做这类题时最容易卡住的现象整理成了表格,供你排查时快速对号入座:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 本地能跑,题目环境白屏 | PHP版本不同、函数被禁用、路径大小写问题 | 查看题目环境响应头、报错输出,对比本地配置 |
| 文件包含点读不了文件 | 被添加了后缀、过滤了../、伪协议被禁 | 尝试绝对路径、php://filter、data://,看报错信息 |
include报错但不显示内容 | display_errors关闭 | 故意访问一个不存在文件,观察状态码和长度变化 |
| SQL语句报错但返回参数被过滤 | 过滤了关键字未过滤编码 | 尝试URL编码、内联注释、大小写变形 |
| 命令执行无回显 | 输出被丢弃或函数改为shell_exec | 使用时间差验证,先别上反弹shell |
preg_replace利用失败 | PHP版本过高,e修饰符已移除 | 换思路,查看是否存在assert或create_function |
| 反序列化poc不生效 | 魔术方法没有触发、属性类型不匹配 | 本地用同版本PHP生成序列化数据,逐段调试 |
这张表不是标准答案,但它覆盖了初学者80%以上的卡点。如果你卡住了,先别急着改payload,先回到代码里确认环境特性。环境搞不对,payload写得再漂亮也白搭。
5.2 在在线平台上做审计的独家经验
最后分享一些线上线下差异相关的经验。墨者学院这类靶场平台,和本地审代码最大的区别在于:环境是共享的、不稳定的、有时间限制的。我在上面做过几次题,积累了几个比较实用的习惯:
- 珍惜“第一次访问”。有些题目环境会记录你的整个操作过程,如果你上来就对着题目网站乱试,刷出大量异常流量,反而会让自己后面利用时被误导。先想清楚payload,再一次性发出去。
- 优先看题目页面的源码注释和响应头。有时出题人会在HTML注释里留下提示,比如“过滤了单引号”、“flag在数据库”之类的信息,比你自己瞎猜高效得多。
- 如果题目环境比较卡,减少重复请求。很多验证没必要在线做,先本地把payload调试好,再放到题目环境一次通过。
- 善用本地的差异比对。同一个payload在本地和在线都通,说明环境一致度很高;只通一个,就要马上检查PHP版本和配置差异。
- 写完利用链后,建议截图和记录请求包。这类平台有时候会重置环境,你辛苦试出来的路径,下一轮可能要重新走。有记录能极大加快复现速度。
5.3 一个我常用的“溯源”思维模型
做多了这类题之后,我发现“溯源”其实可以抽象成一个反向的调用链图。正常代码是“输入 -> 处理 -> 输出”,攻击则是“危险点 -> 往回找触发 -> 再往回找输入”。所以我建议你在纸上画三列:第一列是可控输入,第二列是处理函数和过滤函数,第三列是危险终点。然后用线把它们连起来,能连成一条完整路径的,就是解题入口。
这个图不一定要画在纸上,可以是一个简单的文本笔记。比如:
$_GET['id'] -> intval? -> SQL拼接 -> 报错注入 -> 读管理员密码 $_COOKIE['data'] -> base64_decode -> unserialize -> __destruct -> eval一开始手可能生,画过三道题之后就自然形成条件反射了。我强烈建议你在做墨者第4题之前,先拿前几题练一练这个思维模型。本身这类题就是练审计手感最好的载体,题目短、目的明确、反馈也快。
在第4题上,我一开始也犯了“只盯着危险函数”的毛病,浪费了不少时间。后来想明白一个事:代码分析溯源的关键不是“找到eval”,而是“理解整条逻辑链”。你把一条攻击路径从输入参数一路追到执行结果,把过程中每一个过滤、跳转、拼接都搞清楚,答案自己就浮出来了。最后再分享一个很小的技巧:看到任何可疑参数,先问自己一个问题——“它最远能走到哪一步?”,顺着这个问题追下去,基本不会迷路。希望这篇东西能帮你少走一点弯路,有更好的思路也欢迎交流。