前言
在我印象中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_) |
UserModel | think_user |
UserTypeModel | think_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
事件 - 执行全局中间件
- 执行路由调度(
Route
类dispatch
方法) - 如果开启路由则检查路由缓存
- 加载路由定义
- 监听
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篡改正常的实例化和方法执行过程,导致任意命令执行。
参考: