拿到“攻防世界:Web_php_unserialize”这道题的时候,我的第一反应是:终于遇到一道能把PHP反序列化这个知识点讲透的经典关卡了。如果你对CTF中的Web方向有过一点了解,大概率听过“反序列化”这个词。它既不像SQL注入那样有固定语句可以套,也不像文件上传那样靠猜路径就能搞定,它考察的是你对PHP对象生命周期、魔术方法触发机制以及语言特性边界的理解。这道题恰恰把这些点都串在了一起,非常适合作为PHP反序列化从入门到进阶的分水岭练习。
我推荐所有正在刷Web方向、尤其是第一次接触PHP对象注入的读者来解这道题。它能帮你在实际题目里建立“代码审计 → 定位触发点 → 构造Payload → 绕过过滤 → 拿到Flag”的完整思路闭环。这篇文章我会从底层机制讲到实战Payload,再把我踩过的坑和排错经验全部摊开,全程不扯废话,涉及关键原理的地方我会解释清楚“为什么能这样”,而不是只丢给你一堆能抄的字符串。
1. 挑战拆解与核心考点
1.1 这是个什么样的关卡
从题目名称就能看出来,这是一道Web方向的PHP反序列化题目。通常在攻防环境里,这类关卡会给你一个PHP源码文件,或者直接让你访问一个Web页面,页面上隐藏着包含反序列化调用点的代码逻辑。你的目标非常单一:通过构造特殊的序列化数据,让程序在反序列化时执行你期望的动作,最终得到Flag。
这道题的精妙之处在于,它没有把反序列化点直接裸奔给你,而是利用了一些很典型的过滤逻辑和魔术方法做干扰。你需要在代码审计阶段就识别出:哪里调用了unserialize()、传入的数据怎么被加工过、有哪些魔术方法会在反序列化过程中被自动触发、这些魔术方法里有没有能帮你读取或输出Flag的通道。
一个典型的题目模型大致长这样:页面接收某个参数(可能是Cookie、POST字段或GET参数),这个参数经过弱过滤后被交给unserialize()处理。代码里定义了一个带有__wakeup()方法的类,这个方法会在反序列化时自动调用,可能把Flag字段给抹掉。而另一个魔术方法__destruct()则会输出某个属性内容。你要做的事就是:绕开过滤,构造一个序列化对象,让它携带正确的属性值,同时想办法让__wakeup()不把Flag清掉,最后在对象销毁时把Flag打出来。
这道题的考点覆盖得非常典型:PHP魔术方法、序列化字符串格式、属性种类导致的字节差异、正则过滤绕过、CVE-2016-7124这类版本特性,几乎一网打尽。
1.2 前置知识清单
我不建议零基础直接硬啃,但也不是非要你先把PHP底层源码全部啃完才准上手。你只需要具备下面这四块知识,解这道题就会顺手得多。
第一,熟悉PHP的面向对象基本语法,至少要看得懂类、属性、方法、访问修饰符。第二,知道什么是魔术方法,尤其是__wakeup()、__destruct()、__toString()这几个高频触发点,以及它们分别在什么时候被调。第三,理解序列化字符串的存储结构,也就是能在没有工具辅助的情况下手写或者微调一段O:4:"flag":1:{...}这样的字符串。第四,对反序列化漏洞的原理有基本的认知,明白为什么攻击者能通过控制输入来劫持对象属性和方法流转。
如果你前面这些还没完全掌握,不要急,我接下来从序列化本身讲起。这一块虽然看着像理论,但其实和后面构造Payload强相关,特别是私有属性和受保护属性在序列化字符串里的表示方式,不看懂这里,后面百分之百会踩坑。
2. PHP反序列化的底层机制,一次讲透
2.1 序列化字符串的内部格式
很多人第一次看到序列化字符串会觉得像天书:O:4:"flag":2:{s:4:"flag";s:16:"flag_here_123456";},这到底是什么意思?
拆开看其实很好理解。O代表Object对象,4是类名的长度,"flag"是类名,2表示这个对象里有2个属性。后面花括号里就是属性的具体键值对。s表示字符串类型,4是键名长度,"flag"是属性名,后面s:16:"flag_here_123456"则是属性值,表示一个长度为16的字符串。
这个格式是固定约定,PHP内部就是靠这种自描述结构来还原对象的。你可以把它理解成一张快递面单:收货人、地址、物品数量都被结构化成固定字段,反序列化就是快递员照着面单把包裹还原出来。
但有一个特别容易翻车的点,就是属性种类不同,键名的写法也不同。公有属性直接写属性名,比如s:4:"flag"。受保护属性要在属性名前面加上\0*\0,注意这里的\0是ASCII码为0的字符,不是两个字符\和0。私有属性则要写成\0类名\0属性名。这也是为什么你经常会看到Payload里出现O:4:"flag":1:{s:10:"\0flag\0key";...}这种带一堆空字符的东西。
手动构造的时候千万不能直接把私有属性键名写成s:4:"key",否则PHP反序列化时根本认不出那个属性,它会被当成另一个不存在的公有属性。这个坑我当年排了好久,看着明明是按网上模板改的,就是不触发目标方法,后来才发现是\0没写对。
2.2 魔术方法触发规则
魔术方法是PHP类中一些特殊命名的方法,它们不需要你显式调用,PHP会在特定时机自动触发。反序列化漏洞最常打交道的三个是__wakeup()、__destruct()和__toString()。
__wakeup()会在unserialize()还原对象的过程中被调用,通常用来重新初始化资源或者做校验。攻击者最怕的就是这个方法,因为很多开发者会在里面做“消毒”操作,比如把敏感属性重新赋值为空,这就等于你费劲构造的Payload一落地就被废掉了。防御方靠它挡人,进攻方就得想尽办法绕过它。
__destruct()是析构方法,当对象被销毁时触发。如果对象不再被引用、脚本结束、或者显式调用unset(),它就会执行。很多题目把输出点藏在这里,比如echo $this->flag;。对象销毁时机往往由PHP的垃圾回收机制决定,这也是为什么有时候你Payload明明没问题,却看不到输出,因为对象可能在脚本结束前一直没被销毁,或者销毁顺序和你预期的不一样。
__toString()在对象被当作字符串使用的时候触发,比如echo $obj;或者strlen($obj)。有些题目会把读取文件、查询数据库的操作放在这里。
理解这三个方法的触发时机,你才能真正明白“为什么构造这个类、触发哪个方法”的逻辑。很多人写Payload是纯背模板,一旦题目稍微换了个输出点,就完全不知道从哪下手,根源就在于没搞懂方法触发链。
2.3 从反序列化到代码执行的完整逻辑链
反序列化漏洞的本质是什么?一句话概括:攻击者控制了一个对象的属性值,进而劫持了对象方法的行为。
举个最直白的例子。如果代码里有一个FileReader类,它有个$filename属性,__destruct()里面执行了file_get_contents($this->filename)并把内容输出。正常情况下,这个类可能是被正常业务代码创建,$filename是开发者设置的合法文件路径。但由于存在反序列化入口,攻击者可以自己构造序列化字符串,把$filename改成敏感文件路径,再把这个字符串交给unserialize()。对象还原后,$filename已经被篡改,析构时自然就去读了你指定的文件。
所以反序列化漏洞的利用核心,不在于unserialize()本身有多少漏洞,而在于它还原出来的对象属性完全由攻击者操控。这就像你把一个快递盒交付给自动拆包机,盒子外面写着“充电器”,实际上里面装了一块你根本不想收的板砖。反序列化机制只负责拆包,不负责验证内容是否安全。
在CTF题目里,最终的目的很少是直接执行命令,更多是读取某个文件的Flag,或者通过类的联动构造一条输出链。但原理都是一样的:找反序列化入口,构造恶意对象,让它的属性值指向攻击目标,等待魔术方法触发。
3. 代码审计:靠什么线索锁定漏洞入口
3.1 常见过滤逻辑拆解
在这道题里,入口代码通常不会太复杂,但往往会加一个过滤函数,让Payload不能直接过关。我遇到过最常见的一种过滤是这样写的:
$data = $_GET['data']; $data = preg_replace('/flag/i', '', $data); unserialize(base64_decode($data));这段代码的逻辑是:接收GET参数data,先base64解码,然后把内容里的flag字样全局替换成空字符串,最后才交给unserialize()。
为什么会有这样的过滤?因为开发者知道,类的名字、属性的名字大概率会和flag沾边,你构造Payload时不可避免地要写上flag三个字母,过滤掉这些字面量,就能让你的序列化结构“缺胳膊少腿”,PHP在反序列化时就会因为格式不合法而直接报错,从而中断攻击。
还有一种过滤是限制开头字符,类似于:
if (preg_match('/^[Oa]:\d+/', $data)) { die('hacker'); }这步操作比上一种更狠,它直接检查序列化字符串是不是以O:数字或者a:数字开头。O:代表对象起始,a:代表数组起始,这两种恰恰是构造Payload最常用的结构。只要检测到,直接拒绝执行。
3.2 过滤的软肋究竟在哪
正则替换flag这种过滤方式,软肋非常明显:它是纯文本层面的替换,不关心替换之后的结构是否仍然合法。这就给了我们逆向操作的空间。
经典思路是利用替换次数不一致来构造畸形字符串。比如你原本想写s:3:"abc",你把abc替换掉,就会变成s:3:"",这肯定不合法。但如果你的序列化结构里恰好存在一个长度值,而这个长度值在过滤后变得和实际内容不匹配,就可能产生一个长度欺骗的效果。
回到flag替换这个场景。你可以在属性名里故意写成flagflag,正则替换后变成flag,而属性名长度那个数字还保留着替换前的值。原本s:8:"flagflag"经过替换变成s:8:"flag",长度明明是8,实际内容却只有4个字符。PHP继续按照s:8去读取内容,就会把后续字符串的字符也吃掉,从而造成下一个字段的解析错位。
这种错位有时候很致命,但如果你能控制好长度差值,反而可以利用它。比如你让替换后长度比实际内容多4个字节,PHP就会把这多出的4个字节当成属性名的一部分,然后从后续内容里凑够属性值。攻击者可以在后续内容里精心布局,让这种“错位读取”恰好构成一个合法的属性键值对。不过这种方法实现起来比较复杂,需要一遍遍验证溢出字节。
更简单、更常被用的是改动序列化字符串中的结构标识。比如把O:4改成O:+4。很多过滤正则写的是/^O:\d+/,它要求对象标识后面必须直接跟数字,如果你在O:和4之间插入一个+号,变成O:+4:"flag":...,正则就匹配不上了。而PHP在解析序列化字符串时,却能兼容这种带+号的写法。一个字符就绕过了开头检查,这就是语言解析器的宽容性带来的攻击面。
如果代码里只过滤了flag这个单词,还有个更直接的思路:把过滤目标本身变成Payload的一部分。比如类名就叫fl4g,或者用大小写混写FLaG。如果正则没有开大小写不敏感修饰符/i,那直接改大小写就能绕过。
4. Payload构造全流程模拟
4.1 手动构造序列化字符串
这一节我们进入实战推演。以一道典型的类似题目为例,假设源码长这样:
<?php class flag { public $flag; public function __wakeup() { $this->flag = "bad flag"; } public function __destruct() { echo $this->flag; } } $data = base64_decode($_GET['data']); $data = preg_replace('/flag/i', '', $data); unserialize($data); ?>我看到这个源码,脑子里立刻就有两条信息:flag类的属性$flag在__destruct()里被echo输出,是我想利用的输出点;__wakeup()会把这个属性改成"bad flag",是最大的阻碍。
所以我的目标很明确:构造一个flag类的反序列化对象,让它的$flag属性等于真实Flag内容,同时让__wakeup()不要执行,或者执行了也不影响我的属性值。
先来手写一个最基本的合法Payload。假设真实的Flag放在服务器某个文件里,这里我们先构造一个假的字符串占位,比如/flag_is_here:
原始序列化字符串应该是:
O:4:"flag":1:{s:4:"flag";s:14:"/flag_is_here";}解读一下:O:4:"flag"是对象头,1表示只有一个属性,花括号里s:4:"flag"是属性名,长度为4的字符串flag,后面是属性值,长度14的字符串/flag_is_here。
现在要做的是把它base64编码。在PHP命令行里跑一下:
echo -n 'O:4:"flag":1:{s:4:"flag";s:14:"/flag_is_here";}' | base64得到一串字符串。但重点来了,这个Payload直接提交大概率失败,原因就是__wakeup()会在反序列化时把$flag重置成"bad flag"。你辛辛苦苦塞进去的/flag_is_here在对象还原的瞬间就被覆盖了。
4.2 绕过__wakeup与过滤的双重阻击
这时候就需要动用CVE-2016-7124这个历史悠久的技巧了:只要反序列化时声明的属性数量大于实际属性数量,PHP就会跳过__wakeup()的执行。
这是影响PHP 7.4之前多个版本的一个特性。理论上讲,unserialize()本应先检查属性数量、再逐个还原属性、最后触发__wakeup()。但当声明的属性数量超过实际可还原的属性数量时,处理逻辑出现了偏差:属性还原不完,__wakeup()直接被跳过。
利用起来极其简单,把属性数量改成比实际值大的数字就行:
O:4:"flag":2:{s:4:"flag";s:14:"/flag_is_here";}这里我把1改成了2,但花括号里仍然只填了一个属性。PHP处理时发现声明了两个属性,实际数据不够,于是跳过__wakeup(),对象直接进入下一步生命周期。等到脚本结束或对象被注销时,__destruct()被触发,输出我们指定的属性值。
还有一道坎横在眼前:preg_replace('/flag/i', '', $data)会把Payload里的所有flag字母组合删除。我的Payload里类名是flag,属性名也是flag,这俩都被点名了,处处是雷。
但我可以利用preg_replace替换后结构错位的原理来构造。我的目标是在过滤后,服务器收到的字符串仍然是一个合法可解析的序列化字符串。因此,我可以在过滤前故意多写字符,让过滤后恰好形成我要的目标字符串。
一个实用的思路是这样:属性名本来需要是flag,我在Payload里写成flflagag。经过preg_replace把中间的flag删掉,剩下的就是左右两边的fl和ag拼接而成?等等,不对,flflagag这个字符串里,正则匹配到的flag是在第3到第6个字符,替换为空后剩下的确实是flag(前两个字符fl加后两个字符ag)。
聪明,但还不够。别忘了属性名前面的长度值s:4表示的是4个字符,这个长度值并不受过滤影响。攻击者提交的是flflagag,长度8,preg_replace后变成flag,长度4,但同时长度字段还是s:8。这就错位了:PHP用s:8去读键名,会从后续字节里吃掉额外的4个字符。我必须准确计算这些额外吃掉的内容,让它们不影响属性值的正确读取。
这个计算过程绕来绕去,很容易出Bug。我实际更喜欢一个更暴力、更直白的思路:反正代码是preg_replace('/flag/i', '', $data),只删flag这个词,那我干脆把类名和属性名换成不含flag的写法。但题目中类名就叫flag,我没法改类名,否则PHP对不上。
另一条路:直接利用O:+4这种格式绕过开头检测的正则通常是只针对O:\d+写的,但本题过滤是删除flag,不是管开头,所以这里更匹配的绕过方式是替换后拼接法。
我们来把这个更精细构造写出来。先确定目标Payload字符串,不经过过滤的版本是:
O:4:"flag":2:{s:4:"flag";s:14:"/flag_is_here";}为了经过preg_replace后得到它,我提交的原始字符串必须在flag出现的位置上,替换后恰好是目标字符串。一个可行的做法是把原始字符串中的每个flag都写成flflagag。因为替换时会把中间的flag抽掉,剩下flag。但注意,类名部分"flag"也要改。那我把原始Payload写成:
O:4:"flflagag":2:{s:4:"flflagag";s:14:"/flag_is_here";}等一下,这里有个致命问题:类名长度。原始字符串中O:4表示类名长度是4,但flflagag长度是8。preg_replace之后类名变成flag,长度是4,但序列化字符串里写的还是O:4,恰好匹配,这步可行。属性名同理,原始flflagag替换后是flag,长度字段s:4也恰好匹配。属性值里的/flag_is_here里面有个flag,这是会出事的地方,因为替换后它会变成/is_here,长度从14变成10,但s:14长度字段不匹配了。
所以我不能把属性值写成/flag_is_here。我可以改用另一个不含flag的占位字符串,比如/here_is_flag?不行,里面还是有flag。那就用/tmp/flag.txt?有flag。干脆直接想一个不会在过滤中变形的字符串,比如/etc/passwd或者/flag本身,但如果属性值是/flag,它也会被删掉。这里就需要换一个完全不含flag字母的字符串。我可以在真实场景里再替换,但当前目标只是演示逻辑,那我干脆让属性值用一个不含flag的占位符,例如/data/secret.txt,长度是16。目标Payload变成:
O:4:"flflagag":2:{s:4:"flflagag";s:16:"/data/secret.txt";}手动验证替换流程:输入字符串经过preg_replace,把连续的flag抽走。我看到flflagag:从左到右,fl+flag+ag,中间匹配到flag被删,剩下flag。类名合法了。属性名同样合法。属性值里没有flag,完好无损。最终得到:
O:4:"flag":2:{s:4:"flag";s:16:"/data/secret.txt";}属性声明数量是2,实际属性是1,__wakeup()被跳过。把整段字符串base64编码后提交到data参数,预期效果就是__destruct()输出/data/secret.txt。这一步验证了构造逻辑完全可行。
4.3 验证与获取Flag的完整流程
本地验证Payload可不可用,最直观的方法是把整条链路在PHP环境里跑一遍。写一个和题面完全一致的测试文件,然后自己提交请求观察输出。
本地模拟时注意两点:第一,PHP版本尽量贴近题目环境,别在PHP 8.2上验证CVE-2016-7124,因为新版PHP大概率修掉了这个绕过手法。第二,如果你本地没有PHP环境,也可以用在线沙箱,但要注意数据安全,别把真实比赛中要用的关键思路代码直接贴到公共平台上。
假设本地验证通过,我实际提交时的URL大概长这样:
/?data=TzozOiJmbGZsYWdhZyI6Mjp7czo0OiJmbGZsYWdhZyI7czoxNjoiL2RhdGEvc2VjcmV0LnR4dCI7fQ==上面那串base64是我凭手算编的,不一定完全准确,实际作业时直接用PHP函数生成即可,不要手拼base64,容易浪费大量时间。
服务端收到数据后,先base64解码,再执行preg_replace,把字符串处理成合法Payload,接着unserialize()。如果一切顺利,浏览器会直接把对象里的属性内容打印出来。看到输出后,再顺着那串内容去找真实Flag的存放位置,或者把属性值替换成实际已知的Flag路径,就能拿到最终的结果。
5. 踩坑记录与排错速查表
5.1 序列化字符串被破坏的三种典型症状
接着来聊在实战中我用血泪换出来的一些排查经验。
第一种典型症状是提交Payload后页面一片空白,或者直接报Fatal error: Uncaught Error之类的内容。多半是序列化字符串的结构不合法。最常见的坑包括:属性数量不对、花括号不闭合、长度字段和内容实际字节数不匹配。排查方法也很原始:把你最终打算提交的字符串先拿本地unserialize()试一遍,如果本地报错,那就先把字符串打印出来,逐字节对着序列化格式规范检查。不要偷懒,这一步没有任何捷径。
第二种症状是页面有输出,但内容不对,输出的不是你要的Flag,而是类似“bad flag”这样的默认值。这说明__wakeup()正常执行了,你的绕过没生效。原因大概率是你用的PHP版本比较新,CVE-2016-7124已经不可用了。此时要么换低版本环境去验证,要么想别的办法绕过__wakeup(),比如寻找其他入口类和POP链,而不是死磕单个类。
第三种症状是页面输出了一堆乱码,有点像\0被直接打印出来了。这常见于你手写私有属性或受保护属性时,把\0字符直接敲成了反斜杠加数字0。序列化字符串里必须有真正的ASCII空字符,你在代码里拼接时得用双引号包裹的"\0",而不是单引号字符串'\0'。很多新手在这里卡了一整天,怎么看都觉得自己的字符串是对的,其实就是这个细节。
5.2 和过滤正则死磕到底的思路
过滤绕过不是每次都能靠改flag为flflagag来解决,你首先得判断正则的匹配模式。preg_replace('/flag/i', '', $data)这种是全局替换,可以做拼接绕过。preg_match('/^O:\d+/', $data)这种是前缀检测,可以用O:+4绕过。preg_match('/[0-9]/', $data)这类数字检测就更麻烦,可能需要用数组或者直接换一种序列化格式。
有个排错小技巧是给本地脚本加几行调试代码,把每一步处理后的字符串都打印出来。比如:
$data = base64_decode($_GET['data']); var_dump($data); $data = preg_replace('/flag/i', '', $data); var_dump($data); unserialize($data);这样你能看到过滤前的原始字符串和过滤后的最终字符串。如果是长度错位问题,一眼就能看到var_dump输出的字符串到底哪里多了或少了几字节。调试完记得删掉这些输出,否则会影响最终Flag页面的展示。
5.3 版本差异的隐性坑
PHP反序列化在不同版本之间的行为差异很大。__wakeup()绕过CVE-2016-7124在PHP 7.4之前的部分版本有效,到了PHP 8.x基本被修复。而O:+4这种加号绕过,不同版本的解析器兼容性也不一样。所以解题前先确认题目运行环境,如果题目页面没直接暴露PHP版本,可以通过响应头、报错信息或者一些PHP探针页面判断。
版本差异还体现在属性类型处理上。PHP 8.0之后对未定义属性的容忍度更低了,可能直接抛异常而不会默默丢弃多余的属性声明。如果你在旧版本思路下构造的属性数和实际数不一致,新版会直接报错,而不是跳过__wakeup()。这就是为什么我建议你随时准备两套Payload方案,一套用CVE-2016-7124,另一套老老实实配合真实环境去触发正确的魔术方法。
6. 回到现实:如何修掉这些洞
6.1 防御端不可回避的整改项
刷完这道题,如果只是爽了一波拿Flag,那价值就太低了。更该做的是反推一下:如果我是这套系统的开发者,我应该怎么避免这个洞被别人打穿?
第一道防线是不要轻易对用户输入调用unserialize()。能JSON就JSON,JSON格式不存在对象劫持问题,更安全。如果业务上必须用PHP原生序列化,那你至少要把允许被反序列化的类名放进白名单。利用unserialize($data, ['allowed_classes' => ['SafeClass']])这个第二参数,可以限制只有白名单里的类才会被还原,其他类一律转为__PHP_Incomplete_Class,攻击面瞬间被砍掉一大截。
第二道防线是给__wakeup()和__destruct()这些魔术方法加防御逻辑,不要把敏感操作直接摊在明面上。比如在__wakeup()里校验对象属性是否合法,如果属性值包含可疑路径或非法字符,直接拒绝。当然这只能拦低水平的攻击者,高手还是会找别的类来串联。
第三道防线是定期的代码审计。反序列化漏洞隐蔽性很强,静态扫描工具能帮上不少忙,但最终还是要靠人去看逻辑。像这种在析构函数里输出文件内容、在__toString()里执行命令的写法,审计时一经发现就必须返工。安全编码规范里应该明确列出一类高危API清单,unserialize()绝对排在前几名。
6.2 我个人的实战体会
说句实在话,我在第一次做反序列化题目时,也被序列化字符串里的长度字段折磨得够呛。那时候我总觉得手动改字符串就能搞定,结果改了半个小时,越改越乱,最后老老实实写了个PHP脚本帮我生成Payload。从那以后我养成了一个习惯:凡是需要手工构造序列化数据的题目,我第一件事就是在本地写好构造脚本,把目标对象按正常PHP方式创建,然后序列化它,再根据过滤条件去调节字符串。这比整个人脑模拟解析器高效十倍。
另外,我建议你做这道题的时候,不要急着找现成的WP。先自己把源码读明白,把魔术方法的触发链路画在纸上,然后手动推演一遍过滤前后的字符串变化。哪怕你最后没做出来,这个推演过程本身就是在锻炼代码审计能力,这是CTF里最值钱的技能。我见过很多选手刷题刷得挺多,但一问到“这个点为什么能这样打”,就只会说“别人这么写的”。这种刷法,刷一百道题也长不出真正的渗透思维。
反序列化这道题,本质上是在考你“能不能当一个不讲武德的快递员”。PHP帮用户拆包裹,用户要求包裹里装什么,PHP就还原什么,它不关心你的东西是来路不明的恶意对象。作为攻击者,你只要把序列化字符串改得合法合规、又刚好能触发目标逻辑,就能让程序乖乖交出Flag。回到现实中,这个问题的关键从来都不是那一行unserialize(),而是你代码里有没有可以被劫持的类、有没有能把属性值转化为危险操作的方法。看清这一点,你玩这道题才算真正回本了。