0x01 公告事实核对
下表是公告陈述与核对结果的对照。转述类稿子最常见的毛病是把公告没写的东西当成公告里写了的,技术上的计算错误反而是少数,所以这一节全部按"能否在原始数据里找到"来判定。
项目 | 公告 / 转述稿 | 核到的原始值 | 判定 |
|---|---|---|---|
GHSA 编号 | — |
| A |
CVE 编号 | CVE-2026-111057 |
| A(NVD 侧尚无记录) |
标题 | 未认证 RCE |
| A |
严重性 | critical |
| A |
CVSS | 10.0 |
| A |
CWE | — |
| A |
影响版本 | 3.0.0–3.2.3 |
| A |
修复版本 | 3.2.4 |
| A |
公开时间 | — |
| A |
成因 | 一个缺失的逗号 | 公告原文 | A(陈述)→ 本文 0x03 执行验证 |
PoC 环境 | 本地 Docker,Mercury 3.2.3,默认配置 | 公告 | B(研究者陈述,本文不复现) |
实测 | 有 | 公告 | B(同上) |
已进 CISA KEV | 部分转述稿暗示 | KEV 目录 | A(否定了这个暗示) |
表里有两处需要展开说。
第一处是包名。转述稿写的是 mljar/mercury(仓库名),PyPI 上的包名是 mercury,不是 mljar-mercury。查 mljar-mercury 时 PyPI 返回 {'message': 'Not Found'}。这个差别在写自动化自查脚本时会变成"包扫不到、以为没装"。
第二处,"CVE 已分配"和"NVD 已发布"是两件事。nvd_published_at = null 意味着此刻这个编号只在 GHSA 侧成立。如果漏洞管理流程以 NVD 同步为准,这条漏洞现在还在"厂商已确认、NVD 未收录"的窗口里,这类窗口恰恰最容易漏。
另外纠正传播中的一处说法:有稿子把影响版本写成"3.x 全系列"。公告写的是 <= 3.2.3,3.2.4 起已修;而 3.2.5 又往清单里加了一条(见 0x0a)。"全系列"既夸大了受影响面,也掩盖了"3.2.4 修得不完整"这个更有价值的信息。
0x02 审计环境与取证口径
源码坐标
mkdir -p src && cd src
git clone --no-checkout https://github.com/mljar/mercury.git mercury
git -C mercury worktree add ../mercury-v323 v3.2.3
git -C mercury worktree add ../mercury-v324 v3.2.4用 --no-checkout 加 worktree,而不是 clone 两次,是为了让两个版本共享同一份对象库,同时行号在两个版本上都能独立解析。本文所有 file:line 指的是 src/mercury-v323/... 或 src/mercury-v324/... 下的真实文件。
第三方依赖同样落盘,因为根因的一半在依赖里:
目录 | 来源 | 版本 |
|---|---|---|
|
| 2.18.2 |
| 同上 | 0.5.4 |
| 从验证用 venv 里拷出 | 6.4.2 |
中间踩过一个坑:terminals 的处理器并不在 jupyter_server/terminals/ 下。2.x 之后它搬到了独立的 jupyter_server_terminals 包,而 jupyter_server 2.18.2 的 METADATA 第 60 行强制依赖 jupyter-server-terminals>=0.4.4。最初按 1.x 的路径去 grep jupyter_server/terminals,扑了个空,最后是从 auth/utils.py:55-60 里硬编码的终端 URL 正则反推出真实位置的。
证据分级
A 级 — 在本机源码上直接读到,或本机执行算出(规则条数、合并后的正则文本、路径匹配结果、traitlets 特征值、git 提交日期、PyPI 上传时间、GHSA 字段)。
B 级 — 公告与研究者的陈述。引用,不复现、不背书。
C 级 — 想确认但没确认到的。写出来标着,不猜。
探针清单与执行范围
六支探针加一次自查复跑,脚本随文附在 scripts/,完整输出在 mercury-repro-output.txt(探针 1–5 采集于 2026-10-20T06:28:07Z,探针 6 采集于 2026-10-20T07:11:11Z,自查 A 转录采集于 2026-10-20T07:37:55Z;tornado 6.4.2,Python 3.9.6)。
编号 | 脚本 | 证明了什么 | 级别 |
|---|---|---|---|
探针 1 |
| 两个版本运行时各 3 / 4 条规则;合并文本 98 字符;3.2.3 相对 3.2.4 少拦住 6 条路径形状 | A |
探针 2 |
| 16 条路径 × 2 版本, | A |
探针 3 |
| 按 | A |
探针 4 | 交叉验证 | v3.2.4 自带的测试跑在 v3.2.3 源码上 → | A |
探针 5 |
| 真实 traitlets 加载器 + 真实 | A |
探针 6 |
| 0x0c 第 4 条建议的不变式测试,拿 3.2.3 / 3.2.4 真实文件跑通:3.2.3 下 | A |
自查 A |
| 0x0d A 两条命令在两个工作树上的真实终端输出( | A |
没跑的部分:Mercury 进程、任何 HTTP/WebSocket 请求、terminal_manager.create()、uid=0 的实际观测。所以后文凡是写"这条链在运行时逐跳为真",其中"终端处理器真的被注册""匿名身份真的被发出""POST 真的执行"这三跳是按源码行号推出来的(B 级),不是执行出来的。0x08 里逐跳标了哪几跳是算的、哪几跳是读的。
有一处执行偏差需要说明。探针 3 没有调用 tornado 的 RuleRouter.find_handler(),而是手写了 for rule in router.rules: if rule.matcher.match(req) 的匹配循环。原因是 find_handler 会走到 get_target_delegate 并断言 request.connection 存在,而构造的是一个只有 .path 的假请求。这个偏差只影响"命中之后怎么实例化 handler",不影响"命中哪条规则"这一被测命题。
0x03 根因:隐式字面量拼接导致的规则合并

src/mercury-v323/mercury_app/block_handler.py 全文 16 行,问题在 6-12 行:
BLOCKED_PATTERNS = [
# JupyterLab UI + API
r"/lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.*)?"
r"/api/terminals(?:/.*)?",
r"/api/shutdown(?:/.*)?",
r"/files(?:/.*)?",
]第 8 行行尾没有逗号。Python 里相邻的字符串字面量在编译期直接拼成一个,所以第 8、9 两行是一个元素。列表运行时长度是 3,不是 4。
这不是推断。探针 1 把两个版本的模块分别 import 进来量过:
v3.2.3 len(BLOCKED_PATTERNS) = 3
v3.2.4 len(BLOCKED_PATTERNS) = 4
两个版本源码里的 r"..." 字面量都是 4 个;差别只在第 8 行行尾有没有逗号


合并后的那条正则如下(98 字符,探针 1 ② 原文):
/lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.*)?/api/terminals(?:/.*)?合并 == 第一条直接接第二条? True
它到底拦住什么(探针 1 ③,re.fullmatch 口径):
路径 | 命中 |
|---|---|
| 命中 |
| 命中 |
| 命中 |
| 不命中 |
| 不命中 |
| 不命中 |
这张表是整件事的核心。合并没有让规则变松,它让规则换了一个没人想要的主语:原来两条独立规则各自覆盖一片,现在变成一条要求"/lab 开头 + 以 /api/terminals 结尾"的复合规则。它仍然会命中一些路径,只是那些路径恰好本来就不危险。
这类缺陷麻烦的地方在于它不报错。列表长度 3 和 4 都是合法状态,import 不会警告,mypy 不管字面量拼接,lint 默认也不查。它是一个安静的语义漂移。
清单到路由的装配过程



