Thinkphp3.2.3 sql注入

Saofe1a

前言

最近投了星盟网安团队,面试的时候y2shin大师傅拷打我thinkphp框架是否在本地搭环境调试过,说实话之前一直是看着其他师傅的博客来学习,尽管最后如愿进入了预备队,但觉得还是有必要本地调试一下来提升代码审计能力,于是打算利用国庆假期把thinkphp系列的漏洞都debug完

环境搭建

Github获取:https://github.com/liu21st/thinkphp ,把文件解压在apache的web根目录即可

在浏览器访问此文件,如:http://localhost:8888/thinkphp-master/出现如下界面即代表成功

环境搭建这里真的踩了好多坑,师傅们的博客都说的不够详细
我们得先访问根目录中的thinkphp文件,整个框架会被初始化,自动生成模块,不然接下来的控制器配置会非常无厘头,我就是在这里墨迹了巨长他妈长的时间,一个劲在子目录找不存在的文件

数据库配置:打开ThinkPHP/Conf/convention.php

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

再向我们的mysql写入这个库,接着用Navicat连接

where注入

控制器配置

修改Application/Home/Controller/IndexController.class.php的index方法

1
2
3
4
5
public function index()
{
$data = M('users')->find(I('GET.id'));
var_dump($data);
}

传参得到我们的查询结果:

payload

?id[where]=1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)%23

Debug调试

$data处下个断点,先试试普通的sql注入会发生什么,在浏览器传入id=1'

这个M方法处理的是users,并不是我们传入的值,是用来建立模型的,不用管,直接步出,进入到I()方法,I()函数中获取参数,会经过ThinkPHP/Common/functions.php:391 htmlspecialchars()进行处理

总之,I方法用来获取我们通过GET传入的id值,然后传入find方法,跟进find方法,经过赋值后的where变量是一个array(借用pazuris大师傅的图)

find函数最关键的部分如下,这个select就是正式进行查询,我们后面会讲到:

跟进_parseOptions()方法

