作者:LoRexxar'@知道創宇404實驗室
日期:2019年6月12日
英文版本:http://www.bjnorthway.com/954/

2019年6月11日,RIPS團隊在團隊博客中分享了一篇MyBB <= 1.8.20: From Stored XSS to RCE,文章中主要提到了一個Mybb18.20中存在的存儲型xss以及一個后臺的文件上傳繞過。

其實漏洞本身來說,畢竟是需要通過XSS來觸發的,哪怕是儲存型XSS可以通過私信等方式隱藏,但漏洞的影響再怎么嚴重也有限,但漏洞點卻意外的精巧,下面就讓我們一起來詳細聊聊看...

漏洞要求

儲存型xss

  • 擁有可以發布信息的賬號權限
  • 服務端開啟視頻解析
  • <=18.20

管理員后臺文件創建漏洞

  • 擁有后臺管理員權限(換言之就是需要有管理員權限的賬號觸發xss)
  • <=18.20

漏洞分析

在原文的描述中,把多個漏洞構建成一個利用鏈來解釋,但從漏洞分析的角度來看,我們沒必要這么強行,我們分別聊聊這兩個單獨的漏洞:儲存型xss、后臺任意文件創建。

儲存型xss

在Mybb乃至大部分的論壇類CMS中,一般無論是文章還是評論又或是的什么東西,都會需要在內容中插入圖片、鏈接、視頻等等等,而其中大部分都是選擇使用一套所謂的“偽”標簽的解析方式。

也就是說用戶們通過在內容中加入[url][img]等“偽”標簽,后臺就會在保存文章或者解析文章的時候,把這類“偽”標簽轉化為相應的<a><img>,然后輸出到文章內容中,而這種方式會以事先規定好的方式解析和處理內容以及標簽,也就是所謂的白名單防御,而這種語法被稱之為bbcode

這樣一來攻擊者就很難構造儲存型xss了,因為除了這些標簽以外,其他的標簽都不會被解析(所有的左右尖括號以及雙引號都會被轉義)。

function htmlspecialchars_uni($message)
{
    $message = preg_replace("#&(?!\#[0-9]+;)#si", "&amp;", $message); // Fix & but allow unicode
    $message = str_replace("<", "&lt;", $message);
    $message = str_replace(">", "&gt;", $message);
    $message = str_replace("\"", "&quot;", $message);
    return $message;
}

正所謂,有人的地方就會有漏洞。

在這看似很絕對的防御方式下,我們不如重新梳理下Mybb中的處理過程。

