半年前你审过这个 MCP server 包,当时没问题。今早 CI 跑了一次 npm install,拉到了一个新版本——维护者账号昨晚被接管,这个版本悄悄把对话 context 和扫描到的密钥发往一个外部主机。症状通常是其中之一:Agent 向陌生域名发起出站连接;本该只存在于 Agent 内存里的敏感值出现在日志中;npm audit 标记了某个依赖;或者某个 postinstall 脚本写了一个你从没创建过的 .github/workflows/ 文件。
最快的止血动作(按顺序先做这些):先停掉 Agent 和任何运行该 MCP server 的进程,然后用 git diff / npm pack 把可疑版本和上一个已知安全版本对比,确认问题。把这个进程能读到的每一个凭证都当作已泄露,吊销并重新签发(而不是原地轮换)——npm token、云密钥、OPENAI_API_KEY、CI/CD secret。之后用提交到仓库的 lock 文件、以 npm ci 重新锁回最后一个干净版本。加固(发布冷却期、ignore-scripts、egress 防火墙)放在止血之后。
这不是假设。自我复制的 npm 蠕虫 Shai-Hulud 在 2025 年底感染了 500 多个包;Mini Shai-Hulud(微软,2026 年 5 月)攻陷了 170 多个 npm 包、404 个恶意版本,专门用 TruffleHog 扫描文件系统找高熵密钥,并 dump 整个 process.env(抓走 GITHUB_TOKEN、AWS_ACCESS_KEY_ID 之类)。2.0 变种把所有包名含 mcp-server 的包都加进了攻击目标列表——因为 MCP server 运行在你的 Agent 进程内部,拥有和正常 server 完全相同的文件系统、环境变量和工具调用权限。
你属于哪种情况
| 你看到的症状 | 最可能的原因 | 跳到 |
|---|---|---|
| node/python 进程向未知主机发出站连接 | 被攻陷的版本在外发数据;或某个间接依赖在外发 | Step 1(确认)、Step 5(egress) |
| 密钥出现在日志里 / 没记录过的 key 冒出来了 | server 继承了完整父进程环境;载荷 dump 了 process.env | Step 3(最小环境)、应急响应 |
出现了你没写过的 .github/workflows/*.yml | 蠕虫通过 postinstall 植入持久化(Shai-Hulud 模式) | 应急响应、Step 4(ignore-scripts) |
最近一次安装后 npm audit 或 Socket 标记了该包 | 已知恶意版本在被下架前到达了你 | Step 1、Step 2(锁版本 + 冷却期) |
| 昨天还好,自动部署后就坏了 | 范围版本(^/~)+ 没用 npm ci,拉到了坏版本 | Step 1、Step 2 |
常见原因
1. 用了范围版本而不是精确锁定
package.json 里写 "^1.0.0",下次在新机器上 npm install 就会自动拉到更新的、可能已被攻陷的 1.0.1。脱字号(^)允许 minor/patch 升级。
怎么判断:打开 package.json,看任何 MCP server 或安全敏感依赖上是否有 ^ 或 ~。除了精确锁定("1.2.3")加上提交到仓库的 lock 文件,其他都算暴露。
2. 缺少包完整性校验
npm 会在 package-lock.json 里为每个包记录一个 integrity 哈希(sha512-...)。如果 lock 文件没提交,或者提交时缺了 integrity 字段,就没有防篡改能力,tarball 替换攻击会被静默放过。
怎么判断:
# 统计 lock 文件里的 integrity 哈希数量——应大致等于包的数量
grep -c '"integrity"' package-lock.json
3. 安装时运行了 postinstall 生命周期脚本
npm 的 postinstall 脚本会在 npm install 时自动执行任意代码,是被利用最多的供应链入口——Shai-Hulud、Mini Shai-Hulud,以及 2026 年 4 月的 Axios 投毒事件都是通过安装钩子触发的。
怎么判断:
# 在信任之前,先列出依赖树里所有 install/postinstall 脚本
npm query ':attr(scripts, [postinstall])' 2>/dev/null
# 或者直接看这个包
cat node_modules/@vendor/mcp-server/package.json | grep -A2 '"scripts"'
4. MCP server 以完整父进程环境启动
server 进程会继承 shell 里可见的每一个 OPENAI_API_KEY、DATABASE_URL、AWS_SECRET_ACCESS_KEY。一个 dump process.env 的载荷会一次性拿走全部。
怎么判断:把启动进程能看到的环境变量 key 打到日志里。任何 server 并非严格需要的 key 都是不必要的暴露。
5. 没有限制出站网络访问
被攻陷的 server 会连上它的 command-and-control 主机并外发 context。Mini Shai-Hulud 用的是走 443 端口的加密 C2 over HTTPS,还带 Git Data API 兜底——所以”看起来像正常 HTTPS”并不等于安全。
怎么判断:查运行该 server 进程 UID 的 egress 日志或防火墙计数器。任何不在显式 allowlist 里的目标都可疑。
6. 用裸 npx 跑 MCP server(不受控的包解析)
MCP server 常用 npx @vendor/mcp-server 启动,而每一次调用都是一次不受控的包解析——会拉取并运行注册表当前提供的任何东西。因为 MCP server 有文件系统和环境变量访问权,这是风险最高的模式之一。
怎么判断:在 Agent/MCP 配置(.cursor/mcp.json、claude_desktop_config.json、CI 脚本)里 grep 那些没有锁定版本、也没有预装 lock 文件的 npx MCP server 调用。
7. 出问题的是间接依赖,不是 server 本身
server 可能是干净的,但它某个嵌套依赖被劫持了。一个有网络访问权的深层依赖一样危险。
怎么判断:npm audit 查已知公告,npm ls @vendor/mcp-server 看完整依赖树。新出现的恶意版本可能还没进公告库,所以要配合发布冷却期(Step 2)。
应急响应:你怀疑已经发生了
在做任何加固之前,按顺序做下面这些。速度很关键——很多恶意版本几小时内就被下架了,但它读走的密钥早已外泄。
- 先收口爆炸半径。 杀掉 Agent 和 MCP server 进程;把受影响的 CI runner 下线。
- 确认攻陷。 把可疑版本和上一个已知安全版本做 diff(见 Step 1)。找新增的网络调用、
process.env读取、base64 blob、以及新加的安装脚本。 - 排查持久化(Shai-Hulud 模式)。 检查你没写过的文件:
.github/workflows/shai-hulud-workflow.yml或任何意外的 workflow,以及窗口期内你账号下被创建的仓库。2.0 变种会用反转文本的仓库描述niagA oG eW ereH :duluH-iahS标记被攻陷的仓库。 - 吊销并重新签发每一个可触达的密钥——而不是原地轮换。当作已烧毁处理:npm token、
GITHUB_TOKEN、SSH 密钥、云密钥(AWS_*、GCP、Azure),以及该进程能读到的任何模型 API key。正是出于这个原因,GitHub 在 2026 年 5 月那波攻击中作废了 61,274 个 npm token。 - 审查工具调用和 egress 日志,覆盖暴露窗口,确定哪些数据外泄了。
- 锁回干净版本并重建,从提交到仓库的 lock 文件以
npm ci安装。
最短加固路径
Step 1: diff 可疑版本以确认
# 把两个版本并排拉下来,对高风险模式做 diff
npm pack @vendor/mcp-server@1.2.2 # 上一个已知安全版本
npm pack @vendor/mcp-server@1.2.3 # 可疑版本
mkdir old new
tar xzf vendor-mcp-server-1.2.2.tgz -C old
tar xzf vendor-mcp-server-1.2.3.tgz -C new
diff -r old new | grep -Ei "fetch|http|axios|net\.|child_process|eval|process\.env|postinstall|atob|base64"
Step 2: 精确锁版本、提交 lock 文件、加发布冷却期
锁定能挡住静默升级。发布冷却期是 2026 年新增的手段——它给社区留出时间在你安装前标记坏版本,因为大多数恶意版本几小时内就会被举报和下架。
// package.json —— 精确锁定,不用 ^ 或 ~
{ "dependencies": { "@vendor/mcp-server": "1.2.3" } }
# .npmrc —— npm 11.10.0+(2026 年 2 月):拒绝发布不足 3 天的版本
min-release-age=3
# CI:严格按 lock 文件安装;有任何改动就失败
npm ci
# pnpm v11(2026 年 4 月)默认带 minimumReleaseAge=1440(1 天)
截至 2026 年 6 月,npm 的 min-release-age 还没有按包豁免的机制,npm v12 预计会默认禁用安装脚本;pnpm v11 已经默认 1 天冷却期。
Step 3: 把 server 的环境限制到最小
import { spawn } from "child_process";
const mcpProcess = spawn("npx", ["@vendor/mcp-server"], {
env: {
PATH: process.env.PATH,
MCP_SERVER_TOKEN: process.env.MCP_SERVER_TOKEN, // 只给它真正需要的
// 不要传 OPENAI_API_KEY、DATABASE_URL、AWS_* —— 一个 dump
// process.env 的载荷只能偷到你递给它的东西。
},
stdio: ["pipe", "pipe", "pipe"],
});
Step 4: 阻断安装期代码执行
# .npmrc —— 全局禁用生命周期脚本
ignore-scripts=true
# 或按次禁用;只对你信任并自己控制的包放开脚本
npm ci --ignore-scripts
Step 5: 在操作系统层限制出站网络
# Linux:把 egress 限定到运行 server 的 UID;除 allowlist 外一律拒绝
iptables -A OUTPUT -p tcp -m owner --uid-owner mcp-server-user ! -d 10.0.0.0/8 -j REJECT
# macOS:用 pf 规则或 Little Snitch,作用域限定到 node 进程
# Kubernetes:用 NetworkPolicy 的 Egress 块,限定到你的内网 CIDR/443
Step 6: 用 allowlist 审计工具调用
const EXPECTED_TOOL_CALLS = new Set(["read_file", "write_file", "list_directory"]);
function auditToolCall(toolName: string, sessionId: string): void {
if (!EXPECTED_TOOL_CALLS.has(toolName)) {
logger.error({ event: "unexpected_tool_call", tool: toolName, sessionId });
throw new Error(`Unexpected tool call blocked: ${toolName}`);
}
}
Step 7: 部署前在 CI 校验包哈希
#!/bin/bash
# ci/verify-mcp-hashes.sh
EXPECTED_HASH="sha256:abc123def456..."
ACTUAL_HASH=$(sha256sum node_modules/@vendor/mcp-server/dist/index.js | cut -d' ' -f1)
if [ "sha256:$ACTUAL_HASH" != "$EXPECTED_HASH" ]; then
echo "SECURITY: MCP server hash mismatch —— 中止部署"
exit 1
fi
echo "MCP server integrity verified."
如何确认已修好
npm ci能复现安装且 lock 文件无改动,锁定的版本与你最后一次干净 diff 一致。npm config get ignore-scripts返回true,npm config get min-release-age返回你设的冷却期(如3)。- 一次测试运行中,server 进程没有向 allowlist 之外的任何目标发出站连接(运行期间盯 egress 日志或防火墙计数器)。
- 没有未授权的
.github/workflows/文件,暴露窗口内你账号下也没有被创建的仓库。 - 每个可触达的密钥都已重新签发,旧 token 测试时全部失效。
预防建议
- 对每个 MCP server 和安全敏感依赖都用精确版本,绝不用
^/~。提交 lock 文件,CI 里跑npm ci,绝不用裸npm install。 - 加发布冷却期(npm 11.10.0+ 的
min-release-age,pnpm v11 默认开启),让刚发布的版本先熬过下架窗口。 - 设
ignore-scripts=true,只对你审过的包放开生命周期脚本。 - 把锁定版本的 MCP server 预装在受 lock 文件控制的工作区里,而不是用裸
npx启动;如果非用npx不可,就锁定精确版本。 - 用最小且显式的环境启动 MCP server;用操作系统防火墙或 NetworkPolicy 限制出站网络。
- 订阅你用的每个 MCP 包的 GitHub Security Advisories 和 npm advisories;升级锁定版本前先看 changelog 和 diff。
- 在 CI 里用扫描器(Socket、
npm audit、Snyk),高危发现时让构建失败。
常见问答 (FAQ)
Q: 被攻陷的 npm 包能多快到达生产环境?
A: 用范围版本加裸 npm install,在恶意版本发布后几小时内就能到。锁了 lock 文件、用 npm ci 部署的话,在你主动改锁定版本之前都是受保护的——再加一个 min-release-age 冷却期,即便那时也还有缓冲。
Q: 我轮换了密钥——这就算完事了吗? A: 只有当你是重新签发而非原地轮换,并且确认了恶意代码没有其他持久化(没有流氓 workflow 文件、没有被创建的仓库)时才算。微软对 2026 年这几波的建议是:把所有可触达凭证都当作已烧毁并重新签发,因为载荷外泄的是密钥值本身。
Q: 怎么判断当前安装的是不是已经被攻陷了?
A: 把已安装版本和一个已知安全的 npm pack 做 diff(Step 1),grep process.env dump 和 base64 blob,查 egress 日志里的未知主机,并找意外的 .github/workflows/ 文件。如果出现类似 Index.js/cat.py 的文件,或反转的 Shai-Hulud 仓库描述,就当作已被攻陷。
Q: 发布冷却期会不会拖累我的工作流? A: 基本不会。它只延迟刚发布的版本;超过阈值的版本立即可装。截至 2026 年 6 月 npm 还没有按包豁免,所以如果你偶尔需要尝鲜,把窗口设成几天(常用 3 天)而不是几周。
Q: tool poisoning 和供应链攻击有什么区别? A: Tool poisoning 改的是发给模型的工具描述/行为,不改已安装的代码(靠审计 manifest 检测)。供应链攻击改的是已安装的代码本身(靠文件完整性和 diff 检测)。两者都会导致异常行为,但检测手段不同。