src/mercury-v323/mercury_app/app.py:108-119:
# disable notebooks edit resources
if is_mercury_app():
sa = getattr(self, "serverapp", None)
if not sa:
return
app = sa.web_app
base_url = (sa.base_url or "").rstrip("/")
block_rules = []
for pat in BLOCKED_PATTERNS:
full_pat = (base_url + pat) if base_url else pat
block_rules.append(Rule(PathMatches(full_pat), BlockedHandler))
app.default_router.rules = block_rules + app.default_router.rules这段代码里还有三个事实(A 级,直接读出):
它是前置,不是覆盖。
block_rules + app.default_router.rules把阻断规则插在默认路由表前面,靠"先匹配先赢"生效。所以一条规则漏了,等价于这个路径从来没有被拦过,而不是"拦了但被绕过"。它只在独立部署下装配。
is_mercury_app()(app.py:53-60)只在sys.argv[0]以mercury_app/__main__.py或mercury结尾时返回 True。作为 JupyterLab 扩展导入时这整段不执行,那时/api/terminals的暴露面属于 JupyterLab 自己的权限模型,不在本条缺陷的射程内。base_url会前缀化。部署在子路径下时每条规则前会拼上base_url。这对自查有影响:线上抓到的路径不是清单里的字面值。
0x04 匹配口径的一致性验证
这一节要回答一个前置问题:用 re.fullmatch 得到的结论,能不能代表 tornado 在生产里的行为。如果不能,0x03 那张表就没有意义——真实流量走的是 tornado 的匹配。