/inc/class_parse.php line 435 的 parse_mycode函數中就是主要負責處理這個問題的地方。

    function parse_mycode($message, $options=array())
    {
        global $lang, $mybb;

        if(empty($this->options))
        {
            $this->options = $options;
        }

        // Cache the MyCode globally if needed.
        if($this->mycode_cache == 0)
        {
            $this->cache_mycode();
        }

        // Parse quotes first
        $message = $this->mycode_parse_quotes($message);

        // Convert images when allowed.
        if(!empty($this->options['allow_imgcode']))
        {
            $message = preg_replace_callback("#\[img\](\r\n?|\n?)(https?://([^<>\"']+?))\[/img\]#is", array($this, 'mycode_parse_img_callback1'), $message);
            $message = preg_replace_callback("#\[img=([1-9][0-9]*)x([1-9][0-9]*)\](\r\n?|\n?)(https?://([^<>\"']+?))\[/img\]#is", array($this, 'mycode_parse_img_callback2'), $message);
            $message = preg_replace_callback("#\[img align=(left|right)\](\r\n?|\n?)(https?://([^<>\"']+?))\[/img\]#is", array($this, 'mycode_parse_img_callback3'), $message);
            $message = preg_replace_callback("#\[img=([1-9][0-9]*)x([1-9][0-9]*) align=(left|right)\](\r\n?|\n?)(https?://([^<>\"']+?))\[/img\]#is", array($this, 'mycode_parse_img_callback4'), $message);
        }
        else
        {
            $message = preg_replace_callback("#\[img\](\r\n?|\n?)(https?://([^<>\"']+?))\[/img\]#is", array($this, 'mycode_parse_img_disabled_callback1'), $message);
            $message = preg_replace_callback("#\[img=([1-9][0-9]*)x([1-9][0-9]*)\](\r\n?|\n?)(https?://([^<>\"']+?))\[/img\]#is", array($this, 'mycode_parse_img_disabled_callback2'), $message);
            $message = preg_replace_callback("#\[img align=(left|right)\](\r\n?|\n?)(https?://([^<>\"']+?))\[/img\]#is", array($this, 'mycode_parse_img_disabled_callback3'), $message);
            $message = preg_replace_callback("#\[img=([1-9][0-9]*)x([1-9][0-9]*) align=(left|right)\](\r\n?|\n?)(https?://([^<>\"']+?))\[/img\]#is", array($this, 'mycode_parse_img_disabled_callback4'), $message);
        }

        // Convert videos when allow.
        if(!empty($this->options['allow_videocode']))
        {
            $message = preg_replace_callback("#\[video=(.*?)\](.*?)\[/video\]#i", array($this, 'mycode_parse_video_callback'), $message);
        }
        else
        {
            $message = preg_replace_callback("#\[video=(.*?)\](.*?)\[/video\]#i", array($this, 'mycode_parse_video_disabled_callback'), $message);
        }

        $message = str_replace('$', '&#36;', $message);

        // Replace the rest
        if($this->mycode_cache['standard_count'] > 0)
        {
            $message = preg_replace($this->mycode_cache['standard']['find'], $this->mycode_cache['standard']['replacement'], $message);
        }

        if($this->mycode_cache['callback_count'] > 0)
        {
            foreach($this->mycode_cache['callback'] as $replace)
            {
                $message = preg_replace_callback($replace['find'], $replace['replacement'], $message);
            }
        }

        // Replace the nestable mycode's
        if($this->mycode_cache['nestable_count'] > 0)
        {
            foreach($this->mycode_cache['nestable'] as $mycode)
            {
                while(preg_match($mycode['find'], $message))
                {
                    $message = preg_replace($mycode['find'], $mycode['replacement'], $message);
                }
            }
        }

        // Reset list cache
        if($mybb->settings['allowlistmycode'] == 1)
        {
            $this->list_elements = array();
            $this->list_count = 0;

            // Find all lists
            $message = preg_replace_callback("#(\[list(=(a|A|i|I|1))?\]|\[/list\])#si", array($this, 'mycode_prepare_list'), $message);

            // Replace all lists
            for($i = $this->list_count; $i > 0; $i--)
            {
                // Ignores missing end tags
                $message = preg_replace_callback("#\s?\[list(=(a|A|i|I|1))?&{$i}\](.*?)(\[/list&{$i}\]|$)(\r\n?|\n?)#si", array($this, 'mycode_parse_list_callback'), $message, 1);
            }
        }

        $message = $this->mycode_auto_url($message);

        return $message;
    }

當服務端接收到你發送的內容時,首先會處理解析[img]相關的標簽語法,然后如果開啟了$this->options['allow_videocode'](默認開啟),那么開始解析[video]相關的語法,然后是[list]標簽。在488行開始,會對[url]等標簽做相應的處理。

if($this->mycode_cache['callback_count'] > 0)
    {
        foreach($this->mycode_cache['callback'] as $replace)
        {
            $message = preg_replace_callback($replace['find'], $replace['replacement'], $message);
        }
    }

我們把上面的流程簡單的具象化,假設我們在內容中輸入了

[video=youtube]youtube.com/test[/video][url]test.com[/url]

后臺會首先處理[video],然后內容就變成了

<iframe src="youtube.com/test">[url]test.com[/url]

然后會處理[url]標簽,最后內容變成

<iframe src="youtube.com/test"><a href="test.com"></a>

乍一看好像沒什么問題,每個標簽內容都會被拼接到標簽相應的屬性內,還會被htmlspecialchars_uni處理,也沒辦法逃逸雙引號的包裹。

但假如我們輸入這樣的內容呢?

[video=youtube]http://test/test#[url]onload=alert();//[/url]&amp;1=1[/video]

首先跟入到函數/inc/class_parse.php line 1385行 mycode_parse_video

鏈接經過parse_url處理被分解為

array (size=4)
  'scheme' => string 'http' (length=4)
  'host' => string 'test' (length=4)
  'path' => string '/test' (length=5)
  'fragment' => string '[url]onmousemove=alert();//[/url]&amp;1=1' (length=41)

然后在1420行,各個參數會被做相應的處理,由于我們必須保留=號以及/ 號,所以這里我們選擇把內容放在fragment中。

在1501行case youtube中,被拼接到id上

case "youtube":
    if($fragments[0])
    {
        $id = str_replace('!v=', '', $fragments[0]); // http://www.youtube.com/watch#!v=fds123
    }
    elseif($input['v'])
    {
        $id = $input['v']; // http://www.youtube.com/watch?v=fds123
    }
    else
    {
        $id = $path[1]; // http://www.youtu.be/fds123
    }
    break;

最后id會經過一次htmlspecialchars_uni,然后生成模板。

$id = htmlspecialchars_uni($id);

eval("\$video_code = \"".$templates->get("video_{$video}_embed", 1, 0)."\";");
return $video_code;

當然這并不影響到我們上面的內容。

到此為止我們的內容變成了

