ThinkPHP安全分析

前言

在我印象中thinkphp一直是安全问题的重灾区,因为访问thinkphp框架的服务一般可以直接看到版本号,所以日常中遇到thinkphp服务的网站基本上都可以直接网上搜索对应版本的漏洞,特别是反序列化漏洞,感觉在ctf里已经遇到很多次thinkphp的反序列化漏洞了,一般网上抄个payload就能打。借此机会,以本篇博客理解一下thinkphp的文件结构和工作模式,分析一下thinkphp的安全问题。

ThinkPHP6.0框架下载与安装

安装composer

因为我用的是windows环境,所以是在cmd下安装的。

地址:

https://getcomposer.org/Composer-Setup.exe

安装后命令行输入composer有反应即证明安装成功

稳定版thinkphp安装

在命令行下面,切换到WEB根目录并执行命令:

composer create-project topthink/think tp     

然后执行在thinkphp目录下执行:

php think run

默认会在http://127.0.0.1:8000启动服务,浏览器访问该网页看得到欢迎页面即证明安装成功。如果8000端口被占用没法启动服务可以使用命令:

php think run -p 端口号

不得不说官方文档果然好用,照着做就没问题。

ThinkPHP的mvc模式

ThinkPHP基于MVC模式,并且均支持多层(multi-Layer)设计

MVC是一种编程思想,m是model的缩写,用作数据处理,v是view,就是网页视图,c是controller,控制器的意思,用作逻辑处理。mvc可以方便不同逻辑的开发,提高代码可读性和可维护性,同时降低代码的耦合性,等价聚合性。

模型(M)

模型(M):模型的定义由Model类来完成

ThinkPHP的官方文档是这么写的:

也就是说模型类的作用大多数情况是操作数据库,按照系统的规范来命名模型类的话,大多数情况下是可以自动对应数据表的,如:

模型名约定对应数据表(假设数据表的前缀定义是think_)
UserModelthink_user
UserTypeModelthink_user_type

也就是说模型可以简化数据库操作的语句,完成业务逻辑处理,包括对数据表的增删改查(CUED)操作。对处理的数据进行封装;对字段及属性进行验证;完成对象及属性的过滤等功能。而ThinkPHP这里采用了惰性连接,只有进行数据库操作时才会进行与数据库的连接,确实增加了安全性,不过一般我们sql注入的话一般都是和正常查询语句一起执行的,这时数据库肯定已经连接了,因此这样做很难预防sql注入,thinkphp采用惰性连接的主要目的应该还是为了节省物理内存使用量,要预防sql注入采用PDO预编译才是最行之有效的方法。

视图(V)

视图(V):由View类和模板文件组成,模板做到了100%分离,可以独立预览和制作。

  • view类(模板引擎类似Smarty)
  • 模板文件(html模板)

在thinkphp中,视图主要负责信息的展示和输出

在配置目录的view.php文件中我们需要进行模板引擎相关参数的配置,类似于取别名的操作

如果我们在Action中赋值了一个name模板变量:

$name = 'ThinkPHP';
$this->assign('name',$name);

使用内置的模板引擎输出变量,只需要在模版文件使用:

{$name}

模板编译后的结果就是

<?php echo($name);?>

我们也可以调用engine方法进行初始化或者切换不同的模板引擎,例如:

<?php
namespace app\index\controller;

use think\facade\View;

class Index
{
    public function index()
    {
        // 使用内置PHP模板引擎渲染模板输出
        return View::engine('php')->fetch();
    }
}

这样我们就可以对当前视图的模板文件使用原生php进行解析

控制器(C)

控制器(C):应用控制器(核心控制器App类)和Action控制器都承担了控制器的角色,Action控制器完成业务过程控制,而应用控制器负责调度控制。

在ThinkPHP的官方文档中是这么描述的:

因此控制器的Controller可以调用数据库数据模型Model,获取到数据后把数据返回给视图界面View后实现数据处理。

本质上对于采用了ThinkPHP的服务,当我们访问其url时最终都会定位到控制器,比如当我们访问http://localhost/时,我们这时浏览器默认访问文件是:应用根目录/app/controller/index.php控制器的index方法,而我们的实际路径实际上是http://localhost/index.php/index/index。

比如我们刚刚安装完成ThinkPHP后访问了一个欢迎页面,上面写着什么”16载初心不改-你值得信赖的PHP框架”(当然,当我们做渗透时,也可以在这里直接看到ThinkPHP的版本,如果直接找不到的话可以访问错误页面,也能看到),事实上当我们查看ThinkPHP的源码,可以看到我们访问的主页面其实是控制器里的一个index方法。并不是像wordpress一样直接www目录下一个index.php文件,它的本质是调用了一个功能,而这个功能返回了html代码,让我们看到了主页上的文字。

