PHP 反序列化漏洞详解
1. 引言:魔法背后的阴影
在现代 Web 应用开发中,数据的持久化和跨进程传递是常见的需求。开发者经常需要将对象的状态保存起来,稍后重新加载,或者在不同的组件、服务之间交换复杂的数据结构。PHP 提供了 serialize()
和 unserialize()
函数来方便地实现这一功能。它们可以将 PHP 对象或数组转换成一个字符串(序列化),然后再将这个字符串还原成原来的对象或数组(反序列化)。
然而,方便的背后常常隐藏着风险。当应用程序使用 unserialize()
函数处理来自不可信源(例如用户提交的请求数据、Cookie、缓存文件等)的序列化字符串时,就可能产生严重的安全漏洞,这就是所谓的 PHP 反序列化漏洞。
本文将深入探讨 PHP 反序列化漏洞的原理、攻击方式、常见的攻击场景以及防御措施,帮助读者全面理解这一危险的漏洞类型。
2. PHP 中的序列化与反序列化
在深入探讨漏洞之前,我们首先需要理解 PHP 中序列化和反序列化的基本概念和用法。
2.1 序列化 (serialize()
)
serialize()
函数用于将一个 PHP 值(可以是基本类型、数组或对象)转换成一个可存储或传输的字符串。这个字符串包含了值的类型、长度以及内容等信息,对于对象,它还包含了类的名称、属性的数量以及每个属性的名称和值。
示例代码:
“`php
name = $name;
$this->password = $password;
}
}
$user = new User(“Alice”, “secret123″);
$serialized_user = serialize($user);
echo $serialized_user;
// 输出可能类似于: O:4:”User”:2:{s:4:”name”;s:5:”Alice”;s:14:”Userpassword”;s:9:”secret123″;}
?>
“`
序列化字符串格式解析:
上述示例输出的字符串 O:4:"User":2:{s:4:"name";s:5:"Alice";s:14:"Userpassword";s:9:"secret123";}
可以这样解读:
O:4:"User"
: 表示这是一个对象(Object),类名长度为 4,类名为 “User”。:2:
: 表示这个对象有 2 个成员属性。{...}
: 包围着成员属性的信息。s:4:"name";
: 表示一个字符串(String),长度为 4,值为 “name”。这是属性名。s:5:"Alice";
: 表示一个字符串,长度为 5,值为 “Alice”。这是属性值。s:14:"Userpassword";
: 注意这里的属性名Userpassword
。对于私有 (private
) 或保护 (protected
) 属性,序列化时会在属性名前加上\0ClassName\0
或\0*\0
的前缀(\0
是空字节),这里User
类私有属性password
的实际序列化名称是\0User\0password
。由于终端或浏览器可能无法正确显示空字节,这里显示的s:14:"Userpassword";
实际上表示的是一个长度为 14 的字符串\0User\0password
。s:9:"secret123";
: 表示一个字符串,长度为 9,值为 “secret123″。这是私有属性password
的值。
了解序列化字符串的格式对于理解如何构造恶意载荷至关重要,因为它暴露了对象结构和属性信息。
2.2 反序列化 (unserialize()
)
unserialize()
函数用于将 serialize()
生成的字符串还原成原始的 PHP 值。如果序列化字符串表示一个对象,unserialize()
会尝试创建该类的实例,并填充其属性值。
示例代码:
“`php
name = $name;
$this->password = $password;
echo “User object created via constructor.\n”;
}
public function __wakeup() {
// 在对象被反序列化后立即调用
echo “User object woke up!\n”;
}
public function __destruct() {
// 在对象被销毁时调用
echo “User object is being destroyed.\n”;
}
// 用于展示私有属性的访问(仅为演示目的)
public function getPassword() {
return $this->password;
}
}
$serialized_user = ‘O:4:”User”:2:{s:4:”name”;s:5:”Alice”;s:14:”Userpassword”;s:9:”secret123″;}’; // 注意私有属性名实际包含空字节
// 实际上,为了在代码中方便表示,恶意载荷通常直接复制序列化输出,包含空字节
$unserialized_user = unserialize($serialized_user);
echo “Deserialization finished.\n”;
// 访问属性
echo “Name: ” . $unserialized_user->name . “\n”;
//echo “Password: ” . $unserialized_user->getPassword() . “\n”; // 私有属性需要通过方法访问
// 脚本结束时或对象不再引用时,__destruct 会被调用
?>
“`
注意: unserialize()
在还原对象时,并不会调用类的构造函数 (__construct
)。这一点非常重要,因为攻击者通常无法控制构造函数的执行。然而,unserialize()
会自动调用某些特殊的“魔术方法”(Magic Methods),这正是反序列化漏洞的核心所在。
3. 漏洞的核心:PHP 魔术方法
PHP 中的魔术方法是一类特殊命名的方法,它们在特定情况下自动执行。在反序列化过程中,有几个魔术方法会被 unserialize()
函数自动调用:
__construct()
: 构造函数,对象创建时调用。注意:unserialize()
不会 自动调用构造函数。__destruct()
: 析构函数,对象销毁时调用(例如脚本结束、对象不再被引用、显式调用unset()
)。unserialize()
成功创建对象后,当该对象生命周期结束时,其__destruct()
方法会被调用。__wakeup()
: 在unserialize()
重新创建对象实例后,但在调用其他方法之前(如果存在),会先检查是否存在__wakeup()
方法。如果存在,则先调用__wakeup()
方法,这通常用于执行一些初始化操作,例如重新建立数据库连接等。__sleep()
: 在对象被serialize()
之前调用,用于确定哪些属性需要被序列化。如果存在,serialize()
会先调用它,并只序列化方法返回的数组中列出的属性。__toString()
: 当对象被当作字符串使用时(例如echo $obj;
或字符串连接$str . $obj
)会自动调用。虽然不是直接由unserialize()
调用,但在反序列化后,如果后续代码将反序列化得到的对象用作字符串,__toString()
可能会被触发,并可能成为攻击链的一部分。__invoke()
: 当对象被当作函数调用时 ($obj()
) 自动调用。__get()
,__set()
,__isset()
,__unset()
: 用于处理对象属性的读取、写入、检查存在性和取消设置。__call()
,__callStatic()
: 用于处理调用对象或类中不存在的方法。
3.1 __wakeup()
和 __destruct()
的特殊性
在反序列化漏洞中,__wakeup()
和 __destruct()
方法尤为关键。
__wakeup()
:unserialize()
成功创建对象后,会立即检查并调用__wakeup()
。如果应用程序在__wakeup()
方法中执行了敏感操作(如文件操作、数据库查询、执行外部命令等),并且这些操作使用了对象的属性(这些属性的值由攻击者通过序列化字符串控制),那么攻击者就可以利用这一点。__destruct()
:unserialize()
成功创建对象后,这个对象会被存储在内存中。当脚本执行完毕、对象不再被引用或被显式销毁时,该对象的__destruct()
方法会被调用。如果__destruct()
方法中包含敏感操作,同样可能被攻击者利用。析构函数非常危险,因为它的执行几乎是不可避免的(只要对象被成功创建并存在于内存中)。
攻击者如何利用?
攻击者的目标是构造一个恶意的序列化字符串,当服务器对其进行 unserialize()
操作时,能够触发某个类中的 __wakeup()
或 __destruct()
方法,并且该方法中的代码能够根据攻击者控制的属性值执行有害操作,例如:
- 文件操作:
__destruct
方法读取或写入一个由攻击者控制的文件名。 - 代码执行:
__wakeup
或__destruct
方法调用eval()
、system()
、passthru()
等函数,并将攻击者控制的属性值作为参数。 - SQL 注入:
__wakeup
或__destruct
方法执行数据库查询,查询语句的一部分由攻击者控制的属性值拼接而成。 - SSRF (Server-Side Request Forgery):
__wakeup
或__destruct
方法发起一个网络请求,URL 由攻击者控制。
由于 unserialize()
不调用构造函数,攻击者无法直接控制对象的初始化逻辑。但通过控制属性值,攻击者可以影响魔术方法中的执行路径和参数,从而实现攻击。
3.2 __wakeup()
方法的一个绕过 (CVE-2008-5498)
在 PHP 5.6.2 之前的版本中,存在一个著名的 __wakeup()
绕过漏洞(CVE-2008-5498)。如果序列化字符串中表示对象成员数量的值大于实际的成员数量,__wakeup()
方法就会被跳过执行。
例如,原本一个对象有两个属性,序列化字符串应该是 O:4:"User":2:{...}
。攻击者可以将其修改为 O:4:"User":3:{...}
。在 PHP 5.6.2 之前,这会导致 __wakeup()
被跳过,而 __destruct()
仍然会执行。这使得攻击者能够绕过 __wakeup()
中可能存在的清理或安全检查逻辑,直接触发 __destruct()
中的恶意代码。
这个绕过在 PHP 5.6.2 及更高版本中被修复,现在如果成员数量不匹配,会触发一个警告,但 __wakeup
仍然会执行。不过,了解这个历史漏洞有助于理解 __wakeup
方法的潜在绕过方式。
4. 构造攻击:寻找 Gadget Chain (小工具链)
直接在一个类的 __wakeup()
或 __destruct()
方法中找到能够直接导致 RCE 或文件操作的理想代码是比较少见的。更常见的情况是,攻击者需要利用应用程序中 多个 类之间的关联,通过链式调用触发最终的恶意操作。这被称为 Gadget Chain(小工具链)。
Gadget Chain 的概念:
攻击者通过反序列化创建的对象(称为起点 Gadget),其 __wakeup()
或 __destruct()
方法会调用另一个对象(由攻击者控制其属性值,可能是起点 Gadget 的一个属性)的方法。这个被调用的方法又会调用第三个对象的方法,以此类推,直到链的末端触发一个能够执行恶意操作的方法(称为终点 Gadget)。
寻找 Gadget Chain 的过程:
攻击者需要对目标应用程序的源代码或其使用的第三方库进行分析(白盒测试)或逆向工程(黑盒测试),寻找满足以下条件的代码片段:
- 起作用的魔术方法: 找到一个类,其
__wakeup()
或__destruct()
方法能够被触发,并且该方法调用了对象自身的属性或另一个对象的方法。- 例如:
class A { function __destruct() { $this->obj->method(); } }
- 例如:
- 链式调用: 找到另一个类 B,其
method()
方法又调用了它的某个属性 C 的方法。- 例如:
class B { public $next_obj; function method() { $this->next_obj->another_method(); } }
- 例如:
- 危险的终点: 找到链的末端,某个类的方法
another_method()
执行了危险的操作(如system($this->cmd);
或file_put_contents($this->filename, $this->content);
)。- 例如:
class C { public $cmd; function another_method() { system($this->cmd); } }
- 例如:
构建恶意载荷:
一旦找到了这样的 Gadget Chain (A -> B -> C),攻击者就可以构造一个序列化字符串,代表一个 A 类对象,其属性 obj
是一个 B 类对象,而 B 类对象的属性 next_obj
是一个 C 类对象。同时,C 类对象的属性 cmd
被设置为攻击者想要执行的系统命令。当这个序列化字符串被 unserialize()
时:
- 创建 A, B, C 的对象实例。
- 填充它们的属性值(包括恶意的
cmd
值)。 - A 对象生命周期结束时,
A::__destruct()
被调用。 A::__destruct()
调用$this->obj->method()
,实际上是调用B::method()
。B::method()
调用$this->next_obj->another_method()
,实际上是调用C::another_method()
。C::another_method()
调用system($this->cmd)
,执行了攻击者注入的命令。
这个过程需要对目标应用的类结构和方法调用关系有深入的了解。在大型应用或使用了复杂框架/库的情况下,寻找 Gadget Chain 可能非常复杂,但存在一些自动化工具,如 PHPGGC (PHP Generic Gadget Chains),它们收集了许多流行 PHP 库和框架(如 Symfony, Laravel, Guzzle, Doctrine, Monolog 等)中已知的 Gadget Chain,可以自动化生成针对特定目标的恶意序列化载荷。
5. 常见的攻击场景与影响
反序列化漏洞可能出现在应用程序处理序列化数据的任何地方,常见的场景包括:
- 用户会话 (Session): 一些框架或应用会将用户会话数据序列化后存储在文件、数据库或缓存中。如果会话 ID 可预测或被劫持,攻击者可能构造恶意会话数据。
- Cookie: 应用程序将对象状态或敏感信息序列化后存储在 Cookie 中,并在后续请求中反序列化。
- 缓存 (Cache): 应用程序将数据序列化后存储在文件或内存缓存中,攻击者如果能控制缓存键或内容,可能注入恶意序列化数据。
- 消息队列 (Message Queue): 在分布式系统中,消息队列中传递的数据可能被序列化,攻击者如果能向队列发送恶意消息,可能影响消费者端。
- 应用程序内部数据交换: 进程间通信、API 调用参数等使用了序列化。
- 文件上传/下载: 应用程序处理用户上传的文件,如果文件内容被错误地当作序列化数据处理。
- URL 参数或 POST 数据: 直接将用户输入的参数进行反序列化(这是最危险的情况)。
攻击影响:
反序列化漏洞的最终影响取决于找到的 Gadget Chain 的终点。最严重的影响是 远程代码执行 (RCE),攻击者可以在服务器上执行任意系统命令。其他可能的危害包括:
- 任意文件读写: 读取敏感文件(如配置文件、源代码)或写入 Web Shell。
- 权限绕过: 通过修改对象属性绕过认证或授权检查。
- 拒绝服务 (DoS): 构造包含大量嵌套对象或循环引用的序列化字符串,导致反序列化过程消耗大量内存或 CPU 资源,使服务器崩溃。
- SQL 注入: 如果 Gadget Chain 导致执行可控参数的 SQL 查询。
6. 防御与缓解措施
PHP 反序列化漏洞的危险性在于它利用了合法的 PHP 功能(魔术方法)与应用程序代码逻辑的结合。因此,防御的核心在于避免使用不可信数据进行反序列化,并限制反序列化可能造成的危害。
6.1 根本解决方案:避免对不可信数据使用 unserialize()
这是最重要、最有效的防御措施。如果应用程序需要存储或传递复杂数据结构,考虑使用更安全、格式更明确的数据交换格式,例如:
- JSON (JavaScript Object Notation): 使用
json_encode()
和json_decode()
。JSON 是一种纯数据格式,不包含代码或类型信息(虽然可以模拟),且json_decode()
默认不会调用对象的魔术方法。 - YAML (YAML Ain’t Markup Language): 类似的结构化数据格式。
- Protocol Buffers 或 Thrift: 跨语言、高效的序列化机制。
为什么这些格式更安全?
这些格式主要关注数据的表示,而不是对象的行为。解析这些格式的数据时,通常不会触发对象方法的自动执行,因此难以利用魔术方法构造攻击链。
如果确实需要存储/传递 PHP 对象的状态怎么办?
如果必须保存对象状态并在 PHP 环境中还原,应该仔细评估风险。可以将序列化数据进行加密签名,确保数据在传输过程中未被篡改。但这并不能防止攻击者构造全新的恶意序列化数据。
6.2 在必须使用 unserialize()
的情况下:利用 PHP 7+ 的 allowed_classes
选项
从 PHP 7.0.0 开始,unserialize()
函数增加了一个可选的 options
参数,其中最重要的就是 allowed_classes
。这个参数允许指定一个白名单数组,只允许反序列化这些白名单中的类。如果序列化字符串尝试创建不在白名单中的类的对象,反序列化将返回 false
。
示例代码:
“`php
cmd . “\n”;
// system($this->cmd); // 假设这里是危险代码
}
}
$safe_data = ‘O:9:”SafeClass”:1:{s:4:”data”;s:5:”hello”;}’;
$dangerous_data = ‘O:14:”DangerousClass”:1:{s:3:”cmd”;s:7:”whoami”;}’;
// 不安全的反序列化 (PHP 7.0 之前或不使用 options)
// $unserialized_obj_unsafe = unserialize($dangerous_data); // DangerousClass 会被创建
// 安全的反序列化 (使用 allowed_classes 白名单)
$allowed_classes = [“SafeClass”];
$unserialized_obj_safe = unserialize($safe_data, [“allowed_classes” => $allowed_classes]);
var_dump($unserialized_obj_safe); // 输出 SafeClass 对象
$unserialized_obj_dangerous = unserialize($dangerous_data, [“allowed_classes” => $allowed_classes]);
var_dump($unserialized_obj_dangerous); // 输出 false,DangerousClass 未被反序列化
?>
“`
使用 allowed_classes
的注意事项:
- 这个白名单应该尽可能小,只包含确实需要被反序列化的类。
- 即使使用了白名单,仍然需要审查白名单中类的魔术方法 (
__wakeup
,__destruct
,__toString
等),确保它们不会在接收攻击者控制的属性值时执行危险操作。换句话说,白名单中的类本身不应该是 Gadget Chain 的一部分或终点。 - 将
allowed_classes
设置为false
可以完全禁止反序列化任何对象(只允许反序列化基本类型和数组),这是最安全的选项,如果你的应用不需要反序列化任何对象的话。
6.3 审查和加固代码
- 审查魔术方法: 特别检查应用程序中所有类的
__wakeup()
,__destruct()
,__toString()
等魔术方法。确保它们不对由外部输入控制的属性值执行敏感操作(如文件操作、代码执行函数调用、数据库查询拼接)。如果必须执行这些操作,确保输入已经被充分验证或过滤。 - 审查依赖库: 了解应用程序使用的第三方库是否存在已知的反序列化 Gadget Chain。及时更新库到最新版本,已知漏洞通常会被修复。使用 PHPGGC 等工具可以帮助识别常用库中的 Gadget。
- 最小权限原则: 运行 PHP 应用的用户应该具有最小必要的系统权限,即使发生 RCE,也能限制攻击者的进一步行动。
6.4 Web 应用防火墙 (WAF)
WAF 可以作为一道额外的防线,但不能作为主要防御手段。一些 WAF 规则可以尝试检测和拦截已知 Gadget Chain 的序列化字符串模式。然而,攻击者可以通过混淆或使用未知的 Gadget Chain 来绕过 WAF。
6.5 禁用魔术方法 (不推荐作为主要防御)
虽然可以通过设置 php.ini
中的 disable_functions
来禁用某些危险函数,但这不能阻止攻击者利用其他合法的、但被用在错误上下文中的函数(如文件操作函数)。禁用魔术方法本身在 PHP 中并不直接可行(它们是语言特性)。
7. 总结
PHP 反序列化漏洞是由于应用程序对来自不可信来源的序列化数据进行 unserialize()
操作,并被攻击者利用 PHP 对象的魔术方法和应用程序内部的类结构(Gadget Chain)来执行恶意代码或操作。
理解序列化字符串的格式、PHP 魔术方法在反序列化过程中的行为以及 Gadget Chain 的概念是理解和防范此类漏洞的关键。
最有效的防御措施是 避免对不可信数据使用 unserialize()
,转而使用更安全的数据格式如 JSON。如果确实需要在 PHP 中反序列化,务必利用 PHP 7+ 提供的 allowed_classes
选项来严格限制可被反序列化的类,并仔细审查这些允许类中的魔术方法是否存在安全风险。此外,及时更新依赖库、进行代码安全审计也是必不可少的环节。
反序列化漏洞通常是“隐藏”在应用程序看似无害的数据处理逻辑中,一旦被发现并利用,其危害往往非常严重。因此,开发者和安全审计人员都应对此保持高度警惕。