☰
PHP反序列化入门:从CVE-2016-7124理解__wakeup绕过与Payload构造
2026/10/14 4:27:42 网站建设 项目流程

第一次看到AreUserialz这个题名,我愣了一下。把它拆开念几遍才反应过来,这大概是在问“Are U serialz”——你序列化了吗。这种谐音梗在CTF题目里不算少见,它基本等于把考点直接写在脸上了:PHP反序列化。那道题在整场Web方向里只值14分,放在赛场上属于开胃菜级别,但真正完整复盘的时候我发现,它把PHP反序列化题目中最容易出错的几个细节全串到了一起:属性访问级别对序列化字符串格式的影响、__wakeup魔术方法的触发时机、CVE-2016-7124带来的属性个数绕过,以及远程利用时base64与URL编码的坑。这篇文章就按我当时从读源码到打通远程环境的完整链路来写,适合刚接触PHP反序列化、想彻底搞懂wakeup绕过和构造payload细节的读者。

1. 拿到题目后的第一眼:源码里藏着一条完整利用链

1.1 入口文件只有十几行,关键全在unserialize

我当时拿到的题目,入口文件非常简单,去掉无关部分,核心逻辑大概长这样:

<?php error_reporting(0); class AreUserialz { public $name; public $cmd; public function __destruct() { if (!preg_match('/^AreUserialz$/', $this->name)) { echo "nonono"; return; } system($this->cmd); } public function __wakeup() { $this->name = "bad name"; $this->cmd = "bad cmd"; echo "wakeup called"; } } if (isset($_GET['data'])) { $data = base64_decode($_GET['data']); unserialize($data); }

先别急着看后面的任何技巧,这个入口文件已经把大半条利用链写在明面上了。$_GET['data']是可控输入,经过base64_decode()之后直接喂给unserialize(),这是一个标准的反序列化触发点。问题只剩一个:我们可控的对象,到底能走到哪个危险函数。

代码里出现了一个自定义类AreUserialz,它有两个公开属性:$name和$cmd。类里定义了两个魔术方法,一个是__wakeup(),反序列化时自动执行,它会立刻把$name和$cmd改成非预期值;另一个是__destruct(),对象生命周期结束时自动执行,逻辑是先校验$name是否严格等于字符串AreUserialz,如果相等,就把$cmd直接交给system()执行。

也就是说,只要能让反序列化出来的对象,在销毁时保留name = "AreUserialz"、cmd = "id"这种组合,命令执行就跑起来了。可问题是,默认情况下__wakeup()会先于__destruct()触发,把name和cmd全部改掉,__destruct()里那层正则校验自然过不去,最终只会打印一个nonono。

所以这道14分题的核心,不是找命令执行点,而是想尽办法让__wakeup()不执行,或者让它执行了但起不到破坏作用。这里还要注意一个细节:入口处用base64_decode()接收参数,不是偶然设计。序列化数据里包含引号、花括号,甚至可能包含空字节,直接放URL里很容易被各种解析逻辑处理坏。base64编码相当于给原始字节流套了一层文本壳,到服务端再解回来,CTF题目里为了传输和调试方便经常这么写。

1.2 魔术方法与危险函数:审计时先找这三样

如果让我总结一条最快的审计路径,那就是看见unserialize()之后,立刻去找三样东西:魔术方法、可控属性、危险函数。三样东西能连成一条线,这道题基本就是一道送分题。

__wakeup()和__destruct()属于典型的生命周期魔术方法,前者在对象从序列化串还原时被调用,后者在对象销毁时被调用。它们之间的时间差,就是整道题的博弈空间。$name和$cmd都是公开属性,这降低了手工构造序列化串的难度,因为不需要为属性名做什么特殊处理。system($this->cmd)是再明显不过的危险函数,配合$cmd可控,等于直接给了一个命令注入口。

审计到这里,攻击目标已经清楚了:name必须完全等于AreUserialz,cmd必须是想要执行的命令。而__wakeup()唯一的作用就是破坏这两个属性。因此,反序列化时只要能让__wakeup()“缺席”,__destruct()里就会忠实地执行我们设置的命令。

2. 序列化字符串里的三处细节:决定你payload能不能被解析

2.1 不同可见性属性在序列化串里的“长相”

在构造payload之前,我建议先把PHP序列化的输出格式彻底看清。处理一个普通public属性的对象,serialize()输出大概是这样的:

$o = new AreUserialz(); $o->name = "AreUserialz"; $o->cmd = "id"; echo serialize($o);

输出:

