ThinkPHP3.2.3SQL注入分析

  • 最近看到一些CMS都是ThinkPHP3.2.3二开的,因此就先来看看ThinkPHP3.2.3的SQL注入,同时也为ThinkPHP3.2.3的远程命令执行漏洞(CNVD-2021-32433)做准备。

环境搭建

create database TP3;
use TP3;
create table tp_user(id int(8) AUTO_INCREMENT PRIMARY KEY,username varchar(255),password varchar(255));
insert into tp_user(id,username,password) value(1,'admin','admin');
<?php
return array(
	//'配置项'=>'配置值'

    'DB_TYPE'               =>  'mysql',     // 数据库类型
    'DB_HOST'               =>  'localhost', // 服务器地址
    'DB_NAME'               =>  'TP3',          // 数据库名
    'DB_USER'               =>  'root',      // 用户名
    'DB_PWD'                =>  'root',          // 密码
    'DB_PORT'               =>  '3306',        // 端口
    'DB_PREFIX'             =>  'tp_',    // 数据库表前缀
    'DB_PARAMS'          	=>  array(), // 数据库连接参数
    'DB_DEBUG'  			=>  false, // 数据库调试模式 开启后可以记录SQL日志
    'DB_FIELDS_CACHE'       =>  true,        // 启用字段缓存
    'DB_CHARSET'            =>  'utf8',      // 数据库编码默认采用utf8
    'DB_DEPLOY_TYPE'        =>  0, // 数据库部署方式:0 集中式(单一服务器),1 分布式(主从服务器)
    'DB_RW_SEPARATE'        =>  false,       // 数据库读写是否分离 主从式有效
    'DB_MASTER_NUM'         =>  1, // 读写分离后 主服务器数量
    'DB_SLAVE_NO'           =>  '', // 指定从服务器序号

);
  • 配置好环境后,在Application/Home/Controller/IndexController.class.php文件中添加下列方法,数据库连接成功。

  • 这里的M函为ThinkPHP大写字母函数的一种,这为操作数据库中的tp_user表,因为数据库表前缀写配置文件了,因此不需要添加,如果没写则默认为空,再去配置文件里读取,也可再次添加。M('user','tp_)都是等价的,再执行find方法。

  • ThinkPHP大写字母函数总结

  • http://127.0.0.1/TP3.2.3/index.php?m=home&c=Index&a=test

    public function Test(){
        $id = '1';
        $data = M('user')->find($id);
        dump($data);
    }

init

where

where

    public function SQL(){
        $id = I('get.id');
        $res = M('user')->find($id);
        dump($res);
    }

I函数

  • 跟进I函数,对应Input就好记住了,然后这里不清楚为啥的就是给$input变量一个浅复制是为啥,准确的说是该变量为啥要指向$_GET请求的内存地址,直接赋值也行啊,完成一次请求解析了,最后设置为空就即可了啊。
function I($name,$default='',$filter=null,$datas=null) {
	static $_PUT	=	null;
// ....  $name="get.id"
    if(strpos($name,'.')) { // 指定参数来源
        list($method,$name) =   explode('.',$name,2);
    }else{ // 默认为自动判断
        $method =   'param';
    }
    switch(strtolower($method)) {
        case 'get'     :   
        	$input =& $_GET;
        	break;
            //.......    这里我们给定的get请求
    }if(''==$name){
        // 获取全部变量
    }elseif(isset($input[$name])) { // 取值操作
        $data       =   $input[$name];
        $filters    =   isset($filter)?$filter:C('DEFAULT_FILTER');
    // ......这里 读取配置文件默认的过滤方式---htmlspecialchars 编码
    }
    is_array($data) && array_walk_recursive($data,'think_filter');
    return $data; //$data=array('where' => '1 and updatexml(1,concat(0x7e,user(),0x7e),1)--',)
}
  • 这里还有配置文件默认的过滤ThinkPHP/Conf/convention.phpDEFAULT_FILTER=>htmlspecialchars,进行实体编码。再调用think_filter函数进行过滤,黑名单过滤,但是并没有包含updatexmlextractvalue等报错注入函数。
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 .= ' ';
    }
}

M函数

  • 这里没什么好分析的,就是实例化model类型,这里就是操作数据库的tp_user表。

find

  • 该函数还算比较长,将其他走不到的代码进行省略了,参数传递就是上面的$data