ThinkPHP的文件结构

在官方文档中,对于单应用模式,ThinkPHP的推荐目录结构为:

www  WEB部署目录(或者子目录)
├─app           应用目录
│  ├─controller      控制器目录
│  ├─model           模型目录
│  ├─ ...            更多类库目录
│  │
│  ├─common.php         公共函数文件
│  └─event.php          事件定义文件
│
├─config                配置目录
│  ├─app.php            应用配置
│  ├─cache.php          缓存配置
│  ├─console.php        控制台配置
│  ├─cookie.php         Cookie配置
│  ├─database.php       数据库配置
│  ├─filesystem.php     文件磁盘配置
│  ├─lang.php           多语言配置
│  ├─log.php            日志配置
│  ├─middleware.php     中间件配置
│  ├─route.php          URL和路由配置
│  ├─session.php        Session配置
│  ├─trace.php          Trace配置
│  └─view.php           视图配置
│
├─view            视图目录
├─route                 路由定义目录
│  ├─route.php          路由定义文件
│  └─ ...   
│
├─public                WEB目录(对外访问目录)
│  ├─index.php          入口文件
│  ├─router.php         快速测试文件
│  └─.htaccess          用于apache的重写
│
├─extend                扩展类库目录
├─runtime               应用的运行时目录(可写,可定制)
├─vendor                Composer类库目录
├─.example.env          环境变量示例文件
├─composer.json         composer 定义文件
├─LICENSE.txt           授权说明文件
├─README.md             README 文件
├─think                 命令行入口文件

看到我们下载的ThinkPHP源码,其目录结构为:

ThinkPHP对于其文件结构有较为严格的规定,有八个大的类,分别是:

  • app 应用目录,里面有controller,我们服务的控制器目录,也有一些公共的函数文件和一些定义
  • config 配置目录,里面全是各种应用的定义
  • view 视图目录,默认为空,需要我们在其中添加需要的视图文件
  • route 路由定义目录,里面有我们的路由文件,默认只有一个hello
  • public WEB目录,也就是对外访问目录,默认有经典的index.php,robots.txt,以及router.php,我们可以通过router.php快速测试代码
  • extend 扩展类库,默认为空
  • runtime 应用的运行时目录,默认为空
  • vendor Composer类库目录,可以用于导入第三方类库

剩下的都是些零碎的配置文件,可以看见ThinkPHP把不同作用的文件放入不同的目录中,有较好的文件划分,在调用时比较清晰直观。

ThinkPHP的工作流程

在官方文档中,对于一个HTTP请求,大致的标准请求如下:

  • 载入Composer的自动加载autoload文件
  • 实例化系统应用基础类think\App
  • 获取应用目录等相关路径信息
  • 加载全局的服务提供provider.php文件
  • 设置容器实例及应用对象实例,确保当前容器对象唯一
  • 从容器中获取HTTP应用类think\Http
  • 执行HTTP应用类的run方法启动一个HTTP应用
  • 获取当前请求对象实例(默认为 app\Request 继承think\Request)保存到容器
  • 执行think\App类的初始化方法initialize
  • 加载环境变量文件.env和全局初始化文件
  • 加载全局公共文件、系统助手函数、全局配置文件、全局事件定义和全局服务定义
  • 判断应用模式(调试或者部署模式)
  • 监听AppInit事件
  • 注册异常处理
  • 服务注册
  • 启动注册的服务
  • 加载全局中间件定义
  • 监听HttpRun事件
  • 执行全局中间件
  • 执行路由调度(Routedispatch方法)
  • 如果开启路由则检查路由缓存
  • 加载路由定义
  • 监听RouteLoaded事件
  • 如果开启注解路由则检测注解路由
  • 路由检测(中间流程很复杂 略)
  • 路由调度对象think\route\Dispatch初始化
  • 设置当前请求的控制器和操作名
  • 注册路由中间件
  • 绑定数据模型
  • 设置路由额外参数
  • 执行数据自动验证
  • 执行路由调度子类的exec方法返回响应think\Response对象
  • 获取当前请求的控制器对象实例
  • 利用反射机制注册控制器中间件
  • 执行控制器方法以及前后置中间件
  • 执行当前响应对象的send方法输出
  • 执行HTTP应用对象的end方法善后
  • 监听HttpEnd事件
  • 执行中间件的end回调
  • 写入当前请求的日志信息

