CVE-2018-20062(ThinkPHP 5.0.23 RCE)
前言
自从染上了Codex,感觉自己的越来越懒了,好久没看过代码,就想到这个 CVE,跟着网上的文章审一遍,锻炼一下代码审计的能力,动一动脑子(虽然有的也是借助 AI 去理解的代码)
环境准备
源码下载地址:https://gitee.com/sysorem-lee/thinkphp_5.0.23_with_extend/repository/archive/master.zip
我是把源码拉到本地,用的 phpstudy 搭的,环境要求是 PHP >= 5.4.0,我用的 PHP 5.4.45

调试配置
配置参考文章:https://blog.csdn.net/m0_60571842/article/details/133246064
注意配置好后先不要下断点,直接调试,会弹出浏览器

然后下断点,浏览器再请求一下,就可以开始调试了

分析
POC1
1 | |

下断点调试,ThinkPHP 框架都会有一个入口文件public/index.php,就在这个文件下断点,这里会去调用start.php文件

在start.php中会加载base.php文件,这个文件主要负责定义系统常量、加载环境变量、注册自动加载与错误处理机制,并载入框架的默认配置,可以直接跳过

然后是到下面这里,App::run()会调用App.php文件中的run()方法,这个方法最终返回的是响应对象,->send()就是把run()返回的内容发送给客户端

跟进来到Loader.php的autoload()方法,先是检测命名空间别名,这里的$class是think\App

继续到下面这里
self::findFile($class)会根据类名去推导文件路径,$file的值就是推导的App.php的文件路径- 然后
!IS_WIN会判断是否是 Windows 环境,如果是就会用pathinfo()分别得到$file和realpath($file)的文件名并进行对比,也就是额外做一次大小写的校验,是防止 Windows 环境下文件包含漏洞 - 之后就会包含
App.php这个文件

继续跟进,来到App.php中的run()方法,首先是创建一个Request类的实例,Request是一个处理请求的类

继续跟进又来到了Loader.php::autoload(),此时的class是think\Request

要包含的是Request.php

继续跟进到Request.php文件中,到下面这里将 POST 数据写入到 input 变量

创建完 Request 实例后,回到run()方法,接下来是一些初始化操作

直接在116行下个断点,运行到这里,这里会调用routeCheck()方法,和路由调度有关的

跟进到routeCheck()方法里,先调用path()方法

跟进path()方法,到下面这里会调用pathinfo()方法,这个方法是获取当前请求的 PATH_INFO,用来做路由解析

跟进pathinfo()方法,下面这一行有个判断,会从Config类中获取到var_pathinfo的值,然后去检查$_GET全局变量中是否存在这个变量

继续跟进到get()方法中,可以走到下面这一行,$range是_sys_,$name是var_pathinfo,var_pathinfo的默认值是s

所以下面这一行就是在判断是否存在 GET 参数s,等同于:
1 | |

如果设置了 GET 参数s,就会到下面这一步,也就是将s的值赋值给$_SERVER['PATH_INFO']

因为这里是直接进行调试的,没有进行传参,所以继续调试会跟到下面的elseif中,此时$_SERVER['PATH_INFO']就是为空

继续往下会走到下面这里,因为$_SERVER['PATH_INFO']为空,所以这里的pathinfo就会被赋值为/

继续跟进,回到path()方法中,到下面这行代码,此时path的值就是和pathinfo的一样是/


所以经过上面分析可知,如果设置了 GET 参数?s=captcha,那么pathinfo的值就是captcha,最后path的值也就是captcha
这里重新调整一下,传入 GET 参数s,重新调试到这里

在Request.php中的404行下断点,然后直接走到这里

此时可以看到有了 GET 参数s,值为captcha,至于后面的XDEBUG_SESSION_START是调试自带的,不用管

这里就进入到了if语句中

再往后来到下面这里,可以看到pathinfo此时是captcha


再往下回到path()方法中,path的值就是captcha

继续往下来到路由检测的地方,这里先调用Route::check()方法
为什么要定位到这里?因为这里有
$request,我们唯一可控的只有请求的数据,所以要重点关注和request有关的代码

跟进check()方法,到857行这里,会执行request的method()方法

跟进method()方法,开始的method为false,所以会进入到elseif中,这里和前面 GET 参数差不多,先从Config类中获取到var_method的值,然后再判断是否存在这个变量

跟进到get()方法中,可以看到var_method的默认值是_method

所以下面这里就是在判断是否 POST 方式传了_method这个参数

如果传入了 POST 参数_method,就会继续执行到下面这两行代码

首先第一行代码是用
strtoupper()函数将_method参数的内容全部转化为大写字母,然后赋值给method变量第二行代码就是把
method的值当作方法名,动态调用当前对象的这个方法,并把$_POST作为参数传进去利用POC举个例子,POC的请求体是:
1
_method=__construct&filter[]=system&method=get&get[]=whoami到这一步就会将
__construct当作方法名,然后将整个 POST 数组都传进去,也就等价于:1
2
3
4
5
6$this->__construct([
'_method' => '__construct',
'filter' => ['system'],
'method' => 'get',
'get' => ['whoami']
]);
这里没有传_method参数,所以直接进入了else中,这里就是先看请求方式是什么,如果没有就默认用 GET

