Thinkphp5实现安全数据库操作以及部分运行流程分析

0x01 前言

文章的灵感来自于此文ThinkPHP3.2.3框架实现安全数据库操作分析,由于接触框架比较晚,本着紧跟技术最前沿(手动滑稽)的想法,学习了TP5。之前在开发过程中遇到问题也会去看一下源码,但一直没有系统的看过TP5底层是如何保证安全的。在动手写之前上面的大佬紧接着又更新了TP5的分析框架filterExp函数过滤不严格导致SQL注入,先膜一发,然后开始读代码。。。

0x02 准备工作

目前(2017.09.20)thinkphp官网上的最新版本是5.0.11,我们用来分析的也是这个版本。

测试环境为Windows + php5.5.3 + mysql + apache

测试代码:

applicationindexcontrollerIndex.php

<?php
namespace app\index\controller;

class Index
{
    public function index()
    {
        $username = input('post.user');
        $info = db('user')->where(array('username'=> $username))->select();
        var_dump($info);
    }
}

解释一下这段代码,input和db这两个函数在tp5中称为助手函数,与TP3中的单字母函数I()M()相对应。

这是官方手册:

input: https://www.kancloud.cn/manual/thinkphp5/118044

助手函数本质上还是调用的相应的类来进行一系列操作,并没有另外进行其他的实现。

以db函数为例:

thinkphphelper.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)
    {
        return Db::connect($config, $force)->name($name);
    }
}

0x03 分析

前面瞎扯的有点多。。。现在我们进入正题。

在分析代码之前,我们需要知道TP5使用了PDO预处理机制及自动参数绑定功能。这种技术号称可以完全防止sql注入,当然这是吹n*的,没有绝对的安全,但是对付一般的注入足够了。

mark

按照惯例加个单引号,不出意外被过滤了,我们不妨把admin'看成一个小蝌蚪,看看它到底经历了什么最终进入了数据库(怎么感觉有点污呢。。。)。

3.1 input()

首先是input函数了:

thinkphphelper.php

if (!function_exists('input')) {
    /**
     * 获取输入数据 支持默认值和过滤
     * @param string    $key 获取的变量名
     * @param mixed     $default 默认值
     * @param string    $filter 过滤方法
     * @return mixed
     */
    function input($key = '', $default = null, $filter = '')
    {
          /*
          input('?post.id'),在不确定是否有id这个参数时使用,实际情况中很少见到
          */
        if (0 === strpos($key, '?')) {
            $key = substr($key, 1);
            $has = true;
        }
        if ($pos = strpos($key, '.')) {
            // 指定参数来源
            list($method, $key) = explode('.', $key, 2);
            if (!in_array($method, ['get', 'post', 'put', 'patch', 'delete', 'route', 'param', 'request', 'session', 'cookie', 'server', 'env', 'path', 'file'])) {
                  //如果参数来源不合法,使用自动判断
                $key    = $method . '.' . $key;
                $method = 'param';
            }
        } else {
            // 默认为自动判断
            $method = 'param';
        }
        if (isset($has)) {
            return request()->has($key, $method, $default);
        } else {
            return request()->$method($key, $default, $filter); 
              /*
            我们在测试代码中指定了数据以post形式传输,这里相当于
            request()->post($key, $default, $filter);
            */
        }
    }
}

可以看到这个函数只是将我们输入的字符串进行解析,参数是什么,用什么方法传递的(get || post || ...),最后调用了另一个助手函数request,继续跟下去。request函数只是返回了一个Request类的实例化,就不贴代码了,现在来到了Request类的post方法。

3.1.1 post()

thinkphplibrarythinkRequest.php

/**
     * 设置获取POST参数
     * @access public
     * @param string        $name 变量名
     * @param mixed         $default 默认值
     * @param string|array  $filter 过滤方法
     * @return mixed
     */
    public function post($name = '', $default = null, $filter = '')
    {
        if (empty($this->post)) {
            $content = $this->input; //获取php://input
            if (empty($_POST) && false !== strpos($this->contentType(), 'application/json')) {
                  //处理json
                $this->post = (array) json_decode($content, true);
            } else {
                $this->post = $_POST;
            }
        }
          //获取到的参数是数组
        if (is_array($name)) {
            $this->param       = [];
            return $this->post = array_merge($this->post, $name); //合并数组
        }
        return $this->input($this->post, $name, $default, $filter);
    }

post方法又对我们传入的参数进行了一些解析,最后将结果传入了input方法。(其实不光post方法,其他如get、param等方法最终也都进入了input方法)。到这里都是对参数进行了解析,对于我们传入的值admin',没有进行任何操作。。。下面重点来了:

3.1.2 input()

thinkphplibrarythinkRequest.php

/**
     * 获取变量 支持过滤和默认值
     * @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,获取变量修饰符,这里涉及到tp5变量获取很有趣的一个功能,下面会提到,
               用的人也比较少
            */
            if (strpos($name, '/')) {
                list($name, $type) = explode('/', $name);
            } else {
                $type = 's'; //默认为s,即字符串类型
            }
            // 按.拆分成多维数组进行判断
            foreach (explode('.', $name) as $val) {
                if (isset($data[$val])) {
                    $data = $data[$val];
                } else {
                    // 无输入数据,返回默认值
                    return $default;
                }
            }
            if (is_object($data)) {
                return $data;
            }
        }
        //input支持input('post.'),获取所有post参数。

        // 解析过滤器,可以是一个或多个过滤函数,也会读取配置文件中默认的过滤函数
        // input('post.user', '', addslashes)
        $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;
    }

小蝌蚪的第一个阻碍好像来了,跟进filterValue函数看一下,贴代码之前还有个有意思的地方

mark

但是不要高兴的太早。。。

3.1.3 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)) { //int, float, string, bool
                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方法。

3.1.4 filterExp() *

thinkphplibrarythinkRequest.php

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|NOTIN|NOT IN|IN)$/i', $value)) {
    $value .= ' ';
  }
  // TODO 其他安全过滤
}

这个函数很简单,匹配一些敏感关键字,如果匹配到的话,就在关键字后面加一个空格。这么做有啥用?这个地方在这里很难说清楚,需要结合后面,我们先记一下。

到这里input之旅就结束了,虽然经过了一些奇奇怪怪的过滤,但是似乎并没有威胁到单引号,事实上如果没有修改配置文件,仅仅靠input('post.user')是无法过滤单引号的(由于pdo的存在,其实完全没必要)。

mark

这里虽然没有sql注入的威胁,但是什么过滤都不加会导致xss。

3.2 select()

从我们在控制器中调用到函数执行走了这么多文件。。。

mark

要搞懂这一连串的调用真有点不容易。。。用一张图片来说明吧

mark

通过分析流程我们知道对我们输入的数据进行过滤等操作的地方是在thinkphp\library\think\db\Builder.php

/**
     * 生成查询SQL
     * @access public
     * @param array $options 表达式
     * @return string
     */
    public function select($options = [])
    {
        $sql = str_replace(
            ['%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'],
            [
                $this->parseTable($options['table'], $options),
                $this->parseDistinct($options['distinct']),
                $this->parseField($options['field'], $options),
                $this->parseJoin($options['join'], $options),
                $this->parseWhere($options['where'], $options), //我是重点
                $this->parseGroup($options['group']),
                $this->parseHaving($options['having']),
                $this->parseOrder($options['order'], $options),
                $this->parseLimit($options['limit']),
                $this->parseUnion($options['union']),
                $this->parseLock($options['lock']),
                $this->parseComment($options['comment']),
                $this->parseForce($options['force']),
            ], $this->selectSql);
        return $sql;
    }
3.2.1 parseWhere()

跟进parseWhere函数

 /**
     * where分析
     * @access protected
     * @param mixed $where   查询条件
     * @param array $options 查询参数
     * @return string
     */
    protected function parseWhere($where, $options)
    {
        $whereStr = $this->buildWhere($where, $options); //*
        if (!empty($options['soft_delete'])) {
            // 附加软删除条件
            list($field, $condition) = $options['soft_delete'];

            $binds    = $this->query->getFieldsBind($options['table']);
            $whereStr = $whereStr ? '( ' . $whereStr . ' ) AND ' : '';
            $whereStr = $whereStr . $this->parseWhereItem($field, $condition, '', $options, $binds); //*
        }
        return empty($whereStr) ? '' : ' WHERE ' . $whereStr;
    }

我们输入的字符串先后进入了buildWhere和parseWhereItem方法,首先跟进buildWhere方法,发现其内部也是调用了parseWhereItem方法,我们直接来看这个方法。

