如果你还没听说过SSTI(服务端模版注入),或者对其还不够了解,在此之前建议大家去阅读一下James Kettle写的一篇 文章 。
作为一名专业的安全从事人员,我们的工作便是帮助企业组织进行风险决策。及时发现产品存在的威胁,漏洞对产品带来的影响是无法精确计算。作为一名经常使用Flask框架进行开发的人来说,James的研究促使我下定决心去研究在使用Flask/Jinja2框架进行应用开发时服务端模版注入的一些细节。
为了准确评估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的踪迹了。
接下来有得我们忙的了,下一步我们便深入模版上下文并探寻攻击者会如何通过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
内置模版,将他们添加到模板上下文我们便能够通过该漏洞使用这些内置模板进行内省。
短暂的暂停,我们来谈谈文档中对于模板上下文的描述。
Jinja globals
Flask template globals
由开发者添加的素材资料
我们最关心的是第一条和第二条,因为他们通常情况下都是默认值,Flask/Jinja2框架下存在SSTI漏洞应用中的任何地方都可以进行利用。第三条取决于应用程序并且实现的方法太多, stackoverflow讨论 中就有几种方法。在本文中我们不会对第三条进行深入探讨,但是在对Flask/Jinja2框架的应用进行静态源代码分析的时候还是很值得考虑的。
为了继续内省,我们应该:
阅读文档
使用 dir
内省 locals
对象来查看所有能够使用的模板上下文
使用 dir
和 help
.深入所有对象
分析感兴趣的Python源代码(毕竟框架都是开源的)
通过内省 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))
我们看到,如果将字符串对象传递给 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])
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()
以下为脚本在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> ...
至此,我们运用我们之前的方法论,祈求能够寻到突破模版沙盒的方法。
通过这些条目我没能找到突破模版沙盒的方法,但为了共享研究在下面的附加信息中我会把一些十分接近的方法放出来。需要注意的是,我还没有尝试完所有的可能性,所以仍然有进一步研究的意义。
我们有可能使用 ftplib.FTP
对象连接到一个我们控制的服务器,并向服务器上传文件。我们也可以从服务器下载文件并使用 config.from_pyfile
方法对内容进行正则表达式的匹配。分析 ftplib
文档和源代码得知 ftplib
需要打开文件处理器,并且由于在模版沙盒中内置的 open
是被禁用的,似乎没有办法创建文件处理器。
这里我们可能在本地文件系统中使用文件协议处理器 file://
,那就可以使用 httplib.HTTP
对象来加载文件的URL。不幸的是, httplib
不支持文件协议处理器。
当然我们也可能会用到 xml.etree.ElementTree.XML
对象使用用户定义的字符实体从文件系统中加载文件。然而,就像在 Python文档 中看到 etree
并不支持用户定义的字符实体
即使我们没能找到突破模版沙盒的方法,但是对于Flask/Jinja2开发框架下SSTI的影响已经有进展了,我确信那层薄纱就快被掀开。
*参考来源: nvisium ,鸢尾编译,转载请注明来自FreeBuf黑客与极客(FreeBuf.COM)