ARTICLE · 1205022
第十五章 服务端模板注入 SSTI
——为什么本来只是“页面里的一个名字”,最后却可能被服务器当成模板程序执行
前面几章已经连续接触了多种“解释器边界”:
SQL 注入用户输入↓SQL 解释器XSS用户输入↓浏览器 HTML / JavaScript 解释环境命令注入用户输入↓Shell路径穿越用户输入↓文件系统路径语义
这一章继续研究另一个非常常见、也非常容易被忽视的解释器:
Template Engine模板引擎
正常情况下,服务器应该这样工作:
开发者编写的模板+用户提供的数据↓模板引擎↓最终 HTML
例如:
<h1>Hello {{ name }}</h1>
服务器传入:
name = Alice
最终:
<h1>Hello Alice</h1>
这是正常的。
真正危险的是:
用户输入↓被当作模板源码↓模板引擎解析
例如开发者错误地把:
Hello {{ 7 * 7 }}
整个字符串交给模板引擎解释。
此时:
{{ 7 * 7 }}
就不再是普通文字。
模板引擎可能真的计算:
49
这就是:
SSTIServer-Side Template Injection服务端模板注入
本章只使用:
127.0.0.1自己的 Python / Flask 环境自己的 Docker / VMCTF明确授权靶场
进行无害实验。
15.1 什么是模板
Web 页面里有很多内容是固定的:
<html><head> <title>Profile</title></head><body> <h1>Hello Alice</h1></body></html>
但:
Alice
会随着用户变化。
开发者不希望:
Alice写一份HTMLBob再写一份HTMLCarol再写一份HTML
于是出现:
Template模板
15.2 一个最简单的模板
例如:
<h1>Hello {{ name }}</h1>
其中:
<h1>Hello
是固定结构。
而:
{{ name }}
表示:
动态数据
15.3 模板渲染
服务器:
Template:<h1>Hello {{ name }}</h1>Data:name = Alice
模板引擎:
Render
得到:
<h1>Hello Alice</h1>
这个过程叫:
Template Rendering模板渲染
15.4 模板引擎是什么
Template Engine:
模板引擎
负责:
读取模板解析模板语法取得变量执行有限逻辑生成最终文本
最常见输出:
HTML
但也可能生成:
Email配置文件SQL文本MarkdownXML普通文本
15.5 常见模板引擎
Python:
Jinja2Django TemplatesMako
PHP:
TwigSmartyBlade
Java:
FreeMarkerVelocityThymeleaf
JavaScript / Node:
EJSPugHandlebarsNunjucks
.NET:
RazorScriban
不同模板引擎:
语法对象模型安全机制能力
都不一样。
15.6 模板本质上也是一种语言
例如 Jinja2:
{{ name }}
表示:
输出变量
还可以:
{% if logged_in %} Welcome{% endif %}
这已经包含:
变量表达式判断循环过滤器函数
所以:
模板不是普通字符串,它是一种需要解释器处理的语言。
15.7 这和上一章非常像
命令注入:
用户数据↓Shell↓被当命令语法
SSTI:
用户数据↓Template Engine↓被当模板语法
共同问题:
Data↓Code / Language Structure
15.8 正常模板使用方式
Flask:
from flask import render_template@app.get("/hello")def hello(): name = request.args.get( "name", "" ) return render_template( "hello.html", name=name )
模板:
<h1>Hello {{ name }}</h1>
这里:
模板=开发者控制
而:
name=用户数据
两者分离。
15.9 数据流
安全模型:
Developer Template │ │ ├────────┐ │ │ ↓ ↓ Template User Data │ │ └───┬────┘ ↓ Template Engine ↓ HTML
关键:
用户只能提供数据
不能:
提供模板代码
15.10 危险模式
例如:
from flask import render_template_string@app.get("/hello")def hello(): name = request.args.get( "name", "" ) template = ( "Hello " + name ) return render_template_string( template )
这段代码看起来和前面的程序:
很像
但安全性质:
完全不同
15.11 问题在哪里
正常输入:
Alice
生成:
Hello Alice
没问题。
但用户实际上控制:
模板源码的一部分
因为:
template = "Hello " + name
然后:
render_template_string(template)
意味着:
把拼接后的整段字符串重新交给模板解释器。
15.12 最简单的无害 SSTI 验证
只在自己的本地实验程序。
输入:
{{ 7 * 7 }}
最终模板:
Hello {{ 7 * 7 }}
如果服务器返回:
Hello 49
说明:
用户输入
已经被:
模板引擎解释
而不是:
作为普通文字输出
15.13 这个实验为什么足够
我们并不需要:
读取服务器文件执行系统命令建立Shell
只要证明:
{{ 7 * 7 }}↓49
就已经证明:
数据进入模板代码上下文
在安全测试中:
**能用无害表达式证明,就不应追求更具破坏性的验证。 *
15.14 SSTI 的根本模型
用户输入↓模板字符串拼接↓Template Engine↓模板语法被执行
核心不是:
{{ }}
本身。
而是:
不可信输入是否成为模板源代码的一部分。
15.15 Source
SSTI 的 Source 可能来自:
GET 参数POST 参数JSONCookieHTTP Header数据库内容CMS内容用户名邮件模板通知模板后台配置消息队列
不要认为:
不是直接HTTP参数
就一定安全。
15.16 Stored SSTI
例如管理员系统允许用户保存:
Custom Greeting
数据库保存:
Hello {{ 7 * 7 }}
之后某个后台:
从数据库读取↓当成模板重新渲染
这时属于:
Stored SSTI
也就是:
存储型服务端模板注入
15.17 Sink
SSTI 的 Sink 是:
任何会解释模板源代码的函数
例如概念上:
render_template_string()Template(...)from_string(...)evaluate(...)process(...)render(...)
具体名称:
取决于模板引擎
15.18 不要看到 render() 就直接说漏洞
例如:
template = env.get_template( "hello.html")return template.render( name=user_input)
这里:
模板文件=固定
用户只是:
变量值
通常不是 SSTI。
真正危险的是:
用户数据↓成为 template source
15.19 最关键的区别
安全:
template = "Hello {{ name }}"data = user_input
危险:
template = "Hello " + user_input
看起来只差:
一点点
实际上是:
数据vs代码
的区别。
15.20 本地 Flask 靶场
创建:
ssti_lab.py
内容:
from flask import Flaskfrom flask import requestfrom flask import render_template_stringapp = Flask(__name__)@app.get("/")def index(): name = request.args.get( "name", "Guest" ) template = ( "<h1>Hello " + name + "</h1>" ) return render_template_string( template )app.run( host="127.0.0.1", port=5000)
15.21 启动
python3 ssti_lab.py
访问:
http://127.0.0.1:5000/?name=Alice
返回:
<h1>Hello Alice</h1>
15.22 本地无害表达式
在自己的靶场输入:
{{ 7 * 7 }}
如果返回:
Hello 49
说明:
Jinja2
真的解释了:
用户输入
15.23 为什么是服务端漏洞
这个表达式:
{{ 7 * 7 }}
不是浏览器算出来的。
流程:
Browser↓HTTP Request↓Flask↓Jinja2↓计算 7 * 7↓生成 HTML↓Browser
浏览器收到的可能已经是:
<h1>Hello 49</h1>
所以:
计算发生在服务器
15.24 SSTI 和 XSS 最大区别
SSTI:
执行位置=Server
XSS:
执行位置=Browser
这是最核心的区别。
15.25 XSS 示例
假设:
<div> USER_INPUT</div>
用户内容最终:
进入浏览器HTML / JS上下文
攻击目标:
浏览器会话DOM客户端数据
15.26 SSTI 示例
用户输入:
模板表达式
进入:
服务器模板引擎
攻击目标:
服务器运行时模板上下文服务器数据
两者风险边界:
完全不同
15.27 一个重要误区
看到:
{{ 7 * 7 }}
并不一定就是 SSTI。
因为:
很多前端框架
也使用:
{{ }}
例如某些:
VueAngularHandlebars
环境。
关键要判断:
这段表达式究竟在哪里被解释?
15.28 Server-Side vs Client-Side Template
服务端模板:
服务器↓渲染↓把最终HTML发给浏览器
客户端模板:
服务器↓发送模板/数据↓浏览器JavaScript渲染
SSTI 专门指:
Server-Side Template Injection
15.29 DevTools 可以帮助判断
如果服务器响应中已经是:
Hello 49
说明:
服务器端完成计算
如果响应中仍然是:
{{ 7 * 7 }}
然后 JavaScript 页面稍后变成:
49
则可能是:
客户端模板
15.30 这也是为什么抓 HTTP 很重要
不要只看:
页面最终显示
还要看:
HTTP Response
判断:
真正在哪一层发生解释
15.31 render_template() 和 render_template_string()
Flask / Jinja2 中:
render_template( "hello.html", name=name)
通常:
模板来源固定
而:
render_template_string( user_controlled_text)
特别值得审计。
15.32 但函数名字本身不是漏洞
例如:
render_template_string( "<h1>Hello {{ name }}</h1>", name=user_input)
虽然使用:
render_template_string
但模板源码:
完全固定
用户输入:
只作为 name
这通常不是 SSTI。
15.33 危险的是数据流
真正危险:
template = ( "<h1>" + user_input + "</h1>")render_template_string( template)
所以代码审计永远要:
Source↓Data Flow↓Sink
而不是:
看见某个函数名↓立刻判漏洞
15.34 修复 Flask 靶场
创建模板:
templates/hello.html
内容:
<!doctype html><html><body><h1>Hello {{ name }}</h1></body></html>
Python:
from flask import Flaskfrom flask import requestfrom flask import render_templateapp = Flask(__name__)@app.get("/")def index(): name = request.args.get( "name", "Guest" ) return render_template( "hello.html", name=name )app.run( host="127.0.0.1", port=5000)
15.35 修复后再输入
输入:
{{ 7 * 7 }}
服务器不会:
再次把 name 当模板解析
通常只会把它:
作为文本数据
输出。
核心:
一次模板解析
中:
name
只是:
变量
15.36 为什么模板变量不会再执行
模板:
{{ name }}
引擎先解析:
开发者模板
然后取得:
name
的值。
如果 name 是:
{{ 7 * 7 }}
它通常不会:
再递归解析第二次
所以:
Template Source
和:
Variable Value
仍然分离。
15.37 Double Rendering
如果开发者错误地:
第一次渲染↓把结果再次当模板↓第二次渲染
就可能重新引入问题。
例如概念:
first = template.render( value=user_input)second = render_template_string( first)
这叫:
Double Rendering
或:
二次模板解释
15.38 为什么二次解释危险
第一遍:
用户数据
只是数据。
第一遍输出后:
数据和模板文本混在一起
第二遍:
整个结果
再次被当成模板代码。
于是:
原本的数据↓进入代码上下文
15.39 这个模式很值得审计
看到:
render↓render again
或者:
格式化字符串↓模板编译
要特别注意:
用户数据是否在第二阶段成为模板源码
15.40 模板上下文是什么
Template Context:
模板上下文
就是模板在渲染过程中:
能够访问的变量和对象
例如:
render_template( "profile.html", user=user, app_name="Demo")
模板上下文可能有:
userapp_name
15.41 模板上下文为什么重要
如果发生 SSTI,
攻击者最初能够看到或操作什么:
取决于模板引擎+上下文暴露了什么+沙箱限制+运行时
所以不同项目的:
SSTI影响
可能差异很大。
15.42 不要向模板暴露过多对象
例如:
render_template( "page.html", app=app, config=config, database=db, secret_manager=secret_manager)
如果模板并不需要:
这些高权限对象
就不应该:
全部放进上下文
15.43 最小模板上下文
模板只需要:
usernameavatar_url
就传:
render_template( "profile.html", username=user.name, avatar_url=user.avatar)
而不是:
render_template( "profile.html", user=entire_internal_user_object, database=db, app=app)
这也是:
Least Privilege
15.44 模板变量和内部对象
假设:
user
不是简单字符串,
而是:
复杂对象
可能包含:
emailroleinternal_idmethodsrelated objects
模板的对象访问能力越强:
潜在暴露面越大
15.45 DTO 再次有价值
上一章访问控制讲过:
DTOData Transfer Object
模板也可以只获得:
页面真正需要的数据
而不是:
整个ORM对象整个Application对象整个Request对象
15.46 模板引擎为什么允许表达式
因为开发者需要:
格式化逻辑循环条件
例如:
{% if user %} Hello {{ user.name }}{% endif %}
又例如:
{% for item in items %} <li>{{ item.name }}</li>{% endfor %}
这就是:
模板语言
存在的正常理由。
15.47 模板能力越强,风险越值得关注
有些模板引擎:
刻意能力有限
有些:
可访问复杂对象调用方法扩展函数
因此 SSTI:
最终影响
必须:
具体引擎具体分析
不能简单说:
有SSTI=一定系统命令执行
15.48 SSTI 不一定等于 RCE
非常重要。
SSTI 可能只能:
计算简单表达式访问有限模板变量输出少量数据
也可能:
访问更多服务器对象
在某些高权限模板环境里:
进一步发展为代码执行
但这不是:
所有模板引擎所有配置
都必然成立。
15.49 漏洞类型和影响要分开
漏洞:
Server-Side Template Injection
影响可能:
信息泄露模板逻辑操纵服务器数据访问拒绝服务更严重的服务器执行能力
要根据:
具体运行时
确认。
15.50 不应该为了证明 SSTI 直接追求 RCE
在授权安全测试中:
简单算术表达式
已经足够说明:
模板表达式被执行
下一步应该:
分析上下文和代码
而不是立即:
尝试执行系统命令
这既更安全,也更专业。
15.51 SSTI 与命令注入的区别
命令注入:
User Input↓Shell
SSTI:
User Input↓Template Engine
只有当模板引擎进一步允许:
触达系统能力
时,
SSTI 才可能进一步:
影响系统命令
15.52 SSTI 与 XSS 的关系
例如模板:
<h1>{{ name }}</h1>
如果:
name
包含 HTML:
是否会XSS
取决于:
Output Escaping
而 SSTI:
取决于name是否成为模板源码
它们是两个不同问题。
15.53 同一个输入可能涉及两层安全
例如:
用户数据↓Template Engine↓HTML↓Browser
第一层:
服务器模板安全
第二层:
浏览器输出安全
所以:
修复SSTI
不意味着:
自动修复XSS
15.54 Autoescape
Jinja2 等模板引擎通常支持:
Autoescape自动输出编码
例如用户输入:
<b>Alice</b>
如果自动转义:
<b>Alice</b>
浏览器显示:
<b>Alice</b>
作为文字,
而不是:
真正的HTML标签
15.55 Autoescape 解决什么
主要解决:
HTML输出编码
也就是:
XSS
类风险。
它不会:
阻止用户字符串先成为模板源码
所以:
Autoescape≠SSTI 防护
15.56 一个常见误区
危险代码:
render_template_string( user_input)
有人认为:
Jinja开启了autoescape所以安全
不对。
因为:
Autoescape发生在模板输出阶段
而:
用户输入已经先被当成模板解析
SSTI 发生得更早。
15.57 safe 过滤器
模板中:
{{ value | safe }}
通常表示:
不要对这个HTML值进行普通转义
它主要影响:
XSS / HTML渲染
不是:
SSTI根因
15.58 不要滥用 safe
如果:
value
来自用户,
标记:
safe
可能:
关闭HTML输出保护
产生:
XSS
所以:
safe
只应该用于:
经过可信Sanitization的内容或开发者确定的安全HTML
15.59 Email Template
SSTI 不只发生在网页。
例如应用允许:
邮件正文模板
管理员输入:
Dear {{ user.name }}
服务器:
动态编译模板
如果:
低权限用户也能控制模板源码
就可能:
引入SSTI攻击面
15.60 CMS 模板
内容管理系统可能允许:
页面模板通知模板主题模板
这类功能本来就:
允许部分用户写模板代码
这时安全问题变成:
谁有权编辑模板?模板能访问什么?模板引擎是否Sandbox?
15.61 权限模型很重要
如果:
只有受信任开发者
才能编辑完整模板,
那是:
受控代码编辑能力
如果:
普通用户
也能提交模板语言:
风险完全不同
15.62 Template-as-Code
有些平台明确把:
模板
视为:
代码
那么模板编辑权限应该:
接近代码发布权限
而不是:
普通文本编辑权限
15.63 沙箱模板
Sandboxed Template:
沙箱模板
表示模板引擎:
有意识限制可访问对象可调用方法文件系统运行时能力
用于:
允许不完全可信用户编写模板
15.64 Jinja2 Sandbox
Jinja2 有:
SandboxedEnvironment
可以:
限制一些危险对象访问
但安全性依赖:
正确配置版本暴露对象自定义Filter自定义Function
15.65 沙箱不是让任意输入自动安全
如果:
业务根本不需要用户写模板
最安全的方案:
不要让用户控制模板源码
而不是:
先允许任意模板然后完全依赖Sandbox
15.66 Defense in Depth
如果业务确实需要用户可编辑模板:
身份权限+沙箱+最小上下文+资源限制+审计+独立Worker
一起使用。
15.67 模板中的拒绝服务
模板不一定要:
执行系统命令
才危险。
复杂模板可能:
进行大量循环递归生成巨大输出耗费CPU / Memory
形成:
Template DoS
15.68 所以模板执行也要限制资源
如果:
不可信用户可以编写模板
可以考虑:
执行超时内存限制输出长度限制循环/递归限制独立Worker
15.69 模板渲染队列
例如:
Web API↓保存模板任务↓Queue↓低权限Template Worker↓输出
Worker:
无数据库高权限无敏感网络无文件系统写权限
可以降低:
模板引擎漏洞或配置错误
造成的影响。
15.70 模板 Worker 不应拥有不必要网络权限
模板本来只需要:
输入数据↓生成HTML
通常不需要:
访问数据库管理端内网管理接口公网任意地址
所以:
Network Least Privilege
同样适用。
15.71 自定义 Filter
模板引擎允许开发者注册:
Filter
例如:
{{ name | upper }}
upper:
把字符串转大写
这是正常扩展。
15.72 自定义 Filter 也可能扩大风险
如果开发者注册:
读取文件执行命令发网络请求
之类强能力函数,
模板:
能调用这些Filter
那么模板权限:
会显著提高
15.73 自定义 Function
同样:
Template Global Functions
例如:
format_date()
通常安全。
但如果暴露:
run_command()read_file()query_database()
模板安全边界:
会完全改变
15.74 模板上下文最小化原则
模板真正需要:
namepricedateitems
就只传:
这些基本值
而不是:
整个Request整个Application整个Database SessionCloud ClientFilesystem Helper
15.75 业务对象也可以转成纯数据
例如:
safe_user = { "name": user.name, "avatar": user.avatar_url}
再:
render_template( "profile.html", user=safe_user)
比:
直接传复杂ORM对象
更容易:
控制模板能力
15.76 SSTI 与秘密泄露
如果模板上下文中:
包含配置SecretTokenAPI Key
一旦 SSTI:
可能出现信息泄露
所以不要:
因为模板“理论上用不到”就把整个配置对象都暴露给模板
15.77 配置对象
一些框架模板可能默认暴露:
RequestSessionConfigHelpers
具体取决于:
框架模板引擎版本配置
因此:
影响评估
必须基于真实项目。
15.78 不要死背模板“利用链”
真正学习 SSTI:
不应该从背某个模板引擎的对象链开始
更重要的是:
模板输入从哪里来↓是否成为模板源码↓模板引擎是什么↓上下文暴露什么↓有哪些安全限制
这是可以迁移到任何框架的能力。
15.79 指纹识别只能作为线索
不同模板引擎语法不同。
例如:
{{ }}${ }<%= %>
可能出现在不同技术中。
但:
看到一种符号
不能直接断定:
就是某个模板引擎
还需要:
源码错误页框架信息依赖文件本地实验
综合确认。
15.80 错误信息可能泄露模板引擎
例如开发环境返回:
TemplateSyntaxError
或:
Jinja2TwigFreeMarker
相关异常。
这可能泄露:
技术栈模板文件路径源码位置
生产环境:
不应直接展示完整模板异常
15.81 服务器错误应该怎么处理
客户端:
{ "error": "template rendering failed"}
服务端日志:
记录完整异常request_idtemplate_iduser_id
但避免:
把Secret或完整模板数据泄露给客户端
15.82 Flask Debug Mode
开发时:
debug=True
很方便。
但生产环境:
不能简单暴露调试器
因为调试页面可能:
泄露源码路径环境信息异常上下文
安全风险可能:
远高于普通错误页
15.83 开发环境和生产环境必须分开
开发:
详细错误自动重载Debug
生产:
关闭Debug统一错误页完整日志留服务器
这不仅针对 SSTI,
是:
所有Web安全
的基础原则。
15.84 Twig 思维
PHP/Twig 正常:
模板文件+变量
例如:
Hello {{ name }}
服务器:
render( "hello.twig", ["name" => $userInput])
这是正常使用方式。
15.85 Twig 危险思维
如果应用:
把用户输入↓交给Twig loader / createTemplate↓当模板编译
就可能出现:
SSTI
所以不同语言:
根因完全一致
15.86 FreeMarker 思维
Java FreeMarker:
固定 .ftl 模板+Model Data
正常。
危险:
用户输入↓构成模板源码↓FreeMarker解析
仍然:
SSTI
15.87 Velocity、Nunjucks、Mako 等也是一样
不要把 SSTI:
等同于 Jinja2
Jinja2 只是:
教学中常见
真正漏洞类别:
跨语言存在
15.88 服务端模板和前端 Vue 不一样
Vue:
通常运行在浏览器
Jinja2:
通常运行在服务器
即使:
都出现 {{ }}
安全边界:
完全不同
15.89 混合模板环境
现代项目可能:
Jinja2↓生成HTML↓Vue↓再次客户端渲染
那么同一份页面:
可能经过两个模板层
安全分析一定要:
知道每一层由谁解析
15.90 模板语法冲突
服务端和客户端都使用:
{{ }}
时,
开发者可能:
使用raw块更换delimiter
避免冲突。
但:
语法冲突
本身不等于:
漏洞
15.91 JSON 和模板
有些开发者:
用模板生成JSON
例如:
{"name":"{{ name }}"}
这时需要同时考虑:
模板安全+JSON编码
最好:
使用JSON序列化库
而不是手工模板生成复杂 JSON。
15.92 JavaScript 和模板
例如:
<script>const username = "{{ username }}";</script>
即使没有 SSTI,
这里仍可能:
出现JavaScript上下文转义问题
所以:
模板变量安全
还取决于:
输出上下文
15.93 上下文敏感编码
HTML 文本:
一种编码
HTML Attribute:
另一种上下文
JavaScript:
又一种上下文
URL:
又一种
所以:
模板系统的autoescape
要正确配置。
15.94 SSTI 和输出编码是两套问题
SSTI 防御:
不要让用户控制模板源代码
XSS 防御:
模板中的用户数据按输出上下文正确编码
两条:
必须同时成立
15.95 一个完整安全模板流程
HTTP Request↓读取业务数据↓验证↓转换为简单DTO↓选择固定模板↓Template Engine↓Context-Aware Escaping↓HTML↓Browser
15.96 一个危险流程
HTTP Request↓User Input↓字符串拼接↓动态Template Source↓Template Engine↓Server-Side Evaluation
15.97 模板文件名也可能产生另一个漏洞
例如:
render_template( request.args["page"])
这里用户可能:
控制模板路径
这更接近:
Template Path Traversal文件包含
而不是典型:
SSTI
15.98 两种问题不要混淆
第一种:
用户控制“模板内容”↓SSTI
第二种:
用户控制“加载哪个模板文件”↓路径穿越 / 模板包含问题
可能同时存在,
但:
根因不同
15.99 模板选择最好使用映射
用户:
theme=dark
服务器:
TEMPLATES = { "light": "themes/light.html", "dark": "themes/dark.html"}
然后:
template = TEMPLATES.get( theme)
用户控制:
业务枚举
不是:
真实模板路径
15.100 为什么这个模式反复出现
路径穿越:
业务ID→服务器路径
SSRF:
Provider→服务器URL
命令注入:
Action→服务器命令
SSTI:
Theme→服务器模板
统一思想:
**让客户端选择业务含义,不让客户端直接控制解释器结构。 *
15.101 不要自己实现模板语言
有些系统会:
自己用replace()
实现:
${name}
后续又逐渐加入:
函数表达式条件
最终变成:
自制解释器
安全复杂度:
越来越高
15.102 如果业务只需要简单占位符
例如邮件:
Hello {name}
只需要:
固定键替换
就不要:
引入具备任意表达式执行能力的完整模板语言
15.103 Logic-less Templates
某些模板系统强调:
Logic-less
尽量减少:
复杂逻辑函数调用对象访问
如果业务需求简单:
能力越小攻击面越小
这是:
Least Functionality
原则。
15.104 表达式语言注入
有些框架不是传统模板,
而是:
Expression Language
例如:
配置表达式规则表达式Spring EL其他EL
如果用户输入进入:
表达式解释器
也可能:
形成类似注入
15.105 SSTI 与 EL Injection
两者都属于:
服务器端解释器注入
但:
解释器不同
代码审计要:
具体看Sink
不能把所有:
{{ }}${ }
都简单叫 SSTI。
15.106 模板缓存
模板引擎常:
编译并缓存模板
提高性能。
如果用户模板:
错误地进入全局缓存
还可能造成:
跨用户内容污染旧模板长期存在
因此:
缓存Key和租户隔离
也很重要。
15.107 多租户模板
SaaS:
Company ACompany B
各自有:
邮件模板主题模板
数据库必须:
template_id+tenant_id
一起授权。
否则:
模板SSTI之外
还可能存在:
跨租户越权
15.108 模板编辑权限
如果平台允许:
管理员编辑模板
至少应该区分:
普通内容编辑
和:
可执行模板编辑
后者权限:
更敏感
15.109 模板发布审批
高安全企业系统可以:
模板草稿↓审核↓发布
并记录:
谁修改谁批准什么时候发布版本差异
这属于:
Change Control
15.110 模板版本管理
模板也是:
应用行为的一部分
应该:
版本化可回滚可审计
尤其:
邮件通知合同支付
等重要模板。
15.111 模板日志应该记录什么
可以记录:
template_idtemplate_versionuser_idtenant_idrender_resultdurationrequest_id
不要:
把完整SecretSession Token敏感用户字段
无必要写入日志。
15.112 异常模板行为检测
可以关注:
大量模板语法错误模板编译失败激增渲染耗时异常输出大小突然变大低权限用户频繁修改模板
作为:
安全监控信号
15.113 WAF 能防 SSTI 吗
WAF 可能:
拦截部分常见模板语法
但:
不同模板引擎语法很多编码方式很多业务模板本身也可能合法使用语法
所以:
WAF
最多是:
辅助防线
不能代替:
正确模板设计
15.114 黑名单同样不可靠
例如:
禁止 {{
并不能:
通用解决SSTI
因为:
不同模板引擎使用不同语法
而且真正的问题:
用户输入被当模板源码
没有解决。
15.115 正确根治方案
不要:
用户输入↓模板源码
而是:
固定模板+用户数据变量
这和:
SQL参数化命令参数数组
的思想完全一致。
15.116 数据和代码分离
SQL:
Query Structure+Parameters
Shell:
Program+argv
Template:
Template Source+Context Data
统一思想:
Structure由开发者控制Data由用户提供
15.117 这就是注入漏洞的统一模型
不可信数据↓进入某种语言↓改变语言结构↓注入
语言可以是:
SQLHTMLJavaScriptShellTemplateLDAPXPath表达式语言
15.118 本地实验二:安全版本
模板:
<h1>Hello {{ name }}</h1>
Python:
@app.get("/")def index(): name = request.args.get( "name", "Guest" ) return render_template( "hello.html", name=name )
访问:
?name={{ 7 * 7 }}
重点观察:
它只是文字
而不是:
49
15.119 实验三:查看 HTTP Response
打开:
DevTools↓Network↓Response
分别测试:
漏洞版安全版
确认:
模板表达式到底是在服务器还是浏览器被处理
15.120 实验四:Autoescape
安全模板:
<p>{{ name }}</p>
输入:
<b>Alice</b>
观察响应:
HTML标签是否被编码
理解:
Autoescape
解决的是:
HTML输出安全
而不是:
SSTI
15.121 实验五:模板上下文最小化
第一版:
render_template( "page.html", user=user)
第二版:
render_template( "page.html", username=user.name)
比较:
模板能访问多少信息
理解:
Context Least Privilege
15.122 实验六:模板映射
用户:
theme=dark
代码:
TEMPLATES = { "light": "light.html", "dark": "dark.html"}template = TEMPLATES.get( request.args.get("theme"))
如果不存在:
拒绝
理解:
业务枚举
代替:
用户控制模板路径
15.123 实验七:固定模板字符串
即使使用:
render_template_string()
也可以安全地理解:
render_template_string( "<h1>Hello {{ name }}</h1>", name=user_input)
尝试输入:
{{ 7 * 7 }}
观察它:
不应被二次当模板解析
15.124 实验八:观察二次渲染概念
可以在纯本地 Python 中模拟:
from jinja2 import Templateuser = "{{ 7 * 7 }}"first = Template( "Hello {{ value }}").render( value=user)print(first)
第一遍:
Hello {{ 7 * 7 }}
如果开发者错误地再:
second = Template( first).render()print(second)
第二遍:
Hello 49
15.125 这个实验特别重要
第一遍:
用户输入=数据
第二遍:
第一次输出=模板源码
于是安全边界:
被重新打破
15.126 实验九:错误页
在自己的开发环境中输入:
故意不完整的模板表达式
观察:
服务器产生TemplateSyntaxError
理解:
错误信息
为什么会:
泄露模板引擎和文件路径
然后关闭:
Debug
比较生产错误页。
15.127 实验十:模板性能
创建自己的普通循环模板:
{% for item in items %} {{ item }}{% endfor %}
传入:
101001000
个测试元素,
观察:
渲染时间输出大小
理解:
不可信模板为什么还涉及资源限制
15.128 SSTI 代码审计第一步
搜索:
render_template_stringfrom_stringTemplate(createTemplateevaluateprocesscompile
具体:
按技术栈变化
15.129 第二步
确认:
Template Source
从哪里来。
问:
它是固定文件?开发者常量?数据库?用户参数?CMS?后台配置?
15.130 第三步
区分:
模板源码
和:
模板变量
这是:
SSTI审计最关键一步
15.131 第四步
确认:
是否存在二次渲染
例如:
string format↓template render
或:
render↓render again
15.132 第五步
检查模板上下文:
暴露哪些变量?是否包含复杂对象?是否包含配置?是否包含数据库对象?是否包含安全敏感Helper?
15.133 第六步
检查:
模板引擎是否Sandbox
以及:
Sandbox是否正确配置
15.134 第七步
检查权限:
谁可以编辑模板?谁可以发布?谁可以调用渲染?不同租户是否隔离?
15.135 第八步
检查:
Autoescape
这是:
XSS层
虽然不是 SSTI 防护,
但模板输出:
同样需要安全
15.136 第九步
检查资源限制:
渲染超时最大输出并发内存Worker隔离
如果:
允许不可信模板
这一项尤其重要。
15.137 第十步
检查:
错误信息日志审计
模板异常不能:
直接暴露完整运行时细节
15.138 不要犯的第一个错误
开启Autoescape=没有SSTI
错误。
Autoescape:
主要防XSS输出问题
15.139 不要犯的第二个错误
用户只控制一个名字=不可能SSTI
错误。
关键不是字段名字叫什么,
而是:
这个值最后有没有进入模板源码
15.140 不要犯的第三个错误
{{ 7*7 }} 没变化=一定没有SSTI
也不能绝对这样判断。
因为:
模板引擎可能不是Jinja语法表达式可能被过滤模板上下文不同
最好结合:
源码技术栈错误信息本地复现
15.141 不要犯的第四个错误
检测到模板表达式=一定RCE
错误。
必须分别确认:
模板注入成立
和:
实际影响
15.142 不要犯的第五个错误
只有Web页面会SSTI
错误。
还可能出现在:
邮件PDF通知短信配置生成报表合同CMS主题系统
15.143 不要犯的第六个错误
只有Jinja2有SSTI
错误。
任何:
服务器端模板语言
只要:
不可信输入能成为模板源码
都可能存在类似问题。
15.144 不要犯的第七个错误
只过滤 {{ 和 }}=修好了
错误。
不同模板:
语法不同
而且:
根因没有消失
15.145 不要犯的第八个错误
后台只有管理员能用=模板永远可信
不一定。
还要看:
管理员账户安全权限边界租户隔离CSRFXSS内部威胁
15.146 最重要防御第一条
固定模板+不可信数据
永远优于:
不可信动态模板
15.147 第二条
用户:
不要控制模板源代码
只控制:
模板变量值
15.148 第三条
用户只需要选择页面时:
Allowlist Mapping
例如:
theme=dark↓themes/dark.html
不要:
用户直接提交模板路径
15.149 第四条
模板上下文:
最小化
只暴露:
页面需要的简单数据
15.150 第五条
模板输出:
启用上下文正确的转义
防止:
XSS
15.151 第六条
如果必须允许用户写模板:
使用专门Sandbox
并且:
低权限Worker最小上下文资源限制网络隔离文件系统隔离
15.152 第七条
不要:
二次渲染
除非:
有明确设计并且第二阶段数据完全可信
15.153 第八条
模板功能:
尽可能小
如果业务只需要:
简单占位符
就不要:
引入强表达式引擎
15.154 第九条
关闭生产:
Debug详细错误页面
避免:
模板栈信息泄露
15.155 第十条
模板修改:
需要审计版本管理权限控制
特别是:
多租户系统
15.156 一个安全架构
HTTP Request │ ↓Business Validation │ ↓DTO / Simple Data │ ↓Fixed Template ID │ ↓Server Template Mapping │ ↓Template Engine │ ↓Context-Aware Escaping │ ↓HTML
15.157 一个危险架构
HTTP Request │ ↓User-Controlled String │ ↓String Concatenation │ ↓Dynamic Template Source │ ↓Template Engine │ ↓Server-Side Evaluation
15.158 一个更危险的组合架构
User Input↓Dynamic Template↓Template Engine↓Powerful Context Objects↓File / Network / Runtime Helpers
风险会随着:
模板引擎能力+上下文能力
扩大。
15.159 蓝队可以看什么
应用日志:
TemplateSyntaxError
突然激增。
模板内容:
出现异常表达式语法
低权限账号:
频繁修改模板
渲染 Worker:
CPU异常升高
都值得关注。
15.160 进程层检测
如果正常模板渲染:
不应该启动系统进程
却出现:
Template Worker↓Shell / Script Process
可能是:
非常高价值的异常信号
这和上一章:
Command Injection检测
可以联动。
15.161 网络层检测
模板 Worker 正常:
只做文本渲染
突然:
连接未知外部主机访问内部管理服务
也非常异常。
这就是:
纵深检测
15.162 文件系统检测
模板进程正常:
只读模板写有限缓存
如果突然:
访问敏感文件写入异常目录
也可以:
触发告警
15.163 漏洞报告怎么写
一个好的 SSTI 描述:
/preview接口将客户端提供的template参数直接作为 Jinja2 模板源码传递给render_template_string()。由于用户输入在服务端模板引擎中被解释而非作为普通数据处理,攻击者可以注入 Jinja 表达式。使用无害算术表达式可确认服务端模板求值行为。
这里包含:
SourceSinkTemplate EngineRoot CauseSafe Proof
15.164 修复建议怎么写
例如:
不要将用户输入作为模板源码动态编译。模板结构应由开发者或受控模板文件确定,用户输入只作为模板变量传入;若客户端需要选择模板,应使用业务枚举到固定模板文件的服务端白名单映射。模板上下文仅提供必要的简单数据,并保留自动输出编码。确需支持用户自定义模板时,应使用专门的沙箱环境、最小模板 API、资源限制和独立低权限 Worker。
15.165 如何复测
修复后:
正常输入:
Alice
仍应:
正常显示
输入:
{{ 7 * 7 }}
应该:
作为普通数据
而不是:
49
15.166 同时检查 XSS
修复 SSTI 后,
再测试:
普通HTML字符
确认:
Autoescape仍然工作
不要出现:
SSTI修好了但XSS被打开了
15.167 同时检查模板路径
如果应用:
允许用户选择模板
确认:
只能选择Allowlist里的模板
不能:
控制任意路径
15.168 同时检查授权
如果模板属于:
用户 / 租户
确认:
Alice不能编辑Bob模板Company A不能访问Company B模板
SSTI 修复:
不能代替访问控制
15.169 同时检查资源限制
如果允许自定义模板:
复杂循环巨大输出
应:
被资源限制控制
不要:
一个模板拖垮Worker
15.170 风险评估
判断 SSTI 严重性要看:
谁能提交模板模板引擎能力模板上下文是否Sandbox是否能读取敏感数据Worker权限文件系统权限网络权限是否有云凭证是否多租户
15.171 较低影响场景
用户模板:
运行在强沙箱
只能:
访问几个字符串变量
并且:
CPU / 输出都受限
风险:
可能相对受控
15.172 更高影响场景
用户模板:
运行在主Web进程
上下文暴露:
复杂应用对象
同时拥有:
数据库权限内部网络Secret
风险:
显著提高
15.173 本章统一注入模型
现在已经可以把几种漏洞统一起来:
SQL InjectionUser Data↓SQL Structure
XSSUser Data↓HTML / JS Structure
Command InjectionUser Data↓Shell Structure
SSTIUser Data↓Template Structure
它们共同违反:
**代码结构和用户数据必须分离。 *
15.174 本章核心图
User Input │ ↓ Web Application │ ┌───────────┴───────────┐ │ │ 安全模式 危险模式 │ │ Fixed Template Dynamic Template │ │ Context Variable String Concatenation │ │ ↓ ↓ Template Engine Template Engine │ │ User Data stays User Data becomes as Data Template Syntax │ │ ↓ ↓ Safe Render SSTI
15.175 本章需要真正理解的 20 个概念
Template:模板。 Template Engine:模板引擎。 Template Source:模板源代码。 Template Context:模板上下文。 Rendering:模板渲染。 SSTI:服务端模板注入。 Server-Side Template:服务端模板。 Client-Side Template:客户端模板。 Autoescape:自动输出编码。 Context-Aware Escaping:上下文相关编码。 Sandbox:模板沙箱。 Double Rendering:二次模板渲染。 Template Path Traversal:模板路径穿越。 Template Expression:模板表达式。 Template Global:模板全局对象/函数。 Filter:模板过滤器。 DTO:模板所需的最小数据对象。 Template DoS:模板资源消耗攻击。 RCE:某些高能力模板环境可能造成的进一步影响。 Data/Code Separation:数据与代码分离。
15.176 本章必须回答的问题
SSTI 为什么产生?
因为:
不可信用户输入
成为:
模板源码的一部分
随后被:
服务器模板引擎解释
{{ 7 * 7 }} 为什么可以用来做教学验证?
因为它:
无害
却可以证明:
输入是否发生服务端模板求值
如果输出:
49
说明:
数据已经进入模板语言
SSTI 一定等于 RCE 吗?
不是。
实际影响取决于:
模板引擎上下文沙箱框架权限运行环境
SSTI 和 XSS 最大区别是什么?
SSTI:
服务器模板引擎执行
XSS:
浏览器执行
Autoescape 能防 SSTI 吗?
不能。
它主要解决:
HTML输出编码
也就是:
XSS
render_template_string() 一定危险吗?
不是。
危险与否取决于:
模板源码是否含用户输入
固定模板源码:
render_template_string( "Hello {{ name }}", name=user_input)
通常:
不是SSTI
为什么二次渲染危险?
因为:
第一次输出里的用户数据
可能在:
第二次渲染
中变成:
模板源码
SSTI 最好的修复是什么?
固定模板+用户数据变量
不要:
动态拼模板源码
如果必须让用户写模板怎么办?
需要:
专门Sandbox最小上下文有限模板能力独立低权限WorkerCPU/内存/输出限制文件和网络隔离审计
第十五章总结
这一章最重要的一句话是:
模板引擎应该解释开发者控制的模板,而用户输入应该永远只是模板中的数据。
正常模型:
Developer Template+User Data↓Template Engine↓Output
危险模型:
User Input↓Dynamic Template Source↓Template Engine↓Server-Side Evaluation
从现在开始,看到:
render_template_string( something)
不要马上下结论,
而是问:
something从哪里来?
看到:
Template( user_input)
要问:
用户是不是正在提供模板源码?
看到:
render↓render again
要想到:
二次渲染
看到:
整个app/config/database对象
进入模板上下文,
要问:
为什么模板需要这么大权限?
看到:
用户可以编辑邮件模板
则要继续问:
谁能编辑?模板能力有多大?有没有Sandbox?Worker能访问什么?有没有资源限制?
真正理解 SSTI 后,你会发现它并不是一个孤立的“Jinja2技巧题”。
它仍然是在研究同一个最核心的问题:
不可信数据究竟有没有越过边界变成解释器能够执行的结构?
这正是注入类漏洞最统一、最值得掌握的思维。
下一章:第十六章《XXE 与 XML 解析安全》
下一章会进入:
XML↓XML Parser↓DTD / Entity↓文件与网络资源
重点会讲清楚:
XML到底是什么?DTD是什么?Entity是什么?内部实体和外部实体有什么区别?为什么一个XML解析器可能读取本地文件?为什么XML解析还可能产生网络请求?XXE和SSRF是什么关系?为什么DOCTYPE在某些业务里应该直接禁用?“Billion Laughs”为什么会导致资源耗尽?DOCX / XLSX / SVG 为什么又会和XML安全联系起来?怎样正确配置Python、Java、PHP、.NET的XML解析器?
核心思想会继续保持一致:
问题并不是“XML危险”,而是解析器是否开启了业务根本不需要的强大能力。















































































