模板注入(SSTICSTI)揭秘:看懂这篇就够了
本文介绍了模板及其在开发中的作用,解释了模板引擎的工作流程,并详细分析了服务器端(SSTI)和客户端(CSTI)模板注入攻击的原理、实现方式及防御策略。 2025-12-30 09:14:28 Author: www.freebuf.com(查看原文) 阅读量:0 收藏

什么是模板

模板是一种预先设计好的文本结构,包含静态内容和动态占位符。模板引擎会用实际数据替换这些占位符,生成最终的输出内容。

模板的作用

模板的主要作用是分离业务逻辑和展示逻辑,使得前端开发者和后端开发者可以并行工作,同时也使得代码更易于维护。

静态部分:由前端开发者设计,包括HTML、CSS、JavaScript等。 动态部分:由后端开发者提供数据,模板引擎将数据填充到模板中,生成最终的页面。

模板的实现(模板引擎)

模板引擎是负责解析模板、将动态数据填充到模板中并生成最终输出的组件。不同的模板引擎有不同的语法和特性,但是他们的主要工作流程却差不多。

模板引擎的工作流程

1.解析模板:将模板文件解析成抽象语法树(AST)或类似的内部表示。

2.处理指令:执行模板中的指令(如循环、条件判断等)。

3.替换占位符:将占位符替换为实际的值。

4.输出结果:将处理后的模板输出为字符串(如HTML)。

模板举例

我们用一个最简单的例子,只有一个占位符的模板。

模板字符串:"Hello, {{name}}!"

输入数据:{name: "World"} 输出:"Hello, World!"

如果输入数据为{name: "aaaa"} 对应输出就是"Hello, aaaa!",这就是模板主要作用。在实际开发中,一般更加的复杂。那么这个过程是如何产生漏洞的呢?主要原因是因为该参数用户可控,既然用户可以输入world,那么就可以输入其他符合当前模板引擎语言规范的内容。

模板注入产生原因

模板注入可分为以下两类:

SSTI(Server-Side Template Injection)

服务器端模板注入,攻击者能够向服务器端模板引擎注入恶意模板代码,从而在服务器端执行任意代码。

CSTI(Client-Side Template Injection)

客户端模板注入,攻击者能够向客户端模板框架注入恶意模板代码,在受害者浏览器中执行JavaScript代码。

模板注入攻击成功的关键,在于攻击者输入的内容(如{{ 1+2 }})能够被模板引擎解析并生成抽象语法树(AST),而不仅仅是作为纯文本处理,AST是连接模板语法和底层代码执行的桥梁。攻击者的输入被解析成AST只是第一步,而真正执行这个AST并使其产生效果的,是模板引擎的代码生成器或解释器

AST本身只是一个静态的数据结构,它描述了“要做什么”,但自己不会执行任何操作。需要一个“执行者”来遍历这棵树,并根据每个节点的含义去执行相应的操作。

什么是AST

抽象语法树(Abstract Syntax Tree,AST)是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说它是“抽象”的,是因为它并不会表示出真实语法中出现的每个细节,比如括号、分号等分隔符,在AST中会被忽略。

为什么需要AST? 在编译过程中,源代码首先被词法分析器(Lexer)分解成Token流(Token 是 AST 的基础构建块,通过词法分析将源代码分解为可处理的语法单元,为后续语法分析和代码生成奠定基础),然后语法分析器(Parser)根据语法规则将Token流组织成语法树(通常称为解析树,Parse Tree)。解析树包含了所有的语法细节,但是过于详细,不利于后续的语义分析和代码生成。因此,我们将其简化为AST,只保留关键的结构信息。

比如表达式1 + 2 * 3

解析树Parse Tree表示

Expression--表达式
├── Expression  (1)--左子节点
├── +              --中间节点
└── Term           --右子节点
    ├── Term  (2) --左子节点
    ├── *         --中间节点
    └── Factor  (3)--右子节点

抽象语法树 AST表示

+
   / \
  1   *
     / \
    2   3

从AST到代码执行一般要经历以下阶段:

  1. 解析与生成AST:模板引擎的解析器将模板文本(包含用户输入的 {{ 1 + 2 * 3 }})转换成AST。
  2. 语义分析与代码生成:模板引擎的后端(通常是代码生成器或解释器)会遍历AST,并将其转换为可执行的目标代码。这个目标代码可能是:
  • 宿主语言的源代码:例如,将AST转换回Python/Java/Ruby等语言的代码字符串。
  • 字节码:一种中间形式的指令,类似于Python或Java虚拟机执行的字节码。
  • 直接调用宿主语言的API:解释器直接根据AST节点调用对应的函数(如加法操作、属性访问)。
  1. 执行与输出:生成的代码或指令在模板引擎的运行时环境中被执行。计算出的结果(例如 1+2*3的结果 )会被插入到模板的对应位置,最终渲染成完整的HTML、配置文件等输出给用户。