修改一下调试配置,改成 POST 请求方式,传入_method参数,直接传入所有payload了
1 | |

这里下个断点重新调试,这次进入到了if语句中

这里存在任意函数调用,当前传入的__construct是Request类的构造方法,继续跟进到Request.php的__construct方法

这里就是把传入数组的键,当成当前对象的属性名,如果对象本来就有这个属性,就直接覆盖它的值
这里存在filter属性,所以直接将filter这个属性的值覆盖为system

payload中的method=get是为了将method的值改回来
继续往下跟进,回到App.php中,走到exec()方法这里,这里的$dispatch['type']值为method

所以跟进exec()方法,会走进method这个分支中

跟进param()方法,这里不会进入if (true === $name)中,会来到最后的input()方法

input()方法首先是对name进行一些处理

然后是判断data是否是数组,然后就会调用array_walk_recursive()函数
这里就是能RCE的关键点了,这里会递归遍历$data数组中的每一个值,把每个值都交给filterValue()方法去处理,并把$filter作为参数传进去

调试到这里看一下,此时的filter=system,data=whoami

跟进到filterValue()方法中,传入的就是filter和data

到下面这里就是会调用call_user_func()函数,传入的参数分别是filter和value,也就是system和whoami,就是导致RCE的漏洞点

然后就是把命令执行的结果赋值给value,最终返回出来

filterExp就是对结果进行一些检查过滤吧

POC2
1 | |

回到Request.php中,下面这里就会调用一次method()方法,传入true

因为传入的是true,所以在method()方法中可以走到下面这一步,这里是先获取真实的 HTTP 请求,如果没有就会默认使用 GET

跟进server()方法,这里也会调用input()方法

然后就是和前面分析的一样了,到了filterValue()方法中会调用到call_user_func()函数,这里的filter仍然是system,value是 POST,也就是请求方式(这里调试的时候还是用的POC1调试的,所以这里请求方式就是 POST,没有被修改)

所以就可以通过修改请求方法为要执行的命令从而实现RCE,也就是设置成:server[REQUEST_METHOD]=whoami
重新调试一下,这里的value就是要执行的命令了

传?s=captcha的原因
传?s=captcha
根据前面的分析可知,两个POC的利用点都是从Request.php中的param()方法里调用到的,如下图

往上找一下,在App.php中的exec()方法里,要满足dispatch['type']的值为method,就能调用到Request::instance()->param()

再往上找一下dispatch是怎么赋值的,往上找到在App.php的139行调用exec()方法,传入的第一个参数是dispatch,向上看到116行可以找到对dispatch赋值的代码行,调用的routeCheck()方法,返回值赋值给dispatch

跟进routeCheck()方法中,可以看到返回值是result,所以dispatch就是result,然后可以找到在648行对result进行赋值,调用的Route::check()方法,返回值赋值给result

跟进check()方法中,在887行会调用checkRoute()方法,将其返回值作为check()方法的返回值

跟进checkRoute()方法,这里遍历rules的下标,有两个元素


继续往下来到964行,调用了checkRule()方法

直接跟进checkRule()方法,来到1201行,这里调用match()方法

跟进match()方法,这里传入的三个参数分别是:

开始会经过一系列的处理,将rule的内容以斜杠分隔符拆分为数组给$var,然后进行遍历去进行判断,每次遍历时用的是val

因为传入的rule是captcha/[:id],所以经过一系列转换后,val的值就是captcha和:id

下面这里的if是判断$val是否是:开头的,如果是就会进入到if中

当val的值为:id,就会进入到if中

然后会执行到下面1370行这里,前面的俩if语句都不会进去,这里是给id进行赋值

最后返回的var就是为空的id

回到checkRule()方法中,因为match返回的结果不是false,所以就会进入if中,就会调用到parseRule()方法

跟进parseRule()方法,在1518行这里,result['type']被赋值为method

最后routeCheck()方法返回的result中的type的值就是method

所以回到run()方法,dispatch['type']的值就是method,之后就能调用到Request::instance()->param()

不传?s=captcha
回头重新调试,如果不传入?s=captcha,就会到elseif中,这里判断$val和$m1[$key]是否不相等,若不相等,strcasecmp($val, $m1[$key])就是返回的非0,这个条件就成立,就会return false,之后的调试就一直是false了

strcasecmp(s1,s2)函数返回值的含义:

然后在routeCheck()方法中就会走到下面这里,去调用parseUrl()方法

跟进parseUrl()方法,最后是会执行到下面这里,返回的type是module

所以result['type']的值为module

dispatch['type']的值也是module,之后也就不能调用到Request::instance()->param()

参考文章
https://xilitter.github.io/2023/10/27/thinkphp-5-0-23-RCE%E5%88%86%E6%9E%90/index.html