以一个经典的为[ThinkPHP]5-Rce例,分析一下其本身的本身工作流程

BUUOJ上直接就有复现很方便,我们可以在https://www.thinkphp.cn/down.html下载到各种ThinkPHP版本的代码,我试了下payload,BUUOJ这个环境应该是5.1.29,我这里下载的是thinkphp_5.1.29版本的源码。

这个版本经典的payload是:

index.php?s=/index/\think\APP/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

在url里输入之后即可执行命令,我们来简单分析下这个payload。

首先看到站点根目录下的public目录下的index.php文件:

这里代码挺简单的,主要是加载了基础文件,然后调用了App.php中的run方法,我们把视线移向run()

这里App的物理位置是指thinkphp\libray\think目录下的App.php,在这里,我们可以看到run函数的全貌了,也从这里开始,我们正式进入了整个程序的入口。这里一共有六百多行代码,而且有很多的调用和加载,看起来非常头疼,我们直接看到一些关键的函数。

在大概598行的位置,这里进行了路由检测,然后记录了当前的调度信息:

当我们跟进$dispatch = $this->routeCheck()->init();可以在大概598行的位置发现这里获取了应用的调度信息,注释这里写的很简略,从后面我们可以知道,事实上这里应该是调用了pathinfo函数,获取了s参数的值

当我们跟进这里的path方法,我们会跳转到Request.php的716行的path方法,我们可以看到这里调用了pathinfo()方法

继续寻找pathinfo()方法,这个方法就在上面一点,678行。

这其中$this->config[‘var_pathinfo’]的值就是config目录下的app.php文件中var_pathinfo的值,也就是”s”

所以我们说,前面所说的获取了应用的调度信息,其实是调用了pathinfo函数,获取了s参数的值。

我们组合起来,其实pathinfo()方法中判断的if (isset($_GET[$this->config[‘var_pathinfo’]])),其实是判断是否接受到s,也就是isset($_GET[‘s’]),因此事实上此时$pathinfo的值是/index/\think\app/invokefunction

因此直到最后pathinfo的值都是/index/\think\app/invokefunction,自然pathinfo的方法的返回值也是如此。

现在相当于完整执行完了$path = $this->request->path();我们回App.php继续看代码

执行完该方法后会判断是否强制路由模式,这也是为什么ThinkPHP的V5的命令执行漏洞很多需要判断是否开启路由模式,因为这里强制执行了判断,不过我们这个版本没开,所以payload能直接打。

