【Web安全】SSTI 从零到一:模板引擎原理深度拆解与服务端模板注入全流程解析

免责声明:本文仅用于网络安全学习与研究目的,旨在帮助开发者理解 SSTI 漏洞原理以提升防御能力。请勿将文中涉及的技术用于未经授权的渗透测试或任何非法用途。未经目标系统所有者明确书面授权,对其进行安全测试属于违法行为。作者不对任何滥用行为承担责任。

一、模板引擎

在正式讲解 SSTI 之前,我们首先需要知道模板引擎的相关概念。

1、什么是模板引擎?

模板引擎是一种将数据展示逻辑分离的工具。它允许开发者定义包含占位符的模板文件,然后在运行时用实际数据填充这些占位符,最终生成完整的输出(通常是 HTML)。

复制代码
模板 + 数据  →  模板引擎  →  最终输出

2、为什么需要模板引擎?

想象一下,你要为 10000 个用户生成个性化的欢迎页面。没有模板引擎时:

python 复制代码
# ❌ 硬编码方式 ------ 噩梦
html = "<html><body><h1>欢迎, " + username + "!</h1><p>你有" + str(msg_count) + "条消息</p></body></html>"

有了模板引擎:

html 复制代码
<!-- ✅ 模板文件 welcome.html -->
<html>
  <body>
    <h1>欢迎, {{ username }}!</h1>
    <p>你有 {{ msg_count }} 条消息</p>
  </body>
</html>

优势一目了然:代码更清晰、维护更容易、前后端职责分离

3、模板引擎的核心功能

(1)变量插值

将变量值渲染到模板中,这是最基础的功能。