_parseOptions()对表达式进行分析(也就是处理我们的查询语句,options是一个array,里面有where,而where也是array,里面放了id="1'"

_parseOptions()前面都在自动获取表名,指定表之类的操作,字段类型验证在后面:

可以看到这个字段类型验证的条件是where是一个array,后续我们sql注入做文章的地方就是这里,先看看如何进行字段验证,关键函数是parseType,跟进看看:

简单来说就是根据表字段的类型来将传入的值强转,可以看到我们传入的1'被intval转成了1(id字段的类型是int),sql注入失效

可以通过将id字段的类型设置成varchar看看会怎么处理字符串,是否存在sql注入。

改了之后可以重新断点跳到这个地方,可以看到是成功跳过了所有的if,回到了parseOptions再回到find函数中,来到find中第二个关键函数select

select中的关键是buildSelectSql,就是这个函数利用我们传入的options生成了sql查询语句,跟进:

parsesql对我们的options进行了处理,跟进

看到了一堆函数,用来处理sql语句的不同逻辑

一通跳转到了处理我们传入的值的函数,parseValue,注意看此时传入前的value仍然是1'

这行代码调用了escapeString来处理我们value,跟进:

这个addslashes直接宣告了常规sql注入的终结,因为会转义单引号,可以看到我们的value变成了1\,失去效果。

既然传字符串不行,可以考虑传入一个数组,而联想到刚刚的判断,可以传入?id[where]=1看看,因为可以直接跳过强转部分,理由稍后阐明(不需要再使得字段是varchar,是int也行)

从头开始一路跟进,回调think_filter函数进行过滤

跟进到find方法中会发现这个options里的where不再是一个数组array,而是一个字符串。

继续跟进,既然where不再是array,这个parseOptions里的字段类型验证直接失效,更不会到parseType中发生强转,成功绕过第一层,

回到find中,到第二个关键函数parseWhere处理了where(因为我们现在相当于传入的是where,处理我们传入的就只有parseWhere,而不会像上次一样传入parseValue处理,自然也就绕过了addslashes),并且直接把where字符串内容返回

最后一通跳转返回parsesql函数,返回了sql语句

我配置的环境是已经修复过的版本,pazuris师傅的未修复的版本是这样的,sql这里比我多了个WHERE 1

这个where后面的值就是我们传入的1,从而可以随意控制值来进行sql注入,相当于没有了任何验证,传入?id[where]=id=-1 union select 1,group_concat(flag4s),3,4 from flags(后面一整个字符串直接拼到sql语句中)得到这一题的flag。

总结:

  1. 传入id值时正常逻辑,find()->_parseOptions()->_parseTypes()->select()->parseSql()->parseValue(),其中_parseTypes()是第一层需要绕过的点,parseValue是第二层需要绕过的点
  2. 传入?id[where]=1,绕过上述两层,find()->_parseOptions()->select()->parseSql()->parseWhere(),直接将内容拼接到sql语句中。

修复后:

可以对比下前后两个版本(白色背景的是pazuris大师傅的)

可知重要的改动是把options变成了this->options,并且不再直接将options传入parseOptions,this->options初始为空,需要赋值,而如果传数组,就没法赋值where给this->Options,所以相当于到parseOptions中我们根本就没有where(直接传数组相当于啥也没传),自然绕不过第二层,不再可以注入。

exp注入

控制器配置

继续修改index方法

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

为什么用GET获取而不用I方法获取,因为I方法内部有过滤,不允许传入参数值以exp开头(后面分析payload就知道为什么要用exp开头了)。直接调用where再调用find主要是为了保证where是我们传入的array

payload

http://localhost:8888/thinkphp-master/index.php?username[0]=exp&username[1]==-1 union select 1,2,3

Debug调试

打上断点,按下F5,浏览器传入payload,又是沟槽的debug的一天

先跟进where方法,一句话:把我们传入的这个数组array('username' => $_GET['username'])传给了$this->options['where']

继续跟进

跟进到parseOptions中,和之前一样,进行字段验证,这个val变量是一个数组,而is_scalar方法判断一个变量是否是标量(存储单个值的数据类型),这里if判断失败,自然实现了绕过。

进入我们的老朋友select方法

进入buildSelectSql方法,然后跳到parseSql一大堆函数,前面的验证都和之前一样,直接跳到不一样的地方,parseWhere,由于我们现在的where是一个二维数组(应该可以这么说),自然处理方式和之前不同

可以看到whereStr现在是空的,也就是说他的值即为parseWhereItem处理的结果

跟进到parseWhereItem

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

这个exp变量获取的是var[0]的值,也就是我们传入的第一个值,就是exp,可以看到这时候whereStr就是key拼上var[1],也就是查询的username拼上我们传入第二个值。细心的同学会发现还有一个bind判断,不急,这是下文的主角

如此看来,whereStr的值这不就是联合注入吗
然后这个语句就毫无过滤的传回去拼到where查询了,实现了sql注入。

总结:
往where方法传入数组,第一个值为exp,第二个值为=-1 union ….,就可以在parseWhere构建whereStr,从而实现sql注入。比如传?username[0]=exp效果如下:

1
select * from `users` where `username`  $val[1]  limit 1

bind注入

控制器配置

继续修改index

1
2
3
4
5
6
7
8
public function index()
{
$User = M("Users");
$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)&passwd=1

Debug调试

打好断点,跟进和上文如出一辙的where方法,进入save方法,save先使用facade方法来处理我们的data

跟进facade方法,这个方法是检查数据字段并进行过滤,看到了经典的检查和强转,不过我们也不是用data进行注入

然后进入update方法

跟进parseSet

很抱歉的通知您,由于Saofe1a师傅手贱,不小心把框架中的代码误删且无法恢复,不愿意再重新配环境了,于是接下来会借用其他师傅的图

跟进bindParam看看:

从而得到bind的值:0 = 1,后面就是常规的bind注入和上面的exp注入一样,会产生whereStr

返回str构成的sql语句:
UPDATE users SET password=:0 WHERE id = :0 and updatexml(1,concat(0x7e,user(),0x7e),1)

进入最终的excute方法:

重点在最下面那个最终的queryStr生成,简单来说strtr就是把所有的:0都替换成了1(运用了之前那个bind规则,当我们的id[1]传入的是0时,刚好拼出一个:0,从而实现替换,使得sql报错)

替换完得到最终的语句,成功sql注入:
UPDATE users SET password=’1’ WHERE id = ‘1’ and updatexml(1,concat(0x7e,user(),0x7e),1)

总结:

首先第一个id[0]=bind传入是为了在parseWhere中按bind处理,拼接返回whereString,

然后第二个id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)是用来报错注入的语句(因为是save操作,并不会有回显,只能用报错注入),这个0是强制的,因为可以替换的仅仅为:0,如果不是,最后就不会替换,从而报错(但是没有注入)。

最后第三个passwd的值就是希望:0替换成的值,由于这里是id,随便输个数字就行。

参考链接:ThinkPHP3.2.3SQL注入复现 | Pazuris - reoreo~~

  • Title: Thinkphp3.2.3 sql注入
  • Author: Saofe1a
  • Created at : 2024-10-01 23:49:09
  • Updated at : 2024-10-01 23:49:04
  • Link: https://saofeia.github.io/2024/10/01/Thinkphp3.2.3 sql注入/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments