Thinkphp3个版本数据库操作以及底层代码分析 - 先知社区
2019-09-21 17:10:23 Author: xz.aliyun.com(查看原文) 阅读量:158 收藏

发现自己对tp的底层不太熟悉,看了@phpoop师傅文章有所启发,于是有此文,记录自己的分析过程
希望大师傅们嘴下留情,有分析不对的地方还请师傅们指出orz

首先开启调试

/Application/Home/Conf/config.php加上

'SHOW_PAGE_TRACE' => true,

并且添加数据库配置

//数据库配置信息
'DB_TYPE'   => 'mysql', // 数据库类型
'DB_HOST'   => 'localhost', // 服务器地址
'DB_NAME'   => 'thinkphp', // 数据库名
'DB_USER'   => 'root', // 用户名
'DB_PWD'    => '123456', // 密码
'DB_PORT'   => 3306, // 端口
'DB_PREFIX' => 'think_', // 数据库表前缀 
'DB_CHARSET'=> 'utf8', // 字符集
'DB_DEBUG'  =>  TRUE, // 数据库调试模式 开启后可以记录SQL日志 3.2.3新增

测试数据如下

添加实例代码
用I函数进行动态获取参数

field

field方法属于模型的连贯操作方法之一,主要目的是标识要返回或者操作的字段,可以用于查询和写入操作

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $age = I('GET.age');
        $User = M("user"); // 实例化User对象
        $User->field('username,age')->where(array('age'=>$age))->find();
    }
}

执行语句相当于

where

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $age = I('GET.age');
        $User = M("user"); // 实例化User对象
        $User->where(array('age'=>$age))->select();
    }
}

接着请求
http://127.0.0.1/thinkphp3/index.php?m=Home&c=index&a=index&age=1

转义代码分析

当我们请求age=1'尝试注入的时候

被自动转义了

find函数里,会解析出options

跟入

继续跟进

parseSql里会依此执行函数

发现在ThinkPHP/Library/Think/Db/Driver.class.php的函数parseWhere

protected function parseWhere($where) {
        $whereStr = '';
        if(is_string($where)) {
            // 直接使用字符串条件
            $whereStr = $where;
        }else{ // 使用数组表达式
            $operate  = isset($where['_logic'])?strtoupper($where['_logic']):'';
            if(in_array($operate,array('AND','OR','XOR'))){
                // 定义逻辑运算规则 例如 OR XOR AND NOT
                $operate    =   ' '.$operate.' ';
                unset($where['_logic']);
            }else{
                // 默认进行 AND 运算
                $operate    =   ' AND ';
            }
            foreach ($where as $key=>$val){
                if(is_numeric($key)){
                    $key  = '_complex';
                }
                if(0===strpos($key,'_')) {
                    // 解析特殊条件表达式
                    $whereStr   .= $this->parseThinkWhere($key,$val);
                }else{
                    // 查询字段的安全过滤
                    // if(!preg_match('/^[A-Z_\|\&\-.a-z0-9\(\)\,]+$/',trim($key))){
                    //     E(L('_EXPRESS_ERROR_').':'.$key);
                    // }
                    // 多条件支持
                    $multi  = is_array($val) &&  isset($val['_multi']);
                    $key    = trim($key);
                    if(strpos($key,'|')) { // 支持 name|title|nickname 方式定义查询字段
                        $array =  explode('|',$key);
                        $str   =  array();
                        foreach ($array as $m=>$k){
                            $v =  $multi?$val[$m]:$val;
                            $str[]   = $this->parseWhereItem($this->parseKey($k),$v);
                        }
                        $whereStr .= '( '.implode(' OR ',$str).' )';
                    }elseif(strpos($key,'&')){
                        $array =  explode('&',$key);
                        $str   =  array();
                        foreach ($array as $m=>$k){
                            $v =  $multi?$val[$m]:$val;
                            $str[]   = '('.$this->parseWhereItem($this->parseKey($k),$v).')';
                        }
                        $whereStr .= '( '.implode(' AND ',$str).' )';
                    }else{
                        $whereStr .= $this->parseWhereItem($this->parseKey($key),$val);
                    }
                }
                $whereStr .= $operate;
            }
            $whereStr = substr($whereStr,0,-strlen($operate));
        }
        return empty($whereStr)?'':' WHERE '.$whereStr;
    }

继续跟进parseWhereItem

