NDA 分支里的恶意钩子:git post-checkout 凭据窃取攻击与 clone 安全边界实测

2026 年 10 月 2 日,REVSYS 创始人 Frank Wiles 公开了一段自己被定向攻击的经历:对方伪装成 EdTech 项目的甲方,用 Dropbox 共享文件夹发来一摞 Markdown 格式的项目文档,文件夹里夹着一个 .git 目录,真正的杀招藏在 .git/hooks/post-checkout 这个几乎没人用的钩子里。Wiles 因为一句「NDA 在 NDA 分支里,你切过去填写」的回复察觉不对,打开 hooks 目录核实时发现了这段恶意脚本。

这个案例值得展开的地方在于:它精确踩在 Git 设计边界的外侧。本文用本机实验验证了这条攻击链成立的前提条件——为什么 git clone 天生拒绝执行仓库里预置的钩子,而把仓库当文件夹传输时防线为什么会失效。所有实验均为无害载荷(钩子只写一个标记文件),不接触任何凭据。

攻击链复盘

Wiles 的完整叙述(发表于其个人博客 frankwiles.com)按时间线是这样的:

  1. 对方以 EdTech Web 项目咨询的名义联系,询问 REVSYS 是否有档期;
  2. Wiles 按常规给了 Calendly 链接约会议,对方要求会前阅读项目说明并签署 NDA,这一步仍在正常商务流程的范围内;
  3. 对方共享了一个 Dropbox 文件夹,里面是几个目录的 Markdown 文件,项目说明「含糊但足够描述 MVP」;
  4. Wiles 在文件夹里没找到 NDA 文本,要求邮件发送,对方回复:NDA 保存在仓库的 NDA 分支里,切过去填写签完寄回;
  5. 这句话暴露了意图——交接项目文档没有理由使用 Git 分支。Wiles 检查 .git/hooks 目录,发现所有钩子都还是 *.sample 模板,唯独有一个真实的 post-checkout;
  6. 脚本内容:以一个 Vercel 应用作为命令控制端,下载与操作系统匹配的二进制文件,赋予执行权限后运行,随后自删除。

Wiles 已向 Dropbox 和 Vercel 的安全团队通报。他补充了一个细节:攻击者还冒充了一家真实开发工作室负责人的身份作为伪装。整个链条的社会工程设计相当克制——每一步(项目询价、约会议、签 NDA、共享文档)都是外包行业再正常不过的动作,唯一突兀的信号就是「NDA 放在 Git 分支里」这句不合常理的话。

Unsplash 上的一面红旗照片,正对应这次事件里"红旗警告"式的破绽

post-checkout 是个什么钩子

Git 在 .git/hooks 目录预置了一批钩子模板。本机 Git 2.54.0 执行 git init 后,该目录包含 14 个 *.sample 样例(pre-commit、commit-msg、pre-push 等),覆盖提交、合并、推送等动作。其中 post-checkout 在三种情况被触发:执行 git checkout / git switch 之后、执行 git clone 完成初始化检出之后、以及 git restore 涉及工作树路径时。

钩子很少被日常使用(Wiles 提到实践中几乎没人用 post-checkout),这反而成了掩护——多数开发者的 .git/hooks 目录从初始化那天起就保持着样例模板的原样,多出来的一个可执行脚本不容易被注意到。钩子本质就是 shell 脚本,拥有当前用户在终端里的全部权限:读写文件、访问网络、执行任意命令,包括读取 SSH 私钥和 cloud credential 文件(~/.ssh、~/.aws/credentials、~/.gitconfig 里保存的凭据 helper 等)。

另外要区分 hooks 的两种来源。一种是直接写进 .git/hooks/ 的文件;另一种是通过 core.hooksPath 配置把钩子目录指到任意路径——企业团队常用后者集中管理钩子(比如 Husky 把前端项目的钩子放在 node_modules 下的目录里)。core.hooksPath 支持相对路径,也支持通过仓库内的配置文件设置,这让它成为比单个钩子文件覆盖面更大的滥用面:一次配置可以劫持该仓库的全部钩子事件。

实测:为什么 clone 安全,文件夹直传危险

这次攻击的关键前提,是受害者把仓库当成普通文件夹接收(Dropbox 同步即等价于此)。如果走的是 git clone,同样的恶意仓库不会执行任何钩子。下面用本机实验证明这两条路径的差异。

