假面试甩来一个测试仓库,他随手丢给 Claude,扒出了藏在补丁里的远控木马
一个加拿大开发者,差一点就把自己电脑的钥匙,亲手交给了一个素未谋面的「面试官」。
事情的诱饵,是一封看起来再正常不过的面试邀约。一家新加坡的风投,HR 彬彬有礼地发来邮件,说看了你在开源社区的活儿,想聊聊顾问的机会。附了一个 LinkedIn 链接,profile 做得有模有样,还顺嘴提了两家被投公司,说它们正好在找技术顾问。你去搜了搜这两家公司,官网是有的,虽然简陋,但看着也就是个早期项目的样子,挑不出毛病。

然后约了个 Google Meet。对面是个带德国口音、说话有点含糊的男人,说自己在出差路上,所以摄像头全程没开。聊得也没什么破绽。
聊完,正菜来了。一封跟进邮件,说给你一个小小的「测试」,跑一下就行。
我是在 Hacker News 上刷到这篇复盘的,作者叫 Matt Mastracci,一个在 crates.io 上有不少包的加拿大程序员。读到这儿我其实是松了口气的,因为我知道他最后没中招。但我更想跟你唠的,是他到底是怎么在最后一秒看见那面红旗的,以及他是靠什么把这个埋了三层的后门一层层扒开的。
剧透一下,是 Claude。
先把这场局的精巧之处摆出来。这不是那种群发的钓鱼邮件,一眼假的那种。这是冲着开发者来的、有耐心、有剧本、有备用人设的定向攻击。Matt 在文章里自己复盘,整套流程从头到尾几乎没有一个单独拎出来会让你立刻警觉的点。VC 找你做顾问,正常。LinkedIn 有 profile,正常。被投公司官网简陋,早期项目嘛,正常。摄像头不开说在出差,有点怪但也不是没有过。
每一个黄灯单看都是黄灯,可它们叠在一起,本该亮成一片红的。
真正让 Matt 停下来的,是那个「测试」本身。对方发来的是一个 TypeScript 仓库,主题包装成一个叫 Ticket Harbor 的渡轮订票 app,里面的 task.txt 列了一堆看着挺无聊的常规任务。但最后一句话是这么写的,跑一遍仓库的 typecheck、测试套件,还有相关的桌面端和服务端构建命令,然后再提交。
Matt 当时的原话是,他有点被这操作搞烦了,但谈不上起疑。可有个东西不对劲,这明明应聘的是个架构分析的活儿,给我一个 TypeScript 仓库让我跑测试干嘛?这指令更像是个 TypeScript 初级岗的笔试题,跟职位对不上。
就是这一点点的「不对味」,加上他自己说的「一半是谨慎一半是懒」,他没有按对方说的去本地跑,而是把整个仓库打了个 zip 包,丢给了 Claude,让它先扫一遍。
好家伙,这一扫,扫出大事了。
Claude 几秒钟后就报回来一段话,我觉得值得原样念给你听。它说,注意到根目录的 package.json 没有 postinstall 或 preinstall 钩子,这很有意思,因为他们用了 patch-package,而这个工具正常都是挂在 postinstall 上跑的。让我检查一下每个 package.json 的生命周期脚本,然后审一遍那些补丁文件,因为补丁才是真正的风险面,patch 是可以往 node_modules 里注入任意代码的。
这里得给不写前端的朋友停一下,解释两个黑话。
patch-package 是个挺常用的小工具,作用是给你装的那些 npm 第三方依赖打补丁。你依赖的某个库有个 bug 一时没修,你又不想 fork 整个仓库,就可以用它打个小补丁本地改两行。本来是个好东西。postinstall 是 npm 的一个钩子,意思是你 npm install 装完包之后,它会自动帮你跑一段脚本。两个东西一组合,正常用法就是装完包自动把补丁打上,无人值守,丝滑得很。
但你品品 Claude 那段话的意思。它发现这事的逻辑链是反过来的,根目录干干净净没有钩子,太干净了反而可疑,因为既然用了 patch-package,钩子总得藏在某个地方。它没有顺着「看起来没问题」往下走,而是盯着那个「不该没有却没有」的空缺,往补丁文件里钻。
Matt 自己也手动翻了翻,发现仓库里塞了多到不正常的 patches 目录。前几个点开看着人畜无害,但你大概也猜到了,那些就是用来制造噪音的,好让真正的炸弹混在里面不被发现。
就在他一个一个慢慢看的时候,Claude 已经把雷给挖出来了。它说,找到了,这是个恶意载荷。藏在 typescript+5.9.2.patch 这个补丁文件里,伪装成一个给 TypeScript 打的、看着平平无奇的模块路径补丁,但在 _tsc.js 和 typescript.js 两个文件的最顶上,注入了一段 base64 的自执行混淆代码。
我把那段代码的样子大致描述给你,它长这样,开头一个分号分号,然后一个立即执行的函数,从一串 base64 里 Buffer.from 出二进制,挨个字节跟数字 73 做 XOR 异或运算解密,最后把解出来的东西塞进 new Function 里执行,还顺手把 require、Buffer、WebAssembly、process、__dirname 这些全喂了进去。后面跟一句没用的字符串当锚点。
又有几个词得翻译成人话。base64 你可以理解成把一坨二进制数据编码成一串看着像乱码的文本,肉眼根本看不出里面是啥。XOR 异或是个最基础的加解密操作,拿一个固定的数字(这里是 73)跟每个字节做运算,就能把内容简单加密一遍,避免被一眼看穿。最骚的是 new Function,它跟 eval 是一个意思,都是把一段字符串当代码现场执行,但攻击者特意不用 eval,估计就是因为 eval 这个词太扎眼,容易被杀毒软件盯上。
所以这一整套连起来是什么效果?Claude 解释得很清楚。这段东西每次你跑 tsc,或者任何代码 import 了 typescript.js 的时候,都会触发执行。换句话讲,就在你听话地敲下第一个 npm run typecheck、第一个 build、第一个 dev 的那一刻,后门就开了。
那个 task.txt 里温柔地让你「提交前先跑一遍 typecheck、测试和构建」的指令,就是整个陷阱真正合拢的扳机。
读到这儿我后背是有点发凉的。你想想这个设计有多懂程序员。它根本不需要你干任何蠢事,它要你做的,恰恰是一个尽职的、想把活儿干漂亮的开发者本能就会去做的事,拿到代码,先跑通构建确认环境没问题。越认真的人,死得越快。
Matt 看到这儿果断收手了,把仓库重新打了个带密码的压缩包,免得自己手一抖在自己机器上把它引爆,剩下的分析全挪到沙箱里去做。
往下扒,这后门的工程量才真正显出来。整条攻击链是这么咬合的。
第一环,有四个独立的 postinstall 钩子都在跑 patch-package,但其中一个还偷偷跑了 git update-index —skip-worktree 去处理那些补丁文件。这个命令的效果,是让 git 假装这些文件压根没被动过,你 git status 看不见任何异常。第二环,就是前面那个注入 typescript.js 的自执行 stub。第三环,那个 stub 会去读一个叫 operators/3.png 的图片文件,对,一张 png 图片,它末尾被偷偷接了一段隐藏数据,里面藏着一小段 WASM。
WASM 是 WebAssembly 的简称,一种能在 Node 里跑的、很接近机器码的格式,用它一方面跑得快,另一方面也更难被静态分析看懂。这段 WASM 跑完,会再悄无声息地拉起一个脱离了终端的、静默的 Node 进程,扛着一个 1.68 MB 的、混淆过的第二阶段载荷继续往下走。第四环,是它在三个层面给自己擦屁股,git skip-worktree 把补丁藏起来,dropper 第一次跑完会把自己注入的那几行从 patch 里删掉,第二阶段的临时目录执行完自删除。
藏、跑、删,一条龙。
最后落地的那个东西,是个 RAT。RAT 是 remote access trojan,远程访问木马,简单说就是攻击者能远程操控你这台机器。Matt 给它起名叫 PinpinRAT,因为里面有些内部字符串长这样。他说一开始还担心这是个偷密码的窃密器,后来发现是 RAT,那可严重多了,偷密码只是偷你已经有的东西,RAT 是直接把你这台机器的控制权拿走。
这玩意做得相当专业。本地生成一对 RSA-2048 密钥,再来一个随机的 AES-256 会话密钥,之后所有跟外面的通信全程 AES-256-CBC 加密,还带 HMAC-SHA256 的完整性校验。这是正经搞密码学的人才会这么干的活儿,比绝大多数业务代码都规整。
它一启动,先干的事是采集你机器的指纹然后偷传出去,你的主 IP 地址,把所有非内网网卡都枚举一遍,用户名,主机名,操作系统类型版本架构,进程 PID 和完整的启动参数,Node 版本。一份完整的身份档案。然后它支持的远程命令是这样一串,env 把你所有环境变量 JSON 化传走(你的密钥、token 大概率都在里面),upload 读取任意路径的文件传走,download 往任意可写路径写入攻击者给的字节,spawn 执行任意进程,还有 ls、cd、pwd、cp、mv 这些文件系统基本操作,甚至有个 dns 命令让你的机器通过指定的解析器去解析任意域名,疑似拿来做 DNS 隧道。最后一个命令叫 dismantle,自我拆除。
C2 服务器,也就是 command and control,木马回连去听指令的那台指挥服务器,地址明晃晃地写在那儿,89.124.107.161。
而 PinpinRAT 本身裹了三层混淆,obfuscator.io 一层(这家工具的卖点之一居然是号称能防 LLM 分析,Matt 在文章里专门嘲了一句 hah),底下还有两层 base64。Matt 没自己硬啃,他把这活儿也交给了 Claude,让它在沙箱里去拆。
然后是这篇文章里最让我安静了一会儿的一句话。他说,Claude 大概花了 5 分钟,就把这多层混淆给逆向拆解开了,远远快过他自己能做到的速度。
5 分钟。
我得说说我为什么对这句话反应这么大。我平时干的活儿,就是给大模型搭那层让它们能真正干活的工程脚手架,agent 的工具链、调度、上下文这些。所以我对「AI 在真实环境里到底能扛多少活」这件事,是有点职业敏感的。逆向一个故意做了多层混淆、还专门号称防 AI 的恶意载荷,这不是写个 CRUD、补个测试那种规规整整的任务,这是脏活,是充满对抗、充满故意误导的活儿。我们这行有句话,进攻方只要对一次,防守方得每次都对。过去这种活儿是顶尖安全研究员的手艺,靠经验、直觉和大把时间。
现在一个开发者顺手把压缩包拖进对话框,几分钟后拿到一份能直接上报给国家网络安全机构的分析报告。
这里面其实藏着一个挺锋利的反转。我们这两年聊 AI 安全,话题几乎全压在「攻击者会怎么用 AI 把坏事做得更狠」上,AI 写钓鱼邮件更像真人,AI 批量生成恶意代码,AI 降低犯罪门槛。这些担忧都对。但 Matt 这个案子,是同一枚硬币被翻了过来给你看,那封假面试邮件里的 LLM 痕迹,恰恰成了暴露它的破绽之一;而真正在几分钟里把整套精密攻击拆穿、把防御那一端的能力一把拉高的,也是 AI。
矛和盾,攻和防,握在同一种东西手里。
Matt 在文章末尾很诚实地复盘了那些他本该更早看见的红旗,我觉得这部分比技术细节更值得你记住,因为它是你我下次可能真的用得上的。那个 LinkedIn profile 乍看正常,细看全是一串看着唬人其实不知所云的头衔缩写,BSc(Hons)、MA(Dist)、PGDipFM、CEng 堆一块儿,凑出一种学历沙拉的味儿,而且没有任何真实的活动痕迹。对方网站上挂的社交媒体账号是有历史的,但名字在 2025 年 11 月才改过,发的全是空泛、含糊、不带任何细节地夸某些公司的帖子。所有那几家有官网的公司,除了门面光鲜,背后都没有任何真实存在的痕迹。对方从头到尾没发过一个正经的日历邀请,就甩了个时间加一个 Google Meet 链接,哪家正经 VC 不用日历啊。摄像头全程黑着,人「在出差」。

