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

image-20260603204408670

调试配置

配置参考文章:https://blog.csdn.net/m0_60571842/article/details/133246064

注意配置好后先不要下断点,直接调试,会弹出浏览器

image-20260606133911110

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

image-20260606133955279

分析

POC1

1
2
3
4
5
6
POST /?s=captcha HTTP/1.1
Host: localhost:8666
Content-Type: application/x-www-form-urlencoded
Content-Length: 59

_method=__construct&filter[]=system&method=get&get[]=whoami

image-20260604133653739

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

image-20260604134235457

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

image-20260604134841261

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

image-20260604135026125

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

image-20260604135909198

继续到下面这里

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

image-20260604140036556

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

image-20260604140919323

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

image-20260604141214777

要包含的是Request.php

image-20260604141358027

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

image-20260604142513125

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

image-20260604143446580

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

image-20260604143640207

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

image-20260604144148548

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

image-20260604144437217

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

image-20260604144917185

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

image-20260604190507342

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

1
if (isset($_GET["s"])) {

image-20260604191613452

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

image-20260604191758081

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

image-20260604191116056

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

image-20260604192412512

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

image-20260604193445013

image-20260604193543990

所以经过上面分析可知,如果设置了 GET 参数?s=captcha,那么pathinfo的值就是captcha,最后path的值也就是captcha

这里重新调整一下,传入 GET 参数s,重新调试到这里

image-20260606134116184

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

image-20260605133635484

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

image-20260605133728081

这里就进入到了if语句中

image-20260605143457412

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

image-20260605143642555

image-20260605143723494

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

image-20260605143811886

继续往下来到路由检测的地方,这里先调用Route::check()方法

为什么要定位到这里?因为这里有$request,我们唯一可控的只有请求的数据,所以要重点关注和request有关的代码

image-20260605143857308

跟进check()方法,到857行这里,会执行requestmethod()方法

image-20260605143941256

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

image-20260604195251574

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

image-20260604195558123

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

image-20260604195721478

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

image-20260604195913764

  • 首先第一行代码是用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

image-20260605145402465

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

1
_method=__construct&filter[]=system&method=get&get[]=whoami

image-20260606134731529

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

image-20260606094503496

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

image-20260606094929523

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

这里存在filter属性,所以直接将filter这个属性的值覆盖为system

image-20260606103647021

payload中的method=get是为了将method的值改回来

继续往下跟进,回到App.php中,走到exec()方法这里,这里的$dispatch['type']值为method

image-20260606104315597

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

image-20260606104245579

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

image-20260606152101642

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

image-20260606155014334

然后是判断data是否是数组,然后就会调用array_walk_recursive()函数

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

image-20260606155054639

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

image-20260606155618827

跟进到filterValue()方法中,传入的就是filterdata

image-20260606155834341

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

image-20260606160222097

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

image-20260606160424488

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

image-20260606160512107

POC2

1
2
3
4
5
6
POST /?s=captcha HTTP/1.1
Host: localhost:8666
Content-Type: application/x-www-form-urlencoded
Content-Length: 59

_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=whoami

image-20260606165135267

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

image-20260606165611931

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

image-20260606165504659

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

image-20260606165944887

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

image-20260606170102827

所以就可以通过修改请求方法为要执行的命令从而实现RCE,也就是设置成:server[REQUEST_METHOD]=whoami

重新调试一下,这里的value就是要执行的命令了

image-20260606170519453

传?s=captcha的原因

传?s=captcha

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

image-20260606205250757

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

image-20260606205508127

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

image-20260606205930939

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

image-20260606210255060

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

image-20260606212521833

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

image-20260606213419971

image-20260606213657787

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

image-20260606213950524

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

image-20260606214053406

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

image-20260606215609253

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

image-20260606230309911

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

image-20260606225836295

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

image-20260606220529456

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

image-20260606225643946

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

image-20260606230656536

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

image-20260606230831040

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

image-20260606231235288

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

image-20260606231740263

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

image-20260606231857078

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

image-20260606231955673

不传?s=captcha

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

image-20260606221000581

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

image-20260606220246690

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

image-20260606232700771

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

image-20260606232943927

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

image-20260606233036823

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

image-20260606233054115

参考文章

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


CVE-2018-20062(ThinkPHP 5.0.23 RCE)
https://yschen20.github.io/2026/06/06/CVE-2018-20062(ThinkPHP-5-0-23-RCE)/
作者
Suzen
发布于
2026年6月6日
更新于
2026年6月6日
许可协议