tornado 的实现在 routing.py:555-571:
def __init__(self, path_pattern: Union[str, Pattern]) -> None:
if isinstance(path_pattern, basestring_type):
if not path_pattern.endswith("$"):
path_pattern += "$"
self.regex = re.compile(path_pattern)
...
def match(self, request: httputil.HTTPServerRequest) -> Optional[Dict[str, Any]]:
match = self.regex.match(request.path)模式不以 $ 结尾就自动补上,然后只用 .match()(从头锚定)。两端都锚定,即在本场景下与 re.fullmatch 等价。
等价需要验证,不能当作假设。探针 2 把 16 条路径在两个版本上分别用两种口径各跑一遍,逐行打印:
path fullmatch PathMatches
/api/terminals False False
/lab False False
/lab/api/terminals True True
/api/shutdown True True
/files/private.txt True True32 个格子,零分歧。后文所有"拦住 / 放行"的表述,两种口径下都成立。
另一个细节也量了:3.2.4 里 /lab 那条规则的 len 是 76,3.2.3 里合并版是 98,差 22。/api/terminals(?:/.*)? 自身长度 21,剩下的 1 是逗号,逗号不进正则。用长度反推拼接是个便宜的校验,比反复读代码可靠。
0x05 路由级穿透:未命中规则的下游落点

0x04 只证明了"正则不匹配"。但"正则不匹配"到"请求被上游处理器接走"之间还差一段:阻断规则前置在 default_router.rules 头部,一条都没命中之后,请求继续往下走,走到谁那里?
探针 3 按 app.py:116-119 的字面逻辑重建了这张表(用真实的 Rule / PathMatches / RuleRouter 对象):
v3.2.3(受影响) BLOCKED_PATTERNS 条数=3
/api/terminals -> jupyter_server_terminals.TerminalRootHandler
/api/terminals/1 -> jupyter_server_terminals.TerminalRootHandler
/lab -> jupyterlab_server.LabHandler
/lab/tree/default -> jupyterlab_server.LabHandler
/api/shutdown -> BlockedHandler(403)
/files/private.txt -> BlockedHandler(403)
v3.2.4(修复后) BLOCKED_PATTERNS 条数=4
/api/terminals -> BlockedHandler(403)
/api/terminals/1 -> BlockedHandler(403)
/lab -> BlockedHandler(403)
/lab/tree/default -> BlockedHandler(403)TerminalRootHandler 这个名字很关键:它是注册在框架里的真实处理器,不是一个 404。在 3.2.3 的独立部署里,POST /api/terminals 面对的是一个完整可用的、带 @web.authenticated + @authorized 两层装饰器的入口(jupyter_server_terminals/api_handlers.py:33-37)。这两层为什么挡不住,见 0x08。
另外,/api/contents 在两个版本里都落到"其他 Jupyter 处理器"。这条线索在 0x07 展开。
0x06 公告未点名的第一条后果:/lab 整片同时失守
公告的 Summary 和 Details 只点名了 /api/terminals,转述稿基本照抄了这一句。
但合并发生在 /lab 那条规则上,代价是双向的。探针 1 ④ 把 3.2.3 相对 3.2.4 少拦住的东西列成差集(re.fullmatch 口径):
/api/terminals 3.2.3=放行 3.2.4=拦住
/api/terminals/1 3.2.3=放行 3.2.4=拦住
/lab 3.2.3=放行 3.2.4=拦住
/lab/ 3.2.3=放行 3.2.4=拦住
/lab/tree/default 3.2.3=放行 3.2.4=拦住
/lab/workspaces/default 3.2.3=放行 3.2.4=拦住六条,不是一条。/lab 是 JupyterLab 的完整 UI 入口,文件浏览器、终端面板、控制台全在里面。对一个"把 notebook 变成网页应用"的产品来说,把 /lab 整片放开,严重性未必低于终端 API 本身。
这条不是推论,是厂商自己的测试测出来的。探针 4 把 3.2.4 新增的 mercury/tests/test_block_handler.py 原样跑在 3.2.3 源码上:
def test_terminal_api_is_blocked():
> assert is_blocked("/api/terminals")
E AssertionError: assert False
def test_lab_and_other_sensitive_endpoints_are_blocked():
> assert is_blocked("/lab")
E AssertionError: assert False
2 failed, 1 passed, 1 warning in 0.06s两条失败,第二条就是公告没点名的那半边。



