从254到278:我把ctfshow反序列化全套刷了三遍,这份笔记你直接拿去
如果你正在刷ctfshow的web入门,大概率会卡在254到278这25道反序列化题上。这套题几乎涵盖了PHP反序列化漏洞从入门到进阶的所有核心考点,从最简单的魔术方法触发,到POP链构造、字符串逃逸、phar反序列化、原生类利用,难度梯度拉得很开。我前后刷了三遍,第一遍照着别人writeup抄,第二遍开始理解每个绕过姿势背后的PHP机制,第三遍才敢说自己真的能吃透这套题。
这篇文章我会按照这套题的学习路径,把每一个关键知识点、绕过手法、踩坑点都拆开讲清楚。不吹不黑,看完这一篇,再回到ctfshow里从254刷到278,你会发现自己不再需要疯狂翻writeup了。
1. 序列化是个什么东西:先搞懂PHP在背后干了什么
1.1 一段序列化字符串的“读法”
很多人刷反序列化题,上来就找POP链,结果一看O:4:"Test":1:{s:4:"data";s:5:"hello";}这种字符串就懵了。其实序列化格式很直白,我给你拆开看:
code复制O:4:"Test":1:{s:4:"data";s:5:"hello";}
O代表Object(对象),C是类,a是数组,s是字符串,i是整数,b是布尔值,N是null。4是类名的长度,Test是类名,1是属性数量。- 大括号里面就是属性名和属性值,
s:4:"data"表示属性名是4个字符的data,s:5:"hello"表示属性值是5个字符的hello。
每一段都是“类型 + 长度 + 内容”的结构。理解这个格式特别重要,因为后面讲的属性数量绕过、字符串逃逸,本质都是在“篡改”这段字符串的结构。
序列化本身是PHP为了跨脚本保存对象状态做的功能,比如把用户session里的购物车对象存起来,下次访问再恢复。serialize()负责把对象变成字符串,unserialize()负责把字符串变回对象。问题就出在这个“变回对象”的过程里,如果字符串内容可控,PHP就会按字符串里的定义创建对象,并且自动触发一系列魔术方法。
1.2 漏洞的根源:可控数据进入了unserialize
反序列化漏洞的核心前提是:用户输入被直接或间接传入了unserialize()函数,且目标代码中存在可利用的魔术方法或类方法。注意,unserialize()本身只是“创建对象”,危险的是对象被创建或销毁过程中自动执行的那些函数。
我用一个例子说明:
php复制class User {
public $username;
public function __destruct() {
echo "Bye, " . $this->username;
}
}
如果我可以控制传给unserialize()的字符串,构造一个username属性为任意内容的User对象,那么当这个对象被销毁时,__destruct就会用我注入的内容执行拼接。如果某个类里有eval()、system()、file_get_contents()这类危险调用,我就能通过精心构造对象状态来实现代码执行或文件读取。
在ctfshow这套题里,254题附近就是从这类最基础的触发开始,慢慢引入各种限制和绕过。你先记住一句话:反序列化做题的本质,是“找入口类 -> 控制属性 -> 触发方法链”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 魔术方法全盘点:每个触发点都是一道送分题
2.1 生命周期方法:__construct、__destruct、__wakeup
这三个方法在反序列化场景里出现频率最高,先看触发时机:
| 魔术方法 | 触发时机 | 利用价值 |
|---|---|---|
__construct() |
对象创建时,但unserialize()不会触发 |
一般没法通过反序列化控制 |
__destruct() |
对象被销毁时 | 高,常用作执行系统命令、写文件 |
__wakeup() |
unserialize()恢复对象时 |
高,但通常被用来“隔离”危险操作,所以也是绕过的重点 |
__sleep() |
serialize()时 |
低,偶尔用于限制可被序列化的属性 |
__toString() |
对象被当作字符串使用时 | 高,常配合文件读取、拼接触发 |
__call() |
调用不可访问方法时 | 高,可把方法调用转向危险函数 |
__get() / __set() |
访问/赋值不可访问属性时 | 中,常用于属性劫持 |
__invoke() |
把对象当作函数调用时 | 高,POP链常见节点 |
__isset() / __unset() |
对不可访问属性用isset()或unset()时 |
低 |
__wakeup是反序列化第一步会碰到的“拦路虎”。很多题目会在__wakeup里把属性重置或者直接退出,比如:
php复制class Target {
public $cmd;
public function __wakeup() {
$this->cmd = "";
}
}
这时候就要用到属性数量绕过。核心依据是CVE-2016-7124:当反序列化字符串中声明的属性数量大于实际属性数量时,__wakeup会被跳过。构造方式是修改O:4:"Test":1:{...}中的1,比如改成2或更大的数。
这个绕过看起来简单,但它背后是PHP解析器在恢复对象时,对属性数量校验逻辑的一个历史遗留bug。虽然高版本PHP修了,但很多CTF题目跑在PHP 5.x或PHP 7.0-7.3环境下,依然有效。所以在做题前,最好先看题目给的环境版本。
2.2 属性魔术方法:__get、__set、__isset
这三个方法解决的是“访问不存在或不可访问的属性”时的兜底逻辑。比如:
php复制class A {
public function __get($name) {
return $this->$name;
}
}
如果某个类在__get里读取了一个我们可控的属性值,甚至直接拼接到敏感函数里,那就可以形成利用链。一个常见场景是:目标类有一个私有属性,外部无法直接设置,但我们通过反射或在序列化字符串中直接构造该私有属性名,就能绕过访问控制。
在ctfshow相关题目里,有时候会给你一个已经序列化好的字符串,但你发现某些属性在__wakeup里被改了,这时候就要检查是否有__get、__set可以借力,把属性调用转向受控的位置。
2.3 字符串与调用魔术方法:__toString、__call、__invoke
__toString是我最喜欢的节点,因为字符串拼接在PHP里太常见了。比如echo $obj、$obj . "xxx"、file_exists($obj)这些操作,只要出现对象被当成字符串,就会触发__toString。如果这个方法里有file_get_contents、phpinfo之类的函数,就能直接读文件或泄露信息。
__call适合处理“调用了不存在的方法”的情况,它会把方法名和参数都接住。有一种经典利用是$this->$method()搭配__call实现任意方法调用。__invoke则是把对象当作函数执行,POP链里经常用它作为“最后一跳”,让一个对象像函数一样被调用,从而触发危险逻辑。
2.4 一个典型的简单利用链示例
下面是这类题目最常见的“标准答案”结构:
php复制class A {
public $cmd;
public function __destruct() {
system($this->cmd);
}
}
// 本地生成payload
$a = new A();
$a->cmd = 'cat /flag';
echo serialize($a);
// O:1:"A":1:{s:3:"cmd";s:9:"cat /flag";}
把生成的字符串传给目标站点,如果目标直接unserialize($_POST['data']),脚本结束后对象销毁,就会执行命令。在ctfshow的早期反序列化题里,你经常能遇到这种直给的版本,后面才会加各种过滤和属性限制。
3. 过滤与绕过:这轮题目的大半江山
3.1 属性数量绕过:改一个数字就能跳过__wakeup
上一节已经提到,这里再说细一点。假设目标代码是:
php复制class Secret {
public $code;
public function __wakeup() {
$this->code = "";
}
public function __destruct() {
eval($this->code);
}
}
正常的payload是:
code复制O:6:"Secret":1:{s:4:"code";s:10:"phpinfo();";}
但反序列化时会触发__wakeup把code置空,所以我们把属性数量从1改成2:
code复制O:6:"Secret":2:{s:4:"code";s:10:"phpinfo();";}
由于对象恢复时发现声明的属性数量2大于实际属性数量1,__wakeup不再执行,随后__destruct就能执行到我们的代码。这个场景在ctfshow相关题目里几乎成了常规操作,一定要熟练到不用想就能改。
3.2 正则过滤下的替换绕过和大小写绕过
很多题目会对unserialize的输入做正则过滤,比如:
php复制if (preg_match('/flag|system|cat/i', $_POST['data'])) {
die('no no no');
}
echo unserialize($_POST['data']);
这里有几条路可以走:
- 如果过滤的是
$this->cmd里的命令字符串,就可以用通配符、变量拼接、反引号等方式绕过,比如cat /f*、system('ca''t /flag')。 - 如果过滤的是序列化字符串里的“类名”或“方法名”,可以尝试大小写混写、十六进制转义,前提是目标环境允许。
- 如果过滤的是
O:这种典型的序列化开头,可以考虑用C:(__serialize形式)或其他格式变异,不过这个要看PHP版本支持情况。
在248到267这个区间的部分题目里,限制往往不只是过滤,还会配合属性数量绕过、命令拼接一起考。做题时我习惯先看过滤规则,再决定payload的构造顺序:先绕过过滤,再触发目标方法。
3.3 引用的妙用:让两个属性指向同一个值
R:在序列化格式里表示引用类型。比如:
code复制a:2:{i:0;R:1;i:1;R:1;}
这表示数组两个元素都引用了第一个元素。在普通对象属性中,引用可以让我们同时控制两个不同属性的值。有一种经典考点是:__wakeup里判断$this->a !== $this->b,通过引用让a和b指向同一块数据,从而绕过判断。
这类题在ctfshow里出现过不只一次。构造方式分为两步:先在本地用PHP脚本创建对象,再用$obj->b = &$obj->a;建立引用,最后serialize()输出。输出结果里会带有R:标记,这样目标在反序列化时就会根据引用关系恢复对象,让两个属性真的指向同一个数据。
3.4 字符串逃逸:把序列化字符串“撑破”和“压缩”
字符串逃逸在反序列化题里属于中高难度,核心是抓住序列化格式中长度+内容的匹配关系。场景一般是:目标先对某些属性值做了替换或过滤,然后把处理后的结果交给unserialize()。
举一个“变短逃逸”的经典模型:
php复制$data = str_replace('x', 'xx', $data);
unserialize($data);
原来内容的长度是L,但替换后长度变成了L+1,序列化字符串里声明的长度就和实际内容对不上了。比如原字符串是O:1:"A":1:{s:1:"a";s:3:"xxx";},其中属性值xxx是3个字符,被替换后变成6个字符,但字符串里仍然是s:3:"",PHP解析器会取前3个字符作为属性值,剩下的字符就会被“挤”出当前的解析范围,成为后续结构的一部分。
利用的方法就是在被替换的前面塞入我们需要的内容,让多余出来的字符正好构成闭合序列化结构的字符串——比如";s:1:"b";s:1:"c";}。这样原本的属性边界被打破,后面紧跟的这些字符会被解析成新的属性。反向的“变长逃逸”同理,利用替换让字符串变短,把本应在内容里的引号提前闭合。
这类绕过的难点在于:你需要精确计算替换前后的长度差,以及塞进去的payload刚好能“吃掉”多出来的字符。我刷题时养成的习惯是:先在本地写脚本输出serialize()结果,手动替换字符数,再用unserialize()验证结果是否符合预期,这样比心算快得多。
4. POP链构造:把多个类串成一条可利用路径
4.1 从“单点触发”到“链式触发”
前面的例子大多是一个类搞定一切。但真实题目里,危险操作往往分散在多个类里,你需要把多个魔术方法拼接成一条链,从入口类一路触发到危险函数。这个链条就是POP链(Property-Oriented Programming,属性导向编程)。
打个比方,__destruct是一扇门,但门后面是走廊,走廊尽头才是保险库。你要做的是找到一条从门到保险库的通路,把途中每个需要开启的门都通过“属性赋值”或“方法调用”设置好。
ctfshow这套题里,POP链的考点通常由两到三个类组成,我会用一个例子说明:
php复制class A {
public $b;
public function __wakeup() {
$this->b->eval_cmd();
}
}
class B {
public $cmd;
public function eval_cmd() {
eval($this->cmd);
}
}
我们可控的是反序列化字符串,目标是让A对象的b属性是B对象,并且B对象的cmd属性是恶意代码。当A对象被unserialize()恢复时,__wakeup触发,调用了$this->b->eval_cmd(),由于b指向B对象,eval_cmd里执行了eval($this->cmd),完成利用。
本地生成payload的方式:
php复制$a = new A();
$b = new B();
$b->cmd = 'system("id");';
$a->b = $b;
echo serialize($a);
4.2 读题方法:先找危险函数,再倒推触发路径
很多新手刷题时看到一大堆类就懵了。我的建议是倒着看:
- 先全局搜索
eval、system、exec、file_get_contents、include这些敏感函数。 - 找到它们所在的方法,看它被哪个魔术方法或普通方法调用。
- 反向追踪:要触发这个方法,需要哪些属性可控?谁调用了这个方法?
- 最后构造入口对象,生成序列化payload。
这样做的好处是不会迷失在类与类之间的调用关系里。在ctfshow系列里,有些题会故意把危险函数藏得很深,比如通过__call转发、通过数组回调、通过闭包传递,但只要你有这层逆向思维,基本都能跟下来。
4.3 实例拆解:三层调用链
再给一个稍微复杂一点的模型,考察点在“方法内调用拼接”:
php复制class A {
public $name;
public function __toString() {
return $this->name->get();
}
}
class B {
public $func;
public function get() {
call_user_func($this->func);
}
}
如果某个地方执行了echo $obj,就会触发A的__toString,接着$this->name->get()调用B对象的get,get里通过call_user_func执行我们指定的函数。构造payload时,A的name属性要赋值为B对象,B的func属性赋值为phpinfo或system。
这一条链在本地写出序列化结果之后,要多留一个心眼:__toString不会在unserialize()时主动触发,它需要有一个“字符串上下文”的操作来带动。所以做题时一定要找到最终的触发动作,比如echo、file_exists、strpos等,否则链子接了也启动不了。
5. 另辟蹊径:phar反序列化与session反序列化
5.1 phar://协议:没有unserialize也能触发
在ctfshow的进阶反序列化题里,有一种情况是目标代码里找不到unserialize(),但存在文件操作,比如file_exists($path)、include($path)、getimagesize($path)。这时候可以借助phar://协议触发文件里的元数据反序列化。
原理是:PHAR文件内部有一个序列化存储的metadata字段,当你通过phar://协议访问该文件时,PHP会读取PHAR的metadata并自动unserialize(),即使代码里没有显式调用。生成一个恶意phar文件的常见姿势:
php复制$phar = new Phar('evil.phar');
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$o = new A(); // 触发链中的某个对象
$phar->setMetadata($o);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();
生成后的phar文件可以改后缀为jpg、png、pdf等,只要目标代码能通过file_exists这类函数访问到它,就会触发metadata里的反序列化。做题时,通常是先把恶意phar上传到服务器,然后寻找一个文件路径可控的入口用phar://去包含它。
这条路径在做ctfshow系列时一定要掌握,因为很多题从反序列化本身直接看是无解的,但如果你把思路切到phar,就豁然开朗了。前提是目标开启了phar.readonly的关闭或可写环境,以及存在可用的危险类。
5.2 session反序列化:从session处理器差异下手
另一种不用显式unserialize()的触发点是session机制。PHP的session序列化处理器有多种配置,常见的有:
php:默认,格式是键名|值php_serialize:整体用serialize(),格式如a:1:{s:4:"name";s:5:"value";}php_binary:二进制格式,键名前有长度字符
如果目标设置了session.serialize_handler = php,而你又要控制session里的某个值,那么当session文件被读取时,PHP会把键名|值中的值部分做unserialize()。这里有一个非常经典的坑:如果你能在session里注入|,就可以把后续内容伪造成序列化字符串,形成反序列化漏洞。
还有一个常见利用场景是session.upload_progress。在PHP 5.4+中,上传文件时如果session.upload_progress.enabled开启,且POST表带里带PHP_SESSION_UPLOAD_PROGRESS字段,PHP就会把进度信息写到session文件里。如果我们控制进度信息字段值,构造出恶意序列化字符串,再结合目标对session读取时的反序列化,就能触发漏洞。
不过ctfshow的反序列化系列里,session反序列化通常作为高阶拓展出现,不是每道题都考。但一旦遇到,你要能快速判断当前session处理器是哪种,然后在payload构造上做对应调整。
5.3 从触发点到利用点的完整思路
遇到没有显式unserialize()的题目,我的排查顺序是:
- 确认目标是否存在文件操作或包含操作,支持哪些协议(phar、zip、filter等)。
- 确认是否存在session相关功能,能不能控制session文件内容。
- 在目标代码里找可用于反序列化劫持的类,看看它们有没有危险方法。
- 结合触发点构造payload,选择上传或注入路径。
这种“间接触发”的玩法,本质上是把反序列化漏洞从单一函数里解放出来,变成一种通用攻击面。
6. 原生类与一些小众技巧:不该忽略的进阶武器
6.1 利用原生类做输出与命令执行
有些题目过滤了用户自定义类的使用,但并没有限制PHP内置类。这里有三个常用武器:
Error/Exception:它们的__toString()会返回包含文件路径、行号、消息的字符串。如果目标把异常对象当作字符串输出,可以通过给异常设置不同的消息来显示文件内容,但更常见的是配合file_get_contents读文件,比如把文件路径放到message里,再用异常触发toString。SoapClient:可以实现SSRF。SoapClient的__call会向指定的WSDL地址发起HTTP请求,在反序列化场景中,可以利用它发起内网请求来打内网服务。GlobIterator、DirectoryIterator:用于列目录和读取目录下文件,适合在没有其他信息泄露时探测文件结构。
以Error为例,如果某个位置有echo $obj的字符串操作,且$obj可以是一个Error对象,那么__toString返回的异常消息会包含传入的message,同时还会附带当前文件路径和行号,这可以用来做信息泄露。有些题目里,配合Error的message里塞入PHP伪协议路径,可以拿到源代码。
6.2 fast-destruct与提前销毁
另一个高频考点是fast-destruct。默认情况下,unserialize()执行完后对象才会被销毁,这会触发__destruct。但有些题目会检测对象的一个属性并在__destruct里做判断,我们需要让对象“提前销毁”,跳过某些拦截逻辑。
实现“提前销毁”的思路有几种:
- 让对象成为数组的key:PHP的数组key只能是整型或字符串,如果你把对象放在key位置,PHP会尝试把对象转换为字符串,失败后该对象引用被释放。
- 利用引用计数归零:把对象属性设置为指向自身的引用,然后让外部引用消失,形成孤立环,PHP的GC会回收它。
- 利用序列化中的
i:0;技巧,通过覆盖数组元素触发旧值释放,让对象在unserialize()过程中就被析构。
在ctfshow里,部分题目用__destruct作为唯一触发点,同时对执行条件做了限制,这时候fast-destruct就是唯一的突破口。
6.3 绕过检测时的对象注入
再补充一个常见小技巧:当__wakeup里会检查md5或sha1时,可以直接把要比较的属性改成引用,让两个值永远一致,在3.3已经提过。但如果比较的是“某个方法返回值”和“属性值”,就不能用简单的引用绕过了。这时候可以考虑在序列化字符串里手动添加额外属性的方式,有些老版本PHP在属性恢复时会忽略多余属性,从而保留我们注入的状态。
这类细节在writeup里经常被一笔带过,但实际刷题时往往就是卡住你两小时的点。
7. 常见问题速查表与排查建议
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
__wakeup总是被触发 |
属性数量未改 | 检查序列化字符串中属性数量是否大于实际数量 |
| payload执行了但无回显 | 命令输出位置不对 | 尝试curl外带、DNSlog、写文件等方式确认 |
unserialize()报错 |
序列化字符串结构不合法 | 用本地var_dump(unserialize($str))调试 |
__destruct没执行 |
对象未在脚本生命周期内销毁 | 考虑fast-destruct或主动触发unset |
__toString不触发 |
缺少字符串上下文操作 | 找echo、拼接、文件函数等入口 |
| 命令执行被过滤 | 输入过滤或WAF | 尝试变量拼接、通配符、编码绕过 |
| phar无法触发 | phar.readonly或协议不可用 | 确认目标环境、上传路径和文件后缀 |
| session反序列化不成功 | 序列化处理器不匹配 | 确认php、php_serialize等不同格式 |
做题时最忌讳的是直接套payload。我见过不少人把别人writeup里的序列化字符串原封不动发过去,结果因为长度、类名、属性名一个字符对不上,就百思不得其解。建议你在本地写一个简单的PHP环境,把目标类复制过来,用serialize()生成payload,再用unserialize()验证执行链,这样能在十秒内发现80%的错误。
再补充一个排查姿势:很多反序列化题源码是加密的或做了混淆,但PHP本身有反射机制。你可以通过反射类获取类的私有属性名、方法列表,再反推构造。虽然在CTF题目里少见,但在实战场景里,ReflectionClass是挖掘反序列化利用链的常用工具。熟悉它,对你理解对象结构也有帮助。
8. 刷题节奏建议:怎样把这25道题刷出效果
最后分享个人刷题的一个节奏。第一遍建议按顺序从254到278,每题至少独立尝试30分钟,卡住了再回头翻原理,而不是直接看writeup。刷完一遍后,把每个题的知识点标签列出来,比如“属性数量绕过”“引用绕过”“字符串逃逸”“phar触发”“POP链”,然后归类复习。
第二遍开始,可以尝试不看payload,只根据提示自己构造。这一遍是加深肌肉记忆,你会发现很多绕过的套路是相通的。第三遍就挑自己当初卡得最久的题,尝试不看任何笔记,从头到尾独立完成。
有一点我特别想说:反序列化题不光是猜payload,它更考验你对PHP内部机制的理解。比如为什么属性数量可以绕过__wakeup、为什么字符串逃逸能改变解析结果、为什么phar的metadata会被反序列化,这些问题弄懂了,无论题目怎么换皮,你都能很快定位到考点。
拿我个人经验来说,ctfshow这套反序列化题最大的价值,是把大量PHP语言底层的“坑”集中暴露给你。刷完之后,你再去分析主流框架的反序列化漏洞,会发现很多CVE的利用链构造思路,和这25道题里的经典模型是一脉相承的。这也是为什么我建议不要把刷题当成任务,而是当成一次PHP内部机制的系统学习。
