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
2
3
4
5
6
7
8
9
10
PHP_FUNCTION(explode)
{
char *str, *delim;
int str_len = 0, delim_len = 0;
long limit = LONG_MAX; /* No limit */
zval zdelim, zstr;
if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss|l", &delim, &delim_len, &str, &str_len, &limit) == FAILURE) {
return;
}

通过zend_parse_parameters来接受传入函数的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
ZEND_API int zend_parse_parameters(int num_args TSRMLS_DC, const char *type_spec, ...) /* {{{ */
{
va_list va;
int retval;
RETURN_IF_ZERO_ARGS(num_args, type_spec, 0);
va_start(va, type_spec);
retval = zend_parse_va_args(num_args, type_spec, &va, 0 TSRMLS_CC);
va_end(va);
return retval;
}

zend_parse_va_args方法中, 首先获取到最少传入的参数个数, 和最多传入参数个数之后, 判断实际传入的参数数量是否在最少与最多的区间范围之内, 如果在这之内的话,继续调用zend_parse_arg方法来获取参数。

1
2
3
4
5
6
7
8
if (zend_parse_arg(i+1, arg, va, &type_spec, quiet TSRMLS_CC) == FAILURE) {
/* clean up varargs array if it was used */
if (varargs && *varargs) {
efree(*varargs);
*varargs = NULL;
}
return FAILURE;
}

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
static int zend_parse_arg(int arg_num, zval **arg, va_list *va, const char **spec, int quiet TSRMLS_DC) /* {{{ */
{
const char *expected_type = NULL;
char *error = NULL;
int severity = E_WARNING;
expected_type = zend_parse_arg_impl(arg_num, arg, va, spec, &error, &severity TSRMLS_CC);
if (expected_type) {
if (!quiet && (*expected_type || error)) {
const char *space;
const char *class_name = get_active_class_name(&space TSRMLS_CC);
if (error) {
zend_error(severity, "%s%s%s() expects parameter %d %s",
class_name, space, get_active_function_name(TSRMLS_C), arg_num, error);
efree(error);
} else {
zend_error(severity, "%s%s%s() expects parameter %d to be %s, %s given",
class_name, space, get_active_function_name(TSRMLS_C), arg_num, expected_type,
zend_zval_type_name(*arg));
}
}
if (severity != E_STRICT) {
return FAILURE;
}
}

severity 定义为了E_WARNING, 并且把severity的引用传递给了zend_parse_arg_impl方法,

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
static const char *zend_parse_arg_impl(int arg_num, zval **arg, va_list *va, const char **spec, char **error, int *severity TSRMLS_DC) /* {{{ */
{
const char *spec_walk = *spec;
char c = *spec_walk++;
int check_null = 0;
.....................
case 's':
{
char **p = va_arg(*va, char **);
int *pl = va_arg(*va, int *);
switch (Z_TYPE_PP(arg)) {
case IS_NULL:
if (check_null) {
*p = NULL;
*pl = 0;
break;
}
/* break omitted intentionally */
case IS_STRING:
case IS_LONG:
case IS_DOUBLE:
case IS_BOOL:
convert_to_string_ex(arg);
if (UNEXPECTED(Z_ISREF_PP(arg) != 0)) {
/* it's dangerous to return pointers to string
buffer of referenced variable, because it can
be clobbered throug magic callbacks */
SEPARATE_ZVAL(arg);
}
*p = Z_STRVAL_PP(arg);
*pl = Z_STRLEN_PP(arg);
if (c == 'p' && CHECK_ZVAL_NULL_PATH(*arg)) {
return "a valid path";
}
break;
case IS_OBJECT:
if (parse_arg_object_to_string(arg, p, pl, IS_STRING TSRMLS_CC) == SUCCESS) {
if (c == 'p' && CHECK_ZVAL_NULL_PATH(*arg)) {
return "a valid path";
}
break;
}
case IS_ARRAY:
case IS_RESOURCE:
default:
return c == 's' ? "string" : "a valid path";
}
}
break;

在处理字符串的这个分支下, 通过Z_TYPE_PP获取参数的数据类型, 如果参数是数组的话 直接进入default分支, return string。
不过在字符串分支下, 没有对传递进来的severity引用进行修改, 所以还是最开始的Warning, 然后进入zend_error方法 在severity为warning时, zend_error 并不会退出程序, 所以可以继续运行下去, 然后返回FAILURE意味着处理参数失败了。

1
2
3
if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss|l", &delim, &delim_len, &str, &str_len, &limit) == FAILURE) {
return;
}

在处理参数失败了之后, 就直接return了。 这里在对return_value指针修改之前就返回了, 所以这里的return_value依旧为默认值。
则return_value为一个未初始化的zval结构体,

1
2
3
4
5
6
7
struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_uint refcount__gc;
zend_uchar type; /* active type */
zend_uchar is_ref__gc;
};

typedef unsigned char zend_uchar;
当一个结构体未初始化时, 结构体内的每个属性根据自己的数据类型都会有自己的默认值, int/char都为0 指针为null之类的。
所以此时的return_value结构体的type属性为0, 0代表的为IS_NULL

1
#define IS_NULL 0

所以如果第二个参数数据类型为数组经过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的值, 再分割成数组即可。