继续看代码,下一步会进行一个路由检测,然后调用了check方法,传入了$path和$must这两个参数,其中path参数的值就是`/index/\̲t̲h̲i̲n̲k̲\app/invokefunc,而must因为默认没开强制路由所以是false

跟进check方法,在library/think/Route.php的877行,我们可以找到check方法,可以看到这里有个强制判断,必须$must=false否则抛出异常,其中$url的值就是我们的s。

在这个方法的最后会进行一次路由解析,也就是实例化UrlDispatch对象,让UrlDispatch继承了Dispathch

而在Dispathch的构造函数这里,我们会发现$dispatch是可控的

构造函数执行完,我们回到App.php,这里routecheck函数的返回值urldispatch,然后调用了init方法

跟进init方法,这里$this->dispatch的值为index|\think\app|invokefunction,即模块|控制器|操作。所以此时模块:index,控制器:think\app,操作:invokefunction。

继续跟进parseUrl,这里对$url调用了parseUrlPath方法

我们再跟进一下parseUrlPath方法,这个方法对分割符进行了替换,按照0是模块,1是控制器,2是操作,所以我们的$url变成了index|\think\app|invokefunction

最后返回$path和$var,其中$var为空

执行完函数返回完值,我们又回到了parseurl函数,然后再执行到返回,回到了Init函数

这时对于$result:

0="index"
1="\think\app"
2="invokefunctio"

然后会执行return (new Module($this->request, $this->rule, $result))->init();会在这里new一个Model对象然后调用Init方法,这里就是整个payload最关键的部分

在Init方法中的70行,ThinkPHP会进行一次获取控制器名字的操作,而这里没做任何过滤

$controller       = strip_tags($result[1] ?: $this->rule->getConfig('default_controller'));
        $this->controller = $convert ? strtolower($controller) : $controller;

因此我们可以在这里执行任意类的任意方法

当我们把init方法执行完之后,会返回当前对象

这时我们才将$dispatch = $this->routeCheck()->init();执行完,回到App.php,在435行这里调用了dispatch方法

跟进dispatch方法:

一直跟进,会在dispatch.php中的168行调用exec方法

我们跟进这个exec方法,这里会实例化控制器,也就是MVC中的controller

进入contrloller方法,$name就是控制器名think\app

跟进parseModuleAndClass方法

这里判断了下$name里有没有\,如果有就将值赋给$class,然后调用request里的module方法,因为我们的$name是控制器名think\app,显然里面是有\的,所以直接进入这条分支然后返回值。执行完之后我们回到controller方法

可以看到当我们执行完parseModuleAndClass之后,下一步会判断一下我们的类$class是否存在,显然是存在的,然后会返回$this->__get($class),这里进行了具体的实例化操作,一直返回到model.php,然后这里正式实例化完控制器

105行这里获取了当前的操作名,然后在112行这里严格获取当前操作方法名,似乎就是new了一个反射方法对象,可能是在这里对操作名做了过滤之类的

此时$reflect的值从think\app变成了think\Container,因为app继承了Contatiner类,而我们的invokefunciton方法也是属于Containterd的

接着十行后在这里会获取function参数值和vars参数值

然后在135行这里执行invokeReflectMethod方法

此时$reflect和$var的值如下:

我们跟随这个invokeReflectMethod,它在Container.php的391行:

似乎只是起到了一个绑定参数的作用,但他返回了一个invokeMethod($callable, $vars),我们跟一下这个invokeArgs,invokeArgs()函数是PHP中的一个内置函数,用于调用指定的反射方法并返回方法的结果,因此我们接下来会跳转到invokeFunction方法内部。这个invokdefunction方法对应着exp:

s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1

中的invokdefunction。exp中的function参数值就是方法中的第一个参数值。vars参数是放到第二个参数值。

这里调用了call_user_func_array

执行了call_user_func_array('call_user_func_array',['phpinfo',['1']]);

因此我们在这里实现了命令执行的功能。

分析出现漏洞的原因,最关键的因素就是ThinkPHP中对$controller没有存在过滤,导致我们可以传入类似于\think\app的控制器,并且可以执行任意类的任意方法。当我们看到ThinkPHP5.0.23以及5.1.31版本,可以看到ThinkPHP官方的补丁主要就是对$controller做了一次过滤,导致我们没法再传入payload中的控制器名。

ThinkPHP的安全风险

回顾整个过程,黑客的攻击是利用了ThinkPHP中的哪个流程呢?

问题的关键在于攻击者通过控制输入就可以操控类的实例化过程,从而造成代码执行漏洞

我们回看thinkphp官方给出的HTTP请求流程:

正常的流程是,当我们访问Index.php,这时会调用App.php中的run方法,然后加载一些autoload文件,此时回实例化一些应用基础类think\App,然后会在这里获取应用目录等相关路径信息,这其中我们会进行一次对控制器的实例化,由于官方没有对$controller做出有效的过滤,导致我们可以操控类的实例化过程,构造并执行任意方法,因此导致了任意命令执行的漏洞。而这个实例化控制器的过程显然也是ThinkPHP中最容易出现漏洞的地方,因为这里实例化并且调用了一些方法,其中有些参数是可以被黑客利用的,如果可控参数够多,条件足够黑客就很容易实现任意命令注入。

之前ThinkPHP5.0.23出现漏洞同样是这个原因:在 ThinkPHP5 完整版中,定义了验证码类的路由地址。程序在初始化时,会通过自动类加载机制,将 vendor 目录下的文件加载,这样在 GET 方式中便多了这一条路由。我们便可以利用这一路由地址,使得 $dispatch['type']等于 method ,从而完成 远程代码执行 漏洞。

回头看来,我认为ThinkPHP之所以出现这么多RCE的漏洞,原因就是在实例化和路由器这里的执行链太长了,我分析的时候也感觉,和套娃似的,一个方法套另一个方法,一个类套另一个类,因为执行链太长和实例化过程太多,导致这其中很容易碰到一些危险的方法,如果不做好足够的过滤,特别是一些方法执行的函数,可能会导致黑客通过构造出巧夺天工的payload篡改正常的实例化和方法执行过程,导致任意命令执行。

参考:

ThinkPHP5 远程代码执行漏洞分析

Thinkphp5.0.0~5.0.23 RCE漏洞复现分析

ThinkPHP6.0完全开发手册

[漏洞复现]thinkphp5代码执行漏洞

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