还有最整体的那个怪味儿。一家号称在新加坡的 VC,作息却跑在欧洲中部时区,来撩一个加拿大的开发者,名下的域名却全冲着美国客户、还都以 .cc 结尾。隔了这么远,你想核实这家机构的底细,难度被刻意拉满了。
没有任何一条是事后诸葛亮之外的铁证。但 Matt 那句话说得好,碎片其实都在那儿,就看你愿不愿意把整张图拼起来看一眼。
而文章最后那句话,是真正让我合上电脑还在想的。他说,最让他后怕和清醒的是,如果这次发来的是个 Rust 仓库,里面塞的是个做了手脚的 build.rs 构建脚本,他可能就真的中招了。
Rust 的 build.rs 是编译时自动执行的脚本,对一个 Rust 开发者来说,cargo build 跑一下 build.rs 是再天经地义不过的肌肉记忆,根本不会多想。换句话讲,这次救了他的不是什么固若金汤的安全意识,是运气,是攻击者恰好选错了语言,把一个 TypeScript 仓库丢给了一个会对「TS 仓库配架构岗」产生一丝违和感的人。
这事儿对我们这些天天 git clone、npm install、cargo build 的人来说,是有点扎心的。我们对自己工具链的信任,是建立在一种「这些命令我闭着眼睛都跑过一万遍」的肌肉记忆上的。攻击者要的就是这个肌肉记忆。他们不攻击你的判断力,他们绕过你的判断力,直接去敲你的反射神经。
我没有要在这儿喊「以后别 npm install 了」这种正确的废话,那不现实,我们靠这个吃饭。我自己看完之后真正变了的一个小习惯,是对「来路不明、还特别热情地催你赶紧把它跑起来」的代码,多一秒钟的迟疑。尤其是当对方递过来的东西,和他声称要你做的事,对不上味的时候。那一丝违和感,Matt 那次就是靠它活下来的。
是谁在背后操盘这一切,Matt 说那是国家机构该去查的事了,他只把能公开的线索都交了上去。他唯一想让你记住的,是这一次的目标不是什么大公司大金库,就是你我这样的普通开发者;而他能看见那面红旗,纯属侥幸。
写到这儿,我想起万能青年旅店那句,是谁来自山川湖海,却囿于昼夜厨房与爱。我们这些人来自代码、依赖和构建脚本,每天囿于一个个看着无害的 install 命令里。大部分时候那是我们安身立命的日常,偶尔,里面藏着一封伪装成机会的信。
下回再有人特别客气地塞给你一个仓库,说跑一下就行。
先别跑。