public function find($options=array()) {
        // 根据复合主键查找记录
        $pk  =  $this->getPk();
       
        // 总是查找一条记录
        $options['limit']   =   1;
        // 分析表达式
        $options            =   $this->_parseOptions($options);
    
        $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函数主要是获取表名tp_user,记录操作模型user,还有字段名和字段类型。_options_filter函数默认为空。
    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;
    }
  • 回到find函数,向下走跟进select方法。由于我们没有bind参数,因此不是PDO预编译。
    public function select($options=array()) {
        $this->model  =   $options['model'];
        $this->parseBind(!empty($options['bind'])?$options['bind']:array());
        $sql    = $this->buildSelectSql($options);
        $result   = $this->query($sql,!empty($options['fetch_sql']) ? true : false);
        return $result;
    }
  • 再跟进buildSelectSql,进行SQL语句的组装。
    public function buildSelectSql($options=array()) {
        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;
        }
        $sql  =   $this->parseSql($this->selectSql,$options);
        return $sql;
    }
  • parseTable,对操作的表进行检测,两边加上反引号,避免干扰。
  • 再跟进parseSql
    public function parseSql($sql,$options=array()){
        $sql   = str_replace(
            array('%TABLE%','%DISTINCT%','%FIELD%','%JOIN%','%WHERE%','%GROUP%','%HAVING%','%ORDER%','%LIMIT%','%UNION%','%LOCK%','%COMMENT%','%FORCE%'),
            array(
                $this->parseTable($options['table']),
                $this->parseDistinct(isset($options['distinct'])?$options['distinct']:false),
                $this->parseField(!empty($options['field'])?$options['field']:'*'),
                $this->parseJoin(!empty($options['join'])?$options['join']:''),
                $this->parseWhere(!empty($options['where'])?$options['where']:''),
                $this->parseGroup(!empty($options['group'])?$options['group']:''),
                $this->parseHaving(!empty($options['having'])?$options['having']:''),
                $this->parseOrder(!empty($options['order'])?$options['order']:''),
                $this->parseLimit(!empty($options['limit'])?$options['limit']:''),
                $this->parseUnion(!empty($options['union'])?$options['union']:''),
                $this->parseLock(isset($options['lock'])?$options['lock']:false),
                $this->parseComment(!empty($options['comment'])?$options['comment']:''),
                $this->parseForce(!empty($options['force'])?$options['force']:'')
            ),$sql);
        return $sql;
    }
  • 再跟进parseWhere,这里因此传递的是字符,因此直接到最后字符where + SQL语句。
  • 这里就是问题的所在,因为上个pareSql方法,将数组直接传递了进来,因此我们可以控制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;
    }
  • 最后一直向上return,一直到select方法,SQL语句为SELECT * FROM `tp_user` WHERE 1 and updatexml(1,concat(0x7e,user(),0x7e),1)-- LIMIT 1 ,在跟进query方法。
public function query($str,$fetchSql=false) {
        $this->initConnect(false);
        if ( !$this->_linkID ) return false;
        $this->queryStr     =   $str;
        if(!empty($this->bind)){
            $that   =   $this;
            $this->queryStr =   strtr($this->queryStr,array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind));
        }
        if($fetchSql){
            return $this->queryStr;
        }
        //释放前次的查询结果
        if ( !empty($this->PDOStatement) ) $this->free();
        $this->queryTimes++;
        N('db_query',1); // 兼容代码
        // 调试开始
        $this->debug(true);
        $this->PDOStatement = $this->_linkID->prepare($str);
        if(false === $this->PDOStatement){
            $this->error();
            return false;
        }
        foreach ($this->bind as $key => $val) {
            if(is_array($val)){
                $this->PDOStatement->bindValue($key, $val[0], $val[1]);
            }else{
                $this->PDOStatement->bindValue($key, $val);
            }
        }
        $this->bind =   array();
        try{
            $result =   $this->PDOStatement->execute();
            // 调试结束
            $this->debug(false);
            if ( false === $result ) {
                $this->error();
                return false;
            } else {
                return $this->getResult();
            }
        }catch (\PDOException $e) {
            $this->error();
            return false;
        }
    }
  • 初始化数据库,创建PDO连接。一直到170行的execute方法,进行数据库查询。

总结

  • SQL原因就是未对数据进行检验直接进行了拼凑。而最终则是$options可控,而v3.2.4 将 $options 和 $this->options 进行了区分,参数不可控无法干扰this->options,因此无法产生注入。
posted @ 2021-06-10 23:22  Pan3a  阅读(1255)  评论(0编辑  收藏  举报