不同编程语言的AST核心与抽象本质相同,但由于具体语法规则和语言特性的巨大差异,其实现细节和节点种类可以千差万别。但是基本原理几乎一致:所有语言的AST都是树状层次结构,都有表达式、语句、声明等基本节点类型。

不同语言的模板引擎,其“执行者”的实现方式也不同:

  • Python(Jinja2, Mako)
  • 执行者:Python解释器。
  • 过程:AST通常会被编译成Python字节码,然后由Python虚拟机执行。这意味着,如果攻击者能注入 {{ __import__(‘os’).system(‘whoami’) }},这个AST最终会被编译成等价的Python字节码,并由Python解释器直接执行系统命令。
  • Java(Velocity, FreeMarker, Thymeleaf)
  • 执行者:Java虚拟机,通过反射机制。
  • 过程:模板引擎会解析表达式,并通过Java的反射API动态地访问和调用目标对象的属性与方法。例如,{{ user.getName() }}的AST会引导引擎使用反射调用 user对象的 getName方法。
  • JavaScript(Handlebars, EJS)
  • 执行者:JavaScript引擎(如V8)。
  • 过程:模板可能被转换为JavaScript函数字符串,然后通过 eval()或 new Function()动态执行,或者引擎直接解释执行AST对应的操作。
  • PHP(Twig、Smarty)
  • 执行者PHP解释
  • 过程:生成AST并编译后,交给PHP解释器执行的。

语法边界

那么一个正常的字符串输入,是如何被识别成一个表达式的,不同编程语言(及其模板引擎)对于“表达式”的识别语法边界是截然不同的。正是这些语法边界定义了模板引擎的“解析规则”,决定了它从哪里开始、到哪里结束去识别一段需要处理的动态代码。

主要语法边界差异

下表对比了不同语言环境中常见的表达式语法边界:

语言/引擎典型的表达式边界示例 (输出49)特点与说明
PHP (原生)<?php ... ?><?php echo 7*7; ?>明确的标签界定整个代码块。
Jinja2 (Python){{ ... }}{{ 7*7 }}双花括号用于表达式求值与输出。
Smarty (PHP){ ... }或{$ ... }{7*7}或{$smarty.version}单花括号,语法较灵活。
Twig (PHP){{ ... }}{{ 7*7 }}与Jinja2类似,是现代PHP模板的常见风格。
Velocity (Java)$!{ ... }或#{ ... }$!{7*7}以美元符号和花括号为特征。
Thymeleaf (Java)[[ ... ]]或[( ... )][[${7*7}]]使用方括号,与HTML属性整合度高。
Freemarker (Java)${ ... }或[= ... ]${7*7}美元符号加花括号是其标准语法。
ERB (Ruby)<%= ... %><%= 7*7 %>使用类似于PHP的原生标签。
JavaScript (EJS)<%= ... %><%= 7*7 %>语法与Ruby的ERB类似。

模板引擎的解析器会按照预定义的规则(语法边界)来切割模板文本:

  1. 识别边界:解析器扫描到 {{、{$、<%等起始边界时,会进入“表达式解析模式”。
  1. 提取内容:它会持续提取内容,直到遇到对应的结束边界(如 }}、}、%>)。
  1. 生成AST节点:提取出的字符串(如 7*7)会被词法分析和语法分析,最终生成一个代表“乘法运算”的AST节点
  1. 脱离边界:遇到结束边界后,解析器切换回“文本模式”,继续处理后续的静态HTML/文本。

心要点

AST是根据边界内提取的内容生成的,而边界符号本身通常不会成为AST的一部分。它们的作用是告诉解析器:“这里面的东西需要被解析成有意义的代码结构,而不是普通文本。

