别再把 MCP Roots 当安全沙箱:一个符号链接就能逃逸目录

别再把 MCP Roots 当安全沙箱:一个符号链接就能逃逸目录

我最近重新检查了一个 MCP 文件服务器实现。

客户端已经让用户选择了工作区,Server 也拿到了 Roots。表面上看,模型只能操作这个目录,边界似乎已经够清楚。

真正做路径测试时,问题马上出来了:

javascript 复制代码
workspace/
├── docs/
├── drafts/
└── latest-log -> /Users/me/.ssh/

请求读取的参数只有:

lua 复制代码
latest-log/config

输入里没有 ../,也不是绝对路径,但符号链接解析后,真实目标已经离开工作区。

这也是 MCP 文件服务器最容易被误解的地方:

Roots 是客户端声明的候选边界,不是 Server 已经完成的安全沙箱。

如果 Server 只检查原始字符串,或者只判断客户端是否提供了 Root,这次读取仍可能被放行。

一个安全请求应该经过哪些检查

更准确的边界关系应该是:

arduino 复制代码
用户选择可访问目录
        ↓
Client 通过 Roots 声明候选边界
        ↓
拒绝绝对路径
        ↓
resolve / realpath 解析 .. 与符号链接
        ↓
校验真实路径仍属于授权 Root
        ↓
检查敏感目录、文件类型、大小和读写策略
        ↓
Resource / Tool 权限控制
        ↓
低权限用户或容器形成最后物理边界

Roots 能告诉 Server 当前工作区在哪里,但它不会自动替你完成这些事:

  • 拒绝绝对路径;
  • 消解 ..
  • 解析符号链接;
  • 阻止读取 .ssh.env 和凭据目录;
  • 限制文件大小和返回长度;
  • 限制写入只能进入草稿目录;
  • 记录谁调用了哪个 Tool、操作了什么路径。

这些仍然是 Server 的职责。

为什么字符串前缀判断不可靠

不少实现会写成这样:

python 复制代码
from pathlib import Path

ROOT = Path("./workspace").resolve()


def unsafe_path(user_path: str) -> Path:
    target = ROOT / user_path
    if not str(target).startswith(str(ROOT)):
        raise ValueError("path escaped")
    return target

这段代码至少有两个问题。

第一,拼接后没有先解析真实路径。符号链接可能把目标带到 Root 外部。

第二,字符串前缀不是目录归属判断。例如 /workspace-safe 也可能通过 /workspace 的前缀检查。

我更倾向于把路径校验收敛成一个独立函数,所有 Resource 和 Tool 都必须走同一入口:

python 复制代码
from pathlib import Path


class PathRejected(ValueError):
    pass


def resolve_inside(root: Path, relative_path: str, *, must_exist: bool) -> Path:
    candidate = Path(relative_path)

    if candidate.is_absolute():
        raise PathRejected("absolute paths are not allowed")

    target = (root / candidate).resolve(strict=must_exist)

    try:
        target.relative_to(root)
    except ValueError as exc:
        raise PathRejected("path escapes the allowed root") from exc

    return target

关键不在代码有多长,而在检查顺序:

bash 复制代码
只接受相对路径
→ resolve / realpath
→ relative_to 判断真实目录归属
→ 检查敏感目录、类型和大小
→ 再执行读写

不要看起来安全,要用攻击路径验证

先准备一个正常文件和一个指向 Root 外部的符号链接:

bash 复制代码
mkdir -p sandbox/docs
printf "ok" > sandbox/docs/readme.md
ln -s ~/.ssh sandbox/latest-log

然后跑四条路径:

python 复制代码
ROOT = Path("./sandbox").resolve()

cases = [
    "docs/readme.md",
    "../.ssh/config",
    "/etc/passwd",
    "latest-log/config",
]

for relative_path in cases:
    try:
        target = resolve_inside(ROOT, relative_path, must_exist=False)
        print(f"ALLOWED  {relative_path} -> {target}")
    except PathRejected as exc:
        print(f"REJECTED {relative_path} -> {exc}")

预期结果应该是:

输入 结果 原因
docs/readme.md ALLOWED 真实路径仍在 Root 内
../.ssh/config REJECTED 规范化后越过 Root
/etc/passwd REJECTED 绝对路径
latest-log/config REJECTED 符号链接解析后指向 Root 外部

如果第四条仍然被放行,说明实现只检查了字符串,没有检查真实路径。

还要注意一个更隐蔽的问题:resolve() 与真正打开文件之间存在时间窗口。高风险、多用户或存在恶意本地进程的场景,仍要考虑 TOCTOU 竞态。

更稳的做法是继续使用目录文件描述符、openat/dir_fdO_NOFOLLOW 或容器只读挂载,把校验和打开尽量绑定在同一权限边界内。

Resource、Tool、Prompt 不要混成一个万能接口

另一个常见问题,是为了省事注册三个能力过大的 Tool:

scss 复制代码
read_any_file(path)
write_any_file(path, content)
run_command(command)

它们开发起来很快,但模型一旦生成错误参数,影响范围也是最大的。

更稳的拆法是:

arduino 复制代码
Resource
  读取可标识、相对稳定的上下文
  例如 README、配置快照、日志片段

Tool
  执行查询、计算或有副作用的动作
  例如搜索 Markdown、写入草稿、生成补丁

Prompt
  用户主动选择的任务模板
  例如"检查当前配置"或"总结指定日志"

Root
  Client 告知 Server 的文件系统候选边界

对应到接口,可以收窄成:

scss 复制代码
search_markdown(query, limit)
read_text(relative_path, max_chars)
write_draft(relative_path, content)
propose_patch(relative_path, patch)

写正式内容、删除文件、移动目录和执行命令,不应该因为"已经接入 MCP"就默认开放。

stdio 还有一个非常容易踩的坑

stdio 模式下,stdout 是协议通道。

下面两行普通日志都可能让客户端报 -32700 Parse error

bash 复制代码
print("server started")
arduino 复制代码
console.log("database connected")

业务日志应该显式写入 stderr 或独立日志文件。

不要为了拦截第三方输出,直接覆盖 process.stdout.write,也不要粗暴执行 sys.stdout = sys.stderr。MCP SDK 自己也需要 stdout 返回合法消息,这种兜底可能把正常协议响应一起破坏。

本地 stdio 和远程 Streamable HTTP 怎么选

当前 MCP 标准传输可以这样理解:

传输 适合场景 重点风险
stdio 本地 IDE、桌面客户端、单用户进程 stdout 污染、PATH、工作目录、子进程权限
Streamable HTTP 跨机器、团队共享、云端服务 认证、Origin、Session ID、代理、超时、审计

远程部署不是把本地 Server 监听到 0.0.0.0 就结束了。至少还要处理:

  • Origin 校验;
  • 身份认证和授权范围;
  • Session ID;
  • 协议版本;
  • 超时、限流和反向代理;
  • 审计日志和密钥管理。

如果团队连身份、限流、密钥管理和审计都没有准备好,传统 REST API 往往比直接把 MCP 暴露到公网更稳。

上线前检查这 10 项

  1. 只接受相对路径。
  2. 使用 resolverealpath 消解 .. 和符号链接。
  3. 用真实目录归属判断,不使用字符串前缀判断。
  4. 默认只读,写入、删除和移动拆成独立 Tool。
  5. 写入只允许进入 drafts/ 或临时目录。
  6. 限制扩展名、文件大小、返回字符数和目录列表数量。
  7. 明确拦截 .ssh.gnupg.aws.env、私钥和凭据目录。
  8. 审计 Tool、相对路径、操作者、结果、耗时和拒绝原因。
  9. 错误响应不暴露服务器绝对路径和完整堆栈。
  10. 使用低权限系统用户或容器运行 Server。

最后可以把整个问题压缩成一句话:

Client 负责告诉 Server"用户允许操作哪里",Server 负责保证"每一次操作都没有离开那里"。

完整版本还包含 FastMCP 文件网关代码、Resources/Tools/Prompts/Roots 的边界、Streamable HTTP 部署要求和上线验收清单:

www.xbstack.com/ai/mcp-prot...

相关推荐
SamDeepThinking1 小时前
Java面向对象在JVM里怎么实现
java·后端·面试
Nturmoils1 小时前
不自己造轮子:在小艺开放平台搭一个真实的健康小助手
人工智能
学以智用1 小时前
HTTP 请求头 Headers 完整详解
前端·后端
statistican_ABin1 小时前
中国城市 PM2.5较高浓度风险识别
人工智能
茶马古道的搬运工1 小时前
AI 深度技能之-解读Multica(一)- 让Agent 变成真·队友
人工智能
YOLO数据集集合1 小时前
自动驾驶道路视觉开源数据集TT100K工程化使用文档|道路标识目标检测资源|开源数据集分享
人工智能·目标检测·自动驾驶
BerryS3N1 小时前
Cursor+GitOps:AI驱动的自动化运维新范式
运维·人工智能·自动化
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十五):控制台静态页面、深入研究时间格式化方案
后端·go·ai编程
程序猿大帅1 小时前
Java 虚拟线程发布两年后,我们把它从生产环境移除了
后端