只挂了11小时,5000台开发机被塞进SSH公钥

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、你的签名校验,全都看不到缓存层。

恶意扩展干了什么

四件事,按危害排序:

  1. 写 SSH 公钥:往 `~/.ssh/authorized_keys` 里追加攻击者的公钥,目的是长期免密登录

  2. 装持久化项:一个 LaunchAgent,命名 `com.apple.sysmond.plist`,冒充系统监控组件

  3. 收集主机指纹:主机名、用户名、OS、架构、home 路径、环境变量名、工作目录、Git 邮箱、公网 IP

  4. 回传:把上面这些送到攻击者的服务器(该服务器已被云厂商下线)

注意第 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 里,等于一个高权限的远程执行入口,目前大多数企业的终端管控都没覆盖它。

处置顺序:卸载不是第一步

很多人第一反应是卸扩展。没用,持久化项会继续跑。正确的顺序:

  1. 断网(先掐掉回传)

  2. 跑官方 `remediate-macos.sh` 清持久化

  3. 检查并删除 `authorized_keys` 里的公钥

  4. 从一台干净的机器上轮换这台机器能碰到的所有凭据(Git token、云 AK/SK、数据库密码、CI secret)

  5. 升级到 2.1.314,重装环境

  6. 查访问日志,看这 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 配置文件纳入终端安全基线?开发机中了这类投毒,是选择重装还是清脚本加轮换凭据?我倾向后者,但说实话,心理上没那么踏实。

有更好的扫描思路或者踩过坑的,评论区聊聊。

相关推荐
xjz184211 分钟前
Rust 运算符优先级:括号不只用来改变顺序
后端
写码跑山的老艾11 分钟前
为什么有了 MySQL,还要 Redis 和 Elasticsearch?从一件商品看数据系统的权衡
后端
对象存储与RustFS17 分钟前
Jenkins 的构建产物别堆在控制器磁盘:用 RustFS 当它的 S3 工件仓库
后端·docker·云原生
王中阳Go18 分钟前
100 件货卖出 116 件,库存停在 -16
后端·mysql·go
泡海椒19 分钟前
jquick-pdf 多重折线(JMultiLineChartData)实战:Java 生成工单 SLA 月报
后端
Swot21 分钟前
SvelteKit Remote Functions 使用指南
前端·后端
Java旅途28 分钟前
让项目过程清晰可追溯:从课题双选到文档归档的一体化平台
后端
黄连木笛30 分钟前
不改 Redis Insight 原有代码,把它汉化了:DOM 整段匹配 + XHR 拦截 + asar 重打包
redis·后端