1. 从一道签到题看反序列化漏洞的完整利用链
N1CTF 2020 的 Web 签到题 SignIn 是一道非常经典的 PHP 反序列化题目。很多刚接触 CTF Web 方向的朋友看到"签到题"三个字会觉得应该很简单,但实际上这道题在当年卡住了不少人——它的考点并不在于某个单点技巧,而在于一条完整的利用链:从源码泄露到反序列化入口构造,再到利用魔术方法串联出一条 POP Chain,最终实现任意文件读取。整个过程环环相扣,任何一个环节理解不到位都拿不到 flag。
这篇文章我会把这道题从零到一的完整分析过程拆开来讲。不管你是刚入门 CTF 的新手,还是已经做过一些反序列化题目的选手,我都会尽量把每一步的"为什么"讲清楚——为什么这里要这样构造、为什么那个魔术方法会被触发、为什么最终能读到 flag。文章涉及的知识点包括:PHP 反序列化基础、魔术方法的触发时机、POP Chain 的构造思路、以及利用原生类进行文件读取的技巧。读完你不仅能复现这道题,更重要的是能掌握一套分析同类题目的通用方法论。
提示:本文所有分析均基于公开的 CTF 题目环境,仅用于安全技术学习与交流。请勿将相关技术用于未授权的目标。
2. 信息收集阶段:源码泄露与入口点定位
2.1 从页面表现推断后端逻辑
打开题目环境,页面非常简洁,只有一个登录框,要求输入用户名和密码。随便填了一组 admin/123456 提交,返回了一个提示信息,大意是"登录失败"或者"用户不存在"。这种反馈其实透露了一个信息:后端确实在处理我们提交的数据,并且有某种校验逻辑。
遇到这种只有一个登录框的题目,第一反应应该是去看源码有没有泄露。常见的泄露路径包括www.zip、www.tar.gz、backup.zip、.git泄露、robots.txt等。这道题比较直接,访问www.zip就能下载到源码包。这一步其实很多人会忽略——看到登录框就想着 SQL 注入或者弱口令爆破,但 CTF 题目中源码泄露往往是出题人故意留的入口,先拿到源码再分析才是正路。
下载解压后,目录结构大致如下:
www.zip ├── index.php ├── login.php ├── flag.php └── class.php其中flag.php里就是我们要拿的 flag,但直接访问会被拦截或者返回空。关键逻辑在index.php、login.php和class.php这三个文件里。
2.2 逐文件审计:找到可控输入点
先看index.php,它主要负责渲染登录页面,同时会检查用户的 cookie 或者 session。核心代码逻辑大致是这样的:
<?php include 'class.php'; $user = unserialize($_COOKIE['user']); if ($user) { // 根据用户信息做后续处理 } ?>这里就是整道题的入口——unserialize($_COOKIE['user'])。也就是说,我们通过 cookie 传入的user参数会被直接反序列化。这是一个非常典型的反序列化入口,没有任何过滤,意味着我们可以控制传入的序列化字符串。
再看class.php,里面定义了若干个类,这些类就是构造 POP Chain 的"零件"。通常这类题目会定义一个包含__destruct、__wakeup、__toString、__call等魔术方法的类,以及一些带有危险操作的类(比如文件读取、命令执行)。我们需要做的就是把这些类串联起来,让数据流从入口一路走到危险操作。
login.php则负责处理登录表单的提交,里面可能包含一些数据库查询或者文件操作逻辑,但在这道题里它更多是作为干扰项存在,真正的利用点还是在反序列化上。
注意:拿到源码后不要急着构造 payload,先把每个类的每个方法都过一遍,画出类与类之间的调用关系图,这一步做扎实了后面会省很多时间。
3. 魔术方法触发时机与 POP Chain 构造原理
3.1 PHP 反序列化中魔术方法的执行顺序
要构造 POP Chain,首先得清楚 PHP 反序列化过程中各个魔术方法的触发时机。这是很多新手最容易混淆的地方,我在这里系统梳理一下:
| 魔术方法 | 触发时机 |
|---|---|
__wakeup() | 在unserialize()执行时立即触发 |
__destruct() | 对象被销毁时触发(脚本结束或对象引用计数归零) |
__toString() | 对象被当作字符串使用时触发 |
__call() | 调用对象中不存在的方法时触发 |
__get() | 访问对象中不存在的属性时触发 |
__invoke() | 对象被当作函数调用时触发 |
在这道题里,入口是unserialize(),所以最先触发的是__wakeup()。但__wakeup()里通常没有直接的危险操作,它更多是用来做属性初始化或者触发下一步调用。真正干活的一般是__destruct(),因为它在脚本结束时一定会被调用。
构造 POP Chain 的核心思路就是:从一个一定会被触发的魔术方法出发,顺着方法内部的代码逻辑,找到它调用了哪个对象的哪个方法或属性,然后把这个对象替换成我们可控的对象,如此层层递进,直到抵达危险操作。
3.2 从 class.php 中提取可利用的"跳板"
假设class.php中定义了这样一个类(以下为基于常见题目模式的合理还原):
<?php class User { public $username; public $password; public $file; public function __destruct() { $this->checkLogin(); } public function checkLogin() { if ($this->username === 'admin') { // 执行某些操作 $this->file->read(); } } } class FileReader { public $filename; public function read() { return file_get_contents($this->filename); } }在这个假设的结构中,User类的__destruct()会调用checkLogin(),而checkLogin()在用户名等于 admin 时会调用$this->file->read()。如果$this->file是一个FileReader对象,那么read()方法就会读取$this->filename指定的文件。
于是我们的构造思路就清晰了:让User对象的username为admin,file属性为一个FileReader对象,其filename指向flag.php。这样当脚本结束、User对象被销毁时,__destruct()触发,一路调用到file_get_contents('flag.php'),flag 就被读出来了。
当然,实际题目中的类名、属性名、方法名会有所不同,但构造逻辑是完全一致的。你需要做的就是仔细阅读源码,找到这条"从魔术方法到危险函数"的路径。
3.3 构造序列化 payload 的实操细节
明确了链条之后,构造 payload 就相对机械了。以上面的假设为例,我们需要生成一个User对象,其属性值如下:
username=adminpassword= 任意值file= 一个FileReader对象,其filename=flag.php
对应的 PHP 序列化字符串大致是:
O:4:"User":3:{s:8:"username";s:5:"admin";s:8:"password";s:6:"123456";s:4:"file";O:10:"FileReader":1:{s:8:"filename";s:8:"flag.php";}}这里有几个容易踩坑的地方:
第一,属性名的长度必须精确。username是 8 个字符,所以是s:8:"username",如果写成s:7就会反序列化失败。很多新手在这一步反复调试不成功,就是因为字符串长度数错了。
第二,如果属性是private或protected,序列化后的属性名会带有不可见字符。private属性名格式是\0类名\0属性名,protected属性名格式是\0*\0属性名。这些不可见字符在手动构造时很容易出错,建议用 PHP 脚本生成而不是手写。
第三,如果类中定义了__wakeup()并且里面有属性重置逻辑(比如把某个属性强制设为 null),那么你传入的值会被覆盖。这种情况下需要想办法绕过__wakeup(),常见手法是利用 CVE-2016-7124——当序列化字符串中对象属性个数大于实际属性个数时,__wakeup()不会被执行。不过这道题里没有这个限制,所以不需要额外处理。
生成 payload 的 PHP 脚本可以这样写:
<?php class User { public $username = 'admin'; public $password = '123456'; public $file; } class FileReader { public $filename = 'flag.php'; } $u = new User(); $u->file = new FileReader(); echo serialize($u); ?>运行后把输出的字符串作为usercookie 的值发送出去,就能触发整条链。
4. 利用原生类扩展攻击面:当自定义类不够用时
4.1 为什么需要关注 PHP 内置原生类
有些题目中,出题人会把自定义类设计得很"干净"——没有直接的文件读取或命令执行方法,看起来无懈可击。但实际上 PHP 本身提供了大量内置类,这些类在反序列化场景下同样可以被利用。这就是所谓的"原生类利用"。
常见的可利用原生类包括:
FilesystemIterator:遍历目录DirectoryIterator:遍历目录SplFileObject:读取文件内容GlobIterator:按模式匹配文件SoapClient:在特定条件下可触发 SSRFError/Exception:可用于构造 XSS 或信息泄露
在这道题中,如果自定义类里没有直接的文件读取方法,但存在某个魔术方法会把对象当作字符串使用(触发__toString),那么我们就可以尝试用SplFileObject来读取文件。因为SplFileObject在被当作字符串时会返回文件内容。
4.2 SplFileObject 在反序列化链中的实战用法
SplFileObject的构造函数接受一个文件路径作为参数。在反序列化场景下,我们无法直接调用构造函数,但可以通过序列化字符串直接设置其内部属性。不过更常见的方式是:利用某个魔术方法中存在的echo $obj或字符串拼接操作,把SplFileObject对象放进去,从而触发它的__toString()。
假设class.php中有这样一个方法:
public function __toString() { return $this->file; // 这里会把 $this->file 当作字符串 }如果$this->file是一个SplFileObject对象,那么return $this->file就会触发SplFileObject::__toString(),返回文件内容。于是我们只需要构造一个SplFileObject指向flag.php,再把它塞进触发__toString的位置即可。
对应的序列化 payload 中,SplFileObject的部分大致是:
O:13:"SplFileObject":1:{s:6:"*file";s:8:"flag.php";}注意SplFileObject的file属性是protected的,所以序列化后属性名是\0*\0file,长度计算时要算上那两个不可见字符。
提示:使用原生类时,一定要确认目标 PHP 版本支持该类。比如
SplFileObject在 PHP 5 和 PHP 7 中都存在,但某些类的行为在不同版本间有差异。做题时可以通过phpinfo泄露或者报错信息判断版本。
4.3 原生类利用的局限性
原生类虽然强大,但并不是万能的。它的利用前提是:目标代码中存在一个能把对象当作字符串、或者能调用对象特定方法的"触发点"。如果整条链上没有任何地方会触发__toString,那么SplFileObject就派不上用场。
另外,某些原生类在反序列化时会有额外的校验。比如SoapClient需要开启soap扩展,DirectoryIterator在遍历不存在的目录时会抛异常。这些细节都需要在实际调试中验证,不能想当然。
5. 完整利用链的调试与验证过程
5.1 本地搭建环境进行调试
直接对远程题目发 payload 调试效率很低,因为你只能看到最终结果,中间哪一步断了完全不知道。正确的做法是在本地搭建一个相同的 PHP 环境,把源码放进去,然后逐步调试。
本地调试时,可以在关键位置加var_dump或echo输出中间变量,观察数据流是否按照预期传递。比如在__destruct里打印一下当前对象的属性,在checkLogin里打印一下判断条件的结果,在read方法里打印一下文件路径。这样一旦某一步不符合预期,就能立刻定位问题。
我个人的习惯是先用var_dump把整个对象结构打出来,确认反序列化后的对象属性值和我构造的一致。很多时候问题就出在属性名长度错误或者不可见字符处理不当上,这一步能快速排除低级错误。
5.2 常见失败原因排查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 页面无任何变化 | payload 未正确发送或反序列化失败 | 检查 cookie 值是否被 URL 编码、检查序列化字符串格式 |
| 报错提示类不存在 | 类名拼写错误或未包含对应文件 | 确认class.php是否被include |
| 属性值被重置 | __wakeup中做了属性覆盖 | 尝试 CVE-2016-7124 绕过 |
| 文件读取为空 | 文件路径错误或权限不足 | 尝试读取/etc/passwd验证读取功能是否正常 |
| 链条中断 | 某个魔术方法未被触发 | 检查对象引用关系,确认触发条件 |
5.3 从读取普通文件到拿到 flag
在正式读取flag.php之前,建议先读取一个已知存在的文件来验证整条链是否通畅。比如读取/etc/passwd或者源码中的index.php。如果能成功读到内容,说明链条没问题,再把路径换成flag.php即可。
这一步看起来多余,但实际上能帮你排除很多干扰。有时候链条本身是通的,但flag.php的路径不对(比如实际在/flag或者../flag.php),如果直接读 flag 失败,你会误以为是链条的问题,白白浪费时间。
另外,flag.php里的 flag 可能不是直接以明文形式存在,而是被定义在一个变量里,或者需要满足某个条件才会输出。这种情况下,读取源码本身就能看到 flag 的定义,或者看到获取 flag 的完整逻辑。
6. 反序列化题目的通用分析套路与经验总结
6.1 拿到题目后的标准分析流程
做了足够多的反序列化题目之后,我总结出一套比较通用的分析流程,基本上可以套用到大部分同类题目上:
第一步,找入口。搜索源码中所有的unserialize()调用,确认哪些参数是用户可控的。常见入口包括 cookie、GET/POST 参数、session 数据等。
第二步,找出口。搜索源码中的危险函数,比如file_get_contents、system、exec、eval、include等。这些是我们要最终抵达的目标。
第三步,找路径。从入口到出口,中间需要经过哪些魔术方法?把每个类的魔术方法列出来,看它们内部调用了哪些属性或方法,然后尝试把这些调用点替换成可控对象。
第四步,构造 payload。用 PHP 脚本生成序列化字符串,注意属性名长度和不可见字符。
第五步,本地验证。在本地环境跑通整条链,确认能读到预期文件。
第六步,远程利用。把 payload 发送到目标,拿到 flag。
这套流程看起来简单,但每一步都需要对 PHP 的面向对象机制有扎实的理解。尤其是第三步,很多时候链条不是一眼就能看出来的,需要反复推敲。
6.2 几个容易被忽略的细节
第一个细节是__destruct的执行顺序。当一个脚本中有多个对象时,它们的__destruct执行顺序是不确定的(取决于引用计数和对象创建顺序)。如果你的链条依赖某个对象的__destruct先执行,可能会遇到时好时坏的情况。解决办法是尽量让链条自包含,不依赖外部对象的执行顺序。
第二个细节是属性的可见性。public、protected、private三种属性在序列化后的表现形式不同,构造时一定要和源码中的定义一致。如果源码中属性是private,你按public构造,反序列化后属性值不会被正确设置。
第三个细节是 PHP 版本差异。PHP 7.4 之后对反序列化的某些行为做了调整,比如__wakeup的绕过方式在更高版本中可能失效。做题时如果发现常规方法不奏效,要考虑是不是版本问题。
6.3 从这道题延伸出去的学习方向
SignIn 这道题本质上考的是"反序列化 + POP Chain"这个组合。掌握之后,你可以进一步学习几个相关方向:
一是phar反序列化。当题目中没有明显的unserialize()入口时,可以通过上传phar文件,利用phar://协议触发反序列化。这是一个非常实用的技巧,在很多没有直接反序列化入口的题目中都能派上用场。
二是session反序列化。某些框架会把 session 数据序列化后存储,如果 session 内容可控,同样可以触发反序列化漏洞。
三是原生类利用的更多姿势。除了文件读取,原生类还可以用于构造 SSRF、XXE、XSS 等攻击。深入研究 PHP 内置类的行为,能让你在遇到"看似无解"的题目时找到突破口。
四是 POP Chain 的自动化挖掘。当源码规模较大时,手动分析链条效率很低。可以尝试用一些静态分析工具辅助定位魔术方法和危险函数之间的调用关系,提高分析效率。
我个人在做这类题目时最大的体会是:不要急于构造 payload,先把源码读透。很多时候卡住不是因为技术不够,而是因为对代码逻辑的理解有偏差。把每个类的每个方法都搞清楚,把对象之间的引用关系画出来,链条自然就清晰了。另外,本地调试环境一定要搭,远程盲打效率太低,而且容易误判问题原因。