jinja2 复制代码
{# Jinja2 示例 #}
Hello, {{ user.name }}!
你的余额是: {{ user.balance | round(2) }}
(2)控制结构

支持条件判断和循环。

jinja2 复制代码
{# 条件判断 #}
{% if user.is_admin %}
  <a href="/admin">管理后台</a>
{% else %}
  <a href="/profile">个人中心</a>
{% endif %}

{# 循环 #}
<ul>
{% for item in product_list %}
  <li>{{ item.name }} - ¥{{ item.price }}</li>
{% endfor %}
</ul>
(3)过滤器

对变量进行格式化处理。

jinja2 复制代码
{{ "hello world" | upper }}        {# → HELLO WORLD #}
{{ username | length }}             {# → 字符串长度 #}
{{ content | truncate(100) }}       {# → 截断为100字符 #}
{{ price | round(2) | string }}     {# → 链式过滤器 #}
(4)模板继承

避免重复代码,实现页面布局复用。

html 复制代码
<!-- base.html 父模板 -->
<html>
  <head><title>{% block title %}默认标题{% endblock %}</title></head>
  <body>
    <nav>导航栏...</nav>
    {% block content %}{% endblock %}   <!-- 子模板填充这里 -->
    <footer>页脚...</footer>
  </body>
</html>
html 复制代码
<!-- page.html 子模板 -->
{% extends "base.html" %}

{% block title %}商品详情{% endblock %}

{% block content %}
  <h1>{{ product.name }}</h1>
{% endblock %}

4、常见模板引擎一览

语言 模板引擎 语法示例
Python Jinja2 {``{ variable }} {% for %}
Python Mako ${variable} % for
Python Tornado {``{ variable }}
Java Freemarker ${variable} <#if>
Java Thymeleaf th:text="${variable}"
PHP Twig {``{ variable }} {% if %}
Ruby ERB <%= variable %> <% if %>
Node.js Pug = variable each
Node.js EJS <%= variable %> <% for %>
Go html/template {``{ .Variable }}

5、模板引擎的工作原理

这是理解 SSTI 的关键部分。

复制代码
① 读取模板文件
        ↓
② 词法分析(Lexing)
   将模板拆分为 Token:
   [文本块] [变量块] [标签块] ...
        ↓
③ 语法分析(Parsing)
   构建抽象语法树(AST)
        ↓
④ 渲染(Rendering)
   遍历 AST,结合数据上下文生成输出
        ↓
⑤ 返回最终 HTML 字符串

假设我们有这样一个模板和数据:

模板字符串:

复制代码
<h1>Hello, {{ name }}!</h1>
{% if show_items %}
<ul>
  {% for item in items %}
  <li>{{ item }}</li>
  {% endfor %}
</ul>
{% endif %}

数据上下文:

python 复制代码
context = {
    "name": "Alice",
    "show_items": True,
    "items": ["Apple", "Banana"]
}
(1)读取模板文件

没什么好说的,就是把上面那段模板字符串读进内存。

(2)词法分析

扫描器从左到右扫描模板,遇到 {``{{%{# 这些定界符就"切一刀",把模板拆成一个个 Token:

复制代码
Token 1:  TEXT        "<h1>Hello, "
Token 2:  VAR_START   "{{"
Token 3:  NAME        "name"
Token 4:  VAR_END     "}}"
Token 5:  TEXT        "!</h1>\n"
Token 6:  BLOCK_START "{%"
Token 7:  NAME        "if"
Token 8:  NAME        "show_items"
Token 9:  BLOCK_END   "%}"
Token 10: TEXT        "\n<ul>\n  "
Token 11: BLOCK_START "{%"
Token 12: NAME        "for"
Token 13: NAME        "item"
Token 14: NAME        "in"
Token 15: NAME        "items"
Token 16: BLOCK_END   "%}"
Token 17: TEXT        "\n  <li>"
Token 18: VAR_START   "{{"
Token 19: NAME        "item"
Token 20: VAR_END     "}}"
Token 21: TEXT        "</li>\n  "
Token 22: BLOCK_START "{%"
Token 23: NAME        "endfor"
Token 24: BLOCK_END   "%}"
Token 25: TEXT        "\n</ul>\n"
Token 26: BLOCK_START "{%"
Token 27: NAME        "endif"
Token 28: BLOCK_END   "%}"

这一步的核心就是分类------每段内容是纯文本、变量、还是控制标签。

(3)语法分析

解析器消费这些 Token,构建出一棵 AST(抽象语法树)。用伪代码表示大致是:

复制代码
Root
├── TextNode("Hello, ")
├── VariableNode(name="name")
├── TextNode("!</h1>\n")
└── IfNode(condition="show_items")
    └── body:
        ├── TextNode("\n<ul>\n  ")
        ├── ForNode(target="item", iterable="items")
        │   └── body:
        │       ├── TextNode("\n  <li>")
        │       ├── VariableNode(name="item")
        │       └── TextNode("</li>\n  ")
        └── TextNode("\n</ul>\n")

注意关键变化:扁平的 Token 流变成了嵌套的树结构ifendfor 这些标签不再出现,它们的作用被体现在了树的父子关系中------ForNodebody 里装着循环体内容。

(4)渲染

遍历 AST,每个节点根据自己的类型做不同的事:

复制代码
访问 TextNode("<h1>Hello, ")     → 直接输出 "<h1>Hello, "
访问 VariableNode("name")        → 查上下文,name="Alice" → 输出 "Alice"
访问 TextNode("!</h1>\n")        → 直接输出 "!</h1>\n"
访问 IfNode("show_items")        → 查上下文,show_items=True → 进入 body
  访问 TextNode("\n<ul>\n  ")    → 直接输出
  访问 ForNode("item" in "items")→ items=["Apple","Banana"],循环两次:
    ── 第1次迭代:item="Apple"
       TextNode("\n  <li>")      → 输出
       VariableNode("item")      → 输出 "Apple"
       TextNode("</li>\n  ")     → 输出
    ── 第2次迭代:item="Banana"
       TextNode("\n  <li>")      → 输出
       VariableNode("item")      → 输出 "Banana"
       TextNode("</li>\n  ")     → 输出
  访问 TextNode("\n</ul>\n")     → 直接输出

如果 show_itemsFalse,IfNode 就直接跳过整个 body,<ul> 那一段根本不会出现。

(5)最终输出

把上面所有输出拼接起来:

html 复制代码
<h1>Hello, Alice!</h1>
<ul>
  <li>Apple</li>
  <li>Banana</li>
</ul>

6、上下文

模板引擎在渲染时有一个"上下文"对象,包含所有可用的变量和对象:

python 复制代码
# Flask + Jinja2 示例
from flask import Flask, render_template

app = Flask(__name__)

@app.route('/profile/<username>')
def profile(username):
    user = get_user(username)
    # 将数据传入模板上下文
    return render_template('profile.html', 
                           user=user,        # 可在模板中用 {{ user.xxx }}
                           title="个人主页") # 可在模板中用 {{ title }}

7、安全边界:转义

现代模板引擎默认会对输出进行 HTML 转义,防止 XSS:

jinja2 复制代码
{# 假设 user_input = "<script>alert(1)</script>" #}

{{ user_input }}
{# 输出: &lt;script&gt;alert(1)&lt;/script&gt; → 安全 ✅ #}

{{ user_input | safe }}
{# 输出: <script>alert(1)</script> → 危险 ⚠️ #}

二、SSTI

有了上面的基础知识,现在思考一个问题:如果用户提供的数据不在上下文中而是直接插入到模板之中了呢?

我们先回顾正确的做法,再看错误的做法,两者的对比就是 SSTI 的本质。

1、安全写法:用户数据在上下文中

python 复制代码
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/greet')
def greet():
    username = request.args.get('name', 'Guest')
    # 用户数据作为"变量"传进上下文
    template = '<h1>Hello, {{ username }}!</h1>'
    return render_template_string(template, username=username)

此时工作流程是这样的:

复制代码
① 模板字符串(开发者写死的): '<h1>Hello, {{ username }}!</h1>'
② 上下文数据(用户提供的):   {"username": "Alice"}
③ 引擎渲染 → 输出:            '<h1>Hello, Alice!</h1>'

关键点:模板的结构 是开发者定义的,用户只能影响数据{``{ username }} 这个占位符是开发者写的,用户提供的 "Alice" 只会被当作纯文本填进去。

即使用户输入 {``{ 7*7 }}

复制代码
上下文: {"username": "{{ 7*7 }}"}
输出:   '<h1>Hello, {{ 7*7 }}!</h1>'   ← 原样显示,不会被计算

因为引擎在词法分析阶段处理的是开发者写的模板,而不是用户输入。用户输入是在渲染阶段才以纯文本形式填入的,根本不会经过词法分析和语法分析。

2、危险写法:用户数据拼接进模板

python 复制代码
@app.route('/greet')
def greet():
    username = request.args.get('name', 'Guest')
    # 用户数据被拼接进了模板字符串本身!
    template = '<h1>Hello, ' + username + '!</h1>'
    return render_template_string(template)

看出区别了吗?用户输入不再是上下文里的变量,而是模板字符串的一部分

现在假设攻击者访问:

复制代码
/greet?name={{ 7*7 }}

我们跟着模板引擎的工作流程一步一步走。

(1)拼接模板字符串
python 复制代码
username = "{{ 7*7 }}"
template = '<h1>Hello, ' + '{{ 7*7 }}' + '!</h1>'
# 拼接结果:
# '<h1>Hello, {{ 7*7 }}!</h1>'

注意!这个字符串长得和安全写法的模板一模一样 ,但本质完全不同------这里的 {``{ 7*7 }}用户注入的,不是开发者写的。

(2)词法分析

引擎不知道也不关心这段模板是怎么来的,它只看到一段字符串,照常"切 Token":

复制代码
Token 1:  TEXT       "<h1>Hello, "
Token 2:  VAR_START  "{{"
Token 3:  EXPR       "7*7"       ← 攻击者注入的内容被识别为表达式!
Token 4:  VAR_END    "}}"
Token 5:  TEXT       "!</h1>"

对比安全写法,安全写法中引擎看到的 Token 3 是 NAME "username"(一个需要查上下文的变量名)。而这里 Token 3 是 EXPR "7*7"(一个会被计算的表达式)。

(3)语法分析

构建 AST:

复制代码
Root
├── TextNode("<h1>Hello, ")
├── ExpressionNode(expression="7*7")    ← 这里不再是查变量,而是计算表达式
└── TextNode("!</h1>")
(4)渲染
复制代码
访问 TextNode("<h1>Hello, ")         → 输出 "<h1>Hello, "
访问 ExpressionNode("7*7")           → 计算 7*7 = 49 → 输出 "49"
访问 TextNode("!</h1>")              → 输出 "!</h1>"
(5)最终输出
html 复制代码
<h1>Hello, 49!</h1>

攻击者输入的 {``{ 7*7 }} 被模板引擎当作模板语法解析并执行了。

3、从计算到危害:攻击升级

7*7 只是个无害的概念验证。真正的危险在于,模板引擎的表达式求值往往能触及底层语言的能力。

以 Jinja2(Python)为例,攻击者可以逐步升级:

复制代码
第一步:确认漏洞存在
{{ 7*7 }}  →  49

第二步:探索可用对象
{{ config }}  →  泄露 Flask 配置信息(可能包含密钥)

第三步:访问 Python 内置类
{{ ''.__class__.__mro__ }}  →  获取字符串的继承链,找到 object 基类

第四步:遍历子类,寻找危险类
{{ ''.__class__.__mro__[1].__subclasses__() }}  →  列出所有子类

第五步:找到可以执行命令的类(如 os._wrap_close),实现远程代码执行
{{ ''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__['popen']('id').read() }}

一个简单的字符串拼接,最终可能导致服务器被完全控制

4、根因对比

安全写法 危险写法
用户输入的角色 上下文中的数据 模板字符串的一部分
引擎如何处理 渲染阶段以纯文本填入 词法分析阶段被解析为语法
{``{ 7*7 }} 的结果 原样显示 {``{ 7*7 }} 计算并输出 49
安全性 用户无法影响模板结构 用户可以注入任意模板代码

5、一句话总结

SSTI(Server-Side Template Injection,服务端模板注入)是指攻击者的输入被拼接进模板字符串而非作为数据传入上下文,导致模板引擎将用户输入当作模板语法解析并执行,从而实现从信息泄露到远程代码执行的攻击。

三、构造 SSTI 攻击

攻击 SSTI 通常遵循以下三步流程:

复制代码
检测 (Detect) → 识别 (Identify) → 利用 (Exploit)

SSTI 极易被误认为 XSS 或完全被忽视。任何允许用户输入进入模板的地方都应被视为潜在攻击面。防御的核心原则是永远将用户输入作为数据传入模板,而非拼接到模板中

1、检测

SSTI 漏洞常被忽视,因为它只有在审计者刻意寻找时才容易发现。一旦发现,利用起来往往非常简单(尤其在未沙箱化的环境中)。

(1)Fuzz 测试

第一步是向模板中注入一组模板表达式中常见的特殊字符:

复制代码
${{<%[%'"}}%\

如果服务器返回了异常/错误信息,说明注入的模板语法可能被服务端解析,这是存在 SSTI 的一个信号。

(2)纯文本上下文

在这种上下文中,用户输入在模板渲染时被直接拼接为字符串。

示例代码:

复制代码
render('Hello ' + username)

检测方法:将参数设为数学表达式:

复制代码
http://vulnerable-website.com/?username=${7*7}

如果输出为 Hello 49,则说明数学运算在服务端被执行,证明存在 SSTI。

这种情况有时会被误认为简单的 XSS 漏洞。

(3)代码上下文

用户输入被嵌入到模板表达式内部。

示例代码:

复制代码
greeting = getQueryParameter('greeting')
engine.render("Hello {{"+greeting+"}}", data)

正常请求:

复制代码
http://vulnerable-website.com/?greeting=data.username

输出:Hello Carlos

检测步骤:

(3.1)第一步:先注入 HTML 标签确认不是 XSS
复制代码
?greeting=data.username<tag>

拼接后的模板源码:

html 复制代码
Hello {{data.username<tag>}}

模板引擎渲染的时候,<tag> 会导致语法错误,出现页面空白、报错,或根本看不到 <tag> 的情况。

如果是普通 XSS (比如直接 print(greeting)),浏览器会收到 Hello data.username<tag><tag> 会变成真实 HTML 标签。

(3.2)第二步,尝试闭合模板语法并注入 HTML
复制代码
?greeting=data.username}}<tag>

拼接后的模板源码:

html 复制代码
Hello {{data.username}}</tag>}}

如果输出正确渲染了 Hello Carlos<tag>,则确认存在 SSTI。

2、识别模板引擎

检测到注入点后,下一步是确定正在使用哪种模板引擎。

(1)通过错误信息识别

提交无效语法通常就能暴露模板引擎信息。例如,无效表达式 <%=foobar%> 在 Ruby ERB 引擎中会触发如下错误:

复制代码
(erb):1:in `<main>': undefined local variable or method `foobar' for main:Object (NameError)
from /usr/lib/ruby/2.5.0/erb.rb:876:in `eval'
from /usr/lib/ruby/2.5.0/erb.rb:876:in `result'
from -e:4:in `<main>'
(2)通过决策树识别

通过注入不同引擎特有的数学运算表达式,根据返回结果逐步排除,来确定引擎类型。

决策树逻辑:

复制代码
${7*7}  →  成功?
├── 是 → a]  ${7*7} = 49
│         进一步测试 → a]  可能是 Jinja2, Twig, 或其他
└── 否 → {{7*7}}  →  成功?
          ├── 是 → {{7*'7'}}  →  返回什么?
          │         ├── 49      → Twig
          │         └── 7777777 → Jinja2
          └── 否 → 继续测试其他语法...

常见引擎及对应语法:

模板引擎 语法风格 语言
Jinja2 {``{ }} / {% %} Python
Twig {``{ }} / {% %} PHP
FreeMarker ${ } / <# > Java
Velocity $variable / #directive Java
ERB <%= %> Ruby
Tornado {``{ }} / {% %} Python
Smarty { } PHP
Handlebars {``{ }} JavaScript
Pebble {``{ }} / {% %} Java
Django {``{ }} / {% %} Python

同一载荷可能在多个引擎中都返回成功结果。例如 {``{7*'7'}} 在 Twig 中返回 49,在 Jinja2 中返回 7777777。不要仅凭一次测试就下结论。

3、利用

确认漏洞存在并识别出模板引擎后,即可开始尝试利用。利用过程通常包含以下三个阶段:

(1)阅读文档
(1.1)学习基本语法

了解模板引擎的基本语法、关键函数和变量处理方式是第一步。

(1.2)查阅安全相关文档

许多模板引擎的文档中包含安全相关章节,列出了已知的危险行为和内置的安全限制。

(1.3)查找已知利用方法

由于主流模板引擎使用广泛,通常可以在互联网上找到已被公开的利用方法(documented exploits),可据此调整以适配目标。

各引擎利用示例:

  • ERB (Ruby)<%= system("whoami") %>
  • Tornado (Python){% import os %}{``{os.system('id')}}
  • FreeMarker (Java)<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
  • Handlebars (JS):查找公开的 RCE 利用链
  • Django (Python){% debug %} 获取调试信息;{``{settings.SECRET_KEY}} 泄露密钥
(2)探索环境

如果文档中没有现成的利用方法,下一步是探索模板运行环境,发现所有可访问的对象。

  • 许多模板引擎会暴露一个 "self""environment" 对象,它充当命名空间,包含引擎支持的所有对象、方法和属性。
  • 在 Java 模板中,可以尝试列出所有环境变量:${T(java.lang.System).getenv()}
  • 需要特别关注开发者自定义的对象,这些对象更可能包含敏感信息或可利用的方法。
  • 同一网站的不同模板中,可用对象可能不同,需逐一探索。
(3)构造自定义攻击

当没有现成利用方法时,需要自己构造攻击链。

(3.1)利用对象链

识别可访问的对象后,审查每个对象的方法和属性,寻找可以链式调用以实现任意代码执行或文件读写的路径。

(3.2)利用开发者自定义对象

网站可能将自定义的业务对象传入模板,这些对象可能暴露了危险的方法。例如某些对象可能提供文件操作方法,可以被利用来读取或删除服务器上的文件。

自定义利用示例(Tornado + 自定义对象):

复制代码
# 设置目标文件为用户头像
user.setAvatar('/home/carlos/.ssh/id_rsa','image/jpg')

# 通过 GDPR 删除功能删除该文件
user.gdprDelete()

即使无法实现 RCE,仍可通过 SSTI 实现文件读取、敏感数据泄露等高危攻击。

四、防御措施

1、避免用户输入进入模板

最根本的防御是不允许用户修改或提交模板。但有时业务需求无法完全避免。

2、使用无逻辑模板引擎

尽量使用 "logic-less" 模板引擎 (如 Mustache),这类引擎将逻辑与展示分离,大大减少了可被利用的攻击面。

3、沙箱化执行

在沙箱环境中执行用户代码,移除危险的模块和函数。但需要注意:沙箱绕过是一个常见问题,不应完全依赖沙箱。

4、容器化隔离

将模板执行环境部署在锁定的 Docker 容器中,即使攻击者实现了任意代码执行,其影响范围也被限制在容器内。

5、综合建议

防御层级 措施 效果
设计层 不允许用户输入进入模板 从根源消除风险
引擎选型 使用无逻辑模板引擎 (Mustache 等) 大幅减少攻击面
运行时 沙箱化执行环境 限制可调用的功能
基础设施 Docker 容器隔离 限制攻击影响范围
输入处理 将用户输入作为数据参数传入而非拼接 避免注入点产生
相关推荐
JS_SWKJ4 小时前
2026年起,等保三级不再只是“防火墙”的事!这类设备成过审“硬通货”
网络安全
小红卒5 小时前
Go语言安全开发学习笔记3:TLS加密反弹Shell 原理与落地实现
网络安全·go语言
Chengbei116 小时前
AI 自动逆向 JS 加密!自动抓密钥、出报告,彻底解放双手,解决抓包数据包加密难题
开发语言·javascript·人工智能·安全·网络安全·网络攻击模型
Z1eaf_complete7 小时前
文件上传漏洞绕过方法
安全·网络安全
白帽黑客-晨哥8 小时前
CTF保姆级教程:从零基础到参赛拿奖,2026年最全指南!
网络安全·渗透测试·ctf比赛·网络安全大赛
~央千澈~8 小时前
《卓伊凡 · 网络安全研究室》之从网络安全角度看:为什么“养虾”其实是一种极其危险的行为
网络安全·养虾·肉鸡
℡終嚸♂68010 小时前
Goby资产测绘漏洞扫描工具红队版自带1000+poc,以及附赠收集的1000+poc(附下载链接)
安全·web安全·php
乾元11 小时前
红队测试:如何对大模型进行系统性的安全红队评估
运维·网络·人工智能·神经网络·安全·网络安全·安全架构
不灭锦鲤11 小时前
网络安全学习第47天
学习·web安全