SSTI漏洞
SSTI漏洞
模板引擎
模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,利用模板引擎来生成前端的html代码,模板引擎会提供一套生成html代码的程序,然后只需要获取用户的数据,然后放到渲染函数里,然后生成模板+用户数据的前端html页面,然后反馈给浏览器,呈现在用户面前。
SSTI
SSTI 就是服务器端模板注入(Server-Side Template Injection)
当前使用的一些框架,比如python的flask,php的tp,java的spring等一般都采用成熟的的MVC的模式,用户的输入先进入Controller控制器,然后根据请求类型和请求的指令发送给对应Model业务模型进行业务逻辑判断,数据库存取,最后把结果返回给View视图层,经过模板渲染展示给用户。
漏洞成因就是服务端接收了用户的恶意输入以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。其影响范围主要取决于模版引擎的复杂性。
凡是使用模板的地方都可能会出现 SSTI 的问题,SSTI 不属于任何一种语言,沙盒绕过也不是,沙盒绕过只是由于模板引擎发现了很大的安全漏洞,然后模板引擎设计出来的一种防护机制,不允许使用没有定义或者声明的模块,这适用于所有的模板引擎。
Python中的SSTI
python常见的模板有:Jinja2,tornado。
Jinja2是一种面向Python的现代和设计友好的模板语言,它是以Django的模板为模型的
Jinja2是Flask框架的一部分。Jinja2会把模板参数提供的相应的值替换了 块
Jinja2使用 结构表示一个变量,它是一种特殊的占位符,告诉模版引擎这个位置的值从渲染模版时使用的数据中获取。
语法:
{{ ... }} 是执行表达式并把结果插入页面的位置
{% ... %} 用来声明变量,也可以用于循环语句和条件语句
这边使用vulhub提供的环境进行复现,搭建成功后访问首页如图:
进入docker容器来看一下web代码:
1 | |
t = Template(“hello” + name) 这行代码表示,将前端输入的name拼接到模板,此时name的输入没有经过任何检测,尝试使用模板语言测试:

如果使用一个固定好了的模板,在模板渲染之后传入数据,就不存在模板注入,就好像SQL注入的预编译一样,修复上面代码如下:
1 | |
编译运行,再次注入就会失败

之前写的是:
1
2t = Template("Hello " + name)
return t.render()这会把用户输入直接拼进模板字符串,如果用户输入带有
{{ ... }},就会被当作代码执行,导致 SSTI 漏洞。现在这段代码:
1
2t = Template("Hello {{n}}")
return t.render(n=name)模板字符串是固定的,变量值通过参数传入,Jinja2 只会把变量内容作为纯文本插入,不会执行其中的代码。
由于在jinja2中是可以直接访问python的一些对象及其方法的,所以可以通过构造继承链来执行一些操作,比如文件读取,命令执行等:
1 | |
用file对象读取文件(python2)
python2在线编译:Python2 在线工具 | 菜鸟工具
代码解析
1 | |
这段代码用 Python 写的,主要目的是通过 Python 内置类型的反射机制,查找并使用文件(file)类,打开并读取名为 joker.txt 的文件内容。下面详细解释:
{}.__class__

{}是一个空字典,类型是dict。{}.__class__就是<class 'dict'>,即字典的类对象。
2.{}.__class__.__base__
dict的基类(父类),通常是object。也就是获取字典的父类。

{}.__class__.__base__.__subclasses__()
object类有个方法__subclasses__(),返回所有直接继承自它的子类列表。- 这段代码用
object这个基类调用__subclasses__(),获取系统中所有继承自object的类列表。
1 | |
1 | |
可以看到file类是第40个。
for c in ...
- 遍历所有的
object的子类。
if(c.__name__=='file')
- 判断当前类的名字是否是字符串
'file'。 - 这里想找到名为
file的类。
print(c)
- 打印该类对象。
print c('joker.txt').readlines()
- 实例化该类,传入
'joker.txt'作为参数,等同于打开joker.txt文件。 - 调用
readlines()方法,读取文件所有行并返回列表。 - 然后打印读取的内容。
使用jinja2的语法封装成可解析的样子:
1 | |
{% for ... %} 和 {% if ... %} 是控制流标签
{{ ... }}:模板输出值的位置
也可以写成:
1 | |
代码解释:
[40]("joker.txt"):取出第 40 个类,并实例化它
.readlines():读取内容
用内置模块执行命令
通过 __globals__ 去利用别人已经导入的 os。
上面的实例中我们使用__subclasses__()把内置的对象列举出来,其实可以用__globals__更深入的去看每个类可以调用的东西(包括模块,类,变量等等),如果有os这种可以直接传入命令,造成命令执行
os模块:因为 Python 的 os 模块是对 操作系统功能 的封装,其中包含了调用系统命令、文件操作、环境控制等能力。一旦你能访问 os 模块,就可以做几乎任何事情——也就是所谓的“任意命令执行”。
即使你不能 import os,你可以通过 __globals__ 去利用别人已经导入的 os。
我们可以继续深入拿到函数类的 __globals__,找到 os 模块,然后用 os.popen("whoami").read() 远程执行命令。
1 | |