O:10:"AreUserialz":2:{s:4:"name";s:10:"AreUserialz";s:3:"cmd";s:2:"id";}

拆开看:O代表对象,10是类名字符串长度,"AreUserialz"是类名,2是属性个数。后面花括号里是键值对,每个属性的格式是s:长度:"属性名";,每个值的格式则是s:长度:"值";,如果值是整数就是i:,如果是布尔就是b:。

看起来很简单,但初学者最容易栽在可见性修饰符上。一旦属性不是public,序列化串里的属性名就会发生变化:

  • private属性:属性名会变成\0类名\0属性名,注意这里有两个空字节,一个在类名前面,一个在属性名前面。比如某个private $secret,实际键名是\0ClassName\0secret。
  • protected属性:属性名会变成\0*\0属性名,同样是空字节加星号加空字节的结构。

这种格式带来的麻烦是双重的。第一,你在手工构造payload时,如果按照secret这个短名字去写,PHP反序列化后得到的属性名和类里真实属性名对不上,值根本赋不进去,对象销毁时$this->secret就是未定义状态。第二,\0是空字节,在URL传输时如果直接粘贴,很容易被各种中间层吃掉或截断,必须特殊处理。

我见过不少人在这一步卡很久:payload看起来结构完整,长度也对,但反序列化后对象就是一个空壳。别怀疑,基本都是属性名里少了空字节或者长度没算对导致的。这也是为什么我一直强调,能用serialize()生成就不要手写,后面章节会再展开。

2.2 反序列化中魔术方法的触发顺序

一个对象的生命周期里,和序列化、反序列化相关的魔术方法大概有四个:__sleep()在调用serialize()时触发,__wakeup()在调用unserialize()时触发,__destruct()在对象销毁时触发,__toString()在对象被当成字符串使用时触发。CTF题里最常见的组合就是__wakeup()和__destruct()。

正常流程下,这个顺序是固定的:

  1. unserialize($data)解析序列化串,创建对象。
  2. 按序列化串里的内容填充对象属性。
  3. 如果类里定义了__wakeup(),立即调用。
  4. 脚本继续执行,对象一直存活。
  5. 脚本结束或引用计数归零时,对象销毁,调用__destruct()。

所以在普通场景下,__wakeup()必然先于__destruct()执行。这道题正是利用这个必然性,把两个属性全部重置,让后面的命令执行点变成死路。想绕过它,常规办法就是让第3步直接消失。PHP历史上确实存在这样一个绕过,只要满足特定条件,__wakeup()就不会被调用,这就是下一章要说的CVE-2016-7124。

3. __wakeup绕过的原理:为什么改一个数字就能决定生死

3.1 CVE-2016-7124影响版本与触发条件

CVE-2016-7124这个编号,对应的是PHP反序列化时的一个逻辑缺陷。受影响的版本主要是PHP 5.6.25及更早、PHP 7.0.10及更早。触发条件非常具体:当序列化字符串中声明的对象属性个数,大于这个对象实际包含的属性个数时,__wakeup()就得不到执行。

举个最简单的例子,一个类只有一个属性$x,但它对应的序列化串里写的是O:1:"A":2:{...},属性个数写成了2。PHP在解析这个对象时,会发现声明数量与实际不匹配,于是走进了一条不会调用__wakeup()的异常路径。官方后续版本修复了这个问题,所以在新版本PHP上,这个技巧不再生效。

为什么会有如此奇怪的行为?对PHP内核来说,反序列化需要先根据属性个数去分配存储结构,再逐个把属性塞进去。如果属性个数不一致,处理流程会走到一条特殊分支,这个分支省略了魔术方法调用。这本身是个实现层面的漏洞,但CTF赛题里利用它来绕过__wakeup()相当稳定——前提是目标环境正好落在受影响版本范围内。

回到这道题,AreUserialz类只有两个属性:$name和$cmd。正常情况下,序列化串里第二段会写:2:。如果我把这个数字改成3、4、5甚至更大,声明属性个数大于实际个数,目标环境一旦存在CVE-2016-7124,__wakeup()就会整段跳过,$name和$cmd保持原样。

3.2 用本地环境验证属性个数不等式

不亲手验证一次,总感觉这个技巧有点悬。我建议你在本地起一个PHP 7.0.10或更早版本的环境,测试下面这个最小样例:

<?php class A { public $x; function __wakeup() { echo "wakeup called\n"; } function __destruct() { echo "destruct called\n"; } } // 实际属性只有1个,声明属性个数为2 $s = 'O:1:"A":2:{s:1:"x";s:1:"1";}'; var_dump(unserialize($s));

