11 小时窗口:官方 ID、官方渠道、官方签名
2026 年 10 月 29 日,Cursor 在自己的扩展市场里发现了一件不该发生的事:Anthropic.claude-code的2.1.312与2.1.313两个版本被人改过,装进去的是一套持久化 SSH 后门。恶意版本从当天 09:27 UTC 挂到 20:35 UTC,前后约11 小时,Cursor 估算受影响用户最多5000人。
这件事最刺眼的地方不是后门本身,而是它出现的位置。恶意包用的不是仿冒名称,也不是山寨发布者,而是 Anthropic 在市场上的官方扩展 ID,走的是官方分发账号。攻击者拿到的不是某个开发者的电脑,而是整条更新通道。
版本线很清楚。恶意版本是 2.1.312 和 2.1.313,Anthropic 随后发布 2.1.314,并从 Open VSX 撤下 2.1.313 的相关发布;Phorion 在 10 月 30 日发布分析时只标了 2.1.313,10 月 31 日按 Cursor 的通报补充为 2.1.312 与 2.1.313。Ars Technica 在报道里指出,截至其发稿,Anthropic 尚未就此事向客户发出公开警告。
Cursor 的处置动作可以拆成五步:立即下架恶意版本并阻断分发;出于谨慎整体关闭扩展市场;审计访问记录,确认没有其他扩展被动过;轮换密钥、重建市场后重新上线;联合托管服务商把攻击者的服务器下线,让反向隧道与上报接口同时失效。
把这件事当成“又一个钓鱼包”会看错重点。钓鱼包需要用户主动搜索、主动安装、主动放行;这次不需要,因为默认设置就是自动更新,用户只要在那 11 小时里打开过一次编辑器,扩展就可能已经被换掉。攻击窗口从“用户点不点”压缩成了“下一次启动什么时候”。
所以本文不讨论攻击者是谁,也不猜测账号是怎么丢的。真正可复用的部分在载荷里:一个扩展包能对开发机做什么,主机侧留下哪些痕迹,以及团队在拿到一个 .vsix 时能检查什么。
扩展加载即执行:不需要用户做任何动作
被改过的extension.js有一个很明显的形态:在压缩后的整包前面,插入了一段立即执行函数。这段函数一开头就引入了child_process、fs、os、path、http、crypto六个 Node 内置模块,后面的业务代码看上去完全正常,Claude Code 的字符串、VS Code 的 API、activate与deactivate导出都在。
关键在时机。Node 加载这个文件时就会执行文件顶层代码,注入的那段函数跑在扩展的activate回调之前。也就是说,用户不需要打开侧栏、不需要发一条消息、不需要点任何命令,只要编辑器把扩展宿主进程拉起来,载荷就已经开始工作。
权限模型决定了这一步的代价。扩展运行在 IDE 的进程里,继承 IDE 与当前登录用户的权限,能读本地文件、源码、Git 配置、开发工具链。IDE 主程序签名有效,也拦不住扩展宿主进程去改~/.ssh下的文件,因为签名验证的是二进制来源,不是运行时的行为边界。
VS Code 系的编辑器把扩展放在独立的扩展宿主进程里跑,这个设计能防止扩展把编辑器主进程拖死,但它不是安全沙箱。宿主进程以当前用户身份启动,读写的是同一个文件系统、同一份环境变量、同一套凭据文件,没有额外的路径隔离。所以“进程隔离”解决的是稳定性问题,不是权限问题。
这也解释了为什么这类事件总能一次影响几千台机器。分发侧的信任是集中式的,安装侧的判断是分散式的,中间还夹着一个默认开启的自动更新。三者叠加,攻击者只要拿下发布账号,就能把“可信更新”变成“可信投递”。
第一步:把攻击者的公钥写进 authorized_keys
载荷的第一个动作很直接:确认~/.ssh目录存在,把一枚攻击者控制的 Ed25519 公钥追加到~/.ssh/authorized_keys,目录权限设成0700,文件权限设成0600。公钥注释是svc-telemetry,看起来像一个正常的运维标识。
这里有个细节值得所有做取证的人记住。脚本判断“是否已经写过”的方式,是检查文件内容里有没有svc-telemetry这个注释字符串,而不是比对公钥指纹。注释是攻击者自己写的,改一个字就能绕过判重,于是同一台机器可能被追加多枚公钥。排查时按注释搜会漏,按公钥指纹查才准。
公钥落地之后,第二步是让 SSH 服务跑起来。三个平台各有一套尝试:Linux 走systemctl启sshd或ssh,失败再退到service与/etc/init.d/;macOS 先查 Remote Login 状态,再用sudo -n systemsetup -setremotelogin on尝试打开;Windows 用 PowerShell 安装并启动 OpenSSH Server。
整条链里没有任何提权利用。非交互的sudo -n在普通用户下基本会失败,装系统服务需要管理员权限,这两点在绝大多数开发机上都不成立。所以 Cursor 的通报里写得很清楚:在多数 macOS 与 Windows 主机上,开启 SSH 这一步会失败,攻击者无法通过隧道登录。
但这不等于主机是干净的。公钥已经写进authorized_keys,持久化已经落地,侦察包已经发出。只要这台机器原本就开着 SSH,或者用户后来手动打开了远程登录,那枚公钥立刻就能用。攻击链断在最后一环,和攻击链不存在,是两件完全不同的事。
macOS 上的那个空权限检查
Phorion 的分析里有一段很值得抄进检测规则。macOS 分支先用systemsetup -getremotelogin读状态,只有输出里匹配到Off才去执行开启命令。逻辑本身没错,错在它假设这条读取命令在普通用户下能返回真实状态。
实际跑起来是这样的:标准用户执行systemsetup -getremotelogin,工具会打印一行“需要管理员权限”的提示,然后以状态码 0 退出。stdout 里没有Off,正则匹配失败,脚本就跳过了开启分支。它把“我没有权限检查”当成了“SSH 已经开着”。
这个错误同时带来两个后果。一方面,攻击链的入站入口在很多 Mac 上是断的;另一方面,主机上不会出现systemsetup -setremotelogin on这条命令,如果检测规则只盯这条命令,就会整批漏报。规则要盯的是文件写入与持久化,而不是那条可能永远不会执行的开启命令。
Windows 分支也有自己的问题。脚本把一个 PowerShell 服务安装命令交给内部辅助函数去跑,而这个函数把 shell 固定成了/bin/sh,在标准 Windows 环境里根本走不通。两个平台各有一个实现缺陷,这也是本次事件影响面没有进一步扩大的直接原因。
把实现缺陷当成防线是危险的。攻击者下一次修掉这两行代码,同样的载荷就会完整生效;而这次的公钥、持久化脚本、隧道私钥都已经真实落盘,清理工作量一点没少。
反向隧道:-R *:0:localhost:22 是什么意思
公钥只是门票,真正把门打开的是隧道。脚本用内嵌的一枚未加密 Ed25519 私钥,向134.209.82.225的 22 端口发起出站 SSH,登录用户名是tunnel。这枚私钥的注释是svc-telemetry-tun,和前面种到受害者机器上的公钥不是同一对,用途正好相反:一枚负责让攻击者进来,一枚负责让受害机器连出去。
私钥的落地方式也设计过。它被写到操作系统临时目录,文件名是.stk_加上MD5(hostname + HOME)的前 10 位十六进制。名字带点、内容随机,既不会撞车,也不容易被普通的临时文件清理规则注意到。
隧道参数是这段代码里最值得记住的部分:-R *:0:localhost:22要求服务端动态分配一个监听端口,把打到该端口的流量原路转发回本机的 22;-N表示不执行远程命令,只保留端口转发。配套的选项是StrictHostKeyChecking=no、UserKnownHostsFile=/dev/null、BatchMode=yes、IdentitiesOnly=yes,再加上 30 秒心跳与ExitOnForwardFailure=yes。
这套组合的效果是:攻击者不需要在 JavaScript 里写任何命令处理逻辑。他只要在自己的服务器上,连上被分配到的那个端口,用种下去的公钥认证本机的 22,就拿到一个完整的交互式 shell 和文件传输能力。C2 通道是系统自带的 sshd,流量看起来就是一条出站 SSH。
维持连接的部分同样省事。装了autossh就用它,没装就用普通ssh,外面套一个脱离终端的循环,每 60 秒重试一次,用pgrep避免重复起进程。因为是脱离进程,用户关掉编辑器,隧道还在。
取证时有个容易看错的地方:如果攻击者真的通过隧道登录过,SSH 日志里的来源地址会显示127.0.0.1或::1,认证用的就是那枚svc-telemetry公钥。看到“本地登录”不要当成正常的本机操作,要去看它用的是哪把钥匙。
从网络侧看,这条隧道很难被“异常端口”规则抓到。它用的是 22 端口,协议是标准 SSH,加密握手正常,出站方向在多数企业网络里默认放行。真正可用的信号是目的地:一台开发机主动连一个陌生公网 IP 的 22 端口,并且长期保持连接,这件事本身就值得告警。
三套持久化:伪装成系统进程的 LaunchAgent
为了让访问在重启后依然可用,脚本按平台写了三套落地文件。它们都藏在用户目录的隐藏路径里,其中 macOS 那份还特意借用了苹果的命名前缀。
| 平台 | 落地文件 | 持久化方式 |
|---|---|---|
| Linux | ~/.config/.smt、~/.config/.smtk | 写入重建隧道并循环连接的脚本,向用户 crontab 添加@reboot条目 |
| macOS | ~/.config/.smt、~/.config/.smtk、~/Library/LaunchAgents/com.apple.sysmond.plist | 用户级 LaunchAgent,RunAtLoad与KeepAlive均为真,用launchctl加载 |
| Windows | ~/.config/smt.cmd、~/.config/smtk、启动目录下的sysmon.cmd | 先尝试创建以 SYSTEM 身份运行的计划任务SysMon,失败则回退到启动文件夹 |
macOS 那一行是这次事件里最像“正规软件”的一处。com.apple.sysmond.plist放在用户的 LaunchAgents 目录里,标签带com.apple.前缀,配置内容是执行/bin/sh ~/.config/.smt。普通用户扫一眼文件名,很容易以为这是系统自带的后台服务。
Linux 那份还带了一个副作用缺陷:检查 crontab 是否已有条目的逻辑写得不严谨,重复执行时可能把用户原有的定时任务一起清掉。这类“顺手破坏”在真实事件里很常见,也提醒一件事:清后门之前先备份 crontab,否则取证和恢复都会丢线索。
检测信号其实比文件路径更稳。一个非苹果签名的进程去写带com.apple.标签的 plist,本身就是异常;一个 LaunchAgent 用/bin/sh去跑用户主目录下的隐藏脚本,也基本没有正常业务会这么做。规则写在这两条上,比枚举文件名更耐用。
侦察包里最值钱的是变量名
载荷的最后一步是发一个一次性上报包。字段包括主机名、用户名、操作系统、架构、主目录、当前工作目录、git config --global user.email里配置的邮箱,以及通过ifconfig.me(失败回退api.ipify.org)拿到的公网 IP,再加上每个阶段是否成功的状态标记。
真正值得停下来看的是环境变量那一段。脚本用Object.keys(process.env)取到所有变量名,用/KEY|TOKEN|SECRET|PASS|AWS|GCP/i过滤,最多取 40 个,然后只把名字发出去,不取值。
为什么不取值?因为值的格式、长度、编码各不相同,采集和解析成本高,还容易在日志里留下明文痕迹。名字的信息量已经够了:一台机器上同时出现 AWS 相关的键名、模型服务的 token 名、支付服务的密钥名,这台机器在攻击者眼里就是高价值目标,值得后续单独投入。
这是一个很明确的战术选择:先用低成本侦察做资产清点与优先级排序,再对少数主机做定向利用。它把“广撒网偷凭据”换成了“先看谁值得偷”,对被盯上的团队反而更危险,因为后续攻击会更安静、更有针对性。
上报通道是明文 HTTP:POST /beacon打到134.209.82.225:8099,同时还发一份到127.0.0.1:8099。用明文说明运营者并不担心中间人看到内容,因为包里确实没有秘密值;本地那一份的用途没有解释,脚本本身也没有在本地起监听。上报在初始化后 1.5 秒触发一次,响应直接用res.resume()丢弃。
防御侧由此多了一条很具体的规则:环境变量名也是敏感信息。正常业务很少需要把几十个匹配 KEY、TOKEN、SECRET 的变量名打包外发,出现这种行为,无论目的地是不是已知恶意 IP,都该当事件处理。
卸载扩展不等于清干净
Cursor 与 Sophos 在通报里都强调了同一句话:卸载扩展不会移除后门。原因很简单,扩展只是投递器,真正落地的是公钥、持久化脚本、隧道进程和临时目录里的私钥文件,这四样都在扩展目录之外。
清理要按阶段来。第一步杀掉活动隧道:结束那个/bin/sh重试循环,以及所有连向134.209.82.225的ssh或autossh进程。第二步清持久化:macOS 卸载并删除com.apple.sysmond.plist,Linux 清掉@reboot条目,Windows 删掉SysMon计划任务与启动目录脚本,再删~/.config下的隐藏文件。
第三步清 SSH 状态:从authorized_keys里删掉注释为svc-telemetry的公钥(按指纹确认),删除临时目录里的.stk_*,并确认 Remote Login 没有被意外打开。第四步轮换凭据:凡是命中KEY|TOKEN|SECRET|PASS|AWS|GCP这组过滤器的变量,对应的令牌都按已暴露处理,尤其是那台机器确实开启过 SSH 的情况。
排查范围可以先用三个硬指标圈定:扩展版本是否为 2.1.312 或 2.1.313;安装或自动更新时间是否落在 10 月 29 日 09:27 到 20:35 UTC 之间;主机上是否存在~/.config/.smt或~/.config/smt.cmd。三者任意一条命中,就进事件响应流程,而不是只做一次卸载。
如果要做哈希比对,Phorion 公布了三个可以直接用的值:被篡改的extension.js的 SHA-256 是ecb58cb29078d9646ad1dcdbe51526f2d9cfea06be5fb2ab183e8ecc3ab6bf15;种到受害者机器上的公钥指纹是SHA256:0Z6R+DF64Do2nwQic8a7kkfDLNNk4IiK+2U2Rci4htk;隧道用的公钥指纹是SHA256:KOAU57+ZG6u3MG4WwzPzPX2O5YEe9qdOC1Bl+6MVyDQ。
给扩展包做静态体检:80 行可运行的审计脚本
上面这些行为特征有一个共同点:它们都必须出现在扩展的入口脚本里,否则跑不起来。这就给了我们一个成本很低的检查点——拿到 .vsix 之后先做静态体检,读 manifest、扫入口脚本、算哈希、按权重打分,再决定这个包能不能进环境。
下面这段代码是完整可运行的,只依赖标准库。它把authorized_keys写入、SSH 开启、反向隧道、持久化、环境变量枚举、密钥名过滤、IP 字面量、beacon 路径、child_process九类信号做成规则表,命中即累加权重,最后给出pass、review、block三档结论,并用退出码 0 或 3 交给 CI 判断。
#!/usr/bin/env python3 """vsix_audit.py — VSIX 扩展包静态体检:manifest、入口脚本、持久化与网络指标。""" import hashlib import json import re import sys import zipfile from pathlib import Path SIGNALS = [ ("ssh_key_write", 40, r"authorized_keys"), ("ssh_enable", 25, r"remotelogin|systemctl\s+enable\s+(sshd|ssh)|Add-WindowsCapability"), ("reverse_tunnel", 40, r"-R\s+\*:0:localhost:22|autossh|ExitOnForwardFailure"), ("persistence", 30, r"LaunchAgents|com\.apple\.[a-z]+\.plist|@reboot|schtasks|Startup\\\\sysmon"), ("env_enum", 20, r"Object\.keys\(\s*process\.env\s*\)"), ("secret_filter", 20, r"KEY\|TOKEN\|SECRET\|PASS\|AWS\|GCP"), ("ip_literal", 15, r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), ("beacon_path", 15, r"/beacon|:8099"), ("child_process", 10, r"child_process"), ] PINNED = { "anthropic.claude-code": "", } def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def audit(vsix: Path) -> dict: findings, js_files = [], [] with zipfile.ZipFile(vsix) as zf: names = zf.namelist() manifest = json.loads(zf.read("extension/package.json").decode("utf-8")) for name in names: if not name.endswith((".js", ".cjs", ".mjs")): continue raw = zf.read(name) text = raw.decode("utf-8", "ignore") entry = {"file": name, "sha256": sha256_bytes(raw)[:16]} js_files.append(entry) for rule, weight, pattern in SIGNALS: hits = re.findall(pattern, text) if hits: findings.append({"rule": rule, "weight": weight, "file": name, "hits": len(hits), "sample": str(hits[0])[:48]}) prefix_iife = bool(re.match(r"\s*[;!]?\s*\(\s*function\s*\(", zf.read( next(n for n in names if n.endswith(".js"))).decode("utf-8", "ignore"))) score = min(100, sum(f["weight"] for f in findings)) ident = f"{manifest.get('publisher')}.{manifest.get('name')}" pinned = PINNED.get(ident) verdict = "block" if score >= 60 else ("review" if score >= 30 else "pass") return { "package": ident, "version": manifest.get("version"), "main": manifest.get("main"), "activationEvents": manifest.get("activationEvents", []), "vsix_sha256": sha256_bytes(vsix.read_bytes()), "hash_pinned": bool(pinned) and pinned == sha256_bytes(vsix.read_bytes()), "js_entry_prefixed_iife": prefix_iife, "js_files": js_files, "score": score, "verdict": verdict, "findings": findings, } def main() -> int: target = Path(sys.argv[1]) report = audit(target) print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if report["verdict"] == "pass" else 3 if __name__ == "__main__": sys.exit(main())用法只有一条命令:python vsix_audit.py 某个扩展.vsix。我本地用两个合成包验证过:一个只有正常命令注册的包得到score 0、verdict pass、退出码 0;另一个按本次事件的行为特征构造的包,九条规则全部命中,得到score 100、verdict block、退出码 3。CI 里直接按退出码拦截即可,不需要人读 JSON。
三个设计点值得说明。第一,哈希按字节算,不按版本号算。版本号可以被重新发布,同一个 2.1.313 既可能是干净的,也可能是被改过的,只有vsix_sha256能区分。第二,只看 manifest 不够。本次恶意包把注入代码放在入口文件顶部,manifest 里的发布者、名称、激活事件全都正常,所以脚本必须打开main指向的脚本看内容,还要检查入口文件是否以立即执行函数开头。
第三,权重集中在上链的关键动作上。写authorized_keys、开反向隧道各 40 分,持久化 30 分,环境变量枚举与密钥名过滤各 20 分,child_process只给 10 分。单个child_process在很多正常扩展里都有,不该单独判死;但写 SSH 授权文件这件事,几乎没有正当用途。
它也有明确边界。字符串匹配会被压缩、混淆、拼接、编码绕过,动态加载的代码也扫不到;静态体检只能当第一道筛子,不能替代沙箱运行与主机侧的文件完整性监控。真正稳的组合是:静态扫描挡掉已知形态,沙箱限制扩展宿主能碰的路径,主机侧盯住~/.ssh与持久化目录的写入。
规则表还可以再加一层指纹黑名单:把本次公布的三枚哈希与两枚公钥指纹写进PINNED之外的黑名单字典,命中直接判block,不参与打分。打分适合发现新变种,黑名单适合拦住已知样本,两者不冲突,一个是泛化能力,一个是确定性。
把信任从“发布者”换成“字节”
这次事件的根因不在代码,而在权限:谁能发布,谁就能改所有人的机器。所以防御的重心也应该放在分发与更新链上,而不是只盯着恶意代码特征。
第一件事是关掉自动更新,或至少改成手动确认。Cursor 默认自动更新,可以在设置里改。代价是每次更新都要点一下,收益是把“11 小时无人察觉”变成“更新前有人看一眼版本号”。对个人开发者这是一分钟设置,对团队这是策略。
第二件事是版本与哈希双锁。版本锁定不够,因为同一版本号可以被重新发布;要在 CI 或内部制品库里锁vsix_sha256,装包前比对,不一致就拒绝。上面那段脚本里的hash_pinned字段就是给这一步留的接口。
第三件事是收紧发布权限本身。市场侧的账号被盗是这次事件的核心问题,团队侧能做的是:给扩展发布账号开双因素、限制可发布人员、发布走双人复核、保留每次发布的产物哈希与审计日志。市场方能轮换密钥、重建索引,团队能做的就是把“谁能推一版到所有人机器上”这个问题回答清楚。
第四件事落在主机侧。把~/.ssh、~/Library/LaunchAgents、用户 crontab、Windows 启动目录纳入文件完整性监控;出口侧盯住非常规出站,比如开发机主动连陌生 IP 的 22 端口、或者往 8099 这类端口发明文 JSON。这些信号与具体恶意样本无关,换一个攻击者照样有效。
第五件事是凭据处置的触发条件。以前大家默认“值没泄露就不用轮换”,这次要改:只要变量名被外发过,就按该机器上的凭据已暴露处理。因为名字的作用是给攻击者排序,排序之后就是定向窃取,时间差可能只有几天。
留下的三个判断
先把事实收拢一下:2026 年 10 月 29 日 09:27 到 20:35 UTC,Cursor 扩展市场里的Anthropic.claude-code2.1.312 与 2.1.313 被植入 SSH 后门,最多 5000 名用户受影响,默认自动更新是扩散主因,卸载扩展不能清除后门。
第二个判断关于影响面。代码里两个平台的实现缺陷让开启 SSH 这一步在多数主机上失败,这确实降低了“被直接登录”的概率,但没有降低“公钥已写入、持久化已落地、侦察包已发出”的事实。评估影响时要按最坏情况算:只要那台机器开着 SSH,后门就是可用的。
第三个判断关于“官方”这两个字。官方 ID、官方市场、官方签名,这三件事同时成立时,仍然可能有 11 小时的窗口被人利用。官方证明的是发布者身份,不是这一次发布的字节内容。把这两者分开,才谈得上供应链安全。
如果只记一个动作,就记这个:先查版本,再清后门,最后轮换凭据。顺序不能反,因为只轮换不清理,等于把新钥匙交给一个还站在屋里的人。