公告标题选了后果里最显眼的一条,这可以理解。但如果据此把修复优先级排成"只关心终端 API 的服务可以缓一缓",就会漏掉同一批部署里 /lab 也是敞开的。影响面需要自己算差集。
0x07 公告未点名的第二条后果:3.2.4 修复范围不完整
探针 1 ⑤ 顺手量了后续版本的清单条数:
v3.2.3 rules=3 v3.2.4 rules=4 v3.2.11 rules=5
v3.2.11 清单:
rule[0] /lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.*)?
rule[1] /api/terminals(?:/.*)?
rule[2] /api/shutdown(?:/.*)?
rule[3] /api/contents(?:/.*)?
rule[4] /files(?:/.*)?
/api/contents: 3.2.3 拦住= False 3.2.4 拦住= False 3.2.11 拦住= True/api/contents 是 Jupyter 的文件内容 API,在 3.2.3 和 3.2.4 里都不在拦截范围内。它是 3.2.5 才补上的,对应提交 73a40ce,提交信息 "Secure anonymous kernel execution",时间 2026-08-27,也就是 3.2.4 发版之后 6 天。
所以"3.2.4 修复"这个说法,对 CVE-2026-111057 这一条成立;换成"3.2.4 之后阻断清单就没有匿名可达的敏感入口了",不成立。
这个区分对部署方是实用的:按公告把 3.2.3 升到 3.2.4 就收工的话,关掉的是一个能起 shell 的口子,留下的是一个匿名读源文件的口子。后者对"把 notebook 当应用发布"的产品来说,读到的是别人的数据和代码。




"终端为什么默认开启"这条链,三处源码事实加一处执行:
jupyter_server/serverapp.py:1989-1991:@default("terminals_enabled")返回True。探针 3 里ServerApp().terminals_enabled实测就是True。jupyter_server_terminals/app.py:81-86:只有terminals_enabled为假时才return,不注册任何处理器。jupyter_server_terminals/api_handlers.py:88-91:default_handlers里(r"/api/terminals", TerminalRootHandler)。
终端 API 的存在不是配错,是上游出厂默认。Mercury 想关掉它,唯一的抓手就是那份阻断清单,而清单只有 3 条。
0x08 未认证身份的成因:七跳链路与各级证据等级

"未认证"这三个字容易被读成"运维配错了"。实际不是:它是 Mercury 的启动器写进进程参数、再由 jupyter_server 的默认值一路递上来的结果。逐跳看:
第 1 跳(A,执行)来自 src/mercury-v323/mercury_app/__main__.py:91-98:
if token_from_cli:
# User provided token
new_argv.append(f"--IdentityProvider.token='{token_from_cli}'")
elif token_from_env:
new_argv.append(f"--IdentityProvider.token='{token_from_env}'")
else:
# Default behavior: disable token
new_argv.append("--IdentityProvider.token=''")注释是作者自己写的:# Default behavior: disable token。
第 2 跳(A)在同一文件 :204-209,token 为空且没设密码时顺手关掉 XSRF:
if token_is_empty and not password_is_set and not disable_xsrf_already_set:
new_argv.append("--ServerApp.disable_check_xsrf=True")



这里有一个我一度没把握的问题:注入的是带引号字面量的 --IdentityProvider.token='',argv 里的值是 4 个字符的 "''",不是空串。traitlets 会不会把它当成一个 2 字符的 token?如果会,"未认证"这一跳就不成立。
这个问题探针 5 直接回答了,用的是真实的 traitlets 加载器和真实的 PasswordIdentityProvider:
Mercury 默认:
配置里的原始值 = DeferredConfigString("''")
落到 trait 之后 = '' len=0 真值=False
token_generated = False
login_available = False
对照:不注入:
落到 trait 之后 = '89d4c70f9c40f45a426d2b448f1a9fbe6de2ae258f3afb9c' len=48 真值=True
token_generated = True
login_available = True引号被吃掉了,token 真是空串,login_available 真是 False;对照组(不注入那一项)拿到 48 位随机 token、login_available=True。这一跳从"读出来的"变成了"算出来的"。
另外 _is_empty_token(__main__.py:190-194)显式把 "''" 和 '""' 也当空值,说明作者知道这一项会带着引号进 argv。
第 3 跳(A,执行)在 jupyter_server/auth/login.py:297-301:
@classmethod
def get_login_available(cls, settings):
"""DEPRECATED in 2.0, use IdentityProvider API"""
return bool(cls.password_from_settings(settings) or settings.get("token"))探针 5 把上面两种配置分别喂进这个真实函数:Mercury 默认下返回 False,对照组返回 True。
第 4 跳(B,读)在 login.py:224-227:
if not handler.login_available:
# Completely insecure! No authentication at all.
# No need to warn here, though; validate_security will have already done that.
user_id = "anonymous"# Completely insecure! No authentication at all. 是上游自己写在源码里的注释。这一跳需要一个真实 RequestHandler 才能执行到,没有跑。
第 5 跳(B,读)在 auth/decorator.py:60-63:@authorized 只在 if not user 时抛 403。user_id = "anonymous" 是个真值,过。
第 6 跳(A,执行):serverapp.py:1721-1722 的 authorizer_class 默认值是 AllowAllAuthorizer,auth/authorizer.py:82-89 的 is_authorized 直接 return True。探针 5 实测 ServerApp().authorizer_class 就是这个类名。
第 7 跳(B,读):jupyter_server_terminals/api_handlers.py:33-64 的 post() 调 terminal_manager.create(**data)。这一跳刻意没跑:它会真的 fork 一个 shell 进程。




