1. 从一次审计中的“意外”说起:为什么原生类反序列化值得单独拎出来讲
很多做代码审计或者CTF的同行,第一次接触PHP反序列化时,注意力几乎都放在__destruct、__wakeup、__toString这些魔术方法上,觉得只要把POP链拼出来就万事大吉。但真正在实战里摸爬滚打过一段时间之后你会发现,有一类利用方式根本不需要你自己去构造POP链——PHP内置的那些原生类,本身就带着可以被串联起来的魔术方法。这就是“PHP原生类反序列化”这个主题的核心价值所在。
简单来说,PHP原生类反序列化指的是:利用PHP语言自带的内置类(比如SoapClient、DirectoryIterator、FilesystemIterator、SplFileObject、SimpleXMLElement、Error、Exception等),在反序列化场景下触发它们内部的魔术方法,从而实现SSRF、任意文件读取、XSS、甚至代码执行等效果。它解决的核心问题是:当目标代码里找不到用户自定义类的POP链时,我们依然有牌可打。适合谁看?适合已经理解PHP反序列化基础原理、想进一步拓宽利用面的人;也适合做CTF Web方向、想补全原生类利用知识盲区的选手。
我个人的体会是,原生类反序列化的门槛其实比自定义POP链要低,因为不需要你去读大量业务代码找链,但它对PHP内置类行为的熟悉程度要求更高。你得清楚哪个类在__destruct时会做什么、哪个类在__toString时会输出什么、哪个类在__call时会发起网络请求。这篇文章就把我这些年积累的原生类利用思路、踩过的坑、以及实际测试中验证过的细节,系统地梳理一遍。
2. 原生类反序列化的底层逻辑:魔术方法才是真正的入口
2.1 反序列化触发的本质是魔术方法的自动调用
要理解原生类反序列化,得先把反序列化的触发机制说透。unserialize()函数在还原对象时,并不是简单地把属性填回去就完事了,它会根据对象的状态自动调用一系列魔术方法。比如对象被销毁时调用__destruct,对象被当成字符串使用时调用__toString,对象被当成函数调用时调用__invoke,访问不可访问属性时调用__get,等等。
原生类之所以能被利用,就是因为PHP内置的这些类在实现时,内部也定义了这些魔术方法,而且它们的实现逻辑往往带有“副作用”——比如发起网络连接、读取文件内容、输出数据。这些副作用在正常业务里是功能,在攻击者手里就是利用点。
我经常用一个类比来解释:自定义类的POP链像是你自己组装的一串多米诺骨牌,每一张牌都得你自己摆;而原生类反序列化像是直接推倒一摞厂家已经摆好的骨牌,你只需要找到第一张牌在哪就行。
2.2 为什么原生类利用在实战中经常被忽略
原因有几个。第一,很多教程讲反序列化时默认用自定义类举例,导致初学者形成了“反序列化=找POP链”的思维定式。第二,原生类的魔术方法行为在PHP手册里写得比较分散,需要跨多个页面查阅。第三,部分原生类的利用条件比较苛刻,比如需要开启特定扩展(SOAP扩展)、需要特定PHP版本。
但恰恰是因为被忽略,原生类利用在某些场景下反而成了“奇兵”。我在一次内部代码审计演练中就遇到过这样的情况:目标应用里几乎没有任何自定义类,反序列化入口却真实存在,最后就是靠SoapClient打出了一条SSRF链路。
2.3 利用原生类前必须确认的三个前提
在动手之前,有三个前提条件必须逐一确认,否则后面全是白费功夫。
- 反序列化入口是否可控:也就是
unserialize()的参数是否部分或完全来自用户输入。常见入口包括Cookie、POST参数、缓存数据、Session内容等。 - 目标PHP版本与扩展:不同PHP版本对原生类的行为有差异,某些类在特定版本才引入或修改了魔术方法实现。SOAP类需要
php_soap扩展,SimpleXMLElement需要php-xml扩展。 - 魔术方法是否会被触发:即使你构造了对象,如果代码流程中没有触发对应魔术方法的操作,利用也不会生效。比如
__destruct在脚本结束时会触发,但__toString需要对象被当成字符串使用。
提示:在实际测试中,建议先用一个最简单的
phpinfo()页面或者本地环境确认目标PHP版本和已加载扩展,这比盲目构造payload要高效得多。
3. SoapClient:原生类SSRF利用的“头号选手”
3.1 SoapClient的__call机制与SSRF的关联
SoapClient类在反序列化利用中的地位,大概相当于自定义POP链里的“万能入口”。它的核心利用点在于__call魔术方法:当你调用一个SoapClient对象上不存在的方法时,__call会被触发,而SoapClient的__call实现会尝试向location属性指定的URL发起SOAP请求。
这意味着什么?意味着如果你能控制反序列化后的SoapClient对象的location属性,就能让它向任意地址发起HTTP请求。这就是一条典型的SSRF链路。
构造思路大致是这样的:先创建一个正常的SoapClient对象,然后用序列化字符串手动修改其location属性,最后把修改后的payload传给目标的unserialize()。当目标代码后续调用了这个对象上任意一个不存在的方法时,__call触发,请求就发出去了。
3.2 构造SoapClient payload时最容易踩的坑
这里有几个细节,我在实际测试中反复踩过。
第一个坑是user_agent属性。SoapClient在发起请求时会带上user_agent,如果这个字段为空或者格式不对,某些服务端可能会拒绝请求。更关键的是,在CTF场景下,user_agent经常被用来绕过一些简单的WAF规则,因为你可以把payload藏在User-Agent头里。
第二个坑是location的协议。SoapClient默认使用SOAP协议,但你可以通过修改location为http://或https://来让它发起普通HTTP请求。不过要注意,如果目标环境禁用了http流包装器,这条路就走不通。
第三个坑是CRLF注入。SoapClient的user_agent属性如果包含换行符,可以实现HTTP请求头的注入,这在某些场景下能构造出更复杂的请求。但PHP不同版本对换行符的处理不一样,需要实测。
<?php // 构造SoapClient反序列化payload的基本思路 $target = "http://example.com/"; $client = new SoapClient(null, array( 'location' => $target, 'uri' => 'http://test.com', 'user_agent' => "test\r\nX-Custom: injected" )); // 序列化后手动调整属性,或直接使用序列化字符串 echo serialize($client); ?>3.3 一个完整的SSRF验证流程
假设你已经确认目标存在反序列化入口,并且php_soap扩展已开启。完整的验证流程是这样的:
- 在本地构造
SoapClient对象,设置location为你控制的监听地址。 - 序列化该对象,得到payload字符串。
- 将payload通过目标的反序列化入口传入。
- 在监听端观察是否收到请求。
- 如果收到请求,说明SSRF成立,可以进一步尝试访问内网地址。
我通常会在监听端用nc -lvnp 8080或者起一个简单的HTTP服务来观察请求。如果目标环境有出网限制,可以尝试访问内网地址如127.0.0.1、192.168.x.x等,观察响应时间差异来判断端口开放情况。
注意:
SoapClient的利用需要目标代码在反序列化之后调用了该对象上的某个方法。如果代码只是反序列化但没有后续调用,__call不会触发。这一点和__destruct不同,需要特别留意。
4. 文件操作类原生类:DirectoryIterator与SplFileObject的读取能力
4.1 DirectoryIterator的__toString为何能泄露目录内容
DirectoryIterator这个类在反序列化利用中的价值,主要体现在它的__toString方法上。当你把一个DirectoryIterator对象当成字符串使用时,它会返回当前指向的文件名。如果配合__destruct或者循环遍历,就能列出目录内容。
但这里有个关键限制:DirectoryIterator的构造函数需要一个路径参数,而反序列化时构造函数不会被调用。所以你不能直接通过反序列化创建一个指向任意目录的DirectoryIterator。不过,在某些场景下,如果代码在反序列化后对对象进行了操作(比如遍历),仍然可能触发信息泄露。
实际测试中,DirectoryIterator更多是作为POP链中的一环来使用,而不是单独利用。它的__toString可以返回文件名,这在构造文件包含或者路径拼接的场景下很有用。
4.2 SplFileObject读取文件内容的实际效果
SplFileObject是另一个值得关注的原生类。它继承自SplFileInfo,并且实现了__toString方法,返回的是当前读取的行内容。这意味着如果你能控制一个SplFileObject对象指向某个文件,并且在反序列化后触发了__toString,就能读取文件内容。
但同样的问题:构造函数不会被调用,所以你不能直接通过反序列化指定文件路径。不过,SplFileObject的序列化格式中包含了文件名信息,如果你能手动构造序列化字符串,理论上可以设置任意路径。
<?php // SplFileObject的序列化格式示例 $file = new SplFileObject('/etc/passwd'); echo serialize($file); // 输出类似:O:13:"SplFileObject":1:{s:12:"\0*\0filename";s:11:"/etc/passwd";} ?>注意那个\0*\0前缀,这是PHP序列化中表示protected属性的格式。手动构造时如果格式不对,反序列化会失败。
4.3 文件类原生类利用的边界条件
我在实际测试中总结了几条经验:
DirectoryIterator和SplFileObject的利用高度依赖后续代码是否触发了__toString。如果代码只是反序列化后存起来,不进行字符串操作,就不会触发。- 这两个类在PHP 7和PHP 8中的行为基本一致,但PHP 8对序列化格式的校验更严格,手动构造的payload更容易失败。
- 如果目标开启了
open_basedir限制,文件读取的范围会被限制在指定目录内,利用价值大打折扣。
5. 报错与异常类:Error和Exception的信息泄露与XSS
5.1 Error类的__toString输出机制
Error类是PHP 7引入的,它实现了__toString方法,当对象被当成字符串时会输出错误信息,包括错误消息、文件路径、行号、调用栈等。在反序列化场景下,如果你能控制Error对象的message属性,就能让__toString输出任意内容。
这个特性在CTF中经常被用来构造XSS。比如,如果目标应用把反序列化后的对象直接输出到页面上,而对象是Error类型,那么__toString返回的内容就会被浏览器解析。如果message属性里包含<script>标签,就形成了XSS。
<?php // 构造Error对象进行XSS $error = new Error("<script>alert(1)</script>"); echo serialize($error); // 反序列化后如果被输出,脚本就会执行 ?>5.2 Exception类的类似利用方式
Exception类和Error类在这一点上非常相似,都实现了__toString,都会输出消息内容。区别在于Exception是PHP 5就有的,而Error是PHP 7才引入的。在实际利用中,两者可以互相替代。
但要注意,Exception的序列化格式中包含了trace、file、line等属性,手动构造时需要确保这些属性的格式正确,否则反序列化可能失败。我通常的做法是先在本地用PHP生成一个标准的序列化字符串,然后只修改message部分。
5.3 信息泄露类原生类的实战价值评估
说实话,Error和Exception的利用价值取决于具体场景。如果目标只是把反序列化结果存到数据库或者日志里,不输出到页面,那XSS就打不出来。但如果目标有输出点,这就是一个非常直接的利用方式。
信息泄露方面,Error的__toString会输出文件路径和行号,这在信息收集阶段很有用。你可以通过构造特定的Error对象,让目标泄露Web根目录、框架路径等敏感信息。
提示:在PHP 8中,
Error和Exception的__toString输出格式有细微变化,比如调用栈的展示方式不同。测试时最好在相同版本的环境下验证。
6. SimpleXMLElement与XXE:原生类里的XML利用面
6.1 SimpleXMLElement反序列化触发XXE的条件
SimpleXMLElement这个类在反序列化利用中比较特殊。它的构造函数接受一个XML字符串,并解析它。如果XML中包含外部实体引用,且目标环境没有禁用外部实体加载,就会触发XXE。
但问题在于,反序列化时构造函数不会被调用。所以你不能直接通过反序列化让SimpleXMLElement去解析恶意XML。不过,SimpleXMLElement的序列化格式中包含了XML数据,如果反序列化后代码对该对象进行了某些操作(比如asXML()),可能会触发解析。
实际测试中,SimpleXMLElement的利用条件比较苛刻,需要目标代码在反序列化后主动调用相关方法。但一旦条件满足,XXE的危害是很大的,可以读取文件、发起SSRF等。
6.2 与其他XML类原生类的配合思路
除了SimpleXMLElement,PHP还提供了DOMDocument、XMLReader等XML处理类。这些类在反序列化场景下的利用思路类似,都是通过控制XML内容来触发解析行为。
我个人的经验是,XML类原生类的利用在实战中比较少见,因为需要目标代码配合。但在CTF中,如果题目明确涉及XML处理,这就是一个值得尝试的方向。
7. 原生类利用的实战排查链路:从入口到验证
7.1 第一步:确认反序列化入口和可控程度
排查的第一步永远是找入口。常见的反序列化入口包括:
- Cookie中的序列化数据
- POST参数中的序列化字符串
- Session文件内容
- 缓存数据(如Redis、Memcached中的值)
- 数据库中的序列化字段
找到入口后,要确认可控程度。是完全可控,还是只能控制部分字段?这决定了你能构造什么样的payload。
7.2 第二步:探测PHP版本和可用扩展
这一步很关键。不同PHP版本对原生类的支持不一样。比如Error类需要PHP 7+,SoapClient需要php_soap扩展。你可以通过报错信息、phpinfo()页面、或者构造特定payload观察响应来推断。
我常用的一个技巧是:构造一个引用不存在类的序列化字符串,观察报错信息。报错信息中通常会包含PHP版本号。
7.3 第三步:逐个尝试原生类payload
确认环境后,就可以逐个尝试原生类payload了。建议的顺序是:
SoapClient:如果php_soap开启,优先尝试SSRF。Error/Exception:尝试XSS或信息泄露。SplFileObject/DirectoryIterator:尝试文件读取。SimpleXMLElement:尝试XXE。
每尝试一个,都要观察响应差异。有时候payload没有生效,不是因为类不可用,而是因为魔术方法没被触发。
7.4 第四步:根据响应调整payload
如果第一次没成功,不要急着放弃。检查以下几点:
- 序列化字符串的格式是否正确?特别是protected和private属性的前缀。
- 魔术方法是否真的被触发了?可以在本地模拟目标代码的流程。
- 是否有WAF拦截?尝试编码绕过或分块传输。
我在一次测试中,SoapClient的payload一直不生效,后来发现是目标代码在反序列化后对对象进行了类型检查,非预期类型的对象会被丢弃。这种情况下,就需要结合其他漏洞点来绕过。
8. 那些年我踩过的原生类反序列化坑
8.1 序列化字符串中属性前缀写错导致反序列化失败
这是最常见的坑。PHP序列化中,public属性直接写属性名,protected属性前面加\0*\0,private属性前面加\0类名\0。手动构造payload时,如果前缀写错,反序列化会失败或者属性设置不上去。
我建议的做法是:先在本地用PHP生成标准序列化字符串,然后用文本编辑器修改需要改的部分,不要从头手写。
8.2 魔术方法触发时机与预期不符
另一个常见的坑是魔术方法没触发。比如你构造了一个SoapClient对象,但目标代码只是$obj = unserialize($input);然后就没有后续操作了。这种情况下,__call永远不会触发,SSRF也就打不出来。
解决办法是:仔细阅读目标代码,找到反序列化后对对象的操作。如果确实没有操作,可以尝试寻找其他入口,或者结合其他漏洞。
8.3 PHP版本差异导致的payload失效
PHP 7和PHP 8在序列化格式上有一些差异。比如PHP 8对类型的要求更严格,某些在PHP 7下能用的payload在PHP 8下会报错。另外,PHP 8.1引入了readonly属性,对序列化也有影响。
我的建议是:尽量在目标相同版本的环境下测试payload。如果无法确定版本,准备多个版本的payload备用。
8.4 扩展未开启导致类不存在
SoapClient需要php_soap扩展,SimpleXMLElement需要php-xml扩展。如果目标环境没有开启这些扩展,对应的类就不存在,payload会直接报错。
排查方法是:构造一个引用该类的序列化字符串,观察是否报“Class not found”错误。如果是,说明扩展未开启,换其他类尝试。
9. 原生类反序列化的防御视角:开发侧应该注意什么
9.1 避免直接反序列化用户输入
最根本的防御措施是:不要对用户输入直接调用unserialize()。如果业务确实需要序列化,考虑使用JSON等更安全的格式。JSON不支持对象反序列化,天然避免了这类问题。
如果必须使用PHP序列化,可以考虑使用hash_hmac对序列化数据进行签名,反序列化前先验证签名。这样即使攻击者构造了恶意payload,也无法通过签名验证。
9.2 限制unserialize的类白名单
PHP 7引入了unserialize()的第二个参数$options,其中allowed_classes可以指定允许反序列化的类白名单。设置allowed_classes为false可以禁止所有类的反序列化,设置为数组则只允许指定类。
<?php // 只允许反序列化MyClass $data = unserialize($input, ['allowed_classes' => ['MyClass']]); ?>这个措施能有效阻止原生类反序列化利用,因为原生类不在白名单里。
9.3 禁用危险扩展和函数
如果业务不需要SOAP,可以禁用php_soap扩展。同样,如果不需要XML处理,可以禁用相关扩展。此外,可以通过disable_functions禁用unserialize等危险函数(但要注意这会影响正常业务)。
注意:防御措施需要根据实际业务权衡。过度限制可能导致业务功能受损,建议在测试环境充分验证后再上线。
10. 关于原生类反序列化,我个人的几点体会
写了这么多,最后分享几点个人体会。第一,原生类反序列化不是银弹,它的利用条件比自定义POP链更依赖环境。在实战中,我通常把它作为“备选方案”,当自定义链走不通时再尝试。第二,熟悉PHP内置类的行为需要时间积累,建议平时多翻PHP手册,特别是那些实现了魔术方法的类。第三,本地环境测试非常重要,不要直接在目标上试payload,容易打草惊蛇。
另外,随着PHP版本的更新,一些原生类的行为在变化。比如PHP 8对序列化的校验更严格,某些老payload可能失效。保持学习,及时更新知识库,是这个领域的基本功。
如果你也在做代码审计或者CTF,建议把原生类反序列化作为一个专项来练习。找一些经典的CTF题目,手动构造payload,观察每一步的响应。练得多了,遇到实际场景时就能快速反应出该用哪个类、怎么构造。这个技能一旦掌握,在反序列化相关的场景里,你的利用面会比别人宽很多。