而语法边界的差异直接决定了SSTI攻击的Payload构造:

  • 击必须匹配目标引擎的边界:比如针对Jinja2的 {{ config.items() }}在Smarty(使用 {})中会原样输出,因为Smarty的解析器不认识 {{这个起始符号。
  • 测漏洞的第一步就是模糊测试边界:尝试 {{1+1}}、{${1+1}}、<%= 1+1 %>等不同组合,观察哪个被计算,从而别出背后使用的引擎类型
  • 下文决定边界:有时表达式嵌入在HTML属性或JavaScript代码中,需要闭合上下文。例如,在属性中:name=“${userInput}”,攻击者可能需要先闭合引号:”}恶意代码{“。

模板小结

同的编程语言和模板引擎拥有自己独特的语法边界(如{{ }}${ }<% %>),这些边界是其解析器的“触发器”。正是这些触发器,决定了哪部分用户输入会被解析成AST并最终执行,哪部分会被视为纯文本。因此,在SSTI攻击中,识别并正确使用目标系统的语法边界,是成功利用漏洞的第一步,也是构造有效攻击载荷的基础。

Java中的SSTI

上面我们知道在java中的Freemarker (Java)也存在SSTI的可能性。现在就来通过Freemarker认识一下SSTI注入。

环境准备

首先需要准备Freemarker环境:pom.xml使用maven导入如下包:

1767082065_6953885191165f89bea29.png!small?1767082066772

现在使用servlet实现一个freemarker的模板,test.java代码如下:

package com.ssti;

import freemarker.cache.StringTemplateLoader;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.Version;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Map;

/**
 * 存在SSTI漏洞的FreeMarker Servlet
 * 演示FreeMarker模板注入攻击
 *
 * SSTI(Server-Side Template Injection)服务器端模板注入是一种安全漏洞,
 * 攻击者能够将恶意代码注入到服务器端模板中,并在服务器端执行。
 */
@WebServlet("/test")
public class test extends HttpServlet {

    // FreeMarker配置对象,用于管理模板引擎的配置
// 这个对象在Servlet初始化时创建,并在整个Servlet生命周期内共享使用
    private Configuration cfg;

    @Override
    public void init() {
        // init() 方法是 Servlet 生命周期方法,它在 Servlet 容器(如 Tomcat、Jetty)启动时被调用,用于 Servlet 的初始化工作
// 这个方法只会被调用一次,适合进行一些耗时的初始化操作,如创建数据库连接、加载配置文件等

// 初始化FreeMarker配置,指定使用的FreeMarker版本
// 这里使用FreeMarker 2.3.21版本,不同的版本可能有不同的特性和API
        cfg = new Configuration(new Version("2.3.21"));

        // StringTemplateLoader是一个内存中的模板加载器,它可以从字符串中加载模板内容
// 存在危险,因为用户可以控制模板内容!这意味着攻击者可以注入任意模板代码
        StringTemplateLoader stringLoader = new StringTemplateLoader();

        // 将StringTemplateLoader设置为FreeMarker配置的模板加载器
// 这样FreeMarker就知道从StringTemplateLoader中获取模板内容
        cfg.setTemplateLoader(stringLoader);

        // 注意:我们没有设置任何安全限制!
// 默认情况下,FreeMarker允许访问Java类和执行方法
// 这意味着如果用户能够控制模板内容,就可以执行任意Java代码,导致严重的安全漏洞
// 在生产环境中,应该使用cfg.setNewBuiltinClassResolver()来限制可访问的类
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // 设置响应的内容类型为HTML,字符编码为UTF-8
        // 这样浏览器就能正确解析HTML并显示中文
        response.setContentType("text/html;charset=UTF-8");
        // 获取响应输出流,用于向客户端发送HTML内容
        PrintWriter out = response.getWriter();

        // 获取用户输入,参数"content"是用户在表单中输入的内容
// 这个内容将被用作FreeMarker模板的一部分
        String userContent = request.getParameter("content");
// 我们只实现了危险模式
        String mode = request.getParameter("mode");//选择对应的安全模式

// 开始生成HTML响应页面
        out.println("<html><head><title>FreeMarker SSTI 演示</title>");
        out.println("<style>");
        // 定义页面的CSS样式,使页面更加美观
        out.println("  body { font-family: Arial, sans-serif; margin: 40px; }");
        out.println("  .container { max-width: 1000px; margin: 0 auto; }");
        out.println("  .demo { background: #f5f5f5; padding: 20px; margin: 20px 0; }");
        out.println("  .vulnerable { border-left: 5px solid red; }");
        out.println("  .safe { border-left: 5px solid green; }");
        out.println("  .payload { background: #ffe6e6; padding: 10px; margin: 10px 0; }");
        out.println("  code { background: #eee; padding: 2px 5px; }");
        out.println("</style>");
        out.println("</head><body>");
        out.println("<div class='container'>");
        out.println("<h1>FreeMarker SSTI 漏洞演示</h1>");

        // 测试用:直接使用用户输入作为模板(存在漏洞)
// 只有当用户选择"vulnerable"模式并且输入内容不为空时,才执行危险操作
        if ("vulnerable".equals(mode) && userContent != null) {
            out.println("<div class='demo vulnerable'>");
            out.println("<h2>危险:用户输入作为模板内容</h2>");
            // 显示用户输入的内容,使用escapeHtml方法进行HTML转义,防止XSS攻击
            out.println("<p>用户输入的内容:<code>" + escapeHtml(userContent) + "</code></p>");

            try {
                // 危险操作:使用用户输入作为模板名和内容
// 首先获取之前配置的StringTemplateLoader
                StringTemplateLoader loader = (StringTemplateLoader) cfg.getTemplateLoader();

                // 这里将字符串"欢迎 "和用户输入拼接,形成新的模板内容,并注册为"userTemplate"模板
//因此该模板类似为“欢迎”${userContent}
                // 这意味着用户输入的内容将成为FreeMarker模板的一部分,如果输入包含FreeMarker语法,就会被解析执行,类似sql注入
                loader.putTemplate("userTemplate", "欢迎 " + userContent);

                // 准备测试数据模型
// 数据模型是一个Map,用于向模板传递变量
// 在这个例子中,我们放入一个键值对:"name" -> "普通用户"
                // 注意:这个数据模型中的"name"变量在模板中并没有被使用,因为模板中并没有${name}
                Map<String, Object> hashMap = new HashMap<>();
                hashMap.put("name", "测试必须的任意hashmap");

                // 渲染模板
// 获取名为"userTemplate"的模板,这个模板是刚刚用用户输入创建的
                Template template = cfg.getTemplate("userTemplate");
                // 创建一个StringWriter,用于接收模板渲染后的结果
                StringWriter writer = new StringWriter();
                // 将数据模型传递给模板,并执行渲染,结果写入writer
                template.process(hashMap, writer);

                out.println("<h3>渲染结果:</h3>");
                out.println("<div style='border: 1px solid #ccc; padding: 10px; background: white;'>");
                // 输出渲染结果,注意这里没有进行HTML转义,因为模板可能包含HTML标签
// 这可能会导致XSS漏洞,但在这个演示中,我们关注的是SSTI
                out.println(writer.toString());
                out.println("</div>");

                // 添加简单说明
// 显示当前使用的模板内容,使用escapeHtml转义,避免破坏页面结构
                out.println("<p><strong>说明:</strong>模板为:<code>欢迎 " + escapeHtml(userContent) + "</code></p>");
                out.println("<p>您的输入被直接嵌入到模板中,作为模板的一部分执行</p>");

            } catch (TemplateException e) {
                // 捕获模板处理异常,例如语法错误,并显示给用户
                out.println("<p style='color: red;'>模板处理错误: " + e.getMessage() + "</p>");
            }

            out.println("</div>");
        }

        // 输入表单
// 提供一个表单,让用户输入内容并选择模式,提交后再次触发doGet方法
        out.println("<div class='demo'>");
        out.println("<h2> 测试工具</h2>");
        out.println("<form method='get'>");
        out.println("<label>输入内容:</label><br>");
        out.println("<textarea name='content' rows='5' cols='80' placeholder='输入内容,如:aaa 或 ${7*7}'></textarea><br><br>");

        out.println("<label>模式:</label><br>");
        // 单选按钮,选择模式。目前只实现了"vulnerable"模式,所以默认选中它
        out.println("<input type='radio' name='mode' value='vulnerable' id='vuln' checked>");
        out.println("<label for='vuln'>危险模式(用户输入作为模板变量)</label><br><br>");

        out.println("<input type='submit' value='测试'>");
        out.println("</form>");

        // 添加简单示例
// 提供一些示例输入,帮助用户理解如何使用
        out.println("<h3>示例:</h3>");
        out.println("<ul>");
        // 注意:在HTML中,$和{}需要转义,所以示例中使用了\\${}
        out.println("<li>输入 <code>aaa</code> → 输出 <code>欢迎 aaa</code></li>");
        out.println("<li>输入 <code>\\${7*7}</code> → 输出 <code>欢迎 49</code></li>");
        out.println("<li>输入 <code>\\${'hello'?upper_case}</code> → 输出 <code>欢迎 HELLO</code></li>");
        out.println("<li>输入 <code><#if 1==1>yes</#if></code> → 输出 <code>欢迎 yes</code></li>");
        out.println("</ul>");
        out.println("</div>");

        out.println("</div></body></html>");
    }

    /**
     * HTML转义方法,防止XSS攻击
     * 将HTML特殊字符转换为对应的实体编码,避免被浏览器解析为HTML标签或脚本
     * 注意:此方法仅用于显示用户输入,不保护FreeMarker SSTI漏洞
     * FreeMarker SSTI漏洞发生在服务器端模板渲染时,而XSS发生在客户端浏览器
     *
     * @param input 需要转义的字符串,可能包含HTML特殊字符
     * @return HTML转义后的字符串,特殊字符被替换为实体编码
     */
    private String escapeHtml(String input) {
        if (input == null) return "";
        return input.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
}

现在使用tomcat启动这个web项目。

1767082179_695388c314279e9ba5b2f.png!small?1767082181038

启动之后访问http://localhost:8000/SSTI/test 就得到了下面的页面

1767082195_695388d393a2ef1c789b1.png!small?1767082197666

1.漏洞探测

当我们输入任意字符串,将得到对应的欢迎界面,如下:

1767082244_695389047d50e0d870277.png!small?1767082246499

2.观察响应

但是当使用freemarker定义的语法进行注入的时候,就出现了问题:比如输入12+22

1767082308_695389443e70b6211ea83.png!small?1767082309204

返回了计算的结果,说明服务端将输入的内容进行了计算,可能存在执行代码的可能性。同时根据输入内容以及返回结果可以判断目标使用的大概的模板类型,因为不同模板模板语法均不一样。比如freemark使用${}包裹,而PHP中直接使用{},而ruby使用<%= %>。

3.确认与利用

根据freemarker的语法,被  ${  和  }  包围的内容,将被归类为 表达式(Expression),被执行,如下:

1767082346_6953896ab6f1642e23ce0.png!small?1767082348152

输入的代码被执行了,小写转换成了大写。并且如果输入如下的内容:将执行系统命令

1767082382_6953898e89b86131df3b1.png!small?1767082383497

成功执行了系统命令。这就是SSTI的利用。

漏洞分析

当我们输入恶意的模板时,比如:${'freemarker.template.utility.Execute'?new()('whoami')},代码中

1767082445_695389cd1cd1542082dd5.png!small?1767082446615

会将输入作为模板的一部分,加入模板。当服务端需要渲染该模板的时候:会执行下面的代码:

1767082459_695389db7b5525351f666.png!small?1767082460489

会获取这个模板userTemplate,在获取的过程中,会调用一系列的方法如下:会新建一个模板Template

1767082478_695389ee9aab5bea8a21d.png!small?1767082479774

然后调用FMParser的root方法,该类中会调用多个方法,该类是将token解析成ast的核心类

1767082500_69538a049e5644e766737.png!small?1767082501769

该类中有如下方法类型:根据不同的方法类型,会对token做相应的检查:

方法类型作用使用场景
jj_scan获取下一个token词法单元消费
jj_2_xx语法预测决定使用哪个语法分支
jj_3R_xx规则检查验证 token 序列是否匹配规则

比如根据freemarker的语法规则,会将${识别为一个token:

比如其接口中的定义:String[] tokenImage,定义了一些其语法的一些特殊符号,并通过数组的下标对应每个词法单元(Token)的唯一整数编号(kind),数组的值就是该kind对应的描述性字符串。

1767082563_69538a439e945056268a1.png!small?1767082565458

1767082575_69538a4f536a9951ea82f.png!small?1767082576375

比如在FMParser解析器中,会通过不同的符号内容,调用不同的方法。

1767082589_69538a5da4569fb01f6e0.png!small?1767082590967

然后根据语法规则解析成不同的结果,如下图:遇到语法边界${,会解析成表达式

1767082602_69538a6aa58bf6886967d.png!small?1767082604435

与之对应后面的字符串将会被识别成一个整体的表达式直到遇见内部的语法边界。比如问号?。

1767082634_69538a8a585f64be451e5.png!small?1767082635560

然后将其添加到结果result中。

1767082650_69538a9a1cde10f1959cd.png!small?1767082651243

当整个表达式被识别结束之后,会将语句中的内容和参数分别赋值给target和arguments

1767082664_69538aa8e35880502f99d.png!small?1767082665914

1767082674_69538ab2c0250a50e58e5.png!small?1767082676812

最终会得到一个AST结果对象template,结构如下图:

1767082691_69538ac387ab3080c1425.png!small?1767082693363

freemarker模板识别小结:

  1. 识别语法边界:解析器会扫描整个输入字符串,寻找预定义的语法标记。这些标记就像是路标,告诉解析器哪里是语句的开始和结束。常见标记如下:
  • 指令标记:如 <#list>、</#if>。
  • 插值标记:即 ${和 },这是SSTI攻击中最常被利用的入口。
  • 注释标记:<#--和 -->。
  1. 切割与分类:当找到这些标记时,解析器会将它们之间的字符串切割出来,并根据标记的类型,将这段字符串分类为不同的语法元素。
  • 被 ${和 }包围的,被归类为 表达式(Expression)
  • 被 <#if>和 </#if>等包围的,被归类为 指令(Directive)
  • 没有被任何标记包围的部分,则被视为 纯文本(Text Block),直接输出。
  1. 构建表达式树(AST):对于被识别为表达的部分,解析器会进行更深入的“二次截断”,以构建一个树形结构。这是整个解析过程的核心。
  • 词法分析:表达式字符串(如 product.class.getProtectionDomain().getCodeSource())被进一步拆分成更小的“单词”(Token),例如变量名(product)、点号(.)、方法名(getClass)等。
  • 语法分析与建树根据运算符优先级(如点号.调用优先于括号())和语法规则,这些单词被组装成一棵表达式树。例如,a.b()被构建成:一个方法调用节点(MethodCall),它的“目标”是属性访问节点(Dot,左子节点是变量a,右子节点是属性名b)。

渲染结果-命令执行过程

继续在我们的servlet中调用 template.process(hashMap, writer);渲染模板中的内容,并返回结果。

如下图:开始在template中从根节点开始解析:

1767082770_69538b123baa2fd46edae.png!small?1767082771272

得到根节点:

1767082781_69538b1d9958f2f7bc0b0.png!small?1767082782667

看这些方法的名字,大概应该就是访问这个抽象语法树,然后执行相关的方法了:

1767082808_69538b38f22bce17a836e.png!small?1767082810320

1767082818_69538b4272a93cbe4907f.png!small?1767082819980

经过几个eval方法的调用,最终得到一个全路径类名的字符串。

1767082841_69538b5940948c9b609e7.png!small?1767082842642

将得到的全类名作为参数调用new ConstructorFunction(target.evalAndCoerceToPlainText(env), env, target.getTemplate());,其实就是这里的target.evalAndCoerceToPlainText(env) 经过一系列调用最终返回这个value的值

1767082862_69538b6e76c6fdba5c172.png!small?1767082863609

这里直接用参数className接收,调用TemplateClassResolver的resolve方法,这里的方法如下:

1767082875_69538b7b726716bc0f59a.png!small?1767082876525

1767082889_69538b8909d81c4de3bb4.png!small?1767082893710

通过反射的方式获取classname的类对象。如下图:最终得到freemarker.template.utility.Execute该类对象cl。

1767082909_69538b9d6629f6f2da891.png!small?1767082911140

得到类对象,那么接下来肯定会创建该类对象的实例:继续往下执行到exec方法

1767082921_69538ba9e25377d2eb155.png!small?1767082925202

果然得到该execute对象

1767082933_69538bb5092c3658e8d34.png!small?1767082934824

返回这个对象,并且接着调用该对象的exec方法。传入参数arguments=whoami

1767082949_69538bc5eb4a25520b9a4.png!small?1767082951448

而在该方法中直接调用了Runtime.getRuntime().exec()执行命令,得到命令结果

1767082963_69538bd3b8094d2b43135.png!small?1767082964724

完成了SSTI注入的整个流程。

流程小结

FMParser 是 FreeMarker 引擎中将模板文本转化为结构化数据(AST)的“核心转换器”,template则是ast的中间载体,而freemarker.template.utility.Execute类则是执行命令的最终实现。

看似在过程中会通过反射创建一个用户传入的字符串类对象,好像可以创建任意类对象,但是该类必须是继承了TemplateModel 的类,如图:

1767083062_69538c36eff95e6198ef5.png!small?1767083064787

所以该SSTI只有一种方式注入,且只能通过这个实现类来实现注入。

SSTI在其他语言中的表现

译型引擎(如FreeMarker)

以Java的FreeMarker为例,它的设计目标之一是高性能。为了实现这个目标,FreeMarker会在首次加载模板时,将模板文件解析并编译成一个Template对象。这个编译过程就包含了生成AST(抽象语法树)或类似结构的步骤。随后,每次渲染(调用Template.process(...))都是基于这个已编译好的结构进行数据填充和快速输出。

Java(以FreeMarker/Thymeleaf为代表)

Java模板注入的利用思路与Java本身的强类型和反射机制紧密相关。 FreeMarker:如上面解析-其内置一个危险的freemarker.template.utility.Execute类,可以直接实例化并执行命令。现代版本已移除或限制,但错误配置仍可能引入风险。 Thymeleaf:在Spring框架中,如果表达式使用${...}且未作严格过滤,可以调用静态方法(通过T())或进行对象实例化,从而执行类似Runtime.getRuntime().exec()的代码。 Java环境通常有更严格的默认安全配置,如安全管理器禁止执行任意命令,因此Java SSTI的利用条件更为苛刻。

解释型引擎(如Jinja2, Twig)

这类引擎的工作方式更接近“动态解释”。当渲染模板时,引擎会逐行或按块解析模板字符串,遇到{{ ... }}或{% ... %}等标签时,立即调用底层语言(Python、PHP)的解释器来计算表达式或执行语句。用户的SSTI Payload在这个“计算”阶段被动态执行,整个过程完成后通常不会保留一个结构化的语法树。

所以SSTI注入的payload的实现,不同的语言其思路也不一样。其核心思想是服务器会将用户输入的内容当成代码的一部分执行。

虽然SSTI(服务器端模板注入)虽然核心原理相同,但在不同编程语言和模板引擎中,其利用方式、利用链构造和防御重点存在显著差异。在Java中因其严格的安全模型,与Python等动态语言相比,利用难度通常更大。

Python(以Flask/Jinja2为代表)

用链高度依赖于对象反射和内置模块。由于“万物皆对象”的特性,可以从任何一个内置对象(如空字符串'')出发,通过__class__找到其类,再通过__bases__或__mro__回溯到所有类的基类object,最后利用__subclasses__()列出所有已加载的子类。攻击者会在这个子类列表中寻找包含危险模块(如os、subprocess)的类,通过__init__.__globals__获取其全局上下文,最终执行命令,其实现根据其实现的模板类型,设计对应的payload。

比如"a".__class__返回对应的类型。如下图:

1767083274_69538d0aa1e629aa48c1b.png!small?1767083276206

可通过顶级父类获取所有子类,从而获取可以执行代码的模块。并且其返回的是一个数组类型。然后根据其索引即可获取对应的模块。比如这样即可返回对应的模块。

1767083287_69538d177d3ec98695001.png!small?1767083288447

再比如这样,返回一个dict,显示所有可用模块。

1767083301_69538d25833e082402094.png!small?1767083302590

逐步获取可执行代码的方法。

PHP(以Twig为代表)

PHP的利用方式往往更“短平快”。 Twig:虽然本身设计较为安全,但某些版本或配置下,可以通过filter过滤器直接调用system等PHP函数执行命令。 Smarty:旧版本支持{php}标签直接嵌入PHP代码,新版已废弃

不同的开发语言其利用的方式差距巨大,原理几乎差不多,可根据不同的框架,查看对应的语法规则,构造对应的payload。

防御重点的差异

  • Python:重点是禁止或严格过滤用户输入中的魔术方法(如_),并确保模板在安全的沙箱环境中运行。
  • Java:关键是使用模板引擎的最新安全版本,并严格配置其解析策略。例如,在FreeMarker中,应将new内置函数(?new)配置为仅允许实例化安全的类。
  • PHP:核心是禁用模板引擎中的危险函数和过滤器,并避免将用户输入直接拼接为模板。

SSTI总结

SSTI漏洞的成立依赖于两个条件:一是应用动态地拼接用户输入与模板指令;二是目标模板引擎提供了执行系统命令、访问底层对象或反射机制的能力,如Java的freemarker、Python的Jinja2、PHP的Twig以及JavaScript的Handlebars等,尽管语法和内置函数各异,但若存在类似的输入控制缺陷,都可能成为SSTI攻击的载体,造成从信息泄露到远程代码执行(RCE)的严重后果。

CSTI(客户端模板注入)

(CSTI)是一种客户端安全漏洞,与服务器端模板注入(SSTI)相对应。其核心区别在于,CSTI的攻击载荷在用户的浏览器中被前端JavaScript框架解释执行,而SSTI则是在服务器端执行。

漏洞原理:当Web应用(如使用AngularJS、Vue.js等框架)将用户可控的输入直接嵌入到客户端模板中时,如果输入未经正确处理,攻击者就可以注入特定的模板表达式。就能在受害者的浏览器中执行任意JavaScript代码,可能导致窃取Cookie、会话劫持、钓鱼攻击等后果。

与SSTI一样:不同前端框架的模板语法不同,攻击载荷也会随之变化。

CSTI相比SSTI更简单,因为其主要是针对客户端发起的攻击,有些情况下甚至不需要与服务端进行交互。其常见类型如下:

框架检测载荷攻击载荷示例原理简述
AngularJS(沙箱逃逸后){{8*8}}

{{constructor.constructor('alert(1)')()}}

${{constructor.constructor('alert(document.cookie);')()}}

利用constructor属性访问JavaScript的Function构造函数,动态执行代码。
Vue.js 2{{4*2}}{{constructor.constructor('alert(1)')()}}与AngularJS类似,利用Vue实例的构造函数。
Vue.js 3{{3*9}}{{_openBlock.constructor('alert(1)')()}}Vue 3内部API变化,利用_openBlock等内部函数。
Mavo[3*7][self.alert(1)]或[(1,alert)(1)]利用Mavo特有的[...]表达式语法执行JS

CSTI漏洞示例

来个AngularJS的简单的例子,新建一个html文件:12345.html代码如下:

<!DOCTYPE html>
<html ng-app="cstiDemo">
<head>
    <title>AngularJS 1.0.1 CSTI 测试</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/angular.js/1.0.1/angular.min.js"></script>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        input { width: 300px; padding: 8px; margin: 10px 0; }
        button { padding: 8px 16px; background: #4CAF50; color: white; border: none; cursor: pointer; }
        .result { margin-top: 15px; padding: 10px; background: #f5f5f5; border-left: 4px solid #2196F3; }
    </style>
</head>
<body ng-controller="MainController">
    <h3>AngularJS CSTI 动态测试</h3>
    
    <div>
        <input type="text" ng-model="userInput" placeholder="输入Angular表达式,如:{{7*7}}">
        <button ng-click="execute()">执行</button>
    </div>
    
    <div class="result">
        <strong>结果:</strong> {{ result }}
    </div>

    <script>
        // 检查Angular是否加载成功
        if (typeof angular === 'undefined') {
            alert('AngularJS库加载失败,请检查网络或更换CDN');
            document.body.innerHTML = '<p style="color:red">AngularJS库加载失败,请检查网络连接或尝试其他CDN源</p>';
        }
        
        // 创建Angular应用
        var app = angular.module('cstiDemo', []);
        
        app.controller('MainController', ['$scope', function($scope) {
            console.log('AngularJS版本:', angular.version.full);
            
            // 初始化变量
            $scope.userInput = '{{11*11}}';
            $scope.result = '等待输入...';
            
            // 执行函数
            $scope.execute = function() {
                console.log('执行输入:', $scope.userInput);
                
                try {
                    // 移除{{和}},保留中间的表达式
                    var expression = $scope.userInput.replace(/[{}]/g, '');
                    
                    // 使用$eval执行Angular表达式
                    $scope.result = $scope.$eval(expression);
                    
                    console.log('执行结果:', $scope.result);
                } catch (error) {
                    $scope.result = '执行错误: ' + error.message;
                    console.error('执行出错:', error);
                }
            };
            
            // 页面加载后自动执行一次默认输入
            setTimeout(function() {
                $scope.execute();
                $scope.$apply(); // 确保更新视图
            }, 100);
        }]);
    </script>
</body>
</html>

任意浏览器打开该html。

1767083709_69538ebd3e9a1110452a4.png!small?1767083710230

如果输入AngularJS语法格式的表达式:比如{{}}包裹的内容:正常内容:

1767083724_69538eccb5ffa1f68fea7.png!small?1767083725694

漏洞发现的payload:可以是计算表达式等等比如:

1767083738_69538eda2e11aa130da00.png!small?1767083739424

模板会执行表达式中的计算内容。如果使用AngularJS中的函数方法即可实现前端的代码执行了:

比如输入{{constructor.constructor('alert(11111)')()}} ,完成XSS攻击。它可以完全不依赖服务器,也可以与后端交互。

1767083756_69538eec5b925a9686a25.png!small?1767083757343

或者这样

1767083767_69538ef706a90ddcaec41.png!small?1767083767980

SSTI & CSTI 漏洞总结

特性SSTI (服务器端模板注入)CSTI (客户端模板注入)
执行环境服务器。模板引擎在服务端渲染,将数据嵌入HTML后发送给客户端。客户端浏览器。前端框架(如AngularJS, Vue)在浏览器中解析模板。
影响与危严重。可能导致远程代码执行、读取服务器敏感文件、攻击内网等,直接危害应用服务器和安全。中等。通常导致客户端XSS、DOM篡改、Cookie窃取,影响范围限于当前用户会话。
关键检测点检测输入是否在服务端模板语法上下文中被解析。检测输入是否在前端框架的模板语法(如{{ }})中被解析。

Payload格式的多样性

SSTI:因“引擎”而

高难度 - 沙盒环境或严格过滤

现代框架(如Jinja2、Twig)的默认安全配置、存在WAF,需要找到沙盒逃逸技巧、利用有限的函数或属性链进行攻击

比如:Jinja2:从内置对象(如request、self)或子类(如__subclasses__())中寻找危险模块,如下格式:

{{ self.__init__.__globals__.__builtins__.open('/etc/passwd').read() }}

Twig:过滤器链攻击,如map、filter配合call_user_func

{{ ["id"]|map("system")|join(",") }}

语法格式差别较大。

中难度 - 无沙盒或弱过滤

旧版本引擎、自定义或不安全的模板实现,需要了解该引擎的特定危险函数或全局对象

比如Freemarker (Java):直接调用new创建对象,见文章中间内容。

Velocity (Java):访问 Runtime.exec

#set($rt=$class.inspect("java.lang.Runtime").type.getRuntime()) $rt.exec("calc")

低难度 - 直接代码执行

PHP的eval()式模板、某些JavaScript服务器端引擎,几乎无防护,Payload就是一段代码。

PHP (Twig旧版或自定义):可能直接拼接PHP代码

Node.js (EJS withdebug):开启调试模式时可能直接执行JS

CSTI:格式相对统一Payload通常围绕特定前端框架的插值语法

AngularJS (1.x):最经典的CSTI,{{ }}内的表达式在沙盒中执行,但沙盒可逃逸,比如上面的例子。

{{ $eval.constructor('alert(1)')() }}
{{ constructor.constructor('alert(1)')() }} // 沙盒逃逸后

SSTI利用难度远高于CSTI。SSTI需要精准识别后端引擎并构造对应Payload;CSTI更多是绕过前端框架的沙盒或利用不当配置。

漏洞识别方法探讨

主动探测与模糊测试

步骤一:发现注入点

在所有用户输入点(参数、Header、Cookie)尝试插入模板语法的数学表达式或通用表达式。

经典探测Payload

{{7*7}}-> 观察输出是否为 49
${7*7}-> (适用于Freemarker等)
${{7*7}}-> (混合语法)
<%= 7*7 %>-> (ERB等)
{{7*'7'}}-> 在Jinja2中会返回 7777777

目的:观察响应是否被计算,而非原样输出

步骤二:识别引擎

通过观察不同语法的响应、错误信息来“指纹识别”模板引擎。

  • 提交 {{'abcd'}}可能触发错误,错误信息中常包含引擎名称(Jinja2, Twig, Smarty等)。
  • 根据不同的模板语法格式,识别模板引擎比如{},{{}},${}等等
  • 提交无效语法 ,观察错误差异。
  • 使用工具(如 TplmapBurp Suite SSTI Scanner插件)自动化探测

识别关键主动提交计算表达式并观察响应是最有效的识别手段。错误信息是宝贵的指纹来源

步骤三:验证与利用

一旦识别出引擎,即可使用特定Payload验证并尝试利用

结束

SSTI是上下文相关的漏洞。成功利用取决于你对目标系统所用的编程语言、Web框架、模板引擎的深入了解,其利用的最大难点在于从受限的模板环境到系统命令执行的跨越,这需要丰富的知识积累(如Python对象链、Java反射、PHP函数)

SSTI是一种危害巨大但利用复杂的漏洞,考验攻击者的系统知识深度;

CSTI则是前端安全范畴的延伸,通常与XSS结合。


文章来源: https://www.freebuf.com/articles/web/464389.html
如有侵权请联系:admin#unsafe.sh