一个越界读取是怎么发生的
假设你在本地跑一个 MCP 文件系统服务端,用来让 AI 助手读写当前项目。客户端在初始化时把工作区声明为 /home/user/projects/myproject。某次工具调用传入的路径是 ../../.ssh/id_rsa,服务端如果只做字符串拼接,就会读到工作区之外的私钥文件。
这类越界不是攻击者才关心的问题。工具调用参数由模型生成,模型可能因为上下文里出现过某个绝对路径,就把那个路径原样传回来。服务端如果默认信任所有传入路径,工作区声明就形同虚设。
MCP 的 Roots 机制就是为这个边界设计的。客户端把允许服务端操作的目录以 file:// URI 列表的形式暴露出来,服务端据此限制自己的文件访问范围。本文以本地文件系统服务端为场景,说明这套边界如何生效、在哪些情况下会失效,以及生产环境该观察什么。
Roots 协议在做什么
能力声明与请求流程
Roots 属于客户端能力。支持 Roots 的客户端必须在初始化阶段声明:
{
"capabilities": {
"roots": { "listChanged": true }
}
}
listChanged 表示客户端在根目录列表变化时会不会发通知。服务端在初始化完成后,通过 roots/list 请求向客户端索取根目录列表:
{ "jsonrpc": "2.0", "id": 1, "method": "roots/list" }
客户端返回的每个根目录包含 uri 和可选的 name。规范要求 uri 必须是 file:// URI。name 只用于界面展示,不参与路径判断。
根目录列表完全替换服务端已有目录
这是最容易被忽略的一条。文件系统服务端可以接受两种目录来源:命令行参数和 Roots。当客户端通过 Roots 提供目录时,这些目录会完全替换服务端启动时的允许目录,而不是追加。
替换语义带来一个直接后果:如果客户端声明了三个仓库,服务端启动时用命令行参数指定的第四个目录就消失了。工具调用再访问那个目录,会被拒绝。
如果服务端启动时没有命令行参数,且客户端不支持 Roots 或返回空列表,服务端会在初始化阶段报错。它需要至少一个允许目录才能工作。
变更通知与重新拉取
当用户在客户端里增删工作区时,支持 listChanged 的客户端发送:
{ "jsonrpc": "2.0", "method": "notifications/roots/list_changed" }
服务端收到通知后应重新调用 roots/list,用新列表替换本地缓存。规范没有要求服务端在收到通知后立即完成替换,也没有规定替换期间正在执行的操作如何处理。这部分留给实现决定,也留下了一类竞态:通知到达时,一个写文件操作可能正在用旧根目录做校验。
路径校验的完整链路
服务端收到工具调用里的路径参数后,不能直接打开文件。它需要把路径解析成一个规范形式,再判断这个形式是否落在某个根目录之内。
flowchart TD
A[工具调用传入路径] --> B[解析为绝对路径]
B --> C[解析符号链接与 .. 段]
C --> D[得到规范化真实路径]
D --> E{是否位于某个根目录内}
E -- 是 --> F[执行文件操作]
E -- 否 --> G[返回越界错误]
H[roots/list_changed 通知] --> I[重新拉取根目录列表]
I --> J[替换本地允许目录缓存]
J --> E
关键转折点在 C 和 E。C 之前,路径只是用户或模型给出的字符串,.. 和符号链接都可能指向根目录之外。E 是唯一做边界判断的地方,它依赖 D 产出的规范化路径,而不是原始输入。
如果服务端在 B 之后、C 之前就做前缀匹配,/home/user/projects/myproject/../../.ssh/id_rsa 会通过检查,因为它的字符串前缀确实匹配根目录。只有先解析再比较,才能拦住这类输入。
多根目录下的归属判断
当客户端声明多个根目录时,服务端通常按顺序检查路径是否落在任意一个根目录内。这里有两个实现细节值得注意。
第一,根目录之间可能存在嵌套。比如同时声明 /home/user/projects 和 /home/user/projects/myproject。一个路径落在内层目录时,两个根目录都匹配。如果服务端按“最长前缀优先”处理,行为更符合直觉;如果只判断“是否匹配任意一个”,结果相同。真正出问题的是权限模型按根目录区分时,嵌套会让归属变得不明确。
第二,根目录列表的顺序不应被当作优先级。规范没有定义顺序语义。服务端如果按第一个匹配的根目录决定后续行为,换一个客户端实现就可能改变结果。
符号链接与路径规范化失败模式
符号链接绕过
符号链接是边界校验最常见的失败点。假设根目录是 /home/user/projects/myproject,目录内有一个链接:
/home/user/projects/myproject/link -> /home/user/.ssh
工具调用传入 link/id_rsa。如果服务端只做字符串层面的前缀判断,这个路径的字符串形式以根目录开头,会被放行。但操作系统打开文件时跟随链接,实际读到的是根目录之外的私钥。
防御方式是在判断前解析符号链接,拿到真实路径再比较。伪代码大致如下:
function isAllowed(inputPath, roots):
real = realpath(inputPath) // 解析 .. 与符号链接
for root in roots:
rootReal = realpath(root.uri)
if real == rootReal or real startsWith rootReal + separator:
return true
return false
realpath 在多数平台上会解析所有符号链接并返回绝对路径。注意它要求路径的每一段都存在,否则会失败。对于“创建新文件”这类操作,目标文件还不存在,服务端需要先对父目录做规范化,再拼上文件名。
规范化失败的几种表现
路径规范化不是一次调用就能覆盖所有情况。
大小写不敏感的文件系统上,/Home/User/Projects 和 /home/user/projects 指向同一位置。如果根目录和输入路径的大小写不一致,字符串比较会误判为越界。服务端需要在比较前统一大小写,或者依赖文件系统返回的真实路径。
Windows 上的短文件名(8.3 格式)和 UNC 路径会引入额外形式。C:\\PROGRA~1 和 C:\\Program Files 指向同一目录。只处理 file:// URI 的解析逻辑在 Windows 上需要额外转换。
挂载点和绑定挂载会让同一个目录出现在多个路径下。如果根目录通过一个路径声明,而输入通过另一个路径到达同一位置,规范化后的字符串可能不匹配。这类情况在容器和网络文件系统中更常见。
还有一种失败是规范化本身抛错。路径过长、权限不足、中间目录被删除,都会让 realpath 失败。服务端如果把失败当作“不匹配”处理,会拒绝合法请求;如果当作“放行”处理,会打开越界缺口。正确做法是失败时拒绝,并返回可区分的错误信息。
与无边界直接访问方案的对比
很多本地工具在早期实现里直接接受绝对路径,不做任何根目录限制。这种方案在单用户、可信输入的场景下能工作,但一旦工具参数由模型生成,风险就变了。
| 维度 | 无边界直接访问 | Roots 约束访问 |
|---|---|---|
| 路径校验 | 无,直接打开 | 规范化后与根目录比较 |
| 越界行为 | 允许,取决于进程权限 | 拒绝并返回错误 |
| 运行时调整范围 | 需重启进程 | 通过 roots/list_changed 动态替换 |
| 符号链接处理 | 通常不处理 | 需显式解析真实路径 |
| 多目录管理 | 命令行参数,启动时固定 | 客户端声明,可运行时变更 |
| 实现复杂度 | 低 | 中,需要规范化与缓存 |
| 防御强度 | 依赖操作系统权限 | 依赖服务端校验正确性 |
这张表里最需要留意的是最后一行。Roots 不是操作系统沙箱。它约束的是服务端自己的文件操作,前提是服务端正确实现了校验。如果服务端代码里有一条路径绕过了校验函数,Roots 声明不会阻止它。
相比之下,用容器或独立用户运行服务端,由操作系统强制权限边界,防御强度更高,但运行时调整范围需要重启或重新配置。两种方案可以叠加:Roots 处理应用层边界,操作系统权限处理进程层边界。
可观测指标与部署边界
需要观察的信号
服务端至少应记录以下几类事件:
- 根目录列表的每次替换,包括旧列表、新列表和触发来源。
- 被拒绝的路径请求,记录原始输入、规范化结果和匹配失败的根目录。
- 规范化失败的请求,区分“路径不存在”和“权限不足”。
- 符号链接解析结果与原始路径不一致的情况。
这些记录能回答两个问题:边界是否在按预期生效,以及拒绝是否误伤了合法请求。如果拒绝日志里大量出现规范化失败,说明客户端声明的根目录和实际访问路径之间存在形式差异,需要检查 URI 编码或大小写。
部署时的前提条件
Roots 机制生效依赖几个前提。客户端必须声明 roots 能力,否则服务端只能退回命令行参数。服务端必须在每次文件操作前执行校验,而不是只在初始化时校验一次。根目录 URI 必须是 file:// 形式,其他 scheme 在当前规范下不被接受。
当这些前提不成立时,边界会退化。客户端不支持 Roots 时,服务端要么用启动参数,要么在无参数时直接报错。服务端缓存根目录但不处理 list_changed 时,用户在工作区里移除的目录仍然可访问,直到进程重启。
还有一个未解决的问题:规范没有规定根目录被移除时,正在进行的操作该如何处理。服务端可以在下一次校验时拒绝,也可以让当前操作完成。不同实现的选择会影响用户体验,也会影响“移除工作区后立即调用工具”这类操作的结果。
对于把文件系统服务端接入自动化流程的场景,建议在部署时明确三件事:客户端是否支持 Roots、服务端是否在每次操作前校验、以及越界拒绝的错误码是否被上层正确识别。这三项决定了边界是真实生效,还是只写在配置里。