10 月 29 日,Cursor 的 VS Code 扩展市场里出现了一个不是 Anthropic 发布的 Claude Code 扩展,版本 2.1.312 / 2.1.313,存在了大约 11 小时,最多 5000 名用户可能拿到。11 月 14 日 Cursor 公布了完整调查结果。
我把复盘读了两遍,觉得这事对做基础设施的人价值很高,因为它把一个平时没人细想的攻击面摆到了台面上:扩展市场的分发缓存。
攻击是怎么进来的
链条比想象中干净:
攻击者先从一个第三方供应商那里拿到泄露的凭证,据此进入 Cursor 内部系统,又用内部泄露的数据拿到第二个凭证,然后用它把恶意副本直接写进了扩展市场用的分发缓存。
几个关键事实要说清楚,免得归错因:
• 源头 Open VSX 从来没有这个恶意包
• Anthropic 的系统没被攻破,这个版本不是他们发的
• 微软的两个分发渠道(VS Marketplace、npm 上的 @anthropic-ai/claude-code)也没中招
也就是说,问题出在"缓存层"这个中间环节。你信任的上游是干净的,但中间某一层被人塞了东西。这类攻击最难防的地方在于:你的依赖清单、你的 SBOM、你的签名校验,全都看不到缓存层。
恶意扩展干了什么
四件事,按危害排序:
-
写 SSH 公钥:往 `~/.ssh/authorized_keys` 里追加攻击者的公钥,目的是长期免密登录
-
装持久化项:一个 LaunchAgent,命名 `com.apple.sysmond.plist`,冒充系统监控组件
-
收集主机指纹:主机名、用户名、OS、架构、home 路径、环境变量名、工作目录、Git 邮箱、公网 IP
-
回传:把上面这些送到攻击者的服务器(该服务器已被云厂商下线)
注意第 1 条的落地条件:开启 SSH 服务需要管理员权限,大多数 macOS 和 Windows 机器上这一步会失败,攻击者也就连不进来。但公钥文件、持久化脚本和信息上报这三件事已经发生了。
这里有个工程上的细节值得琢磨:它把持久化项命名成系统组件的样子 。`com.apple.sysmond.plist`、`/.config/.smt`、`/.config/.smtk`,肉眼扫一遍 `ls ~/Library/LaunchAgents` 基本发现不了。这是典型的"低技术含量、高成功率"手法,比写个 0day 便宜多了。
自查:一段能直接跑的脚本
Cursor 官方给了一行 grep。我把它扩成了一个可复用的检查脚本,覆盖公钥、持久化项、MCP 配置三个面:
```python
#!/usr/bin/env python3
"""开发机供应链投毒自查:SSH 公钥 / LaunchAgents / MCP 配置"""
import json, pathlib, re, sys
HOME = pathlib.Path.home()
IOC_KEY = "svc-telemetry" # 本次事件的特征串
SUSPICIOUS_NAMES = {"com.apple.sysmond.plist"}
CONFIG_HINTS = {".smt", ".smtk"}
def check_ssh_keys():
f = HOME / ".ssh" / "authorized_keys"
if not f.exists():
return \[\]
hits = [ln for ln in f.read_text(errors="ignore").splitlines()
if IOC_KEY in ln or "svc-" in ln]
return f"{f}: {h\[:80}" for h in hits]
def check_launch_agents():
d = HOME / "Library" / "LaunchAgents"
out = \[\]
if not d.exists():
return out
for p in d.iterdir():
if p.name in SUSPICIOUS_NAMES or IOC_KEY in p.name:
out.append(str(p))
return out
def check_mcp_configs():
"""MCP 配置里的 env 块常带明文密钥,是新的攻击面"""
out = \[\]
for p in HOME.rglob("*mcp*.json"):
if "node_modules" in str(p):
continue
try:
data = json.loads(p.read_text(errors="ignore"))
except Exception:
continue
servers = data.get("mcpServers", {})
for name, cfg in servers.items():
cmd = str(cfg.get("command", ""))
if cmd.startswith(("/tmp", "/var/tmp")) or IOC_KEY in cmd:
out.append(f"{p}: suspicious server '{name}' -> {cmd}")
return out
if name == "main":
findings = (check_ssh_keys() + check_launch_agents() + check_mcp_configs())
if findings:
print("⚠️ 发现可疑项:")
for f in findings:
print(" -", f)
sys.exit(1)
print("✅ 未发现本次事件的 IOC")
```
顺手说 `check_mcp_configs` 那段为什么要加:MCP 配置里的 `env` 块通常直接放 API key,而 `command` 字段本质上就是"让编辑器执行一条命令"。这两样凑在一个 JSON 里,等于一个高权限的远程执行入口,目前大多数企业的终端管控都没覆盖它。
处置顺序:卸载不是第一步
很多人第一反应是卸扩展。没用,持久化项会继续跑。正确的顺序:
-
断网(先掐掉回传)
-
跑官方 `remediate-macos.sh` 清持久化
-
检查并删除 `authorized_keys` 里的公钥
-
从一台干净的机器上轮换这台机器能碰到的所有凭据(Git token、云 AK/SK、数据库密码、CI secret)
-
升级到 2.1.314,重装环境
-
查访问日志,看这 11 小时里有没有异常登录
第 4 步最容易被跳过,但它是危害面最大的一步。SSH 公钥本身不可怕,公钥背后能摸到的东西才可怕。
一个对症的开源工具:bumblebee
`perplexityai/bumblebee`,约 5.1k Star,Go 写的单二进制,Apache-2.0,零第三方依赖。
它只做一件事:读本地元数据,回答"我这台机器上有没有装过出事的那个包"。
设计上有三个选择我觉得是对的:
• **只读**。不执行包管理器、不联网查库。理由很实在:在一台可能已被投毒的机器上,跑 `npm audit` 本身就是风险动作。
• **覆盖广**。npm / pnpm / Yarn / Bun / PyPI / Go / RubyGems / Composer / Homebrew,加上编辑器扩展、浏览器扩展、MCP 配置。
• **输出 NDJSON**。包清单和命中结果分开,可以直接灌进 SIEM 或日志平台做全机队聚合。
```bash
go install github.com/perplexityai/bumblebee/cmd/bumblebee@latest
bumblebee scan --profile baseline --root ~ --findings-only --exposure-catalog ./catalog/
```
它补的正是 SBOM 和 EDR 之间的空白:SBOM 记录的是生产构建,EDR 盯的是运行中的进程,而一台工程师笔记本上装过什么、什么时候装的,多数团队是空白的。写一份 SBOM 要一条流水线,装一个扩展只需要点一下,这两种行为的时间尺度根本不在一个量级。
另一件事:DeepSeek 把工具链搬进了服务端
顺带说个和架构有关的变化。11 月 13 日 DeepSeek 开放全家桶 API,`deepseek-flash` 在 Messages / Responses / Chat Completions 三个接口下,打开 `deepseek_web_search` 就能联网搜索,`deepseek_read_url` 能自己抓链接,文件输入支持 PDF/Office/图片,工具调用和上下文拼接全在服务端完成。Search / Fetch / OCR / ASR / TTS 还能单独调。
价格:搜索 5 元/千次、抓取 5 元/千次、OCR 1.5 元/千页、ASR 0.39 元/小时、TTS 1.5 元/万字符。
从架构角度看,这是把原本长在客户端的一层 orchestration 收到了服务端。好处是接入代码少了,坏处是你的可观测性边界往外挪了一截:一次请求里模型搜了几次、抓了什么链接、读了哪些文件,你只能靠平台返回的字段,自己的 tracing 看不见。
```python
from openai import OpenAI
client = OpenAI(api_key="sk-...", base_url="api.deepseek.com")
resp = client.chat.completions.create(
model="deepseek-flash",
messages={"role": "user", "content": "帮我查一下超聚变最新的营收数据并给出处"},
extra_body={
"tools": {"type": "web_search"}, # 开启联网搜索,服务端自动完成搜索与引用拼接
},
)
print(resp.choices0.message.content)
```
要不要把 orchestration 交出去,本质是同一道题:你愿意用多少控制权换多少交付速度。 扩展市场那次事件给出的答案挺明确:交出去的那部分,出事的时候你也看不见。
一个技术上的二选一,想听你们的做法
方案 A:把工具调用(搜索、解析、语音)留在自己的编排层,供应商只提供模型。控制力强、可观测性好,代价是工程量大、每家都要适配一遍。
方案 B:直接用平台的全家桶 API,工具链交给服务端。接入快、维护少,代价是可观测性边界外移、迁移成本上升。
我们团队在两套方案之间反复横跳过。你们现在是怎么选的?
另外两个问题也想听听:你们公司有没有把 MCP 配置文件纳入终端安全基线?开发机中了这类投毒,是选择重装还是清脚本加轮换凭据?我倾向后者,但说实话,心理上没那么踏实。
有更好的扫描思路或者踩过坑的,评论区聊聊。