详细解析stdio和HTTP两类MCP服务器的威胁模型,提供审计方法识别服务器打开了哪些文件、访问了哪些主机和继承的环境变量。
MCP Server 并不是任何意义上的沙箱插件。当你的编辑器或 Agent 通过 stdio 启动一个本地 Server 时,它会生成一个子进程,以你的身份运行:相同的 UID、相同的 home 目录、相同的可访问 ~/.ssh、~/.aws/credentials 和 .env 文件,以及相同的出站网络。Model Context Protocol 只指定了客户端和服务端如何通信,并没有规定 Server 允许触碰什么,而且主流客户端默认不会对 stdio Server 进行沙箱隔离。
如果你自己写的 Server 只有一个,这还算可控。但如果是从 npm 上拉来的五个 Server,其中三个是因为某个 README 写了 npx -y 就装上的,那就完全不可控了。下面的审计流程大约需要十分钟,能逐个告诉你每个 Server 打开了哪些文件、连接了哪些主机、以及继承了哪些环境变量。
实际场景中主要涉及两种传输方式。stdio Server 是一个子进程:客户端用配置的 command 和 args 启动它,通过管道交换 JSON-RPC。而 HTTP Server(Streamable HTTP,或更早的 HTTP+SSE 变体)则是客户端通过网络调用的远程端点。两者的威胁模型不同——stdio 是本地权限问题,HTTP 是数据泄露和第三方信任问题——而大多数配置中两者混合使用,却没有做区分。
对于 stdio 场景,有四样东西在 spawn 时被移交。
进程身份。Server 在你的账户下运行。你能读的它就能读,你能删的它就能删。类似 --allowed-directory ~/projects 这样的 flag 是由 Server 自身的代码来强制执行的,而非由操作系统强制执行,所以它们只有在代码正确且无法绕过的情况下才有效。
环境变量。不同客户端转发多少 shell 环境的方式不同,所以不要猜测。在 macOS 上,ps eww <pid> 会打印出你拥有的进程的环境变量。在 Linux 上,tr '\0' '\n' < /proc/<pid>/environ 也能做到同样的事。如果一个只需要读取 Markdown 的 Server 的环境变量 dump 里出现了 AWS_SECRET_ACCESS_KEY 或 GITHUB_TOKEN,这就是你的发现。
写入配置文件的密钥。.mcp.json、~/.cursor/mcp.json 或 claude_desktop_config.json 中的 env 块以明文形式存储 API 密钥。对这些路径运行 ls -l。如果其中任何一个是组可读或全局可读的,或者位于你会上传的仓库里,在做其他任何事之前先修复这个问题。
包在今天被解析到的版本。npx -y some-mcp-server 每次启动时都会拉取并执行当前发布的版本。你审批的是上个月读到的代码;你实际运行的是今天发布的版本。
-y flag 会抑制安装提示,而未固定包名的解析目标是 latest。你在三月审计过的 Server 可能在八月发布新代码,并在你注意到版本变动之前就用它处理你的凭证。固定版本:npx -y pkg@1.4.2,或对于 Python Server 用 uvx pkg==1.4.2。
启动你的客户端,让 Server 跑起来,然后逐个 PID 执行下面的操作。claude mcp list(或会话中的 /mcp 面板)会告诉你客户端认为在运行哪些 Server。ps 告诉你实际在运行什么。
# 1. 哪些进程活着、属于哪个用户、argv 是什么
ps -eo pid,ppid,user,etime,args | grep -i -E 'mcp|modelcontext' | grep -v grep
# 2. 一个 Server 打开的文件、socket 和工作目录
lsof -p <pid>
# 3. 仅网络:它在和谁通信
lsof -nP -i -a -p <pid>
# 4. 你执行一个 tool 时观察实时文件系统访问(macOS)
sudo fs_usage -w -f filesys <pid>
# Linux 等价命令
strace -f -e trace=openat,connect -p <pid>
按这个顺序阅读输出。第一步会发现你忘记安装的 Server,PPID 列很重要:如果一个包装脚本启动了第二个进程,那第二个进程才是值得检查的。第二步给出 cwd 加每个打开的描述符——一个限定在单个项目内的文件系统 Server 不应该在 ~/Library 或 ~/.config 中持有句柄。第三步是人们会跳过的一步。一个声称自己是纯本地的 Server 根本不应该有已建立的出站连接,如果有一个来路不明的 TLS 会话连到你不认识的主机,在再次使用那个 Server 之前值得追查。
第四步把快照变成证据。挂上 fs_usage 或 strace,从 Agent 触发一次 tool 调用,然后观察进程打开了什么。这就是"README 说它只读取工作区"和"知道它只读取了工作区"之间的区别。
MCP Server 提供自己的 tool 名称、描述和 JSON schema,你的客户端将这些文本直接注入模型的 context。安全研究人员已经证明,隐藏在 tool 描述中的指令可以引导 Agent 读取用户从未批准的文件或泄露数据,而且 Server 可以在安装时呈现良性的描述然后之后再改。把你的客户端显示的 tool 列表和你批准的列表做 diff,对于无法解释的变更,要像对待变更的 lockfile hash 一样对待它。
审计只描述今天。以下三个变更在包版本升级后依然有效。
固定版本并放入审查。把每个未固定的 npx -y pkg 替换为精确版本。对于每天使用的 Server,安装到项目本地的 node_modules 中,并把 command 指向解析后的二进制路径,这样版本就会进入你的 lockfile 并在 PR 中显示出来,而不只是某人的 dotfiles 里。
给进程比你更少的权限。在 Linux 上,用 bwrap 加显式的 --ro-bind 列表,或者用 --network none 和一个只读挂载启动容器,给你一个内核强制执行的边界,而不是 Server 自我强制的边界。macOS 这边更薄——sandbox-exec 仍然可用但仍然被废弃——所以对于不完全信任的东西,实际的备选方案是一个专用非管理员账户,或一个 VM。
按 Server 划分凭证权限。GitHub MCP Server 需要一个限定到特定仓库的细粒度 token,而不是一个携带你在所有可见范围内 repo 权限的个人访问 token。当 Server 被入侵或只是有 bug 时,爆炸半径等于你交给它的范围——这是完全在你控制下的唯一变量。
如果三件事你只做一件,就做凭证那件。无论审计是否发现问题,范围受限的 token 都能限制损害,而且每个服务大约只需五分钟。