实验设计:构建一个仓库,在 .git/hooks/post-checkout 放入无害载荷(写一个 pwned_marker.txt 标记文件),然后分别用 git clone 和文件夹复制两种方式"接收"它,观察标记文件是否出现。

路径 A:git clone。 克隆完成后检查目标仓库的 .git/hooks 目录:

text
$ ls .git/hooks
applypatch-msg.sample   pre-applypatch.sample    pre-rebase.sample
commit-msg.sample       pre-merge-commit.sample pre-receive.sample
fsmonitor-watchman.sample  pre-push.sample      prepare-commit-msg.sample
post-update.sample      pre-commit.sample       push-to-checkout.sample
sendemail-validate.sample  update.sample

14 个文件全部是样例模板,攻击者的 post-checkout 不在其中;工作树里也没有标记文件。即使再手动执行一次 git checkout main,标记文件依然没有出现——载荷从未落地。

原因是 Git 的传输设计:clone 与 fetch 传输的只是 packfile 里的对象(提交、树、blob),对端重建的是一个干净的 .git 目录,hooks 目录由本机 Git 生成模板。仓库历史中携带的任何文件都不会被写入 hooks 目录。这一条也解释了本实验的第二个变体:攻击者尝试用 git add -f .git/hooks/post-checkout 强行把钩子文件提交进仓库时,命令本身正常退出,但 git ls-files 里只有 README——Git 2.54 静默拒绝把 .git/ 内部的任何路径加入索引,后续 commit 因暂存区为空而失败。钩子文件根本进不了版本库,自然也无法随 clone 传播。

路径 B:文件夹复制。 用 cp -R 复制整个仓库目录(Dropbox 同步、压缩包解压、网盘传输都是这一路径的变体),然后执行一次任何开发者都会敲的命令:

text
$ cp -R payload-repo copy-dest
$ cd copy-dest
$ git checkout main
Already on 'main'
$ cat pwned_marker.txt
hook fired at Sat Oct  3 18:11:53 CST 2026 inside /tmp/githook_exp/copy-dest

标记文件出现了——post-checkout 在 checkout 完成后执行,载荷落地。整条链只需要受害者对传输来的目录执行任何一次触发钩子的 Git 操作,而切分支、恢复文件正是接手他人仓库时最先做的事情。

清理盲区。 实验还验证了事发现场的隐蔽性:git clean -xdff(清除所有未跟踪文件)只删除了标记文件,.git 目录原封不动;git status 输出为空——钩子文件位于 Git 自身的管理范围之外,状态命令永远不显示它。也就是说,一个被植入钩子的仓库在 git status 的视角里完全干净。

两条路径的对比汇总:

接收方式恶意 hook 是否随仓库到达是否被执行
git clone否(packfile 只含对象,hooks 本机重建)否
文件夹复制/云盘同步/解压是(.git/hooks/ 原样落地)是(首次 checkout/switch 即触发)

这条防线是补出来的

现在的行为边界并非 Git 诞生时的设计。GitHub 官方博客在 2014 年 12 月的公告(Vulnerability announced: update your Git clients)中描述了这轮修复的背景:在大小写不敏感的文件系统(macOS 的 HFS+、Windows 的 NTFS/FAT)上,攻击者可以构造一棵恶意 Git 树,让 Git 在克隆或检出时覆盖自己的 .git/config,进而导致客户端任意命令执行。macOS 和全系列 Windows 客户端可被利用,使用大小写敏感文件系统的 Linux 客户端不受影响。

修复以维护版本的形式铺开:Git 核心发布了 v1.8.5.6、v1.9.5、v2.0.5、v2.1.4 和 v2.2.1,Git for Windows(当时还叫 MSysGit)发布 1.9.5,libgit2 和 JGit 两大库同步发布修复版本。此后 .git 内部路径在传输与检出环节的隔离持续收紧,形成了今天实测到的两道防线:clone 不落 hooks、检出拒绝 .git/ 内部路径。

Lobsters 上的讨论帖里,用户 z3bra 用一个构造了非法 .git/hooks/post-checkout 路径的仓库做了同样的测试:git clone 直接报 error: invalid path '.git/hooks/post-checkout',检出失败,并提示可以用 git status 检查后重试。这与本机实验的结果一致,也说明这条防线在多个 Git 版本线上都已生效。

