whip1ash

Thinkphp 5.0.x/5.1.x 变量覆盖 RCE 漏洞分析

2019-03-18

WARNING

在写完这篇文章五个月后的某一天突然看起这篇文章,发现逻辑混乱,狗屁不通,我自己都看不明白。如果你要参考此篇文章,建议你手边放一份代码,边调试边看,大概你通过调试能够使自己这个漏洞有所了解,因为此文可读性极差。强烈建议你通读全文后再决定要不要通过这篇文章来学习此漏洞,以免误入歧途。

Thinkphp 5.0.x/5.1.x 变量覆盖 RCE 漏洞分析

首先讲一下遇到的一个坑点,网上5.0.0 - 5.0.23的POC都是这样的

1
2
3
http://127.0.0.1/thinkphp/public/index.php?s=captcha
POST:
_method=__construct&filter=system&method=get&server[REQUEST_METHOD]=id

s=captcha需要vendor中存在topthink/think-captcha,这个只有在extend版本中存在,core版本是不存在的,如果遇到报错没回显,可以考虑这种情况。(坑了一下午

补丁

在think\Request中添加了校验,只允许$method为常规的五种方法,默认是POST。

5.0.x 漏洞分析

前面的常规流程不讲了,直接从think\App开始,只关注其中的几个点,省略不必要的干扰。

只看打断点的三个方法,可以看到从routeCheck得到$dispatch$dispatch传入exec()。debug稍后再讲。先从exce()开始。


调用Request的param方法,看见Request::isntance()方法可以判断Request类是个单例模式,查看构造方法可以发现Request类的构造方法是protected。
跟进param()。

获取参数,调用method(),返回时调用input方法,跟进method()。

这就是漏洞点所在了,可以很容易的发现这里通过$this->method变量调用函数,这个变量可控,来自于$_POST[Config::get('var_method')],通过查看配置文件发现Config::get(‘var_method’)值为_method。所以可以调用Request类中的任意方法。

回到param()方法中,看看返回了什么,跟进$this->input()方法。


这里获取变量,并做一些过滤,看看过滤器干了什么。跟进$this->filterValue()

这里调用了call_user_func!传入$filter$value两个变量,往前看看这两个变量是否可控。
回看input方法可以发现$filter是通过$this->getFilter()获得的,而$name是传进来的。
先跟进$this->getFilter()

找到源头了,$filter为空时从$this->filter获取值。往前看一眼看看$name是从哪传进来的。
看了看之前的几个方法,好像没有找到$this->server,不过不要紧,再看一下$this->method方法。可以发现刚才我们分析的逻辑是$this->method为false的情况,也就是为空的情况,在这个逻辑中对这个变量进行了赋值。当传入的$method不为FALSE的时候,进入标出的逻辑。

跟进$this->server()

所以如果$this->server$this->filter 在传入filterValue()方法之前能变成我们想要的值就可以执行任意代码。

所以如果能够在第一次调用method()方法的时候覆盖几个关键变量,第二次调用method方法的时候就可以将控制的变量传入input的方法。回想一下,我们可以调用$Request类的任意函数,所以如果有一个方法,能够当前实例的变量,那么上述的要求就能够得到满足。

Request::__construct可以满足上述覆盖变量条件条件,App::routeCheck()方法满足调用条件。调用链为think\App::run() ==> think\App::routeCheck() ==> think\Route::check() ==> Request::method(),在此时使用POST参数覆盖变量,使_method=__construct,filter=system,method=get,server[REQUEST_METHOD]=id,在动态函数处调用构造方法并覆盖上述变量。导致在filterValue()方法中的call_user_func()函数中执行system(id)这个语句,导致RCE。

-w787

变量覆盖部分就结束了,但是还有一个问题,需要在App::exec()方法中进入case ‘method’流程。

case ‘method’流程

将POST数据发送到/public/index.php?s=index这个URL发现无法触发漏洞,这是因为s=index的时候触发case ‘moudle’逻辑,在此逻辑中调用App::moudle()。
-w997

而在moudle方法中将filter属性覆盖为默认属性。
-w1014
安全客的分析文章说是在5.0.23中添加了这个方法,我看了一下几个早期版本(5.0.18/5.0.17)均存在此逻辑,不知道是漏洞爆发后全版本都添加了此段逻辑还是本来就存在此段逻辑,此处已不好判断。

在调用vendor相关组件是触发case 'method'逻辑,找了一下只有在captcha组件中注册了路由,所以使s=captcha。

关于thinkphp的路由分发我在网上找了一圈也没有找到什么很好的资料,composer有自己的自动类加载机制,thinkphp只是调用composer的接口实现了vendor的加载,但是可以看见在路由加载规则的时候已经加载了captcha的命名空间,击中think\Route::check方法中可以断点时可以发现\$rules变量中已经存在了captcha的控制器命名空间。

偷的柠檬师傅的图…..

-w474
这个路由在vendor/topthink/think-captcha/src/helper.php中定义。

1
\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");

这个流程的逻辑调用如下图所示,图偷自绿盟技术博客。

关于debug

回看到最初的App::run()函数中,可以看到如果开启debug的话直接调用\$request->param,从而直接触发漏洞,如果开启debug的话,无论s=xxx,都可以直接触发漏洞。

总结一下

  1. 如果没有开启debug的情况下,此漏洞触发需要存在vendor中存在存在captcha组件
  2. 如果开启debug的话,此漏洞可以在任意路由进行触发。

另外一个POC

请求的URL不变,POST数据变为

1
_method=__construct&filter=system&method=get&get[]=id

这个POC覆盖了\$this->get属性,在\$this->param()方法中进入\$this->get()方法和\$this->route()方法,将返回被覆盖的get属性,赋值给\$this->param属性,也就是POC中的id。如图所示。
-w1464

在函数返回的时候调用\$this->input()方法,\$data的值为\$this->param,也就是POC中的id。
在\$this->input()方法中\$this->getFilter()方法返回\$this->filter属性,也就是system。由于\$data是数组,所以进入array_walk_recursive()方法。
-w1064

执行\$this->filterValue()方法,从而RCE。
-w1340

5.1.x漏洞分析

5.1.x此变量覆盖漏洞依然存在,但是有点差别。

通过composer获取一个5.1.17版本的thinkphp。
composer create-project topthink/think=5.1.17 tp5117
但是这样安装的framework是最新版本的,可以通过更改require中的framework版本来安装指定版本。还可以添加captcha插件,如图所示。

php更改完成后直接composer update就好了。

使用如下POC:

1
2
3
POST /public/index.php

a=system&b=id&_method=filter

5.1的加载流程有一个比较大的改动,这里不再赘述,直接断点到think\App::Run()方法的路由分发routeCheck()方法。


调用check方法,有一系列的check方法调用,一直调用到think\route\RuleGroup的check()。调用栈如下。

在这个方法中调用Request::method()方法,变量覆盖的漏洞触发点。

可以看到这里直接变量覆盖,而不是像5.0.x一样动态函数调用。这里覆盖\$this->filter变量为\$_POST。返回\$this->method属性,值为filter。

进入getMethodRules方法。

在这个方法中合并数组,由于当前对象rules属性中没有filter方法,所以会报错。所以需要在index.php中加入error_reporting(0);。这个漏洞很鸡肋的地方就在这里,这个error_reporting(0);必须写在require下方,否则无效。
如图。

路由分发和变量覆盖结束,接下来是动态加载,加载流程比较复杂,不再一步步跟进,直接断点到think\Request::post();
这个方法在think\Request::param()方法中调用,在这里直接返回我们的POST数组。

-w1299

\$this->param的值为合并数组得到,然后传入input函数,此时param属性的值为

在input方法中getFilter方法返回刚才覆盖的变量。将\$data和\$filter通过array_walk_recursive传入filterValue函数。

这里循环调用filterValue方法,触发其中的call_user_func(),RCE!

关于漏洞分析到此就结束了。
流程图同样偷自绿盟blog

关于5.1.x的可利用版本的问题

我随手测了一下,目前可用的只有5.1.17和5.1.19,测试的其他的5.1.06和5.1.20均不可用,感觉很迷,所以为了探究此问题,需要进行fuzz。
利用如下脚本,下载下来了thinkphp 5.1.x的全部版本。

1
2
3
4
5
6
7
8
9
10
for ((i=0; i<36; i++)); do
composer create-project topthink/think=5.1.${i} tp51${i};
if `cd tp51${i}`;
then
cd tp51${i};
sed -i'.bak' "s/5.1.\*/5.1.${i}/g" composer.json;
composer update;
cd ..;
fi
done

然后使用python批量添加error_reporting(0);并进行批量请求(python代码写的太丑了,就不放了)。结果如下。

可利用的版本为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
5.1.0
5.1.14
5.1.16
5.1.17
5.1.19
5.1.20
5.1.21
5.1.22
5.1.23
5.1.24
5.1.25
5.1.26
5.1.27
5.1.28
5.1.29
5.1.31
5.1.32

刚开始以为5.1.20不能用,结果fuzz的结果中这个版本是可以打的,对比了一下两份代码,发现了此处利用还存在一个条件,那就是vendor的topthink中除了think-installer不能存在其他的依赖(这个结果不一定准确,我测试了安装think-captcha或者think-oracle,结果是不能复现)。

由于这个漏洞比较鸡肋,故决定放弃探究为何5.1.0-5.1.14中间的几个版本不能利用,等遇到实际案例再进行分析。

总结一下,5.1.x在上述版本中的利用需要以下几个条件:

  1. 存在正确位置的error_reporting(0);
  2. vendor中不能存在其他的依赖,除了think-installer
  3. 需要在上述版本中

5.0.x可利用的版本

既然已经花时间fuzz了5.1.x的可利用版本,为何不测一下5.0.x的可利用版本?

经过简单的fuzz,得到的结果如下(以下结果的前提均为不开debug)

  1. 没有利用captcha组件可以利用的版本(也就是在moudle逻辑中没有进行重定义过滤器的操作):

覆盖get变量的poc

1
5.0.2-5.0.12
  1. 利用captcha可以利用的版本

覆盖server[REQUEST_METHOD]的poc

1
5.0.22/23

覆盖get的poc

1
5.0.2-5.0.23

总结一下

thinkphp版本比较多,比较杂,不同的版本需要使用不同的利用,需要使用fuzz去探索哪些poc是可用的,哪些是不可用的,网上的资料比较杂一些,需要做一下总结。

这个变量覆盖的思路是值得学习的,以后如果遇到一个类中的属性可控,可以通过变量覆盖来构造利用链,或者这个可控的变量可以进行动态方法的调用。

Tags: PHP

扫描二维码,分享此文章