CTF圈里有一句老话:反序列化没学好,Web方向后面很多题都会卡住。ctfshow这个平台我刷过好几轮,其中web254到web278这一整段反序列化专题,是我见过把知识点分层做得最清楚的一个系列——从最简单的unserialize利用,一路打到对象注入、魔术方法触发、POP链构造、字符串逃逸、原生类利用、phar反序列化、session反序列化,最后再来两三道综合题把前面所有手法串起来。认真刷完这20多道题,你对PHP反序列化这套东西的认识基本就过关了。
这篇文章我会按照题号顺序,把每一段题目背后的考点、原理和exp思路完整顺一遍。不装高深,就用打题时的真实视角,把每一类题的触发点、绕过点、坑点都摊开来说。不管你是刚开始刷ctfshow的新手,还是已经打过一些Web题但反序列化一直没理顺的选手,这篇应该都能帮你省下不少绕路的时间。
1. 反序列化基础与出题维度
1.1 PHP序列化格式与魔术方法
要打这套题,先把PHP序列化格式刻在脑子里。一个对象被serialize之后不是乱码,而是一种带类型标记的字符串,比如s:5:"hello"表示字符串"hello",i:123表示整数123,数组是a:3:{...},对象则是O:4:"User":2:{s:8:"username";s:5:"admin";},其中O表示对象,4是类名长度,User是类名,2是属性个数,后面大括号里就是属性名和属性值。
你不需要背序列化格式的所有细节,但必须能看懂它,因为构造exp的时候你就得手工拼这些字符串。后面做字符串逃逸题的时候,甚至要精确到分号和花括号的位置。
魔术方法是反序列化利用的核心,最常见的几个触发点:
__construct():对象创建时触发,但unserialize()反序列化时通常不触发它,这一点很多人会搞混。__destruct():对象被销毁时触发,脚本结束、对象引用被覆盖、显式unset时都会调。__wakeup():unserialize()恢复对象时立即触发,这就是绕过的重点,属性个数值大于实际值时会被跳过。__toString():对象被当作字符串使用的时候触发,比如echo $obj、字符串拼接、strpos()等函数传对象。__get():访问不可访问或不存在的属性时触发,比如$this->shield在类里没有被定义。__call():调用不可访问或不存在的对象方法时触发,参数是方法名和参数数组。__invoke():对象被当作函数调用时触发,比如$obj()。__isset()、__set()、__unset():分别在isset、赋值、unset不可访问属性时触发。
这套题里,攻击链基本就是在这些魔术方法之间跳转。你要做的就是想尽办法让程序在反序列化之后,走到某个最终能执行代码或读取文件的方法。
1.2 反序列化漏洞为什么危险
很多人第一次接触反序列化时都有个疑问:不就一个数据还原的过程吗?危险在哪?
关键点在“反序列化不能控制执行什么代码,但能控制数据流”。unserialize()接收到的是你自己拼的字符串,PHP会按照字符串里的类名去实例化对象,填充属性,然后对象一销毁或一被操作,魔术方法就被带着你控制的属性触发。如果这些魔术方法里调用了危险函数,而参数又来自对象属性,那整条链就活了。
ctfshow这套题框架也遵循这个逻辑:每道题都给你一段源码,源码里有一堆类,每个类的方法里有可以利用的操作,比如文件读取、命令执行、SQL查询等。你的任务就是把它们的调用关系串起来,找到一个入口点,unserialize()之后一路触发到最后那个危险操作。
更麻烦的是如果目标环境下不存在这些类,你就算构造了O:开头的数据也没用,PHP会生成一个__PHP_Incomplete_Class对象,魔术方法不会按你的预期触发。所以做反序列化题第一步永远是:把源码里所有的类梳理出来,哪个方法能干什么,一一列出来,然后再想怎么串。
有这一层认知打底,后面每一题都是在做同一件事:把可控的数据流导向危险的函数调用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 254-258:从直接触发到__wakeup绕过
2.1 最入门的反序列化触发
web254和web255这两题基本是给新人找信心的,体感上就是把一个对象序列化之后传进去,然后等__destruct()触发就行。源码里通常会有一个类是给flag的,比如有个flag属性或getFlag()方法,你需要传一个满足条件的对象过去,让它在销毁时把flag打出来。
我第一次打这类题时犯过一个低级错误:直接post了一个PHP序列化字符串过去,但忘了URL编码。ctfshow这类平台接收参数一般走POST或GET,序列化字符串里的"、{、}等字符在GET里容易被截断或转义,最好先urlencode再过。提示弹出来的时候误以为是题目出了问题,其实是我自己没编码。
这一类入门题的核心操作可以用一句话概括:把源码里那个关键类完整的序列化结果拿过来,改属性值,再发给服务端。怎么生成序列化数据?写一个本地php脚本,include题目源码,new一个对象,把要改的属性赋值,然后echo serialize($obj),完事再把结果喊出来。
2.2 属性可见性与对象注入
web256开始,就有一些过滤或者判断了。常见的是反序列化之后会检查某个属性的值是否等于某个字符串,或者某个方法只有在特定条件下才会输出flag。这时候你有两条路可以走。
一条是全局搜索源码里可控的输入点,看看有没有类似file_get_contents($_GET['file'])这种操作,配合phar协议后面再说。另一条是直接构造一个对象,把源码里所有带敏感操作的类的属性值全塞满,让程序按预定逻辑执行。
这里有个必须认真对待的坑:私有属性(private)在序列化后的格式和公有属性不一样。比如private $name序列化后会变成\x00类名\x00name,也就是前面带了两个空字节,显示出来是%00ClassName%00name这个样子;protected属性则是\x00*\x00name。如果你手工拼序列化字符串,漏掉这些空字节,PHP就不会把值赋给对应的属性,后面的利用直接失败。
我记得在web256这类题里,很多人用手拼exp就栽在protected的\x00*\x00上。贴心做法是:用var_dump或print_r把源码里主要的类都打印出来,然后写一段PHP脚本自动生成序列化数据,不要纯手拼。等到后面字符串逃逸的时候,再回到手拼的层次。
2.3 __wakeup绕过(CVE-2016-7124)
web257、web258这两题大概率考的是__wakeup()绕过。漏洞原理可以追溯到PHP 7.4之前的一个经典问题:当反序列化字符串中声明的属性个数大于实际属性个数时,PHP会跳过__wakeup()方法。漏洞编号CVE-2016-7124,虽然老,但新闻价值不减。
举个例子,原始序列化数据是:
php复制O:4:"User":1:{s:4:"name";s:5:"admin";}
其中1是属性个数。当__wakeup()里写了不让你继续执行的条件,比如if ($this->name !== 'guest') die();,而你又想把name改成admin,那就把属性个数改成大于实际个数的值:
php复制O:4:"User":2:{s:4:"name";s:5:"admin";}
反序列化时对象里只有一个name属性,但声明的属性个数是2,PHP内部的处理逻辑就出了纰漏,__wakeup()直接被跳过。这样原本在__wakeup()里拦截你的逻辑就完全被绕了过去。
因为这个绕过影响面太广,PHP 7.4以后官方已经修复了这个行为,当前新版的PHP做不了这个绕过。ctfshow这套题目大部分跑在旧版本PHP环境上,所以依然有效。做题时先看一眼题目环境版本,如果是 7.4 以下,__wakeup绕过就是常规武器;新版本里就要想别的路子了。
__wakeup绕过后,后面通常接的是__destruct()或者某个自定义方法。你只要把下一个节点的属性构造好,整条链就能往下走。
3. 259-266:POP链构造进阶
3.1 正向构造思路
到了web259这一带,题目开始把多个类串成一条调用链。这就是CTF圈说的POP链(Property-Oriented Programming),本质和ROP链相似:你手里是一个个魔术方法节点,每个节点能调用某个方法或访问某个属性,把这些节点按调用关系串起来,最终引爆一个危险函数。
最简单的POP链思路是“正向推导”:从入口点(通常__destruct或__wakeup)开始,顺着源码看它能调用哪些方法,这些方法里又继续调用哪些方法,像走迷宫一样一路标到危险函数。
举个例子,假设源码有这么一段:
php复制class A {
public $obj;
public function __destruct() {
$this->obj->run();
}
}
class B {
public $cmd;
public function run() {
system($this->cmd);
}
}
那思路就非常清晰了:入口是A的__destruct,它调用$this->obj->run(),所以我们要让$this->obj是B对象,B的$cmd填系统命令即可。把A序列化出去,里面嵌套一个B,就是一条最简单的POP链。
php复制class A {
public $obj;
public function __construct() {
$this->obj = new B();
}
}
class B {
public $cmd = "cat /flag";
}
echo serialize(new A());
3.2 反向回溯找链
实际题目里的类会很多,方法也会绕,正向推容易在半路卡住。这时候更高效的是“反向回溯”。
反向回溯的思路是:先看整段源码里哪里调用了危险的函数,比如system()、exec()、file_put_contents()、file_get_contents()、include()等。找到这个危险调用所在的方法后,反查这个方法的调用者是谁,一路回溯到入口点。
我打这套题的经验是:如果源码里某个类有system()或者eval()这类执行函数,几乎百分百它就是链的终点,先把它标记出来,接着看它属于哪个类,方法名是什么,参数从哪里来(属性还是方法调用返回值),再往上找哪个魔术方法能调到它。这样链条会清晰很多。
3.3 常用魔术方法利用
web260到web266这段,很多题的链子会用上__toString、__get、__call、__invoke这几个方法。每一个都有不同的触发场景:
__toString:对象被当作字符串时触发。常见的设计是一个类里有一个属性是另一个类的对象,然后这个类在拼接字符串、输出、比较时用了这个属性,如果属性是对象,就会触发其__toString,于是又串到一个新节点上。__get:经常用于属性链跳转。例如一个类重写了__get($name),返回$this->data[$name],而data属性又由你控制,就能制造任意属性访问。__call:当代码调用对象不存在的方法时触发,比如$this->obj->method(),而$this->obj的类没有method方法,就会进__call。__invoke:当代码把对象当函数调用时触发。有些题目会用$this->filter($input)这种写法,如果filter属性是对象,就会进入它的__invoke,可以在里面写命令执行。
这些方法之间的跳转是POP链的常态。我印象比较深的是某些题里,链路要经过三四层__get跳转,每次跳转都会拼一段字符串或获取一个属性值,再用这个属性值作为下一步调用的参数,跟套娃一样。打这类题别心急,每一步都仔细看参数是怎么传的,有没有被过滤。
还有一个容易漏的点:PHP的__wakeup里如果写了sleep()或者某些修改属性的逻辑,会影响链子后半段。比如__wakeup把某个对象属性改成了空值,等你走到__destruct时就发现参数没了。所以构造链的时候,不光要看垃圾怎么触发,还要看__wakeup会不会把你精心构造的属性给改了。如果会,考虑先绕过__wakeup再走链。
4. 267-270:字符串逃逸与原生类
4.1 变长字符串逃逸
web267到web269这段,是这套题里最烧脑的部分之一,考的是“字符串逃逸”。这类题的核心场景是:程序在反序列化之前,会对序列化字符串做一次替换处理,比如把a替换成ab、把hello替换成hi等。由于序列化字符串中有长度的标记(比如s:5:"hello"),替换后实际字符串长度变了,但PHP解析时依据的仍然是旧的数字长度。结果就是替换后的额外字符“撑破”了原本定义的长度,后面的内容被重新解析,我们就能往反序列化数据流里注入新的属性。
举个最简单的变长逃逸例子。假设源码里有类似这样的替换:
php复制$data = str_replace("a", "bb", $data);
原始序列化字符串是:
php复制O:1:"X":1:{s:1:"a";s:3:"aaa";}
替换后变成了:
php复制O:1:"X":1:{s:1:"a";s:3:"bbbbbb";}
注意值部分bbbbbb实际是6个字符,但PHP解析时认为它是3个字符(因为s:3:),于是它会认为这个字符串值到第3个字符b就结束了,后面的bbb";变成了新的语法结构。如果我们精心控制注入的内容,把“撑破的部分”设计成完整的属性定义,就能在尾部添加我们想要的属性。
实战里做变长逃逸时,一般会这样设计:需要注入的payload放在后面,前面用足够多的被替换字符“撑”掉payload的长度。关键的计算是:被替换字符导致的额外长度,要等于payload的长度。
我在这类题上的经验是,先在本地用PHP脚本模拟一下替换逻辑,算好需要多少个a,再拼最终的exp。手动心算太容易错了,本地脚本验证最稳。
4.2 变短字符串逃逸
变短逃逸和变长相反:程序会做类似str_replace("ab", "a", $data)的替换,把字符串变短。变短造成的效果是PHP解析时,实际数据比声明的长度短,于是本该属于后面属性的数据被“吐”回了字符串值里,破坏了后续结构。
变短逃逸的利用思路比变长稍复杂一些。常见做法是,让被替换变短的字符填满整个属性值和属性个数之间的区域,使PHP解析时误以为某个属性到这里就结束了,从而改变后续数据的解析方式。
拿一个常见模型举例,假设序列化数据长这样,我们要注入一个恶意的属性进去:
php复制O:1:"X":2:{s:1:"a";s:6:"任意内容";s:1:"b";s:1:"c";}
替换规则是"xx" -> "y",那我们可以把任意内容里的前n个字符替换成xx,让它变成y,实际字符串长度小于声明的6,于是取6个字符的时候会多取后面属性的部分,原本的属性结构被破坏,后面注入的数据就能改变解析结果。
变短逃逸比变长更考验对序列化格式边界的计算能力。我的建议是:这类题一定要在本地用和题目一样的类定义写exp,白盒调试。每次算不清楚就打印序列化字符串,拿unserialize跑一下看结构是不是自己想的那个,再继续调整。本地能跑通,题面上基本就能打出来。
4.3 原生类Error/Exception
如果题目里没有给你可利用的类,或者你找不到现成的魔术方法链,那就要引入PHP内置的“原生类”作为链子节点。这套题里最常用的原生类是Exception和Error。
这两个类有一个特性:它们实现了__toString()方法,而且__toString()里会返回错误信息(包括message属性内容)。利用方式通常是配合文件读取或字符串操作类函数,比如file_get_contents、strpos、include等,把一个Exception对象传给这些函数,触发__toString执行,从而让错误信息带出文件内容。
更常见的是配合file_get_contents读文件。比如一段代码是:
php复制echo file_get_contents($url);
$url是反序列化可控的,我们没法直接让它指向/flag,因为过滤了很多伪协议。但如果file_get_contents的参数被改成某个对象,就能触发对象的__toString,通过Exception的__toString返回栈信息和message,间接把flag内容带出来。
构造方式很简单:
php复制$exp = new Exception("<phpinfo之类的内容>");
echo urlencode(serialize($exp));
具体到web270这类题,常看到的一种套路是:目标用一个call_user_func()或file_get_contents函数接收一个对象,配合Error或Exception的__toString去触发对象转字符串,从而打印出/flag内容。注意Exception的message里不能直接放拿不到的东西,需要利用它接收到的外部数据。
原生类还有一个好处:它们自带很多魔术方法,有时题目流程并未显式调用它们,但在某些PHP函数内部处理参数时会被隐式调用。做题时如果感觉链子走不下去,可以查看PHP手册里Error、Exception的魔术方法列表,看有没有能嵌入链子的节点。
5. 271-278:phar、session反序列化与综合利用
5.1 phar反序列化
phar反序列化是反序列化题里跨文件类型利用的经典手法。核心原理是PHAR文件(PHP Archive)的meta-data部分会被序列化存储,当用phar协议(phar://)或者file_exists()、file_get_contents()、is_dir()等文件操作函数读取这个phar文件时,PHP会自动对它内部的meta-data做unserialize,从而触发我们埋好的反序列化POP链。
这种手法的厉害之处在于:触发点不再是unserialize()函数本身,而是任意的文件操作函数。如果目标站点对unserialize()做了过滤,但某个地方允许上传文件或者写文件,给了phar://伪协议入口,那就能绕过。
web271到web273这一段,题目设计一般会有一个文件上传点,允许传图片,后缀校验也能过;或者有一个写入文件的地方,比如file_put_contents()。你先把构造好的phar文件上传上去,然后在目标代码里找到任意一个能读取文件的操作点,把参数改为phar://uploads/xxx.jpg,反序列化链就在读取图片的过程中被触发了。
本地生成一个phar文件需要几步:
php复制<?php
class Evil {
public $cmd = 'cat /flag';
}
@unlink('exp.phar');
$phar = new Phar('exp.phar');
$phar->startBuffering();
$phar->setStub('GIF89a<?php __HALT_COMPILER(); ?>'); // 伪装成GIF
$phar->addFromString('test.txt', 'test');
$phar->setMetadata(new Evil());
$phar->stopBuffering();
注意phar.readonly配置项在php.ini里要设为Off,否则本地生成会报错。生成后把文件重命名为.jpg或.gif再传,目标服务器用phar://读它,才会触发meta-data的反序列化。
phar题里一个高频坑是:目标PHP版本可能已经禁用了phar://或者对phar元数据做了限制(PHP 8.0后对phar反序列化有部分缓解,但仍可触发)。做题时先在环境里跑一个小payload确认phar://是否可用,别一上来就传大payload,免得浪费精力。
5.2 session反序列化差异利用
session反序列化是利用PHP不同session序列化处理器之间格式不一致来制造注入的。PHP的session存储格式有三种常见的处理器:
| 处理器 | 存储格式 |
|---|---|
| php | `username |
| php_serialize | a:1:{s:8:"username";s:5:"admin";} 整个session就是标准数组序列化 |
| php_binary | 长度字节加key加value序列化串,类似 \x08username |
漏洞出现的原因在于:如果session.serialize_handler和session_start()时的处理器不一致,或者程序中某处调用了session_start()后,另一个组件又用不同处理器解析了session数据,就可能导致session文件里的竖线|被错误解析,把我们构造的序列化数据注入进去。
这类题常见的出题方式:代码里有一段会把用户可控的内容写入session,比如$_SESSION['login'] = $_POST['login'];,然后定了一个session.serialize_handler为php_serialize。因为php_serialize会把整个$_SESSION数组序列化,所以如果我们能让session数组里某个key的值是一段以竖线开头的字符串,当目标用php处理器读取session文件时,竖线后的内容就会被当成序列化数据直接unserialize。
构造方法大概是这样:
php复制$_SESSION['x'] = '|O:4:"Evil":1:{s:3:"cmd";s:9:"cat /flag";}';
当使用php_serialize处理器写入session文件后,文件内容会是:
php复制a:1:{s:1:"x";s:43:"|O:4:"Evil":1:{s:3:"cmd";s:9:"cat /flag";}";}
再被php处理器读取时,PHP会找到第一个|,把前面的a:1:{s:1:"x";s:43:"当key,后面的O:4:...开始一整段当作value进行unserialize,于是Evil对象就被还原了,POP链激活。
打session反序列化题的关键是先确认目标用的是哪个session处理器。一般情况下,看phpinfo或源码里的ini_set('session.serialize_handler', ...)就能确定。web274这类的题,经常是先弹个phpinfo给你看环境,或者让你读一个config文件,里面就写着处理器类型。拿到这个信息后,后面构造就顺理成章了。
5.3 综合题与多漏洞串联
web275到web278这段,基本上就是前面所有知识点的排列组合。有的题要求同时用到文件上传和phar反序列化,有的题在POP链末端要结合一下命令执行环境(比如禁用了一些函数),还有的可能要结合SSRF或者SQL注入先把入口打通,再走到反序列化。
打综合题,我的经验是三条:
- 先理清楚输入输出边界。整张页面有哪些参数可以控制?各参数最后流到了哪里?是
unserialize,是session,还是文件读取?把这些点标出来,等于题目给你画好了路线图。 - 把可利用的类和方法全部列成清单。包括方法名、参数、是否含危险函数、能否作为链子节点。这个清单越全,串链越轻松。
- 本地把exp跑通再打远程。CTF比赛里最难受的事情就是exp在本地一切正常,提交到远程却报错。原因通常是远程PHP版本、禁用函数、前缀目录不同。所以在本地写exp时,尽量用兼容性写法,比如命令执行用
system()、passthru()都试一遍,文件读取区分/flag和flag等。
web278我记得是个收尾题,对POP链的要求很高,链子会跨多个类,中途还有字符串拼接、回调函数、甚至需要触发两次__destruct才能把结果带出来。这里很考验你前面几题的积累。我当时做这题时,卡在一个方法参数被另一个类方法的返回值覆盖上,最后重新顺着调用链写了一遍能触发完整顺序的exp才过。
6. 刷完这套题之后的一些体会
反序列化这个方向,知识点多,但逻辑非常清晰,一旦你理解了“数据流可控→魔术方法触发→危险函数落地”这个本质,后面遇到再花哨的链子也不会发怵。ctfshow这套254-278最让我认可的地方,就是它把每个知识点的难易梯度安排得很合理:先用最简单的方式让你跑通一次反序列化,再用对象注入和__wakeup绕过让你意识到“控制数据”的细节,POP链构造逼你读懂整个类与类之间的调用关系,字符串逃逸和phar、session则是把利用面无限拓宽。
给还在刷这套题的朋友两个建议。第一个,本地一定要准备一套和题目环境一致的PHP环境,最好把版本都调到一致,不然__wakeup绕过能不能用、phar能不能触发、某个函数是否被禁用,都会成为干扰项。第二个,每做完一道题,把题目的源码结构、触发点、exp链子梳理成笔记,总结出“这类题的通用打法”,不要只记答案。这个习惯在后面打比赛或者做真实渗透测试时,能帮你省下成倍的时间。