protected function parseWhereItem($key,$val) {
        $whereStr = '';
        if(is_array($val)) {
            if(is_string($val[0])) {
                $exp    =   strtolower($val[0]);
                if(preg_match('/^(eq|neq|gt|egt|lt|elt)$/',$exp)) { // 比较运算
                    $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
                }elseif(preg_match('/^(notlike|like)$/',$exp)){// 模糊查找
                    if(is_array($val[1])) {
                        $likeLogic  =   isset($val[2])?strtoupper($val[2]):'OR';
                        if(in_array($likeLogic,array('AND','OR','XOR'))){
                            $like       =   array();
                            foreach ($val[1] as $item){
                                $like[] = $key.' '.$this->exp[$exp].' '.$this->parseValue($item);
                            }
                            $whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';                          
                        }
                    }else{
                        $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
                    }
                }elseif('bind' == $exp ){ // 使用表达式
                    $whereStr .= $key.' = :'.$val[1];
                }elseif('exp' == $exp ){ // 使用表达式
                    $whereStr .= $key.' '.$val[1];
                }elseif(preg_match('/^(notin|not in|in)$/',$exp)){ // IN 运算
                    if(isset($val[2]) && 'exp'==$val[2]) {
                        $whereStr .= $key.' '.$this->exp[$exp].' '.$val[1];
                    }else{
                        if(is_string($val[1])) {
                             $val[1] =  explode(',',$val[1]);
                        }
                        $zone      =   implode(',',$this->parseValue($val[1]));
                        $whereStr .= $key.' '.$this->exp[$exp].' ('.$zone.')';
                    }
                }elseif(preg_match('/^(notbetween|not between|between)$/',$exp)){ // BETWEEN运算
                    $data = is_string($val[1])? explode(',',$val[1]):$val[1];
                    $whereStr .=  $key.' '.$this->exp[$exp].' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]);
                }else{
                    E(L('_EXPRESS_ERROR_').':'.$val[0]);
                }
            }else {
                $count = count($val);
                $rule  = isset($val[$count-1]) ? (is_array($val[$count-1]) ? strtoupper($val[$count-1][0]) : strtoupper($val[$count-1]) ) : '' ; 
                if(in_array($rule,array('AND','OR','XOR'))) {
                    $count  = $count -1;
                }else{
                    $rule   = 'AND';
                }
                for($i=0;$i<$count;$i++) {
                    $data = is_array($val[$i])?$val[$i][1]:$val[$i];
                    if('exp'==strtolower($val[$i][0])) {
                        $whereStr .= $key.' '.$data.' '.$rule.' ';
                    }else{
                        $whereStr .= $this->parseWhereItem($key,$val[$i]).' '.$rule.' ';
                    }
                }
                $whereStr = '( '.substr($whereStr,0,-4).' )';
            }
        }else {
            //对字符串类型字段采用模糊匹配
            $likeFields   =   $this->config['db_like_fields'];
            if($likeFields && preg_match('/^('.$likeFields.')$/i',$key)) {
                $whereStr .= $key.' LIKE '.$this->parseValue('%'.$val.'%');
            }else {
                $whereStr .= $key.' = '.$this->parseValue($val);
            }
        }
        return $whereStr;
    }

此时我们的key是age,val是1,于是执行

}else {
                $whereStr .= $key.' = '.$this->parseValue($val);
            }

继续跟进parseValue

protected function parseValue($value) {
        if(is_string($value)) {
            $value =  strpos($value,':') === 0 && in_array($value,array_keys($this->bind))? $this->escapeString($value) : '\''.$this->escapeString($value).'\'';
        }elseif(isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp'){
            $value =  $this->escapeString($value[1]);
        }elseif(is_array($value)) {
            $value =  array_map(array($this, 'parseValue'),$value);
        }elseif(is_bool($value)){
            $value =  $value ? '1' : '0';
        }elseif(is_null($value)){
            $value =  'null';
        }
        return $value;
    }

可以发现这里就执行了escapeString

返回了转义后的结果

调用栈如下

如何注入

既然如此,那么怎么去注入呢,底层就调用了escapeString
我们看到parseWhereItem函数

在绿色标记的几个判断语句里,是没有调用parseValue函数的,也就不会调用到escapeString
然后我们又可以看到,exp就是val数组的第一个值

那么我们是不是就能注入了呢
我们修改代码如下

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $age = $_GET['age'];
        $User = M("user"); // 实例化User对象
        $User->field('username,age')->where(array('age'=>$age))->find();
    }
}

