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; $allowed_exts=explode(',' , $upload_setting[$filetype]); $date=date("Ymd" ); $config=array ( 'rootPath' => './' . C("UPLOADPATH" ), 'savePath' => "ueditor/$date /" , 'maxSize' => $upload_max_filesize, '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' , '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 ; 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 ; }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;}}
经过反序列后的结果为, 再回到之前获取允许上传后缀的地方, 就能够发现出问题了。 $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; 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) { 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 ; } case IS_STRING: case IS_LONG: case IS_DOUBLE: case IS_BOOL: convert_to_string_ex(arg); if (UNEXPECTED(Z_ISREF_PP(arg) != 0 )) { 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 { zvalue_value value; zend_uint refcount__gc; zend_uchar type; zend_uchar is_ref__gc; };
typedef unsigned char zend_uchar; 当一个结构体未初始化时, 结构体内的每个属性根据自己的数据类型都会有自己的默认值, int/char都为0 指针为null之类的。 所以此时的return_value结构体的type属性为0, 0代表的为IS_NULL
所以如果第二个参数数据类型为数组经过explode时会抛出warning并且返回null。
所以此时的$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 (), 'maxSize' => 0 , 'exts' => array (), 'autoSub' => true , 'subName' => array ('date' , 'Y-m-d' ), 'rootPath' => './Uploads/' , 'savePath' => '' , 'saveName' => array ('uniqid' , '' ), 'saveExt' => '' , 'replace' => false , 'hash' => true , '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 $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 ; } 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)) { $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 ); } }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文件即可。
0x04 修复 /application/Asset/Controller/UeditorController.class.php 的upload方法中 将获取允许上传的文件后缀代码修改为
1 $allowed_exts=explode(',' , $upload_setting[$filetype]['extensions' ]);
获取这个数组的extensions的值, 再分割成数组即可。