解释代码
search = 'os'
目标字符串,表示你想在类中查找是否有 os 模块被引用。你也可以换成其他模块名,比如 sys、subprocess 等。
if search in i.__init__.__globals__.keys():
尝试获取该类构造函数 __init__ 的 __globals__,也就是这个函数定义时的全局变量字典。检查有没有 search 指定的模块名(例如 'os')在这个类定义时被引用。
print(i, num)
如果找到了引用,打印这个类和它的序号。
except: pass
防止报错(有些类没有 __init__,或者 __init__ 不是 Python 函数对象),就跳过。
可以看到在元组61,76的位置找到了os模块,这样就可以构造命令执行payload:
1 | |
拆解分析:
1 | |
只要这个类的 __init__ 在全局作用域里导入了 os,你就能通过它拿到 os 模块。
于是执行:
1 | |
不过同样,只能在python2版本使用,这时候就要推荐__builtins__
使用__builtin__ 模块导入模块
在 python 中,不引入直接使用的内置函数被称为 builtin 函数,随着 __builtin__ 这个模块自动引⼊到环境中。
我们如何引入模块呢?
⼀个模块对象有⼀个由字典对象实现的命名空间,属性的引用会被转换为这个字典中的查找:
字典访问属性访问简单定义
| 访问方式 | 示例 | 说明 |
|---|---|---|
| 属性访问 | obj.attr |
使用「点」. 来访问对象的属性 |
| 字典访问 | obj['key'] |
使用方括号 [] 来通过键访问字典的值 |
- 两者在语法和底层行为上是不同的。
我们先把含有__builtins__模块的内置函数使用字典都列出来:
1 | |
1 | |
可以发现每个都是字典形式:我们通过__builtins__['__import__'] 来访问__import__模块
然后导入 os 模块:__builtins__['__import__']('os')
这时候我们的命令执行payload就出来了:
__builtins__.__dict__['__import__']('os').system('whoami')
1 | |
通过函数 eval 和 exec导入模块:
还有另一种导入import模块的方法:
通过函数导入模块 eval 和 exec:
1 | |
1 | |
基础payload
获取基本类
1 | |
1.利用os 模块的 popen 函数和 system 函数
1 | |
1 | |
2.利用subprocess
1 | |
1 | |
1 | |
3.利用lipsum内置函数
在 Jinja2 模板中,lipsum 通常是内置的函数(例如在 Flask 中你使用 render_template() 并启用 jinja2.ext.i18n 或 jinja2.ext.do 扩展时),你可能会看到 lipsum 可用于生成虚拟文本(lorem ipsum)。
常见绕waf的姿势
SSTI模板注入Plus | Bypass - h0cksr - 博客园
绕过特殊字符串过滤:
逆序:
如绕过 os 过滤,可以⽤字符串的变化来引⼊ os
1 | |
利⽤ eval 或者 exec ,结合字符串倒序
1 | |
1 | |
拼接
1 | |
1 | |
编码处理
base64 、 hex 、 rot3 、 unicode 、 oct 、字符串拼接等
| 示例 | 技术分类 | 说明 |
|---|---|---|
['__builtins__'] |
直接字符串 | 正常方式访问 |
['\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f'] |
十六进制转义 | 每个字符用 \xNN 表示 |
[u'\u005f\u005f\u0062\u0075\u0069\u006c\u0074\u0069\u006e\u0073\u005f\u005f'] |
Unicode 转义 | 每个字符用 \uXXXX 表示 |
['X19idWlsdGluc19f'.decode('base64')] |
base64 解码 | '__builtins__' 的 base64 编码是 X19idWlsdGluc19f |
['__buil'+'tins__'] |
字符串拼接 | 分段拼接 |
['__buil''tins__'] |
Python 自动连接字面值 | 两个字符串紧挨着 Python 会自动连接 |
['__buil'.__add__('tins__')] |
使用字符串对象方法 | 等价于 '__buil' + 'tins__' |
["_builtins_".join("__")] |
利用 join 拼接 |
结果为 _builtins___builtins_(这个可能是示例写错) |
['%c%c%c%c%c%c%c%c%c%c%c%c' % (95, 95, 98, 117, 105, 108, 116, 105, 110, 115, 95, 95)] |
格式化输出字符 | %c 将数字格式化成 ASCII 字符,得到 '__builtins__' |
16进制编码转换:
1 | |
popen代替system
如果删除了 system 这个函数,我们可以寻找其他进⾏命令执行的函数,如 popen:
1 | |
使用getattr获取对象的属性
getattr() 是 Python 的内置函数,用于根据字符串动态获取对象的属性
可以通过 getattr拿到对象的方法、属性:
1 | |
等价于:
1 | |
字符的绕过
魔法函数绕过[]
获取键值的本质是调用魔法函数__getitem__()
所以可以使用__getitem__()替代中括号取键值当中括号被过滤时,可以使用__getitem__()代替[],实现绕过。
此外对于字典对象的话还可使用pop()函数得到键值,还有其他一些方法如下
1 | |
过滤引号
chr函数
python2:
第一行:获取内置函数 chr
第二行:构造路径并使用file对象读取文件
1 | |
request对象
1 | |
这个 payload:
1 | |
配合访问方式:
1 | |
dict() 拿键
1 | |
payload1:
dict(whoami=1)会构造一个字典:{'whoami': 1}list(...)会把字典的 键 转换成列表:['whoami'][0]取第一个键,也就是'whoami'
payload2:
dict(whoami=1)还是构造字典{'whoami': 1}str(...)转换成字符串(格式可能是:"{'whoami': 1}",注意不同 Python 版本可能有空格)[2:8]是字符串切片,从第3个字符到第8个字符
str 和 [] 结合
1 | |
().__class__ → 是 object,因为 () 是个空元组,元组是类 tuple 的实例,而 tuple 的基类是 object。
().__class__.__new__ → 得到的是 object.__new__ 这个方法。
str(...) → 把这个方法变成字符串(通常是其描述形式):
输出类似于:
1 | |
用索引 [21], [13] 等,是在对这个字符串做 字符索引切片操作,手动“拼接”字符串 "whoami"。
1 | |
空格
通过 () 、 [] 替换
()
利⽤装饰器 @
利⽤魔术⽅法,例如
enum.EnumMeta.__getitem__,一般题目直接过滤小括号的话那可以直接考虑flag在当前app的环境变量中了
TGCTF2025直面天命(复仇)

查看源代码跳转/hint路由,然后跳转到 //aazz路由 ,查看源代码:
1 | |
黑名单:
1 | |
需要1.不含黑名单单词2.所有字符均为可打印字符3.包含完整secret_key才能获取到flag
secret_key为天命 难违,天命被替换为{{`,`难违`被替换为`}}。
我们最后需要把payload写进天命 难违之间。
构造payload:
看有没有哪些类可以用
1 | |
waf里有._ bases
1 | |

subprocess.Popen 是一个类,能更灵活地启动子进程:

把,替换成\n复制到VScode,

可以看到是第352个类。
[][“\x5f\x5fclass\x5f\x5f”]
1 | |

可以看到flag所在的目录:
1 | |
1 | |

也可以用16进制和[]绕过
1 | |
1 | |
polarCTF ghost_render
题目写了渲染再上传到,md里写{{7*7}},界面渲染结果是49

抓包直接传{{[].__class__.__bases__[0].subclasses__()}}

可以看到第280个类是:subprocess

[][“\x5f\x5fclass\x5f\x5f”]
1 | |

根目录下没有,在/var里面:

flag在/var/secret_flag

在这篇里发现了另一个可用的payload[有效载荷/服务器端模板注入/python.md在主人·swisskyrepo/paryloadsallthethings·github](https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/Server Side Template Injection/Python.md?ref=blog.qz.sg#jinja2—remote-command-execution)
1 | |
1 | |

1 | |

使用焚靖
把源代码中黑名单写入waf.txt,删除{ }用__WAF绕关键字攻击__的方法读取关键字黑名单并生成绕过payload。
1 | |
1 | |
得到的payload:
1 | |
把{{` ` }}分别替换为keywords就可以了。
1 | |
