打开一个老牌CTF训练平台的Web方向题库,题目列表里躺着Web_php_unserialize这种名字的时候,老手会心一笑,新手眉头一皱。做过PHP反序列化题的朋友都知道,这地方是CTF入门路上的一道坎,网上WriteUp不少,但很多默认你已经懂序列化格式、懂魔术方法、懂怎么改包,结果就是照着抄也抄不明白。这篇我就出一版胎教级WP,从读源码开始,到手工拼出反序列化字符串,再到绕过__wakeup和正则过滤,每一步都掰开揉碎讲清楚。适合第一次接触反序列化、想真正弄懂原理而不是背答案的朋友。
1. 先搞清楚这道题在考什么
1.1 打开题目页面第一眼看到的东西
这道题打开之后不会给你一个花哨的登录框或者上传点,而是直接甩一段PHP源码在页面上。别慌,这说明考点就在源码里,题目难度也集中在白盒审计和构造Payload上。很多新手一看到源码就懵,觉得“代码是出题人写的,我怎么知道漏洞在哪”。其实CTF的Web题恰恰相反,源码就是出题人留给你的地图,每一行都可能藏着线索。
以我当年做这道题的回忆为例,代码核心结构大概是这样的:
php复制<?php
class Demo {
public $file = 'index.php';
public function __destruct() {
echo file_get_contents($this->file);
}
public function __wakeup() {
$this->file = 'index.php';
}
}
if (isset($_COOKIE['user'])) {
$user = base64_decode($_COOKIE['user']);
if (preg_match('/[oc]:\d+:/i', $_SERVER['HTTP_USER_AGENT'])) {
die('Stop hacking!');
}
unserialize($user);
}
?>
先别急着往下翻,我要提醒一个最基本的结论:这里的入口是Cookie里的user参数,经过base64解码后直接交给unserialize函数。unserialize是反序列化函数,它能把一段字符串还原成一个PHP对象。问题来了,这段字符串是完全由客户端控制的,那对象里的属性值自然也由你说了算。这就是反序列化漏洞的源头——程序信任了来自外部的序列化数据。
1.2 序列化和反序列化到底在做什么
序列化(serialize)就是把一个PHP对象“打包”成一串可存储、可传输的字符串,反序列化(unserialize)就是把这串字符串“拆包”还原成原来的对象。你可以把它理解成快递:发件人把物品打成包裹,贴上标签,写上“里面是什么、有几件”;收件人拿到包裹之后,根据标签把物品重新装好。这里的“标签”就是序列化字符串里的属性名、属性值和类型标记。
举个最直观的例子。如果把new Demo()这个对象序列化,输出大概是这样的:
code复制O:4:"Demo":1:{s:4:"file";s:9:"index.php";}
我拆给你看:
O表示这是一个Object对象。4表示类名Demo的长度是4个字符。"Demo"是类名。1表示这个对象里有1个属性。s:4:"file"表示属性名是一个字符串,长度4,内容是file。s:9:"index.php"表示属性值是一个字符串,长度9,内容是index.php。
如果你学过一点PHP,这段结构其实不复杂。真正要命的是,unserialize在“拆包”的过程中,如果遇到某些特殊方法,它会自动去调用,你根本不需要显式触发,这就是魔术方法。
1.3 魔术方法为什么能左右大局
PHP里有一批方法名很特殊,以两个下划线开头,比如__construct、__destruct、__wakeup、__toString等等。它们会在特定时机被PHP自动调用,因此叫“魔术方法”。
这道题里有两个关键魔术方法:
__destruct():对象被销毁时调用。脚本执行结束、对象不再被引用时都会触发。这里它做的事情是file_get_contents($this->file),也就是读取$this->file指向的文件内容并输出。__wakeup():unserialize一个对象时,如果这个类定义了__wakeup,它会在对象属性填充完之后被调用。这里它做的事情是把$file重置为index.php。
看起来逻辑很合理:反序列化的时候先把file重置回默认文件,防止有人篡改;对象销毁的时候读取默认文件内容。但仔细一想,出题人是不是故意留了后门?__destruct里读取的是$this->file,这个值如果在__wakeup执行之前就已经被污染了呢?或者说,如果__wakeup压根不执行呢?
这就是这道题的核心。目标明确:让反序列化出来的对象$file指向flag文件,同时想办法让__wakeup不要执行,或者执行了也不起作用。
1.4 这道题给你布置的三道关卡
综合上面的分析,出题人其实设了三道关卡,你得一关一关绕过去:
| 关卡 | 位置 | 形式 | 目的 |
|---|---|---|---|
| 第一关 | Cookie入口 | base64编码 | 防止你直接用肉眼看到序列化串,同时避免特殊字符破坏HTTP请求格式 |
| 第二关 | User-Agent | 正则过滤 | 拦截常规的反序列化字符串特征,比如O:4:这种 |
| 第三关 | __wakeup | 属性重置 | 防止你把file改成其他文件读取内容 |
前两关是过滤和编码,第三关才是真正的考点核心。很多新手栽就栽在只看到第三关,忽略了第二关,结果构造的Payload全被正则拦下来了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 源码逐行审计:把出题人思路摸透
2.1 入口代码:可控点藏在Cookie里
再看一眼代码第一块:
php复制if (isset($_COOKIE['user'])) {
$user = base64_decode($_COOKIE['user']);
...
unserialize($user);
}
条件判断isset($_COOKIE['user'])说明请求必须携带名为user的Cookie。base64_decode说明我们要先把Payload做一次Base64编码,因为代码会把Cookie里的值先解码再交给unserialize。
这里有一个新手很容易漏掉的细节:Base64编码之后的字符串可能包含+、/、=这些特殊字符,如果在浏览器地址栏里手动加Cookie,很容易被URL编码机制干扰。我后面实操部分会专门讲怎么提交最稳。
认证一下两点:第一,Cookie是HTTP头部的一部分,但很多人会在Python脚本或Burp里习惯性地把它加到URL参数里,那就全错了;第二,base64_decode只是编码,不是加密,随手用一个在线的Base64工具就能解出原始内容,所以这道题没法靠隐藏数据来防住你。
2.2 真正的杀招:__destruct里的文件读取
php复制public function __destruct() {
echo file_get_contents($this->file);
}
这段代码的意思很直白:当对象销毁时,读取$this->file路径对应的文件,并把内容输出到页面上。file_get_contents是一个非常危险的函数,如果传入的文件路径可控,它就能读服务器上的任意文件。典型的文件读取漏洞,配合反序列化就成了任意文件读取。
如果你把$file设置成flag.php,页面就会把flag内容打出来。如果设置成/etc/passwd这类路径,也能读,但CTF题目一般不会让你读系统文件,flag大概率就躺在当前目录下的flag.php里。
难点在于__destruct是只要你构造出来的对象被销毁就会执行,不需要你额外做什么。而PHP脚本在结束时,unserialize出的临时对象如果没有被引用,会自动进入销毁流程。所以只要反序列化成功,__destruct基本必触发,这就给了你稳定利用的窗口。
2.3 __wakeup:专门挡你路的初始化方法
php复制public function __wakeup() {
$this->file = 'index.php';
}
__wakeup的触发时机很有意思:它在unserialize解析完整个字符串、把属性填充到对象上之后执行。也就是说,你本来可以把file改成flag.php,但它一执行就强制改回index.php,然后在销毁时读的只能是index.php。
这就好比快递员把包裹送到你家,你高高兴兴打开箱子,结果发现里面有一张纸条写着“本包裹内容已重置”。你当然不想让它重置成功。
绕过思路源于一个经典的PHP反序列化漏洞:当序列化字符串中声明的属性个数大于对象真实的属性个数时,PHP在反序列化过程中会跳过__wakeup方法,直接进入对象销毁阶段。这个漏洞编号对应早期的PHP版本问题,在部分老版本PHP环境中依然适用,CTF靶场为了保留考点,经常会特意使用存在这个问题的版本环境。
那具体怎么触发?很简单,把O:4:"Demo":1:改成O:4:"Demo":2:,属性个数从1改成2,但属性列表里依然只有file一个属性。解析器一看,属性个数对不上,就进入“容错”模式,不再调用__wakeup。
2.4 正则过滤:出题人在UA上设的卡子
php复制if (preg_match('/[oc]:\d+:/i', $_SERVER['HTTP_USER_AGENT'])) {
die('Stop hacking!');
}
这段正则匹配的是请求头User-Agent,也就是浏览器的UA字符串。正则字面量/[oc]:\d+:/i的含义是:匹配一个字符o或c,后面紧跟冒号,再跟一个或多个数字,最后跟冒号。i修饰符表示忽略大小写。
为什么要过滤UA?因为很多扫描器和手工测试脚本习惯把Payload放在UA里,出题人就把检查点设在了这里。它抓到的是反序列化字符串的典型特征:O:4:这种格式。一个正常的浏览器UA绝不会出现O:4:这样的片段,出现就说明你大概率在手工构造Payload,于是直接die掉。
注意,这个正则只检查UA,不检查Cookie。所以你如果把Payload放在Cookie里,它其实不会因为这个正则死掉。但构造的时候你还是得小心,因为有些新手会把Payload同时塞进UA里,那就正中枪口了。我们的做法是:把构造好的序列化字符串加进Cookie,而UA保持正常的浏览器标识。
那如果出题人连Cookie里的Payload也检查呢?别急,这道题的经典绕过方法就是加号绕过。把O:4:改写成O:+4:,正则里\d+要求冒号后必须是数字,但加号不是数字,所以正则匹配失败,绕过拦截。而PHP反序列化解析器在面对O:+4:时,会把+4当作十进制数4来解析,依然能正常还原对象。这就是一个典型的“程序与正则判定不一致”的绕过手法。
3. 一步步手写Payload(胎教级拆解)
3.1 目标确认:flag到底在哪个文件
一般这类题目,flag文件常见命名就是flag.php,也有可能是flag.txt、f1ag.php、/var/www/html/flag之类。实在不确定就用题目页面源码里的提示去猜。如果源码里没有明显提示,第一轮可以先把file指向flag.php试试,如果页面回显了flag内容就结束;如果回显了index.php源码,说明你的Payload被__wakeup重置了,还得继续绕过;如果页面直接报file_get_contents的Warning,说明文件不存在,换个文件名再试。
别忘了,index.php本身也是可以读的。第一版Payload如果不绕过__wakeup,你会发现自己读到了index.php的源码,这本身就是一种信息收集,可以确认你的反序列化和文件读取链路是通的。
3.2 手拼序列化字符串:每个字符都算明白
我选择目标文件为flag.php,手工构造一个常规序列化字符串:
code复制O:4:"Demo":1:{s:4:"file";s:8:"flag.php";}
这里最容易翻车的点是长度。Demo是4个字符;file是4个字符;flag.php是8个字符(f、l、a、g、点、p、h、p,数一下确实是8)。如果你把类名长度写成5,或者属性名长度写成5,PHP解析器会直接报错,反序列化失败,而且页面往往不给你任何明确提示,只有一条“Notice: unserialize(): Error at offset”之类的信息,新手很容易被吓住。
我强烈建议不要纯手工数,先用本地PHP打一行代码验证:
bash复制php -r 'class Demo { public $file = "flag.php"; } $a = new Demo(); echo serialize($a);'
输出就是合法的序列化串,你直接复制下来改数字即可。这个习惯能省下大量排查时间。
3.3 关键一步:把属性个数从1改成2
接下来做__wakeup绕过。把上面序列化串里的属性个数从1改成2:
code复制O:4:"Demo":2:{s:4:"file";s:8:"flag.php";}
对照真实对象,Demo只有一个file属性,但字符串里声明有2个属性。PHP反序列化器在解析时发现实际解析到的属性少于声明个数,就会进入容错流程,不再调用__wakeup。这样file就保住flag.php了。
这里必须强调版本问题。这个绕过依赖PHP版本,通常影响PHP 5.x和PHP 7.0的某些版本。如果你在自己电脑上本地复现时发现不管用,先检查一下php -v,大概率是PHP版本太新,这个绕过已经被修掉了。CTF训练平台会专门搭建存在漏洞的老版本环境,所以在题目环境里它是有效的。
3.4 怎么过正则:加号绕过原理与坑
现在处理第二关的正则。出题的过滤是/[oc]:\d+:/i,我们要把O:4:改成O:+4::
code复制O:+4:"Demo":2:{s:4:"file";s:8:"flag.php";}
这里的关键是理解PHP解析器的宽容性。在反序列化的时候,PHP的解析规则是:O后面跟冒号,然后读取类名长度。类名长度部分它是一个数字,数字前面出现一个加号并不会影响PHP把它解析成整数4。而正则那边正等着\d+来匹配数字,加号挡在中间,匹配就断了。
这个技巧非常经典,处理这类正则过滤时几乎百试百灵。还有一个小变体是写成O:04:"Demo"之类的,因为正则\d+也能匹配到04,所以加号是更稳妥的姿势。记住:加号只能加在数字符号处,不能加在类名前,否则PHP解析会出错。
3.5 编码顺序与请求发送
构造完原始字符串后,需要按下面的顺序处理:
- 得到原始序列化字符串:
O:+4:"Demo":2:{s:4:"file";s:8:"flag.php";} - 对整串做Base64编码。
- 把编码结果放进Cookie的
user字段。 - 请求时带上一个正常UA,避开正则过滤。
用Linux命令生成Base64很方便:
bash复制echo -n 'O:+4:"Demo":2:{s:4:"file";s:8:"flag.php";}' | base64
注意一定用echo -n,不要带末尾换行,否则Base64结果会不同。然后发请求就直接上curl:
bash复制curl -v 'http://靶机地址/' \
-H 'User-Agent: Mozilla/5.0' \
-b 'user=你刚生成的Base64串'
如果更习惯用Burp Suite,直接抓包改Cookie也行。很多新手会问:Base64串里的=和+需不需要URL编码?如果放在Cookie里,大多数情况下浏览器和curl能正确处理;但如果放在URL参数里,加号会被解析成空格,那就会出错。所以我建议能用curl或Burp就尽量别用浏览器手动改Cookie,省掉一堆编码问题。
4. 完整实战复现:从发请求到拿到flag
4.1 构造第一版请求:被wakeup教育了
我先按最朴素的思路来一发,不绕__wakeup,直接构造:
code复制O:4:"Demo":1:{s:4:"file";s:8:"flag.php";}
Base64编码后放进Cookie,UA保持正常,请求一发。页面返回的是index.php的源码,说明反序列化成功、__destruct执行成功,但__wakeup也执行了,它把file从flag.php重置成了index.php,于是读出来的是当前页面源码。
这一步很有价值,它验证了三件事:
- 反序列化链路是通的。
- Cookie入口可控。
- 文件读取函数能正常工作。
剩下的事就是把__wakeup绕掉。
4.2 改造利用串:全局只改一个数字
把属性个数从1改成2,同时加号绕过UA正则:
code复制O:+4:"Demo":2:{s:4:"file";s:8:"flag.php";}
重新Base64编码,再次用curl发送。这次页面输出的就是flag.php里的内容,通常是一串flag{...}格式的字符串。我到现在还记得第一次看到flag打印在终端里的感觉,从一个冷冰冰的字符串变成真正可控的文件读取,那种把逻辑彻底打通的感觉非常爽。
如果页面输出了一段PHP代码而不是清晰的flag文本,那可能是因为flag文件里是<?php flag{...} ?>格式,被当作PHP执行了没显示。这种情况下你可以拿返回的HTML源码看,或者把Payload里的文件名改成其他纯文本文件再读。CTF的flag文件一般会写成纯文本形式,所以多数情况直接就能看见。
4.3 拿到flag后的常规操作
拿到flag字符串之后,把它复制到题目的提交框里,这道题就算过了。要注意,有些平台的flag不是flag{...}格式,可能前缀不同,你复制的时候别把空格和换行也带进去。
接下来我建议你做一个动作:把当前环境里的PHP版本记下来,把最终可用的Payload原样保存到自己的笔记里。以后遇到类似的反序列化题,这个Payload就是你的基线模板,改两个地方(类名和目标文件)就能复用。
5. 新手踩坑实录与排查速查
5.1 五个高频翻车点
我见过太多人在这道题上卡住,原因几乎都集中在这几处:
| 错误操作 | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 手工数错字符串长度 | 反序列化报错或页面无输出 | 属性名/类名长度和实际不一致 | 用PHP的serialize函数生成再改 |
| 忘记Base64编码 | 反序列化对象为bool(false) |
代码先base64_decode再unserialize |
先编码再放Cookie |
| 把Payload放到UA里 | 页面直接提示Stop hacking | 正则匹配到O:数字:特征 |
只放Cookie,UA保持正常 |
| 本地复现__wakeup绕过失效 | 属性总被重置 | 本地PHP版本过新 | 确认题目环境版本,或者用Docker搭老版本环境 |
| 把加号放进URL参数 | Payload被解析成空格 | URL编码问题 | 用curl的-b参数或Burp改Cookie |
5.2 快速排查思路
如果构造完Payload发出去,页面没有反应,不要慌,按下面的顺序排查:
第一,确认你的Base64解码结果和预期序列化串一致。很多新手在拼接阶段多了一个空格或者少了一个引号,导致Base64结果完全不对。你可以在本地把Base64解码回来看看。
第二,确认Cookie的键名是user。有的题目可能改成data、payload之类的,一定以源码为准。如果你用代码里的键名不完全一致,程序根本不会进入反序列化分支。
第三,确认UA没有触发正则。如果你实在拿不准,可以临时把UA改成curl/7.0这种不含特征的值,先跑通链路,再考虑加号绕过。
第四,确认响应内容。反序列化失败时PHP会有Notice级错误,很多时候页面会显示警告信息。别忽略这些警告,它们会告诉你unserialize()在哪一个偏移量处报错,定位问题非常有用。
5.3 本地环境复现脚本
如果你想彻底研究清楚,建议在本地起一个同款环境。只需要一个index.php文件加上php -S命令:
bash复制php -S 127.0.0.1:8080
然后把上文那段源码原样放进index.php,在本地请求自己搭的靶机,随便怎么改Payload都不会有负罪感。本地环境还可以开display_errors,把报错信息全部打开,观察unserialize到底在哪一步失败。一个小技巧:
bash复制php -d display_errors=1 -d error_reporting=E_ALL -S 127.0.0.1:8080
配合本地复现,你对这个漏洞的理解会比只看不练扎实非常多。
6. 从这道题延伸出去的通法
6.1 反序列化题目五步套路
做完这道题,我建议你把它沉淀成一套方法论,因为反序列化题目几乎都逃不出这五步:
- 找入口:看代码里有没有
unserialize($_GET)、unserialize($_COOKIE)、unserialize($_POST),找到外部可控的序列化数据。 - 找魔术方法:找
__wakeup、__destruct、__toString、__call之类,看它们做了什么危险操作。 - 画利用链:思考从哪个方法进入、中间经过哪些属性、最终触发什么危险函数。
- 绕过滤:分析
preg_match、str_replace、Base64编码等限制,用加号、大小写、整数溢出等方式绕过。 - 写Payload:用PHP的serialize函数生成原始串,手工微调绕过,最后编码提交。
6.2 后续能碰到的变体
这一题是入门级,但它的知识点能延伸到很多进阶题型:
- 更复杂的POP链:多个类协同工作,利用链像俄罗斯套娃一样逐层嵌套。比如一个类的
__toString调用另一个类的某个方法,后者再触发文件操作。 - Php反序列化配合文件操作:比如上传phar文件后,利用
file_exists等函数触发phar反序列化,在没有任何明显入口的情况下照样利用。 - 原生类反序列化利用:不依赖题目自己写的类,而是利用PHP内置类和方法,比如
SoapClient触发SSRF,SimpleXMLElement做XXE。 - 序列化逃逸:发生在
preg_replace替换导致字符串长度变化时,通过构造特殊输入“逃逸”出预设的属性值覆盖其他属性。
不管变体怎么换,第一步永远是看懂序列化格式,看懂魔术方法触发时机。这也是我为什么强调这道题一定要亲手做一遍的原因。
6.3 我自己的几条经验
最后说点实在的。我最初做这类题目的时候也踩过整整一个晚上的坑,后来发现问题出在属性名长度上,当时恨不得抽自己一巴掌。从那以后我就养成了一个习惯:凡是需要手拼序列化串的场景,一律先在本地用serialize()函数生成,再手工微调数字。人算不如代码算,这个习惯帮我避开了无数低级的长度错误。
另一个习惯是拿到Payload后先在本地验证,再往靶场打。本地环境随便折腾,配合var_dump看对象状态,远比你盲调curl高效。很多时候你觉得“这题好难”,其实是少了这个本地实验的环节,直接把半成品丢到靶机上,得到一堆看不明白的反馈。
这道题你卡几天不丢人,它是许多人反序列化的第一道坎。闯过去之后,后面再碰到POP链、phar、原生类利用,你会发现自己已经有了一种“看到反序列化就条件反射找魔术方法”的直觉。这种直觉没有捷径,就是一遍遍看源码、一遍遍改Payload喂出来的。
