在审计 CloakBrowser 的 cloakserve 功能时,我发现了一个很典型、也很容易被忽略的安全问题:一个看起来只是"浏览器指纹 seed"的参数,最终被当成文件系统路径的一部分使用,并在异常清理流程里进入了递归删除逻辑。
这个问题对应 CloakBrowser GitHub Issue #217:Unauthenticated cloakserve CDP endpoint allows path traversal leading to arbitrary directory deletion。漏洞确认影响 cloakbrowser 0.3.27,修复提交为 babef04。
说明:本文按
CVE-2026-45727记录该漏洞。发布前建议再次确认 CVE/NVD/GHSA 的官方状态,以官方条目为最终准绳。
背景:cloakserve 是什么
cloakserve 是 CloakBrowser 提供的 CDP multiplexer。它允许客户端通过同一个入口连接不同的浏览器实例,并通过 fingerprint 参数指定不同的浏览器身份。
一个典型连接形式大概是:
text
http://host:9222/json/version?fingerprint=12345
从功能设计上看,fingerprint=12345 会被当成一个 seed。相同 seed 可以复用相同浏览器身份,不同 seed 则对应不同的 Chrome 进程和 profile 目录。
问题也正出在这里:这个 seed 不只是业务标识,它还流入了本地 profile 路径。
漏洞入口
漏洞入口是 fingerprint 查询参数。
公开 issue 中描述的关键数据流如下:
parse_connection_params()解析查询字符串;fingerprint被复制到result["seed"];ChromePool.get_or_launch()将 seed 赋值给seed_key;- 服务端拼接
user_data_dir = os.path.join(self._data_dir, seed_key); - Chrome 启动参数中带上
--user-data-dir=<user_data_dir>; - 启动失败或进程清理时调用
shutil.rmtree(proc.user_data_dir, True)。
这里缺少一个关键校验:服务端没有确认最终解析后的 user_data_dir 仍然位于预期的 data_dir 内。
于是,攻击者可控的 fingerprint 就不再只是一个 seed,而变成了路径组件。
从 seed 到路径穿越
如果传入正常 seed:
text
fingerprint=12345
服务端期望得到的 profile 路径类似:
text
/data/cloakserve/profiles/12345
但如果传入路径穿越 payload:
text
fingerprint=../victim-delete-me
拼接后的路径会变成:
text
/data/cloakserve/profiles/../victim-delete-me
解析后实际指向:
text
/data/cloakserve/victim-delete-me
也就是说,目录已经逃出了 profiles 目录。
真正危险的点不只是"Chrome 使用了一个不该使用的 profile 路径",而是后续清理逻辑会对这个路径执行递归删除。
实验室复现
为了避免对真实环境造成破坏,我使用的是路由级复现:保留真实的 handle_json_version() 与 ChromePool.get_or_launch() 逻辑,只 stub Chrome 进程启动和 CDP readiness。这样可以验证真实代码路径,又不会真的下载或运行 Chromium。
请求如下:
http
GET /json/version?fingerprint=..%2Fvictim-delete-me HTTP/1.1
Host: 127.0.0.1:9222
观察到的结果:
text
HTTP status: 502
Response: {"error": "Chrome failed to start"}
Configured data_dir:
<lab>/cloakserve-path-traversal/profiles
Resolved user_data_dir:
<lab>/cloakserve-path-traversal/victim-delete-me
Escaped data_dir: true
Victim sentinel existed before request: true
Victim directory existed after request: false
Deleted by cleanup: true
捕获到的 Chrome 参数尾部类似:
text
--remote-debugging-port=5100
--remote-debugging-address=127.0.0.1
--user-data-dir=<lab>/cloakserve-path-traversal/profiles/../victim-delete-me
这里有一个很有意思的细节:请求最终返回的是 502,看起来像是"浏览器启动失败"。但安全影响已经发生了,因为失败分支触发了清理逻辑,而清理目标正是攻击者通过路径穿越控制后的目录。
这类漏洞非常容易被低估,因为表面现象是失败响应,而不是成功响应。
额外暴露面:未认证 CDP 元数据
除了目录删除,issue 中还记录了另一个相关风险:同一个未认证入口会返回 CDP webSocketDebuggerUrl 元数据。
测试请求:
http
GET /json/version?fingerprint=12345 HTTP/1.1
Host: 127.0.0.1:9222
观察结果:
text
HTTP status: 200
No auth header/token/cookie supplied: true
Response includes webSocketDebuggerUrl: true
这意味着如果 cloakserve 被部署为网络可达服务,攻击者不仅可以触发路径穿越相关逻辑,还可能获得 CDP WebSocket 地址。在暴露部署中,CDP 本身通常就是高危攻击面,因为它可以控制浏览器行为。
影响评估
该漏洞的实际影响取决于部署方式。
如果 cloakserve 只监听本机回环地址,并且没有被反向代理或端口映射暴露给不可信网络,那么攻击面相对有限。
但如果它在 Docker、服务器或云环境中被暴露为网络服务,攻击者可能做到:
text
1. 无需认证访问 /json/version 等 CDP 辅助接口;
2. 通过 fingerprint 参数构造路径穿越;
3. 让 user_data_dir 逃出预期 data_dir;
4. 触发启动失败或清理流程;
5. 删除 cloakserve 进程用户有权限删除的目录;
6. 枚举或连接 CDP 元数据暴露出的调试入口;
7. 使用大量唯一 fingerprint 创建浏览器进程,造成资源耗尽。
公开 issue 中给出的建议评分是:
text
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8
我个人更倾向于把它描述为"网络可达部署下可达 Critical,默认本地部署下风险下降"。因为它是否能被远程利用,强依赖 cloakserve 的监听地址、容器端口映射和外部访问控制。
根因分析
根因其实很朴素:将用户输入直接当成路径组件使用。
fingerprint 在业务语义里是一个 seed,但在实现语义里变成了目录名。只要一个外部输入进入文件路径,就必须回答三个问题:
text
它是否允许路径分隔符?
它是否允许 .. 或绝对路径?
最终 resolve 后是否还在允许的根目录里?
旧实现没有完成这些约束。
而且,删除逻辑使用的是 shutil.rmtree(..., True)。第二个参数会忽略错误,这在普通清理流程里很方便,但在安全场景里会降低可见性:即使删错了目录,调用方也未必能立即看到异常。
修复分析
修复提交 babef04 做了几件关键事情。
第一,增加 seed 格式白名单:
python
SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
RESERVED_SEEDS = {"__default__"}
这一步直接切断了 /、\、.、空字节、绝对路径、超长字符串等危险输入。
第二,在处理 query 参数 seed 时拒绝非法值:
python
if not SAFE_SEED_RE.match(seed) or seed in RESERVED_SEEDS:
raise web.HTTPBadRequest(
text=json.dumps({"error": "Invalid fingerprint seed"}),
content_type="application/json",
)
第三,新增安全删除函数。删除前先 resolve 路径,再确认目标位于 data_dir 内,并且不能是 data_dir 本身:
python
def _safe_rmtree(self, path: str) -> None:
resolved = Path(path).resolve()
data_resolved = Path(self._data_dir).resolve()
if resolved == data_resolved or not resolved.is_relative_to(data_resolved):
logger.error("Refusing to delete path outside data_dir: %s", resolved)
return
shutil.rmtree(path, True)
第四,裸机默认监听从 0.0.0.0 收紧为 127.0.0.1,容器环境下才使用 0.0.0.0:
python
in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
host = "0.0.0.0" if in_container else "127.0.0.1"
web.run_app(app, host=host, port=port, print=None)
这不是路径穿越本身的修复,但能有效降低"开发者在裸机上无意暴露 CDP 服务"的风险。
回归测试
修复还加入了针对性测试,覆盖两类重点。
第一类是 seed 校验:
text
应拒绝:
../foo
../../etc
/etc/passwd
..
.
foo/bar
foo\bar
空字符串
超长 seed
__default__
同时允许正常 seed:
text
12345
my-seed_01
ABC
test-seed
第二类是删除边界:
text
拒绝删除 data_dir 外部目录;
拒绝删除 data_dir 本身;
允许删除 data_dir 内的合法 seed 子目录;
拒绝 data_dir/../victim 形式的穿越路径。
这组测试比较关键,因为单纯校验 seed 只能挡住当前入口;而 _safe_rmtree() 的边界检查则是最后一道防线。即使未来又出现新的路径来源,删除函数本身也不会轻易越界。
结语
CVE-2026-45727 这类漏洞不花哨,但很有代表性。它不是复杂内存破坏,也不是高深协议绕过,而是一个参数在不同语义层之间悄悄变了身份:从浏览器指纹 seed,变成 profile key,再变成文件路径,最后进入递归删除。
很多真实漏洞就是这样来的。
安全审计里最值得盯的,往往不是代码里写着"危险"的地方,而是那些看起来只是"顺手复用一下"的地方。