PHP反序列化漏洞实战:绕过私有属性与字符编码陷阱
1. 项目概述:Unserialize漏洞的实战化理解
在CTF竞赛和实际的安全审计中,反序列化漏洞(Unserialize Vulnerability)一直是一个高频且危险的攻击面。它绝不仅仅是“将字符串还原成对象”那么简单,其背后涉及编程语言的对象生命周期、魔术方法的自动调用、以及序列化字符串本身的语法解析。这次我们聚焦的“[NewStarCTF 2023 公开赛道]Unserialize漏洞实战:绕过私有属性与字符编码陷阱”这个题目,就精准地戳中了两个关键痛点:一是面向对象编程中访问控制(如私有属性)在序列化/反序列化过程中的特殊表现,二是字符编码差异可能导致的字符串长度计算错误,从而为构造攻击载荷(Payload)打开缺口。对于开发者而言,理解这些陷阱是编写安全代码的基础;对于安全研究者来说,掌握这些技巧则是进行漏洞挖掘和利用的必备技能。无论你是正在备战CTF的选手,还是希望深入理解PHP反序列化机制的安全爱好者,这篇从实战角度出发的深度解析,都将带你绕过那些教科书里不会细讲的“坑”,直击漏洞核心。
2. 核心漏洞原理与序列化字符串结构拆解
2.1 反序列化漏洞的根源:对象重建与魔术方法
PHP的反序列化函数unserialize()的核心任务,是根据一个序列化后的字符串,重建出原始的对象(或数据结构)。这个重建过程,并不仅仅是填充属性值那么简单。如果待反序列化的类中定义了特定的魔术方法(Magic Method),如__wakeup(),__destruct(), 或__toString(),那么在这些对象生命周期的特定节点,这些方法会被自动调用。
漏洞的根源就在于此:攻击者可以精心构造一个序列化字符串,当这个字符串被unserialize()处理时,会生成一个攻击者可控的对象。这个对象的属性值、甚至是对象类型,都可能被恶意操控。随后,在对象重建、使用或销毁的过程中,那些被自动调用的魔术方法里的代码就会被执行。如果这些代码中包含了一些危险操作,比如system($cmd),eval($code),或者文件操作,而操作的参数又恰好是对象中攻击者可控的属性,那么远程代码执行(RCE)或其它恶意行为就发生了。
注意:并非所有反序列化都会导致漏洞。它需要两个条件同时满足:1. 存在可被利用的魔术方法(“跳板”或“入口点”);2. 魔术方法内部的操作依赖于对象中用户可控的数据。
2.2 序列化字符串语法深度解析
要构造利用链,必须像读一门微型语言一样读懂序列化字符串。一个标准的PHP序列化字符串由类型、长度、值等部分构成。
基本类型示例:
i:123;-> 整数123s:3:“abc”;-> 字符串“abc”(长度3)a:2:{i:0;s:1:“a”;i:1;s:1:“b”;}-> 数组,包含两个元素 “a” 和 “b”
对象序列化格式:对象的序列化字符串结构为O:<类名长度>:“<类名>”:<属性数量>:{<属性序列化>...}。 其中,<属性序列化>部分需要特别关注访问修饰符(public, protected, private)的表示:
- 公有属性(public):直接使用属性名。例如,类
Test有公有属性$public = ‘hi’,序列化为s:6:“public”;s:2:“hi”;。 - 受保护属性(protected):属性名被转换为
\x00*\x00属性名的形式。这里的\x00是空字符(ASCII 0)。例如,protected $prot = ‘world’;序列化为s:7:“\x00*\x00prot”;s:5:“world”;。 - 私有属性(private):属性名被转换为
\x00类名\x00属性名的形式。例如,在类Test中,private $priv = ‘secret’;序列化为s:11:“\x00Test\x00priv”;s:6:“secret”;。
这些不可见的空字符\x00是第一个关键陷阱。在网页传输、字符串处理(如替换、正则匹配)时,这些空字符很容易被忽略、转义或错误处理,导致序列化字符串的结构被破坏,从而可能绕过某些基于字符串匹配的防御,或者因结构错误引发非预期行为。
2.3 题目核心陷阱一:绕过私有属性访问限制
在PHP中,私有属性(private)只能在定义它的类内部访问。但在反序列化场景下,这个规则存在一个“后门”。如上所述,私有属性在序列化字符串中是以\x00类名\x00属性名的形式存储的。
这意味着,攻击者可以在序列化字符串中手动构造这个格式,并为私有属性赋值。当unserialize()函数解析这个字符串时,它会严格按照这个格式来重建对象,并将值赋给对应的私有属性,而不会去检查执行这段反序列化代码的上下文是否具有访问该私有属性的权限。
举个例子:假设有一个不安全的类:
class VulnerableClass { private $command; public function __destruct() { system($this->command); // 危险操作! } }在正常编程中,外部代码无法直接修改$command。但攻击者可以构造这样的序列化字符串:
O:15:“VulnerableClass”:1:{s:26:“\x00VulnerableClass\x00command”;s:8:“whoami”;}当这个字符串被反序列化时,一个VulnerableClass对象被创建,其私有属性$command被赋值为“whoami”。随后对象生命周期结束,__destruct()被自动调用,从而执行了system(“whoami”)。
实战心得:在审计代码时,如果发现魔术方法(尤其是__wakeup,__destruct)中使用了类的属性,一定要追踪这些属性的赋值来源。即使它们是private或protected,只要整个对象是通过unserialize()从用户输入重建的,这些属性就是完全可控的,其访问修饰符提供的保护在此刻形同虚设。
3. 核心陷阱二:字符编码与字符串长度计算
3.1 长度字段的权威性与“字节”vs“字符”
序列化字符串中的s:<length>:“value”;是漏洞利用的另一个关键。这里的<length>指的是字符串“value”所占用的字节数(byte count),而不是字符数(character count)。在纯ASCII字符集(一个字符占一个字节)中,两者一致。但一旦涉及多字节字符(如中文、日文等UTF-8字符),区别就产生了。
PHP的serialize()函数在计算长度时,是基于原始字节流的。例如,汉字“啊”的UTF-8编码是E5 95 8A,占3个字节。所以serialize(‘啊’)的结果是s:3:“啊”;。
unserialize()在解析时,会严格按照<length>指定的字节数去读取后面的字符串内容。如果实际内容的字节数与<length>声明的不符,解析就会失败,通常抛出一个警告或返回false。
3.2 利用长度不一致构造攻击
题目中提到的“字符编码陷阱”,其攻击手法通常围绕制造这种“声明长度”与“实际字节长度”的不匹配来展开。常见场景有两种:
替换操作改变字节长度:这是CTF中的经典题型。题目代码可能在反序列化前,对序列化字符串进行某种字符串替换。例如:
$data = str_replace(“bad”, “good”, $_GET[‘data’]); $obj = unserialize($data);假设原始Payload中,某个属性的值为
“somebadthing”,其序列化后为s:13:“somebadthing”;。经过替换,变成了“somegoodthing”,实际字节数从13变成了15。但序列化字符串中的长度字段s:13:并没有自动更新。当unserialize()试图读取时,它会只读取13个字节(“somegoodthi”),导致字符串提前结束,破坏了后续的序列化结构(比如闭合的花括号}被当成了字符串内容的一部分),可能使得后续的属性定义被“挤”出当前对象,或者闭合了不该闭合的结构,从而允许攻击者注入额外的序列化对象。多字节字符截断:如果应用在处理序列化字符串时,错误地使用了按“字符”处理的函数(如某些情况下的
substr),或者数据库存储时发生了字符集转换,可能导致多字节字符被损坏。例如,一个声明长度为9(三个汉字)的字符串,如果其中一个汉字被截断了一半,实际有效字节数可能就不足9,同样会导致反序列化失败或非预期解析。
关联热词解析:网络热词“c#如何通过判断汉字字符的unicode编码范围来获取首字母”虽然来自C#领域,但其核心思想——精确识别字符的编码范围——在PHP反序列化安全中同样重要。在构造或过滤Payload时,我们必须清楚地知道我们处理的到底是“字节”还是“字符”。使用mb_strlen($str, ‘8bit’)可以获取字符串的字节长度,这在安全处理中至关重要。
3.3 实战构造:一个简单的长度欺骗案例
假设有一个脆弱的类:
class Product { public $name; public $price; public function __wakeup() { if ($this->price < 0) { echo “Flag: “ . file_get_contents(‘/flag.txt’); } } }目标是让$price为负数。但源代码中可能对输入做了检查。我们可以利用字符串替换来改变结构。
我们想得到这样一个有效的序列化字符串:
O:7:“Product”:2:{s:4:“name”;s:10:“TestItem”;s:5:“price”;i:-1;}假设题目会将“TestItem”替换成更长的“ReplacedItem”。我们提前构造Payload:
O:7:“Product”:2:{s:4:“name”;s:10:“TestItem”;s:5:“price”;i:-1;}注意,name字段的长度声明是10,对应“TestItem”(8字节)?等等,这里有个技巧。“TestItem”实际是8个字节。如果我们声明s:10:“TestItem”;,unserialize()会去读取10个字节,但“TestItem”只有8个,它会继续读取后面的两个字符”;s(即闭合引号、分号和下一个属性的开头s)。这会导致解析错误。
正确的利用方式是,让替换后的字符串长度增加,并利用多出的字符“吞掉”后面用来闭合的引号或分号,从而改变解析边界。这需要精确计算。一个更典型的例子是使用占位符: 原始:s:8:“12345678”;替换规则:把“12”替换成“abcd”。 如果我们构造s:8:“12 345678”;,替换后变成s:8:“abcd345678”;,但实际字符串是10字节(abcd345678)。解析器读取8字节(“abcd3456”)后认为该字符串结束,剩下的“78”;就会被当作后续的序列化语法来解析,很可能导致结构错乱,从而可能注入新的对象属性。
提示:这类题目往往需要结合源代码审计,找到替换规则,然后通过手工或脚本反复调整Payload中的长度字段和占位符,直至构造出一个经过替换后,语法依然正确且能达成攻击目的的新序列化字符串。工具
phpggc或自己编写Python脚本进行模糊测试是常用方法。
4. 完整实战流程:从代码审计到Payload生成
4.1 第一步:源代码审计与入口点定位
拿到题目源码(或通过信息收集获取部分源码)后,按以下步骤进行:
- 寻找反序列化入口:全局搜索
unserialize(、maybe_unserialize(等函数。常见入口点包括:从$_GET/$_POST/$_COOKIE获取的参数、从数据库读取的缓存数据、经过某些解码(如base64_decode)后的数据。 - 识别可利用的类(POP链起点):在入口点附近,检查是否有
__wakeup()、__destruct()、__toString()等魔术方法被自动调用的类。这些类被称为“POP链”(Property-Oriented Programming)的起点或节点。 - 分析魔术方法逻辑:仔细阅读这些魔术方法中的代码,寻找危险函数(
eval,system,exec,file_put_contents,unlink等)或可以触发其他对象魔术方法的操作(如echo $obj会触发__toString)。 - 追踪属性可控性:确认危险函数操作的参数是否来源于该对象的属性。如果是,那么这个属性就是我们的攻击目标。
4.2 第二步:构造利用链(POP Chain)
如果单个类的魔术方法不能直接完成利用(例如,它只是调用了另一个对象的方法),就需要寻找一条“链”。例如:
ClassA::__destruct()-> 调用了$this->obj->save()ClassB::save()-> 调用了file_put_contents($this->filename, $this->data)
那么,我们就需要让ClassA的$obj属性在反序列化后成为一个ClassB对象,并且为这个ClassB对象的$filename和$data属性赋上恶意值。在序列化字符串中,我们需要精确地构造出这个嵌套的对象结构。
4.3 第三步:处理私有属性与编码陷阱
在构造最终的序列化字符串时:
私有/受保护属性:务必在属性名前加上正确的空字符前缀。对于私有属性
$priv,在类MyClass中,其序列化名称应为“\x00MyClass\x00priv”。在编写Payload时,需要直接写入这些二进制字符。在Python中,可以这样写:\x00MyClass\x00priv。# Python示例 class_name = “MyClass” prop_name = “priv” serialized_prop_name = f“\x00{class_name}\x00{prop_name}” # 计算这个新字符串的字节长度 length = len(serialized_prop_name.encode(‘latin-1’)) # 使用latin-1确保字节处理在PHP中构造时,可以使用双引号字符串直接包含
“\x00”,或者使用chr(0)拼接。字符串长度计算:这是最需要细心的一步。永远使用字节长度。
- 在PHP中,用
strlen()计算字节长度,不要用mb_strlen()(除非指定‘8bit’编码)。 - 如果Payload需要经过某个字符串替换,设替换规则是将
X替换为Y。假设原字符串S中包含n个X,那么替换后字符串的字节长度变化为n * (strlen(Y) - strlen(X))。你需要在原始Payload中,将包含X的那个字符串的声明长度s:L:预先调整为L + n * (strlen(Y) - strlen(X)),并确保替换后,整个序列化字符串的语法(花括号配对、分号分隔)依然正确。这通常需要反复试验。
- 在PHP中,用
4.4 第四步:Payload传递与触发
构造好的Payload通常需要以某种形式传递给反序列化入口点。
- URL编码:由于Payload中可能包含空字符
\x00、引号等特殊字符,通常需要进行URL编码(urlencode)后再放入GET参数或POST body。 - Base64编码:有时题目会先对输入做
base64_decode,那么我们就需要将Payload进行base64_encode。 - Cookie或Session:有时漏洞存在于反序列化Session数据(
session_decode)或特定的Cookie值中。 - 触发:Payload被反序列化后,漏洞的触发可能不是立即的。
__wakeup()在反序列化完成后立即执行;__destruct()需要等到对象被销毁(如脚本执行结束);__toString()需要等到对象被当作字符串使用(如被echo、拼接等)。理解触发条件对利用成功至关重要。
5. 防御策略与安全编程实践
理解了攻击手法,才能更好地进行防御。以下是一些关键的安全实践:
根本方法:避免反序列化不可信数据这是最彻底的安全措施。如果业务上必须使用序列化来存储或传输数据,应考虑使用安全的替代方案,如JSON(
json_encode/json_decode)。使用安全的白名单机制如果无法避免,应在反序列化前进行严格的类型检查。PHP的
unserialize()有一个可选参数[‘allowed_classes’ => false],它可以阻止反序列化任何对象类型,只允许基础类型(数组、字符串、数字等)。如果必须允许某些类,应使用白名单,仅允许明确安全的、必要的类。// PHP 7+ $data = unserialize($user_input, [‘allowed_classes’ => [‘SafeClassA‘, ‘SafeClassB’]]);对序列化数据进行签名验证在存储或传输序列化数据时,可以附带一个基于密钥和数据进行计算的签名(如HMAC)。在反序列化前,先验证签名是否有效,确保数据在传输过程中未被篡改。
在魔术方法中避免危险操作审查项目代码,确保
__wakeup()、__destruct()等魔术方法中不包含将对象属性直接传递给危险函数的逻辑。如果必须使用,应对参数进行严格的过滤和验证。谨慎处理字符串替换与编码如果业务逻辑中确实存在对序列化字符串的修改操作,必须确保在修改后,重新计算并更新所有受影响的长度字段,或者采用更安全的结构化数据处理方式。
使用漏洞扫描工具将反序列化漏洞检查纳入代码安全审计(SAST)和动态应用安全测试(DAST)的范畴,使用专业工具辅助发现潜在风险。
6. 常见问题与排查技巧实录
在实战和CTF解题过程中,经常会遇到一些典型问题,以下是排查思路:
问题1:Payload构造正确,但反序列化失败,返回false或抛出异常。
- 排查点1:语法错误。仔细检查序列化字符串:括号
{}是否配对?每个单元是否以分号;正确结束?字符串长度是否精确匹配?特别是手工修改后,很容易遗漏。 - 排查点2:类不存在。如果Payload中包含对象
O:...,但当前执行环境中没有定义这个类,unserialize()会将其反序列化成一个__PHP_Incomplete_Class对象,某些情况下可能影响后续利用。确保类已加载,或者利用题目已有的自动加载机制。 - 排查点3:字符编码问题。确保你计算长度时使用的是字节长度。在多字节环境下(如UTF-8),用
strlen()而非mb_strlen()(默认按字符计算)。在编辑Payload时,使用纯文本编辑器或能正确处理二进制字符的编辑器。
问题2:__wakeup()或__destruct()方法没有执行。
- 排查点1:
__wakeup()的绕过。在PHP版本 < 5.6.25 或 < 7.0.10 时,存在一个著名的CVE-2016-7124漏洞。当序列化字符串中对象的属性数量大于实际数量时,__wakeup()方法会被跳过。例如,将O:7:“Product”:2:{...}中的2改为一个更大的数3。但在新版本中此漏洞已修复。 - 排查点2:对象引用。如果对象被其他变量引用,其析构函数可能会延迟执行,直到所有引用都被释放。
- 排查点3:脚本提前终止。如果反序列化后脚本因为错误或
exit()/die()而立即结束,__destruct()可能来不及执行。
问题3:利用链(POP Chain)在本地测试成功,但在远程靶机上失败。
- 排查点1:PHP版本差异。不同PHP版本在序列化/反序列化细节、魔术方法行为、内置类可用性上可能有细微差别。尽量获取靶机环境信息。
- 排查点2:扩展依赖。你的利用链可能依赖某些特定PHP扩展(如
SimpleXML,PDO)中的类,靶机环境可能未安装。 - 排查点3:字符过滤或WAF。靶机可能对输入进行了全局过滤,去除了空字符
\x00、改变了引号编码等。需要尝试编码、双重编码、或使用其他特殊字符变形来绕过。
问题4:如何高效地构造和调试复杂的序列化字符串?
- 技巧1:分步构造。先序列化一个简单的对象,得到基础模板,然后在模板上手动修改或拼接其他部分。
- 技巧2:使用序列化工具。在本地PHP环境中编写脚本,用
serialize()生成基础部分,然后用字符串操作进行精细修改。利用print_r(unserialize($payload))来验证Payload是否能被正确解析。 - 技巧3:日志输出。在靶机题目(如果允许)或本地测试环境中,在关键位置添加
error_log(print_r($obj, true))或file_put_contents(‘debug.txt’, $serialized),查看对象反序列化后的实际状态。
最后,再分享一个调试时的小技巧:当你无法确定Payload是否被正确解析时,可以尝试让目标输出反序列化后的结果(如果题目有回显),或者利用__toString()方法来回显对象的某个属性,这常常能帮你确认是否成功控制了目标数据。反序列化漏洞的利用就像在拼一幅复杂的拼图,需要对语言特性、字符串编码和程序逻辑有精确的把握,耐心和细致是成功的关键。