3.2.2 parseWhereItem()
// where子单元分析
    protected function parseWhereItem($field, $val, $rule = '', $options = [], $binds = [], $bindName = null)
    {
        // 字段分析
        $key = $field ? $this->parseKey($field, $options) : '';

        // 查询规则和条件
        if (!is_array($val)) {
            $val = is_null($val) ? ['null', ''] : ['=', $val];
        }
        list($exp, $value) = $val;

        // 对一个字段使用多个查询条件
        if (is_array($exp)) {
            $item = array_pop($val);
            // 传入 or 或者 and
            if (is_string($item) && in_array($item, ['AND', 'and', 'OR', 'or'])) {
                $rule = $item;
            } else {
                array_push($val, $item);
            }
            foreach ($val as $k => $item) {
                $bindName = 'where_' . str_replace('.', '_', $field) . '_' . $k;
                $str[]    = $this->parseWhereItem($field, $item, $rule, $options, $binds, $bindName);
            }
            return '( ' . implode(' ' . $rule . ' ', $str) . ' )';
        }

        // 检测操作符
        if (!in_array($exp, $this->exp)) {
            $exp = strtolower($exp);
            if (isset($this->exp[$exp])) {
                $exp = $this->exp[$exp];
            } else {
                throw new Exception('where express error:' . $exp);
            }
        }
        $bindName = $bindName ?: 'where_' . str_replace(['.', '-'], '_', $field);
        if (preg_match('/\W/', $bindName)) {
            // 处理带非单词字符的字段名
            $bindName = md5($bindName);
        }

        if (is_object($value) && method_exists($value, '__toString')) {
            // 对象数据写入
            $value = $value->__toString();
        }

        $bindType = isset($binds[$field]) ? $binds[$field] : PDO::PARAM_STR;
        if (is_scalar($value) && array_key_exists($field, $binds) && !in_array($exp, ['EXP', 'NOT NULL', 'NULL', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN']) && strpos($exp, 'TIME') === false) {
            if (strpos($value, ':') !== 0 || !$this->query->isBind(substr($value, 1))) {
                if ($this->query->isBind($bindName)) {
                    $bindName .= '_' . str_replace('.', '_', uniqid('', true));
                }
                $this->query->bind($bindName, $value, $bindType); /** pdo参数绑定 **/
                $value = ':' . $bindName;
            }
        }

        $whereStr = '';
        if (in_array($exp, ['=', '<>', '>', '>=', '<', '<='])) {
            // 比较运算
            if ($value instanceof \Closure) {
                $whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
            } else {
                $whereStr .= $key . ' ' . $exp . ' ' . $this->parseValue($value, $field);
            }
        } elseif ('LIKE' == $exp || 'NOT LIKE' == $exp) {
            // 模糊匹配
            if (is_array($value)) {
                foreach ($value as $item) {
                    $array[] = $key . ' ' . $exp . ' ' . $this->parseValue($item, $field);
                }
                $logic = isset($val[2]) ? $val[2] : 'AND';
                $whereStr .= '(' . implode($array, ' ' . strtoupper($logic) . ' ') . ')';
            } else {
                $whereStr .= $key . ' ' . $exp . ' ' . $this->parseValue($value, $field);
            }
        } elseif ('EXP' == $exp) {
            // 表达式查询
            $whereStr .= '( ' . $key . ' ' . $value . ' )';
        } elseif (in_array($exp, ['NOT NULL', 'NULL'])) {
            // NULL 查询
            $whereStr .= $key . ' IS ' . $exp;
        } elseif (in_array($exp, ['NOT IN', 'IN'])) {
            // IN 查询
            if ($value instanceof \Closure) {
                $whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
            } else {
                $value = array_unique(is_array($value) ? $value : explode(',', $value));
                if (array_key_exists($field, $binds)) {
                    $bind  = [];
                    $array = [];
                    $i     = 0;
                    foreach ($value as $v) {
                        $i++;
                        if ($this->query->isBind($bindName . '_in_' . $i)) {
                            $bindKey = $bindName . '_in_' . uniqid() . '_' . $i;
                        } else {
                            $bindKey = $bindName . '_in_' . $i;
                        }
                        $bind[$bindKey] = [$v, $bindType];
                        $array[]        = ':' . $bindKey;
                    }
                    $this->query->bind($bind);
                    $zone = implode(',', $array);
                } else {
                    $zone = implode(',', $this->parseValue($value, $field));
                }
                $whereStr .= $key . ' ' . $exp . ' (' . (empty($zone) ? "''" : $zone) . ')';
            }
        } elseif (in_array($exp, ['NOT BETWEEN', 'BETWEEN'])) {
            // BETWEEN 查询
            $data = is_array($value) ? $value : explode(',', $value);
            if (array_key_exists($field, $binds)) {
                if ($this->query->isBind($bindName . '_between_1')) {
                    $bindKey1 = $bindName . '_between_1' . uniqid();
                    $bindKey2 = $bindName . '_between_2' . uniqid();
                } else {
                    $bindKey1 = $bindName . '_between_1';
                    $bindKey2 = $bindName . '_between_2';
                }
                $bind = [
                    $bindKey1 => [$data[0], $bindType],
                    $bindKey2 => [$data[1], $bindType],
                ];
                $this->query->bind($bind);
                $between = ':' . $bindKey1 . ' AND :' . $bindKey2;
            } else {
                $between = $this->parseValue($data[0], $field) . ' AND ' . $this->parseValue($data[1], $field);
            }
            $whereStr .= $key . ' ' . $exp . ' ' . $between;
        } elseif (in_array($exp, ['NOT EXISTS', 'EXISTS'])) {
            // EXISTS 查询
            if ($value instanceof \Closure) {
                $whereStr .= $exp . ' ' . $this->parseClosure($value);
            } else {
                $whereStr .= $exp . ' (' . $value . ')';
            }
        } elseif (in_array($exp, ['< TIME', '> TIME', '<= TIME', '>= TIME'])) {
            $whereStr .= $key . ' ' . substr($exp, 0, 2) . ' ' . $this->parseDateTime($value, $field, $options, $bindName, $bindType);
        } elseif (in_array($exp, ['BETWEEN TIME', 'NOT BETWEEN TIME'])) {
            if (is_string($value)) {
                $value = explode(',', $value);
            }

            $whereStr .= $key . ' ' . substr($exp, 0, -4) . $this->parseDateTime($value[0], $field, $options, $bindName . '_between_1', $bindType) . ' AND ' . $this->parseDateTime($value[1], $field, $options, $bindName . '_between_2', $bindType);
        }
        return $whereStr;
    }

一个长到爆炸的方法,还好注释是中文而且写的十分详细。。。在这个方法中实现了pdo的参数绑定(bind方法),结合注释看一下代码,发现数据基本要过parseValue这个方法,跟进去看一下。

3.2.3 parseValue()
/**
     * value分析
     * @access protected
     * @param mixed     $value
     * @param string    $field
     * @return string|array
     */
    protected function parseValue($value, $field = '')
    {
        if (is_string($value)) {
            $value = strpos($value, ':') === 0 && $this->query->isBind(substr($value, 1)) ? $value : $this->connection->quote($value);
        } elseif (is_array($value)) {
            $value = array_map([$this, 'parseValue'], $value);
        } elseif (is_bool($value)) {
            $value = $value ? '1' : '0';
        } elseif (is_null($value)) {
            $value = 'null';
        }
        return $value;
    }

这里的quote方法并不是tp框架实现的,而是pdo自带的一个方法,具体功能看手册吧。

到这里,我们的查询语句的解析、参数的过滤、sql语句的组装全部都结束了,将组装好的sql语句返回到Query类中执行,我们输入的admin',最终到达了数据库。

3.2.4 回到filterExp()

在最前面也说过了,TP5采用了pdo来操作数据库,一般的注入根本不起作用,现在修改一下测试代码:

<?php
namespace app\index\controller;

class Index
{
    public function test1($user) //可以接受数组、字符串等形式的GET参数 
    {
        //$username = input('post.user/a'); //非常少见
        $info = db('user')->where(array('username'=> $user))->select();
        var_dump($info);
    }
}

php中参数可以用数组的形式传递,TP5接收这种类型的参数有两种方式,一种是通过方法的形参来接收,另一种是用input函数,前者用的比较多,后者基本没见过。

在正式开始之前,先来看一下TP5是如何做到直接通过形参来接受请求参数的,这种骚操作叫参数绑定

这个不是这次的重点,粗略的画个图:

mark

thinkphp\library\think\App.php的bindParam方法直接调用了Request中的param方法(自动判断请求类型),再往后就和我们之前的分析相同了。这里没有经过助手函数input,也就不存在类型问题,字符串、数组照单全收。这趟走下来,对TP5的运行流程也会有一个比较清晰的认识了。

有瞎扯了这么多,下面进入正题。我们在开发过程中对数据库的查询会有许多条件运算,不仅仅是上面最简单的相等(=)运算,还有其他如LIKEINBETWEEN等等其他运算。如果我们控制器的方法允许传入数组,在上面这个例子中,进行什么样的条件运算就是可控的了。

mark

注意传递的参数要和方法中的形参保持一致,这样传递参数理想情况下得到的sql语句应该是这样的:

SELECT * FROM `user` WHERE (
                              `username` NOT LIKE #user[0] 
                              '123'                 #user[1][0]
                            AND                 #user[2] 无引号包裹,可控
                            `username` NOT LIKE #user[0]
                              '456'                #user[1][1]
                           )

我们注意到在parseWhere方法中解析条件运算的部分并没有做任何特殊符号的过滤,一切都是那么的美好,但是。。。它报错了。。报错了。。。

这里就要归功于之前我们记下的filterExp方法了,还记得它吗?它将一些运算符匹配出来,在后面加了一个空格,来到parseWhereItem方法时,会经历这样一个过程:

// 检测操作符
if (!in_array($exp, $this->exp)) {
    $exp = strtolower($exp);
    if (isset($this->exp[$exp])) {
          $exp = $this->exp[$exp];
    } else {
          throw new Exception('where express error:' . $exp);
    }
}

如果传过来的运算符不在框架指定的运算符中,就会报错,这里我们传入的运算符后面被加了一个空格,当然是匹配不到的,所以报错。注入什么的,也不用想了。

3.3 番外

到这里,该说的差不多都说完了,但是TP5操作数据库并不只有上面这一种方法,还有另外一种比较常用的就是使用Model+ORM。我个人也比较喜欢用这种方法,因为它跟我理解的mvc模式比较相近。

这里多说一句有关orm的:

ORM 的基本特性就是表映射到记录,记录映射到对象,字段映射到对象属性。模型是一种对象化的操作 封装,而不是简单的 CURD 操作,简单的 CURD 操作直接使用前面提过的 Db 类即可

显然ORM是一种更高级的用法,即使完全不懂sql语句,也可以与操作数据库。

在修改测试代码之前我们需要先创建app\index\model\User类:

<?php 
namespace app\index\model;
use think\Model;

class User extends Model {
    //无特殊需要留空即可
} 
?>

接着修改测试代码:

<?php
namespace app\index\controller;
use think\Db;
use app\index\model\User;

class Index
{
    public function index() {
        $username = input('post.username');
        
        $user = new User();
        //$user = model('user'); //不需要单独写一个model类
        
        $info = $user->get(['username'=> $username]); //获取一条数据,all为获取所有
        //$info = $user->where(['username'=> $username])->select();
        //$info = $user->getByUsername($username);

        var_dump($info);
    }
}

有好多种使用方式,只写了几个常用的,还有一些。。条条大路通罗马。。。

mark

通过对经过的文件的分析,我们可以看到调用过程和前面是几乎一样的,只是中间经过Model.php做了一些处理和封装,具体内部的调用就不多啰嗦了,总体的流程与我们上面分析的相同。

0x04 5.0.10版本的一处注入

写了这么多,不找个漏洞确实不太合适。根据前面3.2.4分析的,如果允许以数组的形式传入参数,在解析条件运算的时候没有任何过滤,filterExp方法是最后也可能是唯一一道防线,如果他出了问题呢?

首先看5.0.10与5.0.11的filterExp方法的差别:

5.0.10

mark

5.0.11

mark

增加了一个NOT LIKE的匹配,再看一下5.0.10与5.0.9框架提供的数据库表达式的差别:

thinkphplibrarythinkdbBuilder.php

5.0.9

mark

5.0.10

mark

5.0.10新增了一个not like的表达式,但是filterExp方法并没有做出相应的修改,导致漏洞的出现。

mark

由于是框架低层出了问题,不管用什么方法操作数据库都会存在漏洞。

防御的话更新到5.0.11就好了,还有在开发过程中,虽然参数绑定非常方便,最好还是使用input助手函数来获取参数。

0x05 最后

本来想十一放假前完成,磕磕绊绊断断续续一直到现在,本来只是想看一下框架底层如何保证数据库安全的,读的时候就像看一下到底是怎么运行怎么调用的。在读代码的过程遇到了许多困难,也看到了许多骚操作,之后要多学习一下php的高级用法,多读读源码。最后奶一口thinkphp,毕竟是国货,希望它越来越好吧,2333333333。

标签: none
评论列表
  1. p0

    先膜为敬

添加新评论