ThinkCMFX arbitrarily file upload

0x01 前言

ThinkCMF存在两个版本, ThinkCMF基于Thinkphp5开发, ThinkCMFX基于Thinkphp3开发。 好久以前做测试的时候遇到了CMFX, 就下载了一份看了一下。还找到了一些SQL注入和其他的漏洞, 不过好像其他的都看到有人发过了, 这个文件上传还没看到有人谈过。
https://github.com/thinkcmf/cmfx

0x02 分析

在/application/Asset/Controller/UeditorController.class.php中,

1
2
3
4
5
6
7
public function _initialize() {
$adminid=sp_get_current_admin_id();
$userid=sp_get_current_userid();
if(empty($adminid) && empty($userid)){
exit("非法上传!");
}
}

在这个Controller的”构造方法”中, 判断了是否登录, 可以看出这个是普通会员和管理员都可以使用的一个控制器。 Thinkcmfx默认是支持普通用户注册的, 所以没啥影响。

在UeditorController中的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
public function upload(){
error_reporting(E_ERROR);
header("Content-Type: application/json; charset=utf-8");
$action = $_GET['action'];
switch ($action) {
case 'config':
$result = $this->_ueditor_config();
break;
/* 上传图片 */
case 'uploadimage':
/* 上传涂鸦 */
case 'uploadscrawl':
$result = $this->_ueditor_upload('image');
break;
/* 上传视频 */
case 'uploadvideo':
$result = $this->_ueditor_upload('video');
break;
/* 上传文件 */
case 'uploadfile':
$result = $this->_ueditor_upload('file');
break;

这里随便选择一个分支跟进就行, 这里选择uploadfile.

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
private function _ueditor_upload($filetype='image'){
$upload_setting=sp_get_upload_setting();
$file_extension=sp_get_file_extension($_FILES['upfile']['name']);
$upload_max_filesize=$upload_setting['upload_max_filesize'][$file_extension];
$upload_max_filesize=empty($upload_max_filesize)?2097152:$upload_max_filesize;//默认2M
$allowed_exts=explode(',', $upload_setting[$filetype]);
$date=date("Ymd");
//上传处理类
$config=array(
'rootPath' => './'. C("UPLOADPATH"),
'savePath' => "ueditor/$date/",
'maxSize' => $upload_max_filesize,//10M
'saveName' => array('uniqid',''),
'exts' => $allowed_exts,
'autoSub' => false,
);
$upload = new \Think\Upload($config);//
$file = $title = $oriName = $state ='0';
$info=$upload->upload();

这里通过$allowed_exts=explode(',',$upload_setting[$filetype]) 来获取允许上传的后缀。

$upload_setting=sp_get_upload_setting();
upload_setting通过sp_get_upload_setting方法来获取上传的配置。

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
function sp_get_upload_setting(){
$upload_setting=sp_get_option('upload_setting');
if(empty($upload_setting)){
$upload_setting = array(
'image' => array(
'upload_max_filesize' => '10240',//单位KB
'extensions' => 'jpg,jpeg,png,gif,bmp4'
),
'video' => array(
'upload_max_filesize' => '10240',
'extensions' => 'mp4,avi,wmv,rm,rmvb,mkv'
),
'audio' => array(
'upload_max_filesize' => '10240',
'extensions' => 'mp3,wma,wav'
),
'file' => array(
'upload_max_filesize' => '10240',
'extensions' => 'txt,pdf,doc,docx,xls,xlsx,ppt,pptx,zip,rar'
)
);
}
if(empty($upload_setting['upload_max_filesize'])){
$upload_max_filesize_setting=array();
foreach ($upload_setting as $setting){
$extensions=explode(',', trim($setting['extensions']));
if(!empty($extensions)){
$upload_max_filesize=intval($setting['upload_max_filesize'])*1024;//转化成KB
foreach ($extensions as $ext){
if(!isset($upload_max_filesize_setting[$ext]) || $upload_max_filesize>$upload_max_filesize_setting[$ext]*1024){
$upload_max_filesize_setting[$ext]=$upload_max_filesize;
}
}
}
}
$upload_setting['upload_max_filesize']=$upload_max_filesize_setting;
F("cmf_system_options_upload_setting",$upload_setting);
}else{
$upload_setting=F("cmf_system_options_upload_setting");
}
return $upload_setting;
}

首先尝试通过sp_get_option方法获取文件上传的配置信息, 如果sp_get_option方法获取配置信息失败的话会返回一个默认的配置。
如果sp_get_option获取配置信息成功, 最后会再一次的调用F(“cmf_system_options_upload_setting”)来得到\$upload_setting, 此次调用F方法是直接从缓存中获取配置信息了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function sp_get_option($key){
if(!is_string($key) || empty($key)){
return false;
}
$option_value=F("cmf_system_options_".$key);
if(empty($option_value)){
$options_model = M("Options");
$option_value = $options_model->where(array('option_name'=>$key))->getField('option_value');
if($option_value){
$option_value = json_decode($option_value,true);
F("cmf_system_options_".$key);
}
}
return $option_value;
}

F方法尝试读上传的配置文件, 然后反序列该文件内容拿到上传配置信息。
从filename可以看出读取的配置文件为/data/runtime/Data/cmf_system_options_upload_setting.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
function F($name, $value='', $path=DATA_PATH) {
static $_cache = array();
$filename = $path . $name . '.php';
if ('' !== $value) {
if (is_null($value)) {
// 删除缓存
if(false !== strpos($name,'*')){
return false; // TODO
}else{
unset($_cache[$name]);
return Think\Storage::unlink($filename,'F');
}
} else {
Think\Storage::put($filename,serialize($value),'F');
// 缓存数据
$_cache[$name] = $value;
return null;
}
}
// 获取缓存数据
if (isset($_cache[$name]))
return $_cache[$name];
if (Think\Storage::has($filename,'F')){
$value = unserialize(Think\Storage::read($filename,'F'));
$_cache[$name] = $value;
} else {
$value = false;
}
return $value;
}

默认/data/runtime/Data/cmf_system_options_upload_setting.php的文件内容,

1
a:5:{s:5:"image";a:2:{s:19:"upload_max_filesize";s:5:"10240";s:10:"extensions";s:21:"jpg,jpeg,png,gif,bmp4";}s:5:"video";a:2:{s:19:"upload_max_filesize";s:5:"10240";s:10:"extensions";s:23:"mp4,avi,wmv,rm,rmvb,mkv";}s:5:"audio";a:2:{s:19:"upload_max_filesize";s:5:"10240";s:10:"extensions";s:11:"mp3,wma,wav";}s:4:"file";a:2:{s:19:"upload_max_filesize";s:5:"10240";s:10:"extensions";s:42:"txt,pdf,doc,docx,xls,xlsx,ppt,pptx,zip,rar";}s:19:"upload_max_filesize";a:24:{s:3:"jpg";i:10485760;s:4:"jpeg";i:10485760;s:3:"png";i:10485760;s:3:"gif";i:10485760;s:4:"bmp4";i:10485760;s:3:"mp4";i:10485760;s:3:"avi";i:10485760;s:3:"wmv";i:10485760;s:2:"rm";i:10485760;s:4:"rmvb";i:10485760;s:3:"mkv";i:10485760;s:3:"mp3";i:10485760;s:3:"wma";i:10485760;s:3:"wav";i:10485760;s:3:"txt";i:10485760;s:3:"pdf";i:10485760;s:3:"doc";i:10485760;s:4:"docx";i:10485760;s:3:"xls";i:10485760;s:4:"xlsx";i:10485760;s:3:"ppt";i:10485760;s:4:"pptx";i:10485760;s:3:"zip";i:10485760;s:3:"rar";i:10485760;}}

经过反序列后的结果为,
-w473
再回到之前获取允许上传后缀的地方, 就能够发现出问题了。
\$allowed_exts=explode(‘,’,\$upload_setting[\$filetype])
upload_setting为反序列后的结果, \$filetype是选择switch分支的时候硬编码传递进来的为file, 所以可以看到\$upload_setting[‘file’]的结果依旧为一个数组,包含upload_max_filesize和extensions两个key, php explode的作用为把第二个参数通过字符串分割成数组,

1
zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss|l", &delim, &delim_len, &str, &str_len, &limit)

所以第二个参数数据类型限制为字符串, 如果第二个参数数据类型为数组经过explode时会抛出warning并且返回null。
-w932

所以此时的\$allowed_exts为null, 然后加载到\$config数组中, 然后传递给\Think\Upload的构造方法,

在/simplewind/Core/Library/Think/Upload.class.php中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function __construct($config = array(), $driver = '', $driverConfig = null){
/* 获取配置 */
$this->config = array_merge($this->config, $config);
/* 设置上传驱动 */
$this->setDriver($driver, $driverConfig);
/* 调整配置,把字符串配置参数转换为数组 */
if(!empty($this->config['mimes'])){
if(is_string($this->mimes)) {
$this->config['mimes'] = explode(',', $this->mimes);
}
$this->config['mimes'] = array_map('strtolower', $this->mimes);
}
if(!empty($this->config['exts'])){
if (is_string($this->exts)){
$this->config['exts'] = explode(',', $this->exts);
}
$this->config['exts'] = array_map('strtolower', $this->exts);
}
}

构造方法把传递进来的变量和默认的配置信息进行合并 赋值给config属性。

默认的配置信息为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private $config = array(
'mimes' => array(), //允许上传的文件MiMe类型
'maxSize' => 0, //上传的文件大小限制 (0-不做限制)
'exts' => array(), //允许上传的文件后缀
'autoSub' => true, //自动子目录保存文件
'subName' => array('date', 'Y-m-d'), //子目录创建方式,[0]-函数名,[1]-参数,多个参数使用数组
'rootPath' => './Uploads/', //保存根路径
'savePath' => '', //保存路径
'saveName' => array('uniqid', ''), //上传文件命名规则,[0]-函数名,[1]-参数,多个参数使用数组
'saveExt' => '', //文件保存后缀,空则使用原后缀
'replace' => false, //存在同名是否覆盖
'hash' => true, //是否生成hash编码
'callback' => false, //检测文件是否存在回调,如果存在返回文件信息数组
'driver' => '', // 文件上传驱动
'driverConfig' => array(), // 上传驱动配置
);

If the input arrays have the same string keys, then the later value for that key will overwrite the previous one. If, however, the arrays contain numeric keys, the later value will not overwrite the original value, but will be appended.

当两个进行合并的数组存在相同的key时, 第二个数组的key对应的value会覆盖掉第一个数组key的对应的value。
所以此时\$this->config[‘ext’]从’’被覆盖为了null。

在经过构造方法把上传的配置信息配置好了之后, 就调用upload方法正式开始上传了。

upload方法中, 重点关注check方法

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
//通过pathinfo获取文件的后缀名
$file['ext'] = pathinfo($file['name'], PATHINFO_EXTENSION);
/* 文件上传检测 */
if (!$this->check($file)){
continue;
}
..............
$savename = $this->getSaveName($file);
if(false == $savename){
continue;
} else {
$file['savename'] = $savename;
}
/* 检测并创建子目录 */
$subpath = $this->getSubPath($file['name']);
if(false === $subpath){
continue;
} else {
$file['savepath'] = $this->savePath . $subpath;
}
。。。。。。。。。。。
/* 保存文件 并记录保存成功的文件 */
if ($this->uploader->save($file,$this->replace)) {
unset($file['error'], $file['tmp_name']);
$info[$key] = $file;
} else {
$this->error = $this->uploader->getError();
}

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
private function check($file) {
/* 文件上传失败,捕获错误代码 */
if ($file['error']) {
$this->error($file['error']);
return false;
}
/* 无效上传 */
if (empty($file['name'])){
$this->error = '未知上传错误!';
}
/* 检查是否合法上传 */
if (!is_uploaded_file($file['tmp_name'])) {
$this->error = '非法上传文件!';
return false;
}
/* 检查文件大小 */
if (!$this->checkSize($file['size'])) {
$this->error = '上传文件大小不符!';
return false;
}
/* 检查文件Mime类型 */
//TODO:FLASH上传的文件获取到的mime类型都为application/octet-stream
if (!$this->checkMime($file['type'])) {
$this->error = '上传文件MIME类型不允许!';
return false;
}
/* 检查文件后缀 */
if (!$this->checkExt($file['ext'])) {
$this->error = '上传文件后缀不允许';
return false;
}
/* 通过检测 */
return true;
}

这里只需要关注一下checkMime和checkExt方法,
checkMime方法中, 因为\$this->config[‘mimes’]为array(),
empty(array()) 为true, 就直接返回true了。

1
2
3
private function checkMime($mime) {
return empty($this->config['mimes']) ? true : in_array(strtolower($mime), $this->mimes);
}

在checkExt方法中, 从刚才的分析可以知道\$this->config[‘exts’]为null, empty(null)为true, 所以直接返回true了,不会再判断后缀了。

1
2
3
private function checkExt($ext) {
return empty($this->config['exts']) ? true : in_array(strtolower($ext), $this->exts);
}

在通过了check方法之后, 通过getSaveName生成最终保存的文件名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private function getSaveName($file) {
$rule = $this->saveName;
if (empty($rule)) { //保持文件名不变
/* 解决pathinfo中文文件名BUG */
$filename = substr(pathinfo("_{$file['name']}", PATHINFO_FILENAME), 1);
$savename = $filename;
} else {
$savename = $this->getName($rule, $file['name']);
if(empty($savename)){
$this->error = '文件命名规则错误!';
return false;
}
}
/* 文件保存后缀,支持强制更改文件后缀 */
$ext = empty($this->config['saveExt']) ? $file['ext'] : $this->saveExt;
return $savename . '.' . $ext;
}

\$rule为\$config中的savename属性值 array(‘uniqid’,’’),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private function getName($rule, $filename){
$name = '';
if(is_array($rule)){ //数组规则
$func = $rule[0];
$param = (array)$rule[1];
foreach ($param as &$value) {
$value = str_replace('__FILE__', $filename, $value);
}
$name = call_user_func_array($func, $param);
} elseif (is_string($rule)){ //字符串规则
if(function_exists($rule)){
$name = call_user_func($rule);
} else {
$name = $rule;
}
}
return $name;
}

所以这里就是调用uniqid来生成文件名。
文件的后缀来自,
$ext = empty($this->config['saveExt']) ? $file['ext'] : $this->saveExt;
在默认的上传配置信息中’saveExt’ => ‘’,
saveExt为空, 所以这里不会强制修改文件的后缀而是直接使用的上传文件名的后缀。
生成好文件名之后, 就直接通过save方法进行上传了。
save方法中已经没有了任何后缀校验, 所以直接实现了任意文件上传。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function save($file, $replace=true) {
$filename = $this->rootPath . $file['savepath'] . $file['savename'];
/* 不覆盖同名文件 */
if (!$replace && is_file($filename)) {
$this->error = '存在同名文件' . $file['savename'];
return false;
}
/* 移动文件 */
if (!move_uploaded_file($file['tmp_name'], $filename)) {
$this->error = '文件上传保存错误!';
return false;
}
return true;
}

在上传完成之后, 最后还是直接输出了文件名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if(!empty($first['url'])){
if($filetype=='image'){
$url=sp_get_image_preview_url($first['savepath'].$first['savename']);
}else{
$url=sp_get_file_download_url($first['savepath'].$first['savename'],3600*24*365*50);//过期时间设置为50年
}
}else{
$url = C("TMPL_PARSE_STRING.__UPLOAD__").$first['savepath'].$first['savename'];
}
} else {
$state = $upload->getError();
}
$response=array(
"state" => $state,
"url" => $url,
"title" => $title,
"original" =>$oriName,
);
return json_encode($response);

0x03 测试

注册好账户,登录之后,
直接调用这个控制器上传php文件即可。
-w1115

-w767

0x04 修复

/application/Asset/Controller/UeditorController.class.php
的upload方法中 将获取允许上传的文件后缀代码修改为

1
$allowed_exts=explode(',', $upload_setting[$filetype]['extensions']);

获取这个数组的extensions的值, 再分割成数组即可。