探索Flask/Jinja2中的服务端模版注入(一)

如果你还没听说过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 %%}
    

Oops! That page doesn't exist.

%s

{%% endblock %%} ''' % (request.url) return render_template_string(template), 404

 

这段代码的背后场景应该是开发者愚蠢的认为这个404页面有一个单独的模版文件, 所以他在404 view函数中创建了一个模版字符串。这个开发者希望如果产生错误,就将该URL反馈给用户;但却不是经由render_template_string函数将URL传递给模版上下文,该开发者选择使用字符串格式化将URL动态添加到模版字符串中,这么做没错对吧?卧槽,这还不算我见过最糟糕的。

运行该功能,我们应该可以看到以下预期效果

探索Flask/Jinja2中的服务端模版注入(一)_第1张图片

大多数朋友看到以下发生的行为立刻就会在脑子中想到XSS,当然他们的想法是正确的。在URL后面增加会触发一个XSS漏洞。

探索Flask/Jinja2中的服务端模版注入(一)_第2张图片

目标代码存在XSS漏洞,并且如果你阅读James的文章之后就会知道,他曾明确指出XSS极有可能是存在SSTI的一个因素,这就是一个很棒的例子。但是我们通过在URL后面增加{{ 7+7 }}在深入的去了解下。我们看到模版引擎将数学表达式的值已经计算出来

探索Flask/Jinja2中的服务端模版注入(一)_第3张图片

在目标应用中我们已经发现SSTI的踪迹了。

Analysis

接下来有得我们忙的了,下一步我们便深入模版上下文并探寻攻击者会如何通过SSTI漏洞攻击该应用程序。以下为我们修改过后的存在漏洞的view函数:

 

@app.errorhandler(404)
def page_not_found(e):
    template = '''{%% extends "layout.html" %%}
{%% block body %%}
    

Oops! That page doesn't exist.

%s

{%% endblock %%} ''' % (request.url) return render_template_string(template, dir=dir, help=help, locals=locals, ), 404

 

调用的render_template_string现在包含dirhelplocals 内置模版,将他们添加到模板上下文我们便能够通过该漏洞使用这些内置模板进行内省。

短暂的暂停,我们来谈谈文档中对于模板上下文的描述。

 

Jinja globals

Flask template globals

由开发者添加的素材资料

 

我们最关心的是第一条和第二条,因为他们通常情况下都是默认值,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() }}有效载荷。

探索Flask/Jinja2中的服务端模版注入(一)_第4张图片

最有趣的还是从内省config对象时发现的,虽然config是一个类字典对象,但它的子类却包含多个独特的方法:from_envvarfrom_objectfrom_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漏洞的应用中,注意当前配置条目!

探索Flask/Jinja2中的服务端模版注入(一)_第5张图片

之后注入{{ config.from_object('os') }}。这会向config对象添加os库中所有大写变量的属性。再次注入{{ config.items() }}并注意新的配置条目,并且还要注意这些配置条目的类型。

探索Flask/Jinja2中的服务端模版注入(一)_第6张图片

现在我们可以通过SSTI漏洞调用所有添加到config对象里的可调用条目。下一步我们要从可用的引用模块中寻找能够突破模版沙盒的函数。

下面的脚本重现from_objectimport_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               => 
...
[ctypes] PYFUNCTYPE              => 
...
[distutils.archive_util] ARCHIVE_FORMATS => {'gztar': (, [('compress', 'gzip')], "gzip'ed tar-file"), 'ztar': (, [('compress', 'compress')], 'compressed tar file'), 'bztar': (, [('compress', 'bzip2')], "bzip2'ed tar-file"), 'zip': (, [], 'ZIP file'), 'tar': (, [('compress', None)], 'uncompressed tar file')}
...
[ftplib] FTP                     => 
[ftplib] FTP_TLS                 => 
...
[httplib] HTTP                            => 
[httplib] HTTPS                           => 
...
[ic] IC => 
...
[shutil] _ARCHIVE_FORMATS => {'gztar': (, [('compress', 'gzip')], "gzip'ed tar-file"), 'bztar': (, [('compress', 'bzip2')], "bzip2'ed tar-file"), 'zip': (, [], 'ZIP file'), 'tar': (, [('compress', None)], 'uncompressed tar file')}
...
[xml.dom.pulldom] SAX2DOM                => 
...
[xml.etree.ElementTree] XML        => 
[xml.etree.ElementTree] XMLID      => 
...

 

至此,我们运用我们之前的方法论,祈求能够寻到突破模版沙盒的方法。

通过这些条目我没能找到突破模版沙盒的方法,但为了共享研究在下面的附加信息中我会把一些十分接近的方法放出来。需要注意的是,我还没有尝试完所有的可能性,所以仍然有进一步研究的意义。

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的影响已经有进展了,我确信那层薄纱就快被掀开。

你可能感兴趣的:(技术分享,框架漏洞)