Hu3sky's blog

Thinkphp3个版本数据库操作以及底层代码分析

Word count: 3,402 / Reading time: 17 min
2019/09/20 Share

前言(文章首发于先知社区)

发现自己对tp的底层不太熟悉,看了@phpoop师傅文章有所启发,于是有此文,记录自己的分析过程
希望大师傅们嘴下留情,有分析不对的地方还请师傅们指出o r

Thinkphp3.2.3

首先开启调试

/Application/Home/Conf/config.php加上

1
'SHOW_PAGE_TRACE' => true,

并且添加数据库配置

1
2
3
4
5
6
7
8
9
10
//数据库配置信息
'DB_TYPE' => 'mysql', // 数据库类型
'DB_HOST' => 'localhost', // 服务器地址
'DB_NAME' => 'thinkphp', // 数据库名
'DB_USER' => 'root', // 用户名
'DB_PWD' => '123456', // 密码
'DB_PORT' => 3306, // 端口
'DB_PREFIX' => 'think_', // 数据库表前缀
'DB_CHARSET'=> 'utf8', // 字符集
'DB_DEBUG' => TRUE, // 数据库调试模式 开启后可以记录SQL日志 3.2.3新增

测试数据如下
image.png

添加实例代码
用I函数进行动态获取参数

field

field方法属于模型的连贯操作方法之一,主要目的是标识要返回或者操作的字段,可以用于查询和写入操作

1
2
3
4
5
6
7
8
9
10
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$age = I('GET.age');
$User = M("user"); // 实例化User对象
$User->field('username,age')->where(array('age'=>$age))->find();
}
}

执行语句相当于
image.png

where

1
2
3
4
5
6
7
8
9
10
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$age = I('GET.age');
$User = M("user"); // 实例化User对象
$User->where(array('age'=>$age))->select();
}
}

接着请求
http://127.0.0.1/thinkphp3/index.php?m=Home&c=index&a=index&age=1

image.png

转义代码分析

当我们请求age=1'尝试注入的时候

image.png

被自动转义了
image.png

find函数里,会解析出options
image.png

跟入
image.png

image.png

继续跟进
image.png

parseSql里会依此执行函数
image.png

发现在ThinkPHP/Library/Think/Db/Driver.class.php的函数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
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
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);
// }
// 多条件支持
$multi = is_array($val) && isset($val['_multi']);
$key = trim($key);
if(strpos($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,'&')){
$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{
$whereStr .= $this->parseWhereItem($this->parseKey($key),$val);
}
}
$whereStr .= $operate;
}
$whereStr = substr($whereStr,0,-strlen($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
protected function parseWhereItem($key,$val) {
$whereStr = '';
if(is_array($val)) {
if(is_string($val[0])) {
$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 ){ // 使用表达式
$whereStr .= $key.' '.$val[1];
}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;
}

此时我们的key是age,val是1,于是执行

1
2
3
}else {
$whereStr .= $key.' = '.$this->parseValue($val);
}

继续跟进parseValue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function parseValue($value) {
if(is_string($value)) {
$value = strpos($value,':') === 0 && in_array($value,array_keys($this->bind))? $this->escapeString($value) : '\''.$this->escapeString($value).'\'';
}elseif(isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp'){
$value = $this->escapeString($value[1]);
}elseif(is_array($value)) {
$value = array_map(array($this, 'parseValue'),$value);
}elseif(is_bool($value)){
$value = $value ? '1' : '0';
}elseif(is_null($value)){
$value = 'null';
}
return $value;
}

可以发现这里就执行了escapeString
image.png

返回了转义后的结果
image.png

调用栈如下
image.png

如何注入

既然如此,那么怎么去注入呢,底层就调用了escapeString
我们看到parseWhereItem函数

image.png

在绿色标记的几个判断语句里,是没有调用parseValue函数的,也就不会调用到escapeString
然后我们又可以看到,exp就是val数组的第一个值

image.png

那么我们是不是就能注入了呢
我们修改代码如下

1
2
3
4
5
6
7
8
9
10
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$age = $_GET['age'];
$User = M("user"); // 实例化User对象
$User->field('username,age')->where(array('age'=>$age))->find();
}
}