七跳里第 1、2、3、6 跳是执行或逐字读到的,第 4、5、7 跳是行号级推断。准确的表述是"这条链的每一环在源码里都存在,其中四环经本机验证",而不是"在运行时观测到匿名 shell 创建"。后者是公告里那位研究者的观测(B 级)。
还有一个对比值得放在这里:上游 IdentityProvider.token 的默认值是 "<generated>"(auth/identity.py:167-168),help 文本写着 When no password is enabled, the default is to generate a new, random token 和 Setting to an empty string disables authentication altogether, which is NOT RECOMMENDED。上游的默认是安全的,Mercury 在产品层把它覆盖成了空。
0x09 root 结论的来源与限定条件
公告说默认 Docker 部署下实测 uid=0(root)。仓库里的 Dockerfile 与这个说法一致(A 级,逐字):
FROM python:3.12-slim
...
RUN pip install -U mercury
WORKDIR /workspace
EXPOSE 8888
ENTRYPOINT ["mercury", "--ip=0.0.0.0", "--no-browser", "--allow-root"]全文没有 USER 指令,容器进程因此以 root 运行,--allow-root 又显式允许 Jupyter 以 root 启动。这两条是"拿到 shell 就是 root"的直接来源。
这条结论需要加两个限定:
仓库的 Dockerfile 不等于 runmercury.com 上的那个镜像。第 8 行是
pip install -U mercury,构建时抓最新版。仓库这份定义今天构建出来是 3.2.11,已经不受影响。公告测的是"默认配置 + 3.2.3",多半是研究者按文档自己构建的。线上跑的镜像是哪个,得回去查自己的构建记录。--allow-root不是漏洞成因,是后果放大器。就算去掉它,未授权 shell 还是未授权 shell,只是落在mercury用户下。真正的问题是 0x08 那条链。把"加个 USER"当修复的人,会把这条链原样留着。


0x0a 补丁内容与披露窗口