在受影响版本上运行,输出里看不到wakeup called,只有destruct called,说明__wakeup()确实被绕过了。在修复后的版本上运行,两者都会输出。

拿这个理解再回头看我们的题目:原本正常的序列化串是

O:10:"AreUserialz":2:{s:4:"name";s:10:"AreUserialz";s:3:"cmd";s:2:"id";}

只要把:2:{改成:3:{,就会触发同样的绕过逻辑。注意,属性个数可以写得比实际大,但不能小于实际属性个数。写的太小,PHP会认为属性列表不完整,反序列化过程直接失败,返回false,后面什么都不会发生。这是我实测踩过的坑,所以构造payload时,建议只做“把2改大”这种微调,不要随手乱改其他数字。

4. 一次完整的远程利用过程:从payload生成到拿到flag

4.1 最稳妥的payload生成方式:先serialize再改数值

既然我们已经知道要把属性个数从2改成3,那最稳的payload生成方式就是先用PHP自己的serialize()函数生成合法序列化串,再精准修改那一处数字。千万别从零开始手写序列化串,字符串长度算错一个字节,整个payload就会崩。

生成脚本我习惯这么写:

<?php class AreUserialz { public $name = "AreUserialz"; public $cmd = "id"; } $o = new AreUserialz(); $s = serialize($o); // 关键一步:把属性个数从2改成3,绕过__wakeup $s = str_replace(':2:{', ':3:{', $s); echo "serialized: " . $s . "\n"; echo "base64: " . base64_encode($s) . "\n"; ?>

这段脚本的核心思想是:所有属性名、属性值的长度都由serialize()自动计算,绝对正确;我只需要在生成之后,找到:2:{这个特征,替换成:3:{。只要类里确实只有两个属性,这个替换极其安全,不会误伤其他地方。

如果你想用Python生成payload,也完全可以,但要更加小心:

import base64 payload = 'O:10:"AreUserialz":3:{s:4:"name";s:10:"AreUserialz";s:3:"cmd";s:2:"id";}' print(base64.b64encode(payload.encode()).decode())

Python版本只适合当前这道题的public属性场景。如果题目里的属性是private或protected,序列化串里会夹带空字节,手写长度很容易出错,我会优先选择PHP脚本生成。

这里有一个非常实用的习惯:先把cmd设置成id或者whoami,确认命令执行打通,再换成读取flag的命令。一来验证速度快,二来避免一开始就踩到命令过长或其他干扰问题。

4.2 base64与URL传输环节的隐形坑

Payload生成后,直接访问目标的地址,把base64字符串拼到data参数上。看起来简单,但这一步藏着一个挺常见的坑:base64字符集里包含+、/、=,其中+在URL的query string里会被解析成空格,/在某些环境下会影响路径处理,=会被当成参数分隔符。如果你直接把带+的base64原文粘贴到浏览器地址栏里,服务端收到的可能已经是变形的数据了。

解决方式不难:要么把base64结果再整体做一次URL编码,要么用支持自动编码的HTTP工具发送。我更推荐用脚本发请求,省去手动编码的麻烦:

import base64 import requests # 这里替换成你本地生成好的payload payload_b64 = "TzoxMDoiQXJlVXNlcmlhbHoiOjM6e3M6NDoibmFtZSI7czoxMDoiQXJlVXNlcmlhbHoiO3M6MzoiY21kIjtzOjI6ImlkIjt9" url = "http://目标地址/" r = requests.get(url, params={"data": payload_b64}) print(r.text)

requests库在组装URL时会自动处理特殊字符,比手动在Burp里改来改去省心很多。如果你更习惯Burp,也没问题,直接把完整的GET请求丢进Repeater,Burp不会像浏览器那样擅自把+变成空格。

还有一个细节:base64里如果有+,在Burp的请求行里直接原样发是没问题的;但如果用浏览器或某些命令行工具,就要留意编码问题。这个坑看起来很小,实际比赛里确实有人把时间花在这上面。

4.3 命令执行验证与读取flag的三步走

第一发我发的是cmd = id,对应的序列化串是

O:10:"AreUserialz":3:{s:4:"name";s:10:"AreUserialz";s:3:"cmd";s:2:"id";}

请求发进去,响应里没有出现wakeup called,也没有nonono,而是直接打印了一行类似uid=0(root)的输出。看到这个结果,基本就可以确认三件事:环境版本在受影响范围内,__wakeup()被成功绕过,system()命令执行点真实可用。

第二步自然就是看看目标目录结构,把cmd换成ls /,重新用脚本生成payload并发送。注意命令里带空格,但在base64编码后空格会变成普通字符,不会像明文URL那样被截断。

第三步是读取flag。常见路径包括/flag、/flag.txt、/tmp/flag等,稳妥起见可以先执行find / -name "*flag*"针对性查找,再cat出来。在这道题的环境里,flag通常直接放在根目录,一条cat /flag就足够了。

整个利用过程如果只用一个词总结,就是“直来直去”:没有二次过滤,没有绕WAF,入口直接反序列化到命令执行。14分的定位也符合这个难度——它不考你多复杂的绕过,考的是你对PHP反序列化基础链路是否熟练。

5. 这道14分题背后的地图:反序列化考点还能怎么长

5.1 基于相同考点的几种经典变体

把这一道题吃透后,再看旁边的其他反序列化题,就不会觉得它们是孤立的了。最常见的一种变体,是把简单的system($this->cmd)换成一段复杂的POP链。所谓POP链,就是利用多个对象之间的魔术方法相互调用,比如__destruct()里调用某个方法,这个方法里又调用了另一个对象的__call(),一层层往下走,最终到达eval、system或文件读写函数。这种变体更绕,但出发点依然是“寻找受控属性如何流向危险函数”。

第二种很常见的变体是phar反序列化。题目可能根本没有暴露unserialize()入口,但存在文件上传点,并且代码里会对用户可控文件路径执行file_exists()、is_dir()等操作。攻击者可以构造一个phar文件,伪造它的元数据,让这些文件操作函数在解析phar时触发反序列化。这种题目对源码审计能力要求更高,因为你得先发现哪些文件函数可以被用户影响。

第三种变体是session反序列化。PHP的session序列化处理器存在多种格式,比如php格式和php_serialize格式,如果写入和读取配置不一致,攻击者可以在session里构造恶意数据结构,造成反序列化。这类题目通常需要配合污染session文件等手段。

还有一类变体是原生类利用。当代码里没有自定义类,或自定义类不可控时,可以尝试PHP自带类,比如SoapClient可以结合反序列化触发SSRF,SimpleXMLElement可以配合XXE。这类题更进阶,也是近几年的热门方向。

5.2 实战中如何快速判断一个入口是否可能反序列化

如果让我给一套快速判断方法,我会按这几个特征去扫源码:

  • 是否存在unserialize(),且参数来自用户可控入口,如$_GET、$_POST、$_COOKIE、文件内容等。
  • 是否存在大量__开头的魔术方法,尤其是__destruct、__wakeup、__toString、__call、__get。
  • 是否有对象的属性值被传入system、exec、eval、include、file_get_contents这类函数。
  • 是否对输入做了base64_decode、json_decode再到unserialize的转换包装。
  • 是否存在文件上传加phar://协议或file_exists等操作。

这些特征不需要全部满足,命中一两条就值得往反序列化方向深挖。审计顺序可以固定为:先找危险函数,再倒推参数来源,最后确认能否通过反序列化控制这条链路。

5.3 从攻击回到防御:修这个漏洞其实不复杂

站在防守角度看,这类问题修复起来比想象中简单。最彻底的方案,就是不要对不可信数据使用unserialize()。JSON能表达的数据结构,用json_decode()安全得多,也不涉及对象生命周期魔术方法,自然没有wakeup绕过的空间。

如果业务上确实依赖PHP对象序列化,那就必须做好下限控制。第一种做法是在unserialize()调用时指定第二个参数allowed_classes,只允许某些白名单类被反序列化;第二种做法是对序列化串增加签名或加密,只有服务端自己生成的payload才能通过校验;第三种做法是把PHP版本升级到安全版本,让CVE-2016-7124这类历史漏洞失效。

比赛里我们当然在想办法绕过这些限制,但实际开发时,这些限制恰恰是保命线。很多反序列化漏洞爆出来,本质上不是PHP不够安全,而是开发者把用户输入直接喂给了unserialize(),又不做任何边界检查。

最后聊一点个人体会。复现这道题的时候,我发现真正值钱的不是那一行system,而是分析过程中建立起来的版本敏感意识。第一次本地跑通时我随手用了新版本环境,__wakeup绕过根本不生效,浪费了不少时间;后来把容器切到PHP 7.0.9,同样的payload立刻成功。这让我养成一个习惯:凡涉及unserialize()的题目,先确认目标PHP版本,再决定用哪套绕过手法。另一个习惯是先调serialize()生成完整序列化串,再对关键数值做微创手术,而不是整串手写。这两个习惯陪我过了后面不少反序列化题,尤其是POP链比较长的题,手写payload基本等于给自己挖坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询