<iframe width="560" height="315" src="//www.youtube.com/embed/[url]onload=alert();//[/url]" frameborder="0" allowfullscreen></iframe>

緊接著再經過對[url]的處理,上面的內容變為

<iframe width="560" height="315" src="//www.youtube.com/embed/<a href="http://onload=alert();//" target="_blank" rel="noopener" class="mycode_url">http://onload=alert();//</a>" frameborder="0" allowfullscreen></iframe>

我們再把前面的內容簡化看看,鏈接由

[video=youtube]http://test/test#[url]onload=alert();//[/url]&amp;1=1[/video]

變成了

<iframe src="//www.youtube.com/embed/<a href="http://onload=alert();//"..."></iframe>

由于我們插入在iframe標簽中的href被轉變成了<a href="http://onload=alert();//">, 由于雙引號沒有轉義,所以iframe的href在a標簽的href中被閉合,而原本的a標簽中的href內容被直接暴露在了標簽中,onload就變成了有效的屬性!

最后瀏覽器會做簡單的解析分割處理,最后生成了相應的標簽,當url中的鏈接加載完畢,標簽的動作屬性就可以被觸發了。

管理員后臺文件創建漏洞

在Mybb的管理員后臺中,管理員可以自定義論壇的模板和主題,除了普通的導入主題以外,他們允許管理員直接創建新的css文件,當然,服務端限制了管理員的這種行為,它要求管理員只能創建文件結尾為.css的文件。

/admin/inc/functions_themes.php line 264

function import_theme_xml($xml, $options=array())
{
    ...
    foreach($theme['stylesheets']['stylesheet'] as $stylesheet)
    {
        if(substr($stylesheet['attributes']['name'], -4) != ".css")
        {
            continue;
        }
        ...

看上去好像并沒有什么辦法繞過,但值得注意的是,代碼中先將文件名先寫入了數據庫中。

緊接著我們看看數據庫結構

我們可以很明顯的看到name的類型為varchar且長度只有30位。

如果我們在上傳的xml文件中構造name為tttttttttttttttttttttttttt.php.css時,name在存入數據庫時會被截斷,并只保留前30位,也就是tttttttttttttttttttttttttt.php.

<?xml version="1.0" encoding="UTF-8"?>

<theme>
    <stylesheets>
        <stylesheet name="tttttttttttttttttttttttttt.php.css">
            test
        </stylesheet>
    </stylesheets>

</theme>

緊接著我們需要尋找一個獲取name并創建文件的地方。

在/admin/modules/style/themes.php 的1252行,這個變量被從數據庫中提取出來。

theme_stylesheet 的name作為字典的鍵被寫入相關的數據。

$mybb->input['do'] == "save_orders"時,當前主題會被修改。

在保存了當前主題之后,后臺會檢查每個文件是否存在,如果不存在,則會獲取name并寫入相應的內容。

可以看到我們成功的寫入了php文件

完成的漏洞復現過程

儲存型xss

找到任意一個發送信息的地方,如發表文章、發送私信等....

發送下面這些信息

[video=youtube]http://test/test#[url]onload=alert();//[/url]&amp;amp;1=1[/video]

然后閱讀就可以觸發

管理員后臺文件創建漏洞

找到后臺加載theme的地方

構造上傳文件test.xml

<?xml version="1.0" encoding="UTF-8"?>

<theme>
    <stylesheets>
        <stylesheet name="tttttttttttttttttttttttttt.php.css">
            test
        </stylesheet>
    </stylesheets>

</theme>

需要注意要勾選 Ignore Version Compatibility。

然后查看Theme列表,找到新添加的theme

然后保存并訪問相應tid地址的文件即可

補丁

儲存型xss

這里的iframe標簽的鏈接被encode_url重新處理,一旦被轉義,那么[url]就不會被繼續解析,則不會存在問題。

管理員后臺文件創建漏洞

在判斷文件名后綴之前,加入了字符數的截斷,這樣一來就無法通過數據庫字符截斷來構造特殊的name了。

寫在最后

整個漏洞其實說到實際利用來說,其實不算太苛刻,基本上來說只要能注冊這個論壇的賬號就可以構造xss,由于是儲存型xss,所以無論是發送私信還是廣而告之都有很大的概率被管理員點擊,當管理員觸發之后,之后的js構造exp就只是代碼復雜度的問題了。

拋開實際的利用不談,這個漏洞的普適性才更加的特殊,bbcode是現在主流的論壇復雜環境的解決方案,事實上,可能會有不少cms會忽略和mybb一樣的問題,畢竟人才是最大的安全問題,當人自以為是理解了機器的一切想法時,就會理所當然得忽略那些還沒被發掘的問題,安全問題,也就在這種情況下悄然誕生了...


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