补丁本身(git show 8c46712):
mercury/tests/test_block_handler.py | 25 +++++++++++++++++++++++++
mercury_app/block_handler.py | 2 +-
7 files changed, 31 insertions(+), 6 deletions(-)block_handler.py 那 1 行改动就是补一个逗号,其余 4 个文件是版本号。提交时间 2026-08-21 15:31:59 +0200,即 13:31Z;PyPI 上 3.2.4 的 upload_time 是 2026-08-21T14:00:52Z。从提交到发版 29 分钟。
各时间点分别独立核对(A 级):
事件 | 时间 | 来源 |
|---|---|---|
| 2025-12-17 |
|
| 2026-01-15 | PyPI |
3.2.3 上 PyPI | 2026-07-08 | PyPI |
修复提交 | 2026-08-21 13:31Z | git |
3.2.4 上 PyPI | 2026-08-21 14:00Z | PyPI |
3.2.5(补 | 2026-08-27 | PyPI + |
GHSA 公开 | 2026-10-19 17:00:54Z |
|
换算下来是三个数字:缺陷在仓库里存活 247 天,在 PyPI 上可安装 218 天,修复到公开之间 59 天。
3768000 那次提交里还留着两行被注释掉的原型(见卡片 1):
#r"/lab(?:/.*)?",
#r"/lab(?!/(?:extensions/|api/settings(?:/|$)))(?:/.*)?",
r"/lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.*)?"
r"/api/terminals(?:/.*)?",这个形状能看出些东西:作者在同一段里反复改写 /lab 那条正则(放宽 extensions、加 settings、加 translations),改到第三版时,逗号是在"编辑一条长正则"的动作里丢的。缺陷不是写出来的,是改出来的,而且它诞生在文件的第一次提交里。
关于那 59 天:厂商当天提交当天发版,速度没有问题;但公开晚了 59 天,对下游部署方来说"修得快"不等于"知道得早"。这 59 天里部署方没有任何理由去升级一个"看起来一切正常"的 3.2.3:没有公告、没有告警、日志里什么都不会多。自查因此不能依赖公告,得依赖 0x0d 那条能自证的动作。
0x0b 结构性原因:fail-open 的黑名单
逗号本身好修,真正的问题在结构上。三条。
1)清单作为唯一访问控制层且无默认兜底
0x08 那条链的结论是,按默认配置,这个服务里根本不存在一个可鉴权的主体:login_available=False、user_id="anonymous"、AllowAllAuthorizer.is_authorized() → True。
所以 BLOCKED_PATTERNS 前面没有任何一层会再问一次"你谁"。在这种架构下:
清单少一条规则,等价于这台服务对该路径完全没有防护。
这就是一个逗号值 10.0 的原因。不是逗号本身多厉害,是它站在唯一一道门上。CVSS 向量里 PR:N(无需权限)和 S:C(影响范围变更)不是夸张,是对"唯一防线是黑名单"这件事的直译。
2)黑名单是 fail-open 的
新增一个 Jupyter 端点,不需要改 Mercury 一行代码,它就自动可达。清单是人工枚举的,攻击面是框架自带的,两者之间没有任何同步机制。
3.2.3 → 3.2.4 → 3.2.5 的清单增长轨迹(3 → 4 → 5 条)本身就是证据:每一轮扩项都是事后追补。/api/contents 在 3.2.4 修完 6 天后才补上,/api/kernels 到今天仍然不在清单里(探针 2 实测 False)。
白名单方向才是 fail-close:应用只注册它需要的路由,其余一律不存在。Mercury 走的是反方向——它继承一个为交互式设计的全功能服务器,然后试图用一份正则清单把它削成一个只读展示层。
3)不变式测试缺位
247 天里,没有一条测试、一行日志、一个告警会提示"清单少了一项"。
3.2.4 补的那 25 行测试是行为级断言(assert is_blocked("/api/terminals"))。这比没有强得多,探针 4 证明它确实能抓到这个缺陷。但它仍然抓不住另一类问题:上游新增了端点,清单没跟着加——没人断言过"清单应该覆盖哪些前缀"。
这次审计里最实用的一条经验:凡是"字符串常量数组决定安全边界"的地方,写一条断言去量它的长度和成员形状。它不需要理解业务,只需要在有人少写一个逗号时变红。具体写法在 0x0c 第 4 条。
0x0c 修复建议(按边界重排)
按"离根因的远近"排,不按工程量排。前两条是厂商级的,第三条是部署方今天就能做的。
1)访问控制由路径黑名单改为路由白名单
Mercury 的独立部署只需要渲染 notebook 应用,不需要 default_router 里 Jupyter 自带的那一整排 API。可行的形态是只注册白名单路由,其余一律不挂载,而不是全挂载再用规则前置拦。
这一条改的是 0x0b 的第 2 点,也是唯一能让"新增端点"不再自动暴露的做法。代价最大,收益最大。
2)产品层对上游安全默认值的反向覆盖
--IdentityProvider.token='' 是 Mercury 主动写进去的(__main__.py:96-98)。上游的默认是随机 token,而且 help 文本明确写了 Setting to an empty string disables authentication altogether, which is NOT RECOMMENDED。
如果产品定位是"面向非技术用户的公开仪表盘",默认应该是"生成 token 并在启动日志里打印出来",或者至少要求显式传一个 --no-token 才允许匿名。把匿名做成 else 分支的缺省值,等于让"忘了配置"和"故意开放"不可区分。
顺带:--ServerApp.disable_check_xsrf=True 也是同一个函数里顺手加的。终端创建是 POST,XSRF 关掉之后浏览器里任意一个恶意页面都能对内网 Mercury 发这个请求,这一项的影响面比它看起来大。
3)镜像增加 USER 并固定版本
FROM python:3.12-slim
ARG MERCURY_VERSION=3.2.11
RUN pip install mercury==${MERCURY_VERSION}
RUN useradd --create-home --uid 10001 mercury
WORKDIR /workspace
USER mercury
EXPOSE 8888
ENTRYPOINT ["mercury", "--ip=0.0.0.0", "--no-browser"]三点:pip install -U mercury 换成钉版本(否则镜像构建时间决定受不受影响,而这件事没人记录);加 USER;去掉 --allow-root。
这不能替代前两条。它做的是把"清单漏一条"的后果从 root 降到普通用户,属于纵深防御里最便宜的一层。
4)清单形状不变式测试
厂商侧建议长这样(本文建议,非厂商代码):
import inspect, re
import mercury_app.block_handler as bh
def test_blocklist_shape_is_stable():
# 字面量条数 == 运行时条数:抓隐式拼接
src = inspect.getsource(bh)
literals = len(re.findall(r'^\s*r"', src, re.M))
assert literals == len(bh.BLOCKED_PATTERNS), \
f"{literals} literals but {len(bh.BLOCKED_PATTERNS)} rules — implicit concat"
# 每条规则必须是独立可达前缀
for prefix in ("/api/terminals", "/api/shutdown", "/files", "/lab", "/api/contents"):
assert any(re.fullmatch(p, prefix) or re.fullmatch(p, prefix + "/x")
for p in bh.BLOCKED_PATTERNS), f"{prefix} not covered"第一条断言是这次缺陷的直接检测器,而且与业务无关:只要"源码里有 N 个字面量、运行时只有 N-1 条规则",它就红。第二条把 0x0b 第 2 点的漂移变成可发现的。
这条建议不只是停在纸面上。探针 6 拿两个版本的真实文件跑了一遍(importlib 按路径加载,因为两个版本模块同名):
第一行就是 247 天里没人跑的那一句。这张表还给了 0x07 一个独立佐证:3.2.4 通过了形状断言,但 /api/contents 仍然 covered=False。"修得不完整"换一种量法也成立。
inspect.getsource 数字面量这个办法是写探针 1 时想到的,本来只是验证自己的判断,写完发现可以直接留在 CI 里。
0x0d 自查 Runbook
按"能不能在 30 秒内做完"排序。
A. 版本自证(不依赖公告)
python -c "from mercury_app.block_handler import BLOCKED_PATTERNS as b; print(len(b))"输出 | 判定 |
|---|---|
| 受影响,且 |
| 3.2.4:本 CVE 已修,但 |
| 3.2.5 及以上 |
这条比 pip show mercury 好,因为它直接量的是那个决定安全边界的常量本身。版本字符串可以靠改包名糊过去,列表长度不行。
想更精确(A 级口径,与生产匹配):
python - <<'PY'
import re
from mercury_app.block_handler import BLOCKED_PATTERNS as b
for p in ("/api/terminals", "/api/terminals/1", "/lab", "/lab/tree/default",
"/api/contents", "/api/kernels", "/api/shutdown", "/files/x"):
print(f"{p:28} blocked={any(re.fullmatch(x, p) for x in b)}")
PY注意:这里用 re.fullmatch 是已验证与生产等价的口径(0x04,32 格零分歧)。同一份脚本存成了 scripts/check_blocked.py,它额外打印一行 len(BLOCKED_PATTERNS),用法 PYTHONPATH=<你的 mercury 安装目录> python scripts/check_blocked.py。另外若部署在子路径下,app.py:117 会给每条规则前缀 base_url,判断要按实际前缀调整。
上面两条命令在本文的取证工作树上各跑了一遍,下面是原始终端转录(未起服务、未发请求;UserWarning 是按文件路径加载包时的真实 stderr,保留):