这里暂时不用I函数接收参数
传入payload
`http://127.0.0.1/thinkphp3/index.php?
image.png

我们进入了判断
image.png

返回值并没有转义,页面上也能够直接看出来

image.png

为什么用exp不用bind呢,因为bind执行后的结果

image.png

会拼接一个 = : 这显然是对我们注入不利的
image.png

那么 我们利用报错注入

1
http://127.0.0.1/thinkphp3/index.php?m=Home&c=index&a=index&age[0]=exp&age[1]==%271%27%20and%20(extractvalue(1,concat(0x7e,(select%20user()),0x7e)))%20%23

image.png

成功造成了注入

不过我们接收参数修改为I函数

I函数

修改代码

1
2
3
4
5
6
7
8
9
10
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$age = I("GET.age");
$User = M("user"); // 实例化User对象
$User->field('username,age')->where(array('age'=>$age))->find();
}
}

同样的请求发现报错了

image.png

image.png

我们跟进调试一下

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
122
123
124
125
126
127
128
129
130
131
132
133
134
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';
}
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;
}
if(''==$name) { // 获取全部变量
$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])) { // 取值操作
$data = $input[$name];
$filters = isset($filter)?$filter:C('DEFAULT_FILTER');
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)){
$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); // 参数过滤
}else{
$data = filter_var($data,is_int($filter) ? $filter : filter_id($filter));
if(false === $data) {
return isset($default) ? $default : null;
}
}
}
}
}
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;
}
is_array($data) && array_walk_recursive($data,'think_filter');
return $data;
}

首先获取method
image.png

然后取age值并赋值给data
image.png

接着看是否传入了filter

image.png

在手册中也是介绍了
https://www.kancloud.cn/manual/thinkphp/1841
这里就是默认的htmlspecialchars
关于该函数的一些用法

https://www.w3school.com.cn/php/func_string_htmlspecialchars.asp

image.png

image.png

跟入函数,最终是要调到这个call_user_func

image.png

调用htmlspecialchars处理后,对我们的payload影响不太大,那么继续跟

image.png

这里又对是数组data里的两个值exp$payload进行了think_filter函数的调用

1
2
3
4
5
6
7
8
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 .= ' ';
}
}

这里就对一些sql敏感的东西进行了过滤
此时,我们的data[0]exp字符串,这里就匹配了,于是他在exp后面加上了一个空格
也就是exp
然后我们的payload并没有匹配到

image.png

那么自然
到了parseWhereItem也就进不了exp那一个判断了,直接进入报错的地方

image.png

这样 我们也就没办法再进行注入了

总结

也就是说在thinkphp3下,使用了I函数,我们的注入就不太能成功,如果接收参数的时候并没有使用I函数,而是直接接收就传入M函数并实例化,那么我们注入的可能性就更大

Thinkphp5.0.24

在Thinkphp5里,所有单个字母的函数都被取消了
查询语句变成了

1
Db::table('think_user')->where('id',1)->find();

于是修改index.php代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
namespace app\index\controller;

use think\Db;

class Index
{
public function index()
{
$age = $_GET['age'];
$User = Db::name('user');
//为了方便调试我将select设置false
echo $User->where(array('age'=>$age))->select(false);
}
}

image.png

接着修改application/database.php

1
'debug'           => true,

于是看到查询语句

image.png

底层过滤

我们改一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
namespace app\index\controller;

use think\Db;

class Index
{
public function index()
{
$age = $_GET['age'];
Db::name('user')->where(array('age'=>$age))->find();
}
}

传入单引号同样被转义,应该也是在select函数里进行了转义

image.png

跟入select

image.png

image.png

继续跟进,在parseWhere时,返回了占位符
image.png

image.png

在select结束后,返回了预编译的sql语句,:where_AND_age是占位符

image.png

跟入getRealSql

image.png

提取age的值

image.png

在这里,就发生了转义

1
2
3
if (PDO::PARAM_STR == $type) {
$value = $this->quote($value);
}

跟进quote

image.png

里面又调用了quote,关于PDO::quote的介绍
PDO::quote
会转义特殊字符串,也就是我们的单引号

如果一开始的代码是select()不用false
那么调用栈如下

image.png

insert方法

看了网上有分析该方法存在注入,于是调试
修改代码

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

use think\Db;

class Index
{
public function index()
{
$username = $_GET['username'];
$User = Db::name('user');
echo $User->where(array('age'=>'13'))->insert(array('username'=>$username));
}
}

image.png

跟进insert

image.png

继续跟进parseData

image.png

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
protected function parseData($data, $options)
{
if (empty($data)) {
return [];
}

// 获取绑定信息
$bind = $this->query->getFieldsBind($options['table']);
if ('*' == $options['field']) {
$fields = array_keys($bind);
} else {
$fields = $options['field'];
}

$result = [];
foreach ($data as $key => $val) {
if ('*' != $options['field'] && !in_array($key, $fields, true)) {
continue;
}

$item = $this->parseKey($key, $options, true);
if ($val instanceof Expression) {
$result[$item] = $val->getValue();
continue;
} elseif (is_object($val) && method_exists($val, '__toString')) {
// 对象数据写入
$val = $val->__toString();
}
if (false === strpos($key, '.') && !in_array($key, $fields, true)) {
if ($options['strict']) {
throw new Exception('fields not exists:[' . $key . ']');
}
} elseif (is_null($val)) {
$result[$item] = 'NULL';
} elseif (is_array($val) && !empty($val)) {
switch (strtolower($val[0])) {
case 'inc':
$result[$item] = $item . '+' . floatval($val[1]);
break;
case 'dec':
$result[$item] = $item . '-' . floatval($val[1]);
break;
case 'exp':
throw new Exception('not support data:[' . $val[0] . ']');
}
} elseif (is_scalar($val)) {
// 过滤非标量数据
if (0 === strpos($val, ':') && $this->query->isBind(substr($val, 1))) {
$result[$item] = $val;
} else {
$key = str_replace('.', '_', $key);
$this->query->bind('data__' . $key, $val, isset($bind[$key]) ? $bind[$key] : PDO::PARAM_STR);
$result[$item] = ':data__' . $key;
}
}
}
return $result;
}

跟tp3类似的思路

image.png

但是,注意这里的拼接

1
2
case 'inc':
$result[$item] = $item . '+' . floatval($val[1]);

$val[1]进行了一个floatval的强转,那么我们的payload也就不行了

image.png

Thinkphp6开发版

使用composer安装

1
composer create-project topthink/think=6.0.x-dev tp

然后运行

1
php think run

访问127.0.0.1:8000
或者直接访问public目录

index.php代码修改

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

use app\BaseController;
use think\facade\Db;

class Index extends BaseController
{
public function index()
{
$age = input('get.age');
echo Db::table('think_user')->where(array('age'=>$age))->fetchSql()->find(1);
}
}

image.png

跟tp5类似预加载

image.png

跟入fetch

image.png

跟入getRealSql

image.png

这里 调用了addslashes对单引号进行了转义

image.png

我们再看看其他方法

insert

修改代码

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

use app\BaseController;
use think\facade\Db;

class Index extends BaseController
{
public function index()
{
$age = input('get.age');
echo Db::table('think_user')->where(array('age'=>'15'))->fetchSql()->insert(array('age'=>$age));
}
}

跟进insert

image.png

跟入parsedata

image.png

同样的处理方式,把payload进行强转,不过取消了exp

image.png

Referer

CATALOG
  1. 1. 前言(文章首发于先知社区)
  2. 2. Thinkphp3.2.3
    1. 2.1. field
    2. 2.2. where
    3. 2.3. 转义代码分析
    4. 2.4. 如何注入
    5. 2.5. I函数
    6. 2.6. 总结
  3. 3. Thinkphp5.0.24
    1. 3.1. 底层过滤
    2. 3.2. insert方法
  4. 4. Thinkphp6开发版
    1. 4.1. insert
  5. 5. Referer