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 框架。
依旧推荐官方文档,见资料 1
作为一门模板语言,肯定有自己的一套语法规则。
jinja2 的基础语法规则一共 3 种:
{% %},也可以用来声明变量({% set c = "1" %}){{ }},比如输入 1+1,2*2,或者是字符串、调用对象的方法,都会渲染出执行的结果{# #}对于这三种语法,看一个例子就懂了:
1 | |
结果就是输出 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 | |
macro 后面跟函数名与参数,调用方法也很 Pythonic。
文档见资料 5
模板继承允许我们创建一个骨架文件,其他文件从该骨架文件继承。并且还支持针对自己需要的地方进行修改。
jinja2 的骨架文件中,利用 block 关键字表示其包涵的内容可以进行修改。这里直接举例吧:
这个是骨架文件:base.html
1 | |
bbb.html 继承 base.html 的模板:
1 | |
渲染:
1 | |
这里用到了 FileSystemLoader,其实用它的逻辑很简单。我们在 bbb.html 中写了 {% extends "base.html" %},那 jinja2 怎么知道 base.html 在哪呢?FileSystemLoader 就是用来指定模板文件位置的。同样用途的还有 PackageLoader,它是用来指定搜索哪个 Python 包下的模板文件,以及其他,见资料 6
jinja2 中如果不加换行的话,可读性很差,如果加了的话,渲染后又会有多余的空格。我们可以在标签的前后加上 -,这样就不会有空格了:
1 | |

由于模板语法对 Python 语句是有一定程度支持的,所以利用 {% %} 就可以非常轻松地进行攻击,例如:
1 | |
或者知道 index 的话,直接打:
1 | |
这一部分与资料 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,按照资料 2 中的思路([ ] 扣字符拼接、chr 等等)即可(这里面有的姿势我就不列举了,如果不记得的话强烈建议复习一遍)。
其实扣字符可以做的非常细,以至于理论上我们可以扣出所有的字符或者数字(其实还是资料 2 中的思路,姿势很多,以下只举例):
{{ {}|int }}、{{ {}|length }}{{ ({}|int)**({}|int) }}+ 或者是 -+|abs{{ {}|center|last }}、{1:1}|xmlattr|first<:{}|select|string|first>:{}|select|string|last{{ self|float|string|min }} 或者 c.__lt__|string|truncate(3)|firsta-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 | |
如果你觉得每次调用都需要这样写,太麻烦了,payload 也冗余,那么结合 {% set ... %} 就可以这么玩:
1 | |
那结合宏就可以这么玩:
1 | |
当然啦,由于 jinja2 中有自己的一些内置变量等,所以会有一些资料 2 之外的姿势。例如利用 Undefined 实例可以直接拿到 __globals__:
1 | |
所以就可以有:
x.__init__.__globals__.__builtins__
x.__init__.__globals__.__builtins__.evalx.__init__.__globals__.__builtins__.execx.__init__.__globals__.sys.modules.osx.__init__.__globals__.__builtins__.__import__通过查阅源码或者文档可知,默认命名空间自带这几种函数

或者用 self.__dict__._TemplateReference__context 也可以看到。
所以就有:
self.__init__.__globals__lipsum.__globals__.oscycler.__init__.__globals__.osjoiner.__init__.__globals__.osnamespace.__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 也支持取属性,所以可以这样:
1 | |
[ ]分为两种情况:
__getitem__、pop 等等)之外,如果是字典,那么利用 bypass 过滤 . 中的结论,可以用 . 来代替 [key]:{{ {"a": 1}.a }}[] 就好了。在中括号被过滤的情况下,则可以使用 slice 来替代。例如 "1"|slice(1)|list 与 [['1']] 是等价的。{{ }}这种情况下寻找其他语法就行了。比如 {% %}。
{% macro %} 就不提了。
{% print(...) %}:
1 | |
还有 {% if %}、{% set %}、{% for %} ... 都是可以执行命令的,只是无回显。如果非要有回显,利用盲注的思想,可以走带外通道。怎么盲注?见资料 2,原理是一样的,这里就不啰嗦了。
比如时间盲注:
1 | |
当然这种语法本身也可以盲注,比如布尔盲注:
1 | |
甚至可以搞基于报错的盲注:
1 | |
因为对于 jinja2 来说,索引过大是返回 Undefined,不会报错。当然啦,如果把上面的 [1] 换成 [[1]] 就变成了布尔盲注。
姿势主要有三种:
[]|string 等同于 []|format。这种方式主要依赖于使用过滤器的目的,不太好列出统一的替换规则,所以我就不一一列举了。熟悉各种过滤器的作用是这种 bypass 的前提。[] 嵌套 + map() 来使用。例如 []|string 等同于 [[]]|map("str""ing")|list|last。这种姿势相对来说通用,遗憾的是,无法带参数使用过滤器。self.__dict__._TemplateReference__context 中包含了内置的全局函数,可以直接用。除了用上面提到的常规姿势之外:
这个其实也在资料 2 中提及过。
例如 whoami:
{{ dict(whoami=x)|join }}{{ dict(who=x,ami=x)|join }}{{ dict(whoami=x)|list|first }}{{ dict(whoami=x)|items|list|first|first }}艾玛,真香哎
如果遇到特殊字符,再用常规姿势就好了。
如果漏洞点无法支持 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 | |
最后,Python 中有个,GenericAlias 对象充当泛型类型的代理,实现参数化泛型。对于容器类,提供给该类的参数可指示对象包含的元素的类型。
可支持参数化泛型的类可参考资料 11
到这里,我们就可以发现,打印 list[int] 的结果是 list[int],它其实是一个 GenericAlias:

那自然,这样也是 ok 的:list["whoami"],加上 jinja2 的 . 的特殊性,我们就用了一种船新的玩法。比如可以这样执行 whoami,不带有引号:
1 | |
不过由于语法的限制,这样是无法加上空格以及特殊字符(/、.、( ) 等)的,所以没法直接带参数或者带一些特殊的字符执行。
所以为了实用一些,例如 cat th1s_1s_a_lo0o0o0o0o0o0o0ng_fl4g,还是得用到其他获取字符的手段,比如 chr:
1 | |
长度为 243。
或者是扣字符拼接:
1 | |
这个长度为 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 | |
自然,这个技巧只有 > py3.9 才可以使用。
从上面可以看出,这个姿势要实用,很依赖 .,如果这个被干掉了那就要换其他的姿势了。
jinja2 最常见的搭档就是 flask 了。由于 flask 会引入新的变量,所以也会引入新的姿势。这一部分正如本文开头所述,jinja2 的 SSTI 与 Flask 关联紧密,这一部分我放在了 《SecMap - Flask》 中。
实际上,jinja2 有自带的,独立于 Python 的沙盒环境。默认沙盒环境在解析模板时会检查所操作的属性,这种检查是运行时的检查,绕过是比较困难的。也就是说即使模板内容被用户所控制,也是无法绕过沙盒执行代码或者获取敏感信息的。
但在历史上,jinja2 < v2.8.1 由于没有考虑到 format 可触发字符串格式化漏洞,导致沙盒可以被绕过:
1 | |
上面所述的思路,都是局限在 Template 中的,因为我们要思考攻击场景,没有攻击场景的 payload 用处是很有限的。
但是,如果我们在写一些自动化的脚本,用来扫描或者是搜索变量,难免也会用到一些内置函数(例如 dir),通过上面这些思路利用 SSTI 去获取内置函数当然是可以的,但其实 render 本身是可以指定参数传递给模板使用的,所以不限于攻击,可以这样:
1 | |
另外再说一嘴,render 返回的固定是字符串,如果我们想获取变量示例,如果是个字符串或者列表之类的基本类型,那倒简单,直接 eval 下就好。如果是简单一些示例,可以考虑用 pickle 来玩。但是如果是一些非常规的实例,应该怎么拿到呢?例如 self 这个内置变量:
1 | |
这个时候我们其实可以通过 __main__ 来存储模板里的变量,这个办法应该是最完美的了:
1 | |
防御这种漏洞肯定不能用关键字过滤,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但是通过一些变量来拿敏感信息,还是有搞头的,我们后面再讲。
OrangeKiller CTF 第 1 期题解
jinjia2 se
1 | |
jinjia2 plus
1 | |
jinjia2 pro
1 | |
jinjia2 pro max
1 | |
这个 payload 应该还有优化的空间,留给橘友们研究吧~
当然,上面这几题用 1}} {{ payload }} {{1` 这样来闭合外层的 `{{ }},再用 {% set %} 之类的也是 ok 的。
SSTI 这个系列的知识点真是越整理越多 开摆!
罢了,拖更就好了