这里暂时不用I函数接收参数
传入payload
`http://127.0.0.1/thinkphp3/index.php?

我们进入了判断

返回值并没有转义,页面上也能够直接看出来

为什么用exp不用bind呢,因为bind执行后的结果

会拼接一个 = : 这显然是对我们注入不利的

那么 我们利用报错注入

http://127.0.0.1/thinkphp3/index.php?m=Home&c=index&a=index&age[0]=exp&age[1]==%271%27%20and%20(extractvalue(1,concat(0x7e,(select%20user()),0x7e)))%20%23

成功造成了注入

不过我们接收参数修改为I函数

I函数

修改代码

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $age = I("GET.age");
        $User = M("user"); // 实例化User对象
        $User->field('username,age')->where(array('age'=>$age))->find();
    }
}

同样的请求发现报错了

我们跟进调试一下

function I($name,$default='',$filter=null,$datas=null) {
    static $_PUT    =   null;
    if(strpos($name,'/')){ // 指定修饰符
        list($name,$type)   =   explode('/',$name,2);
    }elseif(C('VAR_AUTO_STRING')){ // 默认强制转换为字符串
        $type   =   's';
    }
    if(strpos($name,'.')) { // 指定参数来源
        list($method,$name) =   explode('.',$name,2);
    }else{ // 默认为自动判断
        $method =   'param';
    }
    switch(strtolower($method)) {
        case 'get'     :   
            $input =& $_GET;
            break;
        case 'post'    :   
            $input =& $_POST;
            break;
        case 'put'     :   
            if(is_null($_PUT)){
                parse_str(file_get_contents('php://input'), $_PUT);
            }
            $input  =   $_PUT;        
            break;
        case 'param'   :
            switch($_SERVER['REQUEST_METHOD']) {
                case 'POST':
                    $input  =  $_POST;
                    break;
                case 'PUT':
                    if(is_null($_PUT)){
                        parse_str(file_get_contents('php://input'), $_PUT);
                    }
                    $input  =   $_PUT;
                    break;
                default:
                    $input  =  $_GET;
            }
            break;
        case 'path'    :   
            $input  =   array();
            if(!empty($_SERVER['PATH_INFO'])){
                $depr   =   C('URL_PATHINFO_DEPR');
                $input  =   explode($depr,trim($_SERVER['PATH_INFO'],$depr));            
            }
            break;
        case 'request' :   
            $input =& $_REQUEST;   
            break;
        case 'session' :   
            $input =& $_SESSION;   
            break;
        case 'cookie'  :   
            $input =& $_COOKIE;    
            break;
        case 'server'  :   
            $input =& $_SERVER;    
            break;
        case 'globals' :   
            $input =& $GLOBALS;    
            break;
        case 'data'    :   
            $input =& $datas;      
            break;
        default:
            return null;
    }
    if(''==$name) { // 获取全部变量
        $data       =   $input;
        $filters    =   isset($filter)?$filter:C('DEFAULT_FILTER');
        if($filters) {
            if(is_string($filters)){
                $filters    =   explode(',',$filters);
            }
            foreach($filters as $filter){
                $data   =   array_map_recursive($filter,$data); // 参数过滤
            }
        }
    }elseif(isset($input[$name])) { // 取值操作
        $data       =   $input[$name];
        $filters    =   isset($filter)?$filter:C('DEFAULT_FILTER');
        if($filters) {
            if(is_string($filters)){
                if(0 === strpos($filters,'/')){
                    if(1 !== preg_match($filters,(string)$data)){
                        // 支持正则验证
                        return   isset($default) ? $default : null;
                    }
                }else{
                    $filters    =   explode(',',$filters);                    
                }
            }elseif(is_int($filters)){
                $filters    =   array($filters);
            }

            if(is_array($filters)){
                foreach($filters as $filter){
                    if(function_exists($filter)) {
                        $data   =   is_array($data) ? array_map_recursive($filter,$data) : $filter($data); // 参数过滤
                    }else{
                        $data   =   filter_var($data,is_int($filter) ? $filter : filter_id($filter));
                        if(false === $data) {
                            return   isset($default) ? $default : null;
                        }
                    }
                }
            }
        }
        if(!empty($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:
                    $data   =   (string)$data;
            }
        }
    }else{ // 变量默认值
        $data       =    isset($default)?$default:null;
    }
    is_array($data) && array_walk_recursive($data,'think_filter');
    return $data;
}

首先获取method

然后取age值并赋值给data

接着看是否传入了filter

在手册中也是介绍了
https://www.kancloud.cn/manual/thinkphp/1841
这里就是默认的htmlspecialchars
关于该函数的一些用法

https://www.w3school.com.cn/php/func_string_htmlspecialchars.asp

跟入函数,最终是要调到这个call_user_func

调用htmlspecialchars处理后,对我们的payload影响不太大,那么继续跟

这里又对是数组data里的两个值exp$payload进行了think_filter函数的调用

function think_filter(&$value){
    // TODO 其他安全过滤

    // 过滤查询特殊字符
    if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value)){
        $value .= ' ';
    }
}

这里就对一些sql敏感的东西进行了过滤
此时,我们的data[0]exp字符串,这里就匹配了,于是他在exp后面加上了一个空格
也就是exp
然后我们的payload并没有匹配到

那么自然
到了parseWhereItem也就进不了exp那一个判断了,直接进入报错的地方

这样 我们也就没办法再进行注入了

总结

也就是说在thinkphp3下,使用了I函数,我们的注入就不太能成功,如果接收参数的时候并没有使用I函数,而是直接接收就传入M函数并实例化,那么我们注入的可能性就更大

在Thinkphp5里,所有单个字母的函数都被取消了
查询语句变成了

Db::table('think_user')->where('id',1)->find();

于是修改index.php代码

<?php
namespace app\index\controller;

use think\Db;

class Index
{
    public function index()
    {
        $age = $_GET['age'];
        $User = Db::name('user');
        //为了方便调试我将select设置false
        echo $User->where(array('age'=>$age))->select(false);
    }
}

接着修改application/database.php

于是看到查询语句

底层过滤

我们改一下代码

<?php
namespace app\index\controller;

use think\Db;

class Index
{
    public function index()
    {
        $age = $_GET['age'];
        Db::name('user')->where(array('age'=>$age))->find();
    }
}

传入单引号同样被转义,应该也是在select函数里进行了转义

跟入select

继续跟进,在parseWhere时,返回了占位符

在select结束后,返回了预编译的sql语句,:where_AND_age是占位符

跟入getRealSql

提取age的值

在这里,就发生了转义

if (PDO::PARAM_STR == $type) {
                $value = $this->quote($value);
            }

跟进quote

里面又调用了quote,关于PDO::quote的介绍
PDO::quote
会转义特殊字符串,也就是我们的单引号

如果一开始的代码是select()不用false
那么调用栈如下

insert方法

看了网上有分析该方法存在注入,于是调试
修改代码

<?php
namespace app\index\controller;

use think\Db;

class Index
{
    public function index()
    {
        $username = $_GET['username'];
        $User = Db::name('user');
        echo $User->where(array('age'=>'13'))->insert(array('username'=>$username));
    }
}

跟进insert

继续跟进parseData

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] = $item . '+' . floatval($val[1]);
                        break;
                    case 'dec':
                        $result[$item] = $item . '-' . floatval($val[1]);
                        break;
                    case 'exp':
                        throw new Exception('not support data:[' . $val[0] . ']');
                }
            } 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;
    }

跟tp3类似的思路

但是,注意这里的拼接

case 'inc':
                        $result[$item] = $item . '+' . floatval($val[1]);

$val[1]进行了一个floatval的强转,那么我们的payload也就不行了

使用composer安装

composer create-project topthink/think=6.0.x-dev tp

然后运行

php think run

访问127.0.0.1:8000
或者直接访问public目录

index.php代码修改

<?php
namespace app\controller;

use app\BaseController;
use think\facade\Db;

class Index extends BaseController
{
    public function index()
    {
        $age = input('get.age');
        echo Db::table('think_user')->where(array('age'=>$age))->fetchSql()->find(1);
    }
}

跟tp5类似预加载

跟入fetch

跟入getRealSql

这里 调用了addslashes对单引号进行了转义

我们再看看其他方法

insert

修改代码

<?php
namespace app\controller;

use app\BaseController;
use think\facade\Db;

class Index extends BaseController
{
    public function index()
    {
        $age = input('get.age');
        echo Db::table('think_user')->where(array('age'=>'15'))->fetchSql()->insert(array('age'=>$age));
    }
}

跟进insert

跟入parsedata

同样的处理方式,把payload进行强转,不过取消了exp


文章来源: https://xz.aliyun.com/t/6375
如有侵权请联系:admin#unsafe.sh