但防线的边界同样清晰,Wiles 案例踩住的正是防线之外的部分:Git 的安全模型只约束「Git 自己传输和检出的内容」。一旦 .git 目录不由 Git 传输(文件系统层复制、网盘同步、压缩包),或者钩子经由 core.hooksPath、团队共享的安装脚本(npm install 生命周期钩子执行 husky install 这类流程)进入仓库,Git 不会也无法提供保护。

自查与防护清单

对照这次案例,开发者可以做的检查并不复杂:

检查现有仓库的钩子目录。 一条命令列出所有非样例钩子:

bash
ls -la .git/hooks | grep -v '\.sample$'
git config core.hooksPath

更直接的做法:.git/hooks 下任何不带 .sample 后缀的文件、以及 git config core.hooksPath 有输出时该路径下的文件,都逐个读过再保留。合法的钩子通常很短(跑 lint、跑测试、通知 CI),出现 curl、base64、下载二进制、chmod +x 组合的脚本直接就是恶意样本。

把 .git 目录当作可执行程序对待。 从网盘、压缩包、聊天工具接收的任何包含 .git 的目录,与接收一个陌生 .app 或 .exe 同级处理。稳妥流程是删掉随包的 .git(或至少先 ls .git/hooks 核实),再决定是否接手;需要保留历史的,让发送方推到 Git 托管平台走 git clone——clone 的传输路径顺带消除了钩子注入的可能。

对「在分支里」保持警觉。 这次攻击最终败在一句违反常识的话上:项目文档交接没有理由要求对方 checkout 一个分支。类似的不协调信号还包括要求关闭 GPG 验证、要求用特定客户端打开仓库、以及在文档仓库里混入构建脚本。社会工程攻击的通用规律是利用流程惯性,而破绽几乎总是出现在流程之外的那个多余动作里。

钩子集中管理的团队要多看一眼来源。 团队通过共享脚本安装钩子(husky、lefthook、自研方案)本身是合理的工程实践,但值得确认三件事:安装脚本从哪里拉取钩子内容、core.hooksPath 指向的目录是否在代码评审范围内、新人入职第一次 git init 后本机执行了什么。钩子的强大之处在于它对使用者完全透明——提交照常、推送照常,多出来的动作只有钩子自己知道。

几点观察

第一,这次攻击的载荷投递选择了一个相当聪明的容器。Vercel 的应用域名带有平台合法性,命令控制流量混在正常的无服务器应用流量里,比自建 C2 域名更难被企业出口过滤识别。钩子执行完毕即自删除,磁盘取证窗口很短。

第二,攻击者把 NDA 这个商务必需品做成了钩子执行的触发器。「切到 NDA 分支」同时完成了两件事:诱导受害者执行触发 post-checkout 的命令,以及让受害者在心理上把这次仓库操作归因于正当的商务流程。如果 Wiles 没有对「NDA 在分支里」这句话产生疑问,签字流程大概率照常走完,钩子也就照常执行。

第三,这类攻击与 2022 年以来 GitHub 大规模恶意仓库投放、虚假 PoC 仓库属于同一个谱系——攻击面都是「开发者对代码内容的天然信任」,投递方式从公开仓库扩展到了私信、招聘、外包询价这类一对一渠道。一对一渠道没有平台的安全扫描兜底,社会工程成分更重,也更适合针对有高价值凭据的特定目标。Wiles 的身份(Django Steering Council 成员、PSF Fellow、托管大量客户凭据的咨询公司创始人)正符合定向攻击的目标画像。

对普通开发者来说,这次事件的操作级结论只有一条:git clone 依然是接收代码的最安全路径,任何绕开它的仓库传输方式都值得多一道检查。

说明与来源

  • 当事人自述全文:Frank Wiles, "I got targeted", frankwiles.com, 2026-10-02。Wiles 为 REVSYS 创始人、Django Steering Council 成员、PSF Fellow、前 Django Software Foundation 主席。
  • Lobsters 讨论帖(2026-10-02 上榜,48 分)中 z3bra、tinthedev 等用户的独立复测与讨论。
  • 2014 年漏洞背景:GitHub Blog, "Vulnerability announced: update your Git clients"(2014-12-18),含受影响版本与文件系统条件的官方描述。
  • 本文全部实验在本机 Git 2.54.0 (Apple Git-157) 完成,载荷为无害标记文件;实验脚本与结果见文中引用的输出,未做任何裁剪。