SSTI 模板注入(Server-Side Template Injection)是服务端把用户输入当作模板源码渲染后产生的注入漏洞,危害级别通常等同于 RCE。本文给出 SSTI 模板注入的完整防御链路:最小复现环境、多引擎识别方法、Jinja2 沙箱改造、输入白名单与渲染分层、代码审计与 WAF 拦截规则,并附配置验证与常见问题排查。
适用场景
- 站点或后台使用 Python Jinja2、PHP Twig、Java Freemarker/Velocity 等模板引擎动态拼接模板内容。
- 存在「邮件模板」「通知文案」「报表标题」等允许用户自定义内容的功能,且实现方式是先拼字符串再渲染。
- 已完成 XSS、SQL 注入防护,但未评估服务端模板层的输入边界。
- 代码审计阶段需要批量定位使用了
render_template_string、Template()、from_string()的位置。
前置条件
- Python 3.9 及以上,Flask 2.x/3.x,Jinja2 3.0+(复现环境)。
- 一台隔离的测试主机或容器,不要在生产环境执行注入探测。
- 如需 WAF 层拦截:Nginx + ModSecurity,或已有的云 WAF 自定义规则入口。
- 具备应用源码读权限,便于执行静态排查。
原理说明
模板引擎的工作方式是把模板文本中的占位符替换为数据,再输出最终文本。占位符在渲染阶段会被当作表达式求值:Jinja2 使用 {{ }},Twig 与 Jinja2 语法相近,Freemarker 使用 ${ },Velocity 使用 $!{ }。
问题出在「模板文本」与「数据」的边界被打乱。当代码写作 render_template_string("Hello " + name) 时,name 的内容会进入模板文本位置,用户提交 {{7*7}} 就会得到 49,提交表达式即可链式访问对象属性,最终调用 os.popen 之类的危险方法实现命令执行。这一点与 XSS 有本质区别:XSS 在浏览器端解释执行,SSTI 在服务端解释执行,所以一旦可利用,权限就是 Web 进程的权限。
典型攻击链分四步:探测渲染点(提交算术表达式看是否被求值)→ 读取上下文({{config}}、{{self}})→ 搜索可用对象(沿 __class__.__mro__ 拿到基类并枚举子类)→ 调用执行函数完成命令执行或文件读取。防御的关键不是过滤某个 payload 字符串,而是彻底切断「用户输入进入模板源码」这条数据流。
操作步骤
步骤 1:搭建最小复现环境
python3 -m venv /opt/ssti-lab/venv
/opt/ssti-lab/venv/bin/pip install flask jinja2
mkdir -p /opt/ssti-lab && cd /opt/ssti-lab
写入漏洞示例程序 app_vuln.py,注意第 3 行的字符串拼接写法:
from flask import Flask, request
from jinja2 import Template
app = Flask(__name__)
@app.route("/greet")
def greet():
tpl = "Hello, " + request.args.get("name", "guest") + "!"
return Template(tpl).render() # 危险:用户输入进入模板源码
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
/opt/ssti-lab/venv/bin/python app_vuln.py &
curl -s "http://127.0.0.1:5000/greet?name=%7B%7B7*7%7D%7D"
返回 Hello, 49! 说明存在 SSTI 模板注入。返回原文 {{7*7}} 则为安全实现。可用 {{7*'7'}} 进一步判断引擎:Jinja2 返回 7777777(字符串重复),Twig 返回 49(纯算术)。
步骤 2:识别模板引擎类型
不同引擎的防御位置与修复方式不同,先确认引擎再改代码。常用探测表达式:
- Jinja2 / Twig:
{{7*7}}返回49;{{7*'7'}}在 Jinja2 下返回7777777。 - Freemarker:
${7*7}返回49;<#assign x=7>${x}可执行赋值。 - Velocity:
#set($x=7)$x返回7。 - 通用探测串(polyglot):
${{<%[%'"}}%\,一次提交覆盖多种定界符,再根据报错信息缩小范围。
步骤 3:用沙箱化引擎替换原生渲染
把危险写法改为受控写法。核心是三点:不拼接模板源码、不给用户可控的模板能力、确实需要动态模板时加沙箱与白名单。修复后的 app_safe.py:
from flask import Flask, request
from jinja2.sandbox import SandboxedEnvironment
app = Flask(__name__)
env = SandboxedEnvironment() # 沙箱环境,默认禁止访问危险属性
env.globals.clear() # 清空 cycler/joiner 等默认全局对象
env.filters.clear() # 按需再加回业务用到的过滤器
# 固定模板 + 变量传参:用户输入只作为数据,不进入模板源码
TPL_GREET = env.from_string("Hello, {{ nickname }}!")
@app.route("/greet")
def greet():
nick = request.args.get("name", "guest")
return TPL_GREET.render(nickname=nick)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
修复后 curl "http://127.0.0.1:5000/greet?name={{7*7}}" 的返回是 Hello, {{7*7}}!,表达式被当作普通字符串输出。
步骤 4:输入白名单与渲染分层
- 业务上确实需要用户自定义模板(如邮件模板)时,先做字符白名单再渲染:只允许字母、数字、中文与少量空白,正则禁止
{{ }}、{% %}、${ }等定界符。 - 变量值统一走「过滤器 + 转义」,例如
{{ nickname | e }},避免用户数据在 HTML 层被二次解释。 - 模板渲染与业务逻辑分进程:把模板渲染放进独立服务,仅传入 JSON 数据,禁止访问系统环境变量与文件系统。
- 用户自定义模板还要限制渲染超时与输出长度,防止
{% for %}嵌套造成 CPU 耗尽。
import re
ALLOWED = re.compile(r"^[\w\u4e00-\u9fff \-]{1,64}$", re.UNICODE)
def safe_text(v: str) -> str:
return v if ALLOWED.match(v) else "guest"
步骤 5:WAF 层拦截规则(Nginx + ModSecurity)
先用 nginx -V 2>&1 | grep -o modsecurity 确认 ModSecurity 已加载,再追加自定义规则覆盖模板定界符特征:
# /etc/nginx/modsec/custom-ssti.conf
SecRule ARGS "@rx (\{\{|\}\}|\$\{|\{%|%\}|<%|%>)" \
"id:10001,phase:2,deny,status:403,log,msg:'SSTI template delimiter detected',\
tag:'attack-ssti',severity:'CRITICAL'"
nginx -t && systemctl reload nginx
该规则对参数中出现的模板定界符直接拦截,属于兜底手段,不能替代代码层修复——同名功能(如代码编辑器、Markdown 预览)可能合法提交 {{ }},此时应改为路径白名单放行。
步骤 6:代码审计批量排查
grep -rnE "render_template_string|from_string\(|Template\(" --include=*.py /var/www
grep -rnE "createTemplate|Twig\\\\Environment|fromString" --include=*.php /var/www
grep -rnE "new Template\(|Configuration\(\)|getTemplate\(" --include=*.java /var/www
命中结果逐条确认模板文本是否包含用户可控部分。只有模板文本由用户输入拼接的调用才是漏洞点,模板文本固定、仅变量来自用户的调用是安全的。
配置验证
- 功能验证:修复后再提交
{{7*7}}、{{7*'7'}}、${7*7},输出应为原样字符串,不是49或7777777。 - 沙箱验证:提交
{{ ''.__class__ }},Jinja2 沙箱应抛出SecurityError或返回空,不应返回类对象信息。 - WAF 验证:
curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/?q=%7B%7B7*7%7D%7D"预期 403;同时检查/var/log/modsec_audit.log是否记录了id:10001。 - 回归验证:把真实业务请求(含中文、特殊符号的合法参数)跑一遍接口测试,确认白名单正则未误杀。
常见问题
FAQ 1:把 {{ }} 过滤掉是不是就安全了?
不够。模板定界符可被编码、可被其他语法绕过(Freemarker 的 <#assign>、Velocity 的 #set),而且过度黑名单会破坏正常业务。正确顺序是:先消除「用户输入进入模板源码」的数据通路,再用定界符特征做纵深拦截。
FAQ 2:使用了 SandboxedEnvironment,是否就不会被绕过了?
不是绝对安全。Jinja2 沙箱历史上出现过多次逃逸问题,例如通过属性链或过滤器访问到未受保护的内部对象。生产环境建议同时做到:升级 Jinja2 到 3.1 及以上、清空 env.globals 与不必要的 filters、必要时重写 is_safe_attribute 只放行白名单属性,并把渲染进程的权限降到最低。
FAQ 3:前端框架会出现同类问题吗?
会,属于客户端模板注入(CSTI)。Vue、Angular 等框架中,若把用户输入交给 v-html 或 compile() 之类的动态编译接口,同样可被构造表达式执行。防御思路一致:用户输入只作为数据绑定,不进入模板编译路径。
FAQ 4:漏洞已修复,如何确认历史上未被利用?
检索 Web 访问日志中是否出现过模板定界符特征:grep -Ei "%7B%7B|\{\{|\$\{" /var/log/nginx/access.log | awk '{print $1,$7}' | head。命中来源 IP 需与业务方核实;同时排查 Web 进程账户下是否出现异常子进程与临时文件。
总结
SSTI 模板注入的根因是模板源码与用户数据的边界失守,危害直接指向服务器权限。防御落点共三层:代码层用固定模板 + 变量传参替代字符串拼接,动态模板场景加沙箱与白名单;运行层升级模板引擎版本、最小化沙箱可访问对象、限制渲染超时与进程权限;边界层用 WAF 定界符规则与日志特征检索做兜底与回溯。三层同时落地,才能把这一类注入风险收敛到可控范围。