转录里值得看的有三处:3 / 4 两个长度、3.2.3 段前四行的 blocked=False、以及两版都黄在同一行的 /api/contents。第三处就是 0x07 那条结论在终端里的样子——不需要读公告,跑一次自查就能看见。
B. 清单生效条件的核对
app.py:53-60 的 is_mercury_app() 决定了整段拦截是否装配。如果进程是作为 JupyterLab 扩展跑的,这份清单根本没装,那时 /api/terminals 的暴露面属于 JupyterLab 的权限模型,是另一个问题。判据:sys.argv[0] 是否以 mercury 或 mercury_app/__main__.py 结尾。
C. 匿名可达性核对(只读,不创建终端)
curl -s -w '\n-> HTTP %{http_code}\n' http://<host>:8888/api/terminals403 = 被 BlockedHandler 拦;404 = 清单没装或终端未启用;200 且响应体是 JSON 数组,意味着清单漏了这一条,而且匿名可读(GET 落在 api_handlers.py:28-31,返回 terminal_manager.list())。
这里只用 GET 探可达性,不发 POST。GET /api/terminals 只列出现有终端,不产生副作用;POST 会真的起 shell 进程,不在本文范围内。
D. 进程身份与暴露面
ps -o pid,user,args -C mercury # 或 pgrep -af 'mercury|jupyter-server'
ss -tlnp | grep 8888 # 0.0.0.0 还是 127.0.0.1
docker inspect -f '{{.Config.User}}' <container> # 空 = rootuser=root + 监听 0.0.0.0 + 清单长度 3,三者同时成立就是公告描述的那个状态。
E. 访问日志检索形态
终端创建是 POST /api/terminals,交互走 GET /terminals/websocket/<id> 的 WebSocket 升级。可落地的检索口径:
method="POST" AND path LIKE "/api/terminals%"
("Upgrade: websocket" AND path LIKE "/terminals/websocket/%")以及 0x06 那条公告没点名的:path LIKE "/lab%" 的成功请求。独立部署下 /lab 本该全 403。
一个坏消息:disable_check_xsrf=True 之后,来自浏览器的跨站 POST 不会留下 Origin/Referer 异常以外的强特征,所以别把 Referer 检查当检测层,它只是个提示。
F. 临时缓解(不是修复)
在反代上直接挡掉,注意要挡全(0x06 的六条 + 0x07 的两条):
location ~ ^/(api/terminals|lab|api/contents|api/shutdown|files)(/|$) { return 403; }
location ~ ^/terminals/websocket/ { return 403; }WebSocket 那条不能漏。只挡 POST /api/terminals 的话,已经存在的终端会话仍然可用。
G. KEV 目录的覆盖缺口
CISA KEV 目录(catalogVersion = 2026.10.16,1748 条)不含 CVE-2026-111057。如果升级策略是"进 KEV 才动",这条现在不在雷达上。GHSA 已 reviewed、CVE 已分配、NVD 未发布,这是典型的"KEV 触发型流程会漏掉"的窗口。
0x10 结语
这次审计值得留下的东西,大概这几条。
字符串常量数组决定安全边界时,长度就是不变式。4 个字面量、运行时 3 条规则,这个差值 247 天没人量过。加一条 assert len(...) == <字面量数> 的成本是 3 行,它抓的是这一整类缺陷,不是这一个逗号。
合并也不会让规则变松,而是让规则换一个主语。/lab...(?:/.*)?/api/terminals(?:/.*)? 仍然在拦东西,拦的是本来不需要拦的路径。这类语义漂移比"规则失效"难发现得多:只要没人测那条具体路径,它在测试里也可能通过。
唯一防线是黑名单时,任何一条规则缺失都是 10.0。决定严重性的不是那条路径多危险,是它后面有没有第二道门。0x08 那七跳说明,默认配置下这道门根本不存在(login_available=False → user_id="anonymous" → AllowAllAuthorizer)。先数清自己有几层,再评估一个缺失值几分。
公告的标题只挑了最显眼的那一条后果。同一个逗号还放开了 /lab 整片;"3.2.4 修复"之后 /api/contents 仍然匿名可达,6 天后才补。影响面要自己算差集,不要抄标题。
最后,可执行的结论一定要跑,而且要把"跑过"和"跑对"分开说。探针 5 纠正了一个足以毁掉整条因果链的判断(引号会不会留在 argv 里);探针 1 的差集让我看见了读公告时漏掉的 /lab。同时 0x08 里仍然有 3 跳是推断,0x0f 里有 7 项未确认。能算的都算了,不能算的标了出来——这篇稿子和"看完公告复述一遍"的区别就在这里。
如果手上正好有一个"继承了一个全功能框架、再用一份清单往外削"的服务,先去跑 0x0d 的 A 段,30 秒。我猜至少有一条会让你皱一下眉。
本地复现:
mkdir -p src && cd src
git clone --no-checkout https://github.com/mljar/mercury.git mercury
git -C mercury worktree add ../mercury-v323 v3.2.3
git -C mercury worktree add ../mercury-v324 v3.2.4
pip download jupyter_server==2.18.2 jupyter_server_terminals==0.5.4 --no-deps
python3 scripts/probe_shapes.py
python3 scripts/probe_blocklist.py
python3 scripts/probe_router.py
python3 scripts/probe_identity.py
python3 scripts/probe_invariant.py
PYTHONPATH=src/mercury-v323 python3 scripts/check_blocked.py # 图5 上半段
PYTHONPATH=src/mercury-v324 python3 scripts/check_blocked.py # 图5 下半段


探针 1、2、6 只需 tornado;探针 3 需要 tornado + 两个 jupyter 包可导入;探针 5 需要 jupyter_server 完整安装(traitlets 加载器会解析 entry point)。全部脚本不启动任何服务、不发任何网络请求。
最后一个过期风险声明:探针里的行号绑死在 v3.2.3 / v3.2.4 两个 tag 上,上游后续重构 mercury_app/ 会让 app.py:108-119 这类引用失效。这是把 worktree 留在 src/ 而不是 /tmp 的原因。自己复现时也请先 git worktree add 对应 tag,不要在 main 上跑。
参考
mljar/mercury—GHSA-f8c7-rwf9-fmgm/CVE-2026-111057(type=reviewed,published_at = 2026-10-19T17:00:54Z,nvd_published_at = null)修复提交
8c467129037ee9df269d543ac217ffd33b97bf3f("fix: block unauthenticated terminal access",2026-08-21 15:31:59 +0200)扩项提交
73a40ce("Secure anonymous kernel execution",2026-08-27,对应 3.2.5)PyPI
mercury发行时间线:3.0.0(2026-01-15)、3.2.3(2026-07-08)、3.2.4(2026-08-21T14:00:52Z)、3.2.5(2026-08-27)jupyter_server2.18.2 —auth/identity.py、auth/login.py、auth/decorator.py、auth/authorizer.py、serverapp.pyjupyter_server_terminals0.5.4 —app.py、api_handlers.pytornado6.4.2 —routing.py(PathMatches)CISA KEV 目录
catalogVersion = 2026.10.16(1748 条,不含本 CVE)公告
PoC段(curl + WebSocket 客户端与uid=0输出):只引用其存在、测试环境与影响版本,不复述其操作细节