PHP反序列化漏洞CVE-2016-7124原理剖析与安全实践

📅 2026/7/16 5:02:37
PHP反序列化漏洞CVE-2016-7124原理剖析与安全实践
1. 项目概述从漏洞编号到原理深挖每次看到安全公告里那一串串的CVE编号像CVE-2016-7124、CVE-2020-9484你是不是也和我一样第一反应是去网上搜一下现成的POC概念验证代码然后赶紧在自己的环境里测一下能复现就记下来复现不了就跳过坦白说我干过这事儿而且干过不少。但后来我发现这种“拿来主义”的学习方式在应对真实、复杂的渗透测试或代码审计场景时往往会卡壳。因为你只知道“这个编号的漏洞能打”却不知道“为什么能打”以及“在什么变种情况下还能打”。就拿PHP反序列化这个老生常谈但又历久弥新的安全问题来说CVE-2016-7124是一个绝佳的切入点。它不是一个复杂的、涉及多层魔术方法调用的“POP链”漏洞而是一个非常底层、非常精妙的逻辑缺陷。它的核心直指PHP内核在处理序列化字符串时一个关于对象属性计数器的校验逻辑。如果你只记住了“把序列化字符串里的属性数量改大就能绕过__wakeup”那你可能只看到了冰山一角。真正有趣的是这个计数器背后还藏着PHP对不同可见性公有、私有、保护属性的处理差异藏着不同PHP版本间的行为变迁甚至能引申出我们在编写安全代码时对__wakeup方法究竟该抱有何种期待。这篇文章我们就以CVE-2016-7124为手术刀剖开PHP反序列化的一个核心层面。我不会只给你一个漏洞编号和一段攻击载荷我会带你一起从PHP序列化字符串的格式讲起一步步分析__wakeup方法的执行时机然后亲手触发并调试那个关键的计数器校验漏洞。更重要的是我们会探讨这个漏洞被修复后是否就高枕无忧了在属性数量、类型发生变化的情况下反序列化过程还会抛出哪些意想不到的异常这些异常是否又可能成为新的攻击面理解这些你才能在未来面对“Shiro反序列化”、“Fastjson反序列化”乃至其他语言的反序列化问题时拥有举一反三的能力而不仅仅是记忆一堆编号。2. 核心原理序列化格式与__wakeup的契约要理解漏洞必须先理解机制。PHP的反序列化本质上是将一段序列化字符串一种特定格式的文本还原成一个内存中的对象或数据结构的过程。这个过程并非简单的“字符串转对象”它涉及到对象的创建、属性的赋值、以及一系列魔术方法的回调。2.1 解剖一个序列化字符串我们从一个最简单的类开始。class VulnClass { public $data hello; private $secret password123; // 私有属性 protected $flag protected_data; // 保护属性 public function __wakeup() { echo [__wakeup] Called! Resetting \$data.\n; $this-data reset_by_wakeup; } } $obj new VulnClass(); $obj-data malicious_data; $serialized serialize($obj); echo $serialized . \n;运行上面的代码你会得到一个类似这样的字符串O:8:VulnClass:3:{s:4:data;s:15:malicious_data;s:14:VulnClasssecret;s:11:password123;s:7:*flag;s:15:protected_data;}我们来拆解这个字符串O:8:VulnClass:3:{...}O代表这是一个对象Object。8类名的长度。VulnClass类名。3这是关键数字表示这个对象有3个属性property需要被反序列化。{...}花括号内是所有属性的键值对列表。属性键值对的格式是s:长度:属性名;值;。注意属性名的变化公有属性data直接使用属性名s:4:data。私有属性secret属性名被格式化为类名属性名即s:14:VulnClasssecret。这里的长度14是VulnClass(8) secret(6) 的结果。保护属性flag属性名被格式化为*属性名即s:7:*flag。这里的*是一个字面量字符长度计算在内。注意这个格式化是由PHP的序列化引擎自动完成的目的是在反序列化时能正确地将值赋回具有相应可见性的属性。如果你手动修改序列化字符串必须严格遵守这个命名规则否则反序列化会失败或导致属性丢失。2.2__wakeup反序列化的“安检员”__wakeup()是一个魔术方法。它的官方定义是unserialize()在从序列化字符串重建对象之后会检查是否存在__wakeup()方法。如果存在则调用它。这个“之后”非常关键。它的执行顺序是根据序列化字符串中的类名创建一个新的、空的对象实例不调用构造函数__construct。按照序列化字符串中声明的属性数量就是上面提到的那个数字3依次读取属性名和值并将其赋给这个新对象的对应属性。所有属性赋值完成后调用对象的__wakeup()方法。所以__wakeup通常被用作一个“完整性检查”或“初始化后门”。开发者可能会在这里重新建立数据库连接因为序列化不保存资源句柄。重新计算某些依赖属性的值。进行安全检查例如重置敏感属性、验证数据合法性等。在CVE-2016-7124的漏洞语境中开发者可能这样写public function __wakeup() { // 开发者认为反序列化完成后我可以在这里清理危险的数据 if (preg_match(/恶意模式/, $this-data)) { $this-data ; die(非法数据); } }开发者的逻辑是无论反序列化的字符串里$data被改成什么__wakeup都会是我最后一道防线。然而CVE-2016-7124打破了这份信任。3. CVE-2016-7124属性计数器的逻辑陷阱漏洞的原理一句话就能说清当序列化字符串中声明的对象属性数量大于实际对象在类中定义的属性数量时__wakeup()方法的执行会被跳过。3.1 漏洞触发条件与复现让我们在受影响的PHP版本PHP 5 5.6.25 PHP 7 7.0.10上复现。沿用上面的VulnClass它定义了3个属性data,secret,flag。正常序列化字符串O:8:VulnClass:3:{s:4:data;s:15:malicious_data;s:14:VulnClasssecret;s:11:password123;s:7:*flag;s:15:protected_data;}攻击载荷Exploit Payload 我们手动修改这个字符串将属性数量从3改为一个更大的数比如5同时不增加实际的属性键值对。O:8:VulnClass:5:{s:4:data;s:15:malicious_data;s:14:VulnClasssecret;s:11:password123;s:7:*flag;s:15:protected_data;}注意花括号{}里面还是原来的3个属性。现在我们用这个修改后的字符串进行反序列化$malicious_payload O:8:VulnClass:5:{s:4:data;s:15:malicious_data;s:14:VulnClasssecret;s:11:password123;s:7:*flag;s:15:protected_data;}; $obj unserialize($malicious_payload); var_dump($obj);观察结果在存在漏洞的PHP版本中__wakeup()方法没有被调用你不会看到[__wakeup] Called!的输出。var_dump($obj)显示对象的$data属性值仍然是malicious_data而不是__wakeup方法中设定的reset_by_wakeup。反序列化过程没有报错对象被成功创建。漏洞生效了开发者依赖的“最后一道防线”__wakeup被绕过了攻击者植入的malicious_data被保留了下来。3.2 深入源码为什么会被绕过要理解为什么我们需要窥探一下PHP内核ext/standard/var_unserializer.c在处理反序列化时的逻辑。简化后的关键步骤如下解析头部读到O:8:VulnClass:5:知道要重建一个VulnClass对象并期望有5个属性。创建对象在内存中创建一个VulnClass的实例。读取属性进入花括号开始读取属性键值对。它准备读5个。属性填充它读到了第一个属性s:4:data;s:15:malicious_data;成功赋值。计数器从5变为4。 读第二个、第三个...当读完第三个属性*flag后计数器变为2。预期不符此时序列化字符串的花括号结束了}。但内核的逻辑预期是还要再读2个属性因为计数器还剩2。这个“预期属性数”与“实际到达字符串末尾”的状态发生了冲突。关键决策在存在漏洞的版本中当遇到这种“属性数量声明多于实际数量”的情况时PHP的反序列化器产生了一个内部错误状态并决定提前终止对象构建流程。而__wakeup()的调用是在对象完全成功构建后才执行的。由于构建流程被异常终止__wakeup()就被跳过了。部分成功虽然__wakeup被跳过但前三个已经成功读取和赋值的属性data,secret,flag却被保留在了那个部分构建的对象中。最终这个“残缺”的对象被返回给了用户。这就好比一个严格的装配线检查员__wakeup站在生产线末端。生产线规定每件产品必须有5个零件声明属性数5。工人只装了3个零件就试图把产品送下流水线。检查员发现零件数量不对3 5他直接拦下了整个产品拒绝进行检查跳过__wakeup。但诡异的是这个只有3个零件的产品却被允许出厂返回给用户。漏洞的荒谬之处就在于此校验失败了但“失败”的结果却让危险品通过了安检。3.3 漏洞修复与影响该漏洞在PHP 5.6.25 和 7.0.10 中被修复。修复逻辑非常直观当声明的属性数量与实际数量不匹配时反序列化应该失败并抛出一个警告E_WARNING同时返回false。在修复后的版本中执行上面的攻击载荷你会得到PHP Warning: unserialize(): Error at offset XX of YY bytes in ...并且unserialize()返回false对象创建失败__wakeup自然也不会执行。这才是符合安全预期的行为数据不完整或异常就拒绝服务。4. 绕过之后对象属性计数器的其他安全陷阱CVE-2016-7124被修复了是不是意味着属性计数器的问题就一劳永逸了绝非如此。这个计数器依然是反序列化攻击中一个需要仔细琢磨的“敏感点”。4.1 属性数量少于声明值我们测试了“多于”的情况。那么“少于”呢即序列化字符串中实际属性数量多于声明的数量。O:8:VulnClass:2:{s:4:data;s:15:malicious_data;s:14:VulnClasssecret;s:11:password123;s:7:*flag;s:15:protected_data;}这里声明了2个属性但提供了3个。在修复后的PHP版本中这种行为也会触发警告并反序列化失败。因为反序列化器在读完声明的2个属性后发现字符串还没结束后面还有属性这与预期不符。4.2 属性可见性与计数器的微妙关系这是更隐蔽的一个坑。回顾序列化字符串私有和保护属性的名称被改变了。那么在反序列化时PHP是如何匹配这些属性的呢关键在于反序列化时属性计数器统计的是序列化字符串中“键值对”的数量而属性赋值成功与否取决于这些“键”能否在当前作用域下匹配到对象的对应属性。考虑一个跨作用域的场景// File: victim.php class InternalClass { private $hook; public function __destruct() { system($this-hook); } } // 假设攻击者能控制传入的序列化字符串 $data $_GET[data]; unserialize($data);攻击者构造的载荷O:14:InternalClass:1:{s:19:InternalClasshook;s:10:id;}这个载荷在类定义的作用域内比如在victim.php中反序列化可以成功为私有属性$hook赋值因为键名InternalClasshook能正确匹配。但是如果攻击者是在另一个完全不同的地方例如一个工具脚本生成这个序列化字符串他可能会错误地写成O:14:InternalClass:1:{s:5:hook;s:2:id;}// 错误直接使用了属性名hook这个字符串在victim.php中反序列化时会发生什么声明属性数为1。尝试读取第一个属性键为s:5:hook。PHP尝试在当前作用域下为InternalClass对象找一个名为hook的属性。它找不到因为私有属性$hook在序列化时的名称是InternalClasshook。这个属性赋值失败但计数器依然递减。由于声明数量1与实际成功读取的键值对数量1在计数上“匹配”即使赋值失败了反序列化流程可能不会因数量不匹配而报错。最终对象被创建但$hook属性是未定义状态可能是NULL或默认值。当__destruct被调用时system(NULL)可能不会执行命令攻击失败。实操心得在审计反序列化漏洞时尤其是涉及POP链构造时必须注意链中每个类的属性可见性。你构造的序列化字符串必须模拟这些属性在目标类定义作用域下被序列化时的名称格式。一个错误的属性名就会导致整个攻击链断裂。这也是为什么很多公开的PHP反序列化利用工具或Payload需要你指定完整的类名和命名空间来生成正确字符串的原因。4.3 版本差异与“消失”的属性不同PHP版本在序列化/反序列化行为的细节上可能有差异。例如对于未初始化的属性、静态属性、或者通过__get/__set魔术方法访问的动态属性它们在序列化字符串中是否出现、如何出现都可能不同。假设一个类在版本7.4中序列化时包含了某个动态属性但在8.0中序列化机制发生了变化不再包含它。那么用7.4生成的序列化字符串在8.0中反序列化时声明的属性数就可能多于实际可赋值的属性数从而可能触发类似数量不匹配的错误或警告导致反序列化失败。这种版本间的不兼容性在迁移老旧应用或整合不同系统数据时可能成为服务中断的一个原因虽然不一定是安全漏洞但也是需要关注的稳定性陷阱。5. 实战中的利用场景与防御思考理解了原理我们来看看在实际的漏洞利用和防御中该如何思考。5.1 利用场景延伸CVE-2016-7124的经典利用是绕过__wakeup中的安全重置逻辑。但这只是一个起点。在更复杂的POP链中__wakeup可能扮演其他角色链式触发器的开关有些POP链的起点是__wakeup。攻击者精心构造一个对象其__wakeup方法会调用另一个对象的危险方法。如果__wakeup被跳过整个攻击链就无法启动。这时攻击者就需要确保属性数量匹配让__wakeup顺利执行。条件竞争或状态干扰在某些精心设计的场景下跳过__wakeup可能导致对象处于一个“部分初始化”的异常状态。这个状态本身结合程序的其他逻辑可能会引发类型混淆、空指针引用等二次漏洞。这需要极高的技巧和对目标代码的深刻理解。5.2 开发者防御指南作为开发者绝不能依赖__wakeup作为唯一的安全屏障。以下是更全面的防御策略根本方法避免反序列化用户输入。这是OWASP TOP 10反复强调的。如果业务必须序列化考虑使用JSON等更简单、无副作用的格式。使用安全的白名单机制如果必须使用unserialize应配合allowed_classes参数PHP 7.0。只允许反序列化已知安全的类。$data unserialize($user_input, [allowed_classes [SafeDataObject, AnotherSafeClass]]);这样即使攻击载荷中包含了恶意类的序列化数据PHP也会将其反序列化为一个不完整的__PHP_Incomplete_Class对象其方法无法被调用。升级PHP版本及时将PHP升级到安全支持版本确保类似CVE-2016-7124这样的底层漏洞已被修复。谨慎设计__wakeup和__destruct意识到这些魔术方法是“可被预测的执行点”。不要在__wakeup中做关键的安全决策它更适合做非关键的资源重建。关键的数据验证应在对象正常的方法调用中进行。对序列化数据进行签名或加密在存储或传输序列化数据前对其进行HMAC签名。反序列化前先验证签名确保数据未被篡改。这可以防止攻击者任意修改属性数量或属性值。5.3 安全研究员审计要点当你审计一个含有unserialize($controllable_input)的代码时寻找POP链这是主要任务。全局搜索所有类的__wakeup,__destruct,__toString,__call,__get,__set等魔术方法以及具有“危险功能”如system,eval,file_put_contents的普通方法分析它们能否被连接起来。检查__wakeup的副作用特别关注__wakeup中是否清除了攻击者可能设置的危险属性。如果存在CVE-2016-7124漏洞老旧系统尝试构造属性数量不匹配的Payload进行绕过测试。分析属性可见性在构造利用链时仔细核对每个关键类的属性是公有、私有还是保护。确保你生成的序列化字符串中的属性键名格式完全正确。一个调试技巧是先在目标代码的相同作用域下序列化一个示例对象观察其属性名的格式再用作参考来构造攻击载荷。注意PHP版本目标系统的PHP版本直接影响可利用的漏洞集合和Payload的构造方式。例如PHP 8在序列化/反序列化和魔术方法的行为上又有诸多变化。6. 从PHP到更广阔的反序列化世界通过对CVE-2016-7124的深度剖析我们看到的不仅仅是一个PHP的bug。我们看到的是一种通用的安全思维模式对数据解析器边界条件的挑战。无论是Java的Apache Commons Collections、FastjsonPython的Pickle还是其他语言的反序列化机制攻击者的核心思路往往是相通的寻找执行入口点类似PHP的__wakeup、__destructJava的readObject、toStringPython的__reduce__。构造调用链利用类之间的关联和方法调用将入口点导向一个具有危险功能的“终点”。处理数据格式的“怪癖”就像PHP的属性计数器和属性名格式化其他语言的序列化协议也有自己的规则和边界情况。理解并利用这些“怪癖”是构造成功Payload的关键。所以当你下次再看到CVE-2020-9484Tomcat、Shiro-550、Fastjson 1.2.80这些漏洞时不妨问自己几个问题这个反序列化过程的“契约”是什么协议格式、执行回调攻击者是在哪个环节破坏了这个契约边界条件、逻辑缺陷修复方案是如何强化这个契约的严格校验、白名单带着对PHP反序列化计数器陷阱的深刻理解你去学习其他语言的反序列化漏洞会发现很多概念是触类旁通的。安全研究的乐趣就在于这种从具体漏洞到通用原理的升华。别再只记漏洞编号了去理解它背后的“为什么”你才能在下一次零日漏洞出现时拥有独立分析和应对的能力。