Статья Supply chain attack npm: реверс-инжиниринг двухступенчатого payload от bootstrap до C2

Схема цепочки атаки на кремовой бумаге, снятая сверху на светлом столе. Карандашные стрелки соединяют узлы цепочки, рядом лежат латунное пресс-папье и перьевая ручка.


11 мая 2026 года кто-то за несколько минут прогнал через легитимный CI/CD-пайплайн TanStack десятки вредоносных версий npm-пакетов - каждая с валидной SLSA-аттестацией Build Level 3. Среди пострадавших: @tanstack/react-router, официальный клиент @opensearch-project/opensearch (advisory GHSA-27f5-xjrr-q9ff, MAL-2026-3434), корпоративные пакеты @uipath/*. Статические сканеры не пикнули - артефакты подписаны Sigstore через тот же pipeline, что и чистые релизы. Ниже - полный разбор двухступенчатой архитектуры этой малвари, пошаговая методология реверса и конкретные сигналы, по которым можно поймать компрометацию open source пакета в своих зависимостях.

Kill chain npm атаки на цепочку поставок: от initial access до самораспространения​

Supply chain attack на npm - не точечная инъекция, а полноценная цепочка с чётким бизнес-смыслом на каждом этапе. Атакующий получает доступ к одному мейнтейнерскому аккаунту, а дальше червь работает сам: крадёт токены, публикует заражённые версии, ждёт новых жертв. Цель - массовый сбор облачных credentials из CI/CD-сред, которые потом монетизируются через ransomware, cryptomining или продажу на маркетплейсах. По данным CrowdStrike GTR 2025, 75% вторжений в 2024 году использовали действительные учётные данные; украденные npm/GitHub-токены - частный случай этого класса, но с прямым выходом на enterprise-инфраструктуру. Подробнее - в нашем статье о атаки на цепочку поставок.

От изолированных инцидентов npm typosquatting атаки доросли до системных кампаний с автоматическим распространением. Разберу kill chain кампании Shai-Hulud с маппингом на MITRE ATT&CK.

Initial Access (T1195.001, T1195.002). Атакующий получает доступ к CI/CD-пайплайну или npm-аккаунту. В TanStack использовалась уязвимость pull_request_target(Events that trigger workflows - GitHub Docs) в GitHub Actions - никакого фишинга или заранее украденных кредов не нужно, токен вытаскивается из runner-памяти прямо в ходе атаки. Для контекста: в отдельном инциденте (сентябрь 2025) мейнтейнер Josh Junon (Qix) попался на фишинг - письмо с ложной срочностью, итог - компрометация chalk и debug. Другая кампания, но тот же вектор: мейнтейнерский аккаунт. Результат обеих: скомпрометированные пакеты с суммарной аудиторией в миллиарды загрузок (по данным Fluid Attacks, требует независимой верификации).

Execution (T1059.007, T1204.002). Вредоносный JavaScript запускается автоматически через lifecycle hooks npm - preinstall, postinstall или prepare. Пользователь набирает npm install, и код атакующего отрабатывает до того, как любой runtime-контроль успеет включиться. В поздних итерациях кампании точка исполнения сместилась с postinstall на prepare через optionalDependencies (как в TanStack) - никакого человеческого взаимодействия, выполняется на большинстве билд-серверов.

Defense Evasion (T1027, T1140). Payload обфусцирован в несколько слоёв: hex-кодирование строк, XOR-шифрование, AES-256-GCM, вложенные вызовы Function(atob(...)). Легитимный index.js одного из скомпрометированных пакетов @redhat-cloud-services занимал порядка сотен КБ, заражённая версия - на порядок больше. В TanStack payload - одна строка обфусцированного JavaScript размером в несколько МБ. Матрёшка, только вместо деревянных кукол - слои шифрования.

Credential Harvesting. Малварь перебирает файловую систему (по данным StepSecurity): .npmrc-токены, GitHub PAT, SSH-ключи, AWS/GCP/Azure credentials, Kubernetes service-account tokens, HashiCorp Vault secrets, конфигурации криптокошельков и AI-инструментов. Отдельный модуль на Python читает /proc/<PID>/mem процесса Runner.Worker GitHub Actions, вытаскивая JSON-объекты с {"value":"...","isSecret":true} - все секреты, сконфигурированные для workflow. Всё, что можно утянуть - утягивается.

C2 и Exfiltration (T1104, T1041). Украденные данные шифруются и уходят через несколько каналов: Session Protocol CDN, GitHub GraphQL API (dead-drop коммиты под именем claude@users.noreply.github.com с ветками в стиле Dependabot), прямые POST-запросы на C2-домены. Параллельно создаются публичные GitHub-репозитории с характерными описаниями, куда секреты сливаются в double-Base64.

Self-Propagation. Вот что отличает эту историю от классических атак на цепочку поставок JavaScript. Украденные npm-токены используются для публикации заражённых версий всех пакетов, доступных скомпрометированному мейнтейнеру. Один аккаунт -> десятки пакетов -> тысячи downstream-потребителей -> новые мейнтейнеры -> цикл повторяется без участия оператора. По публичным отчётам, механизм autospreading подтверждён на практике: зафиксированы пакеты, где он сработал, что объясняет взрывной рост числа заражённых npm-пакетов.

Анатомия двухступенчатого вредоносного npm пакета​

1783464999736.webp

Stage 1: дроппер - lifecycle hooks и optionalDependencies как вектор​

[Применимо: анализ npm-пакетов в CI/CD-пайплайнах и на рабочих станциях разработчиков]

Первая ступень решает одну задачу - обеспечить исполнение JavaScript-кода в момент npm install. В кампании Shai-Hulud дроппер реализован через два изменения в package.json скомпрометированного пакета.

Первое: добавление optionalDependencies с GitHub-ссылкой на конкретный коммит в форке репозитория. URL вида github:tanstack/router#<SHA-коммита> визуально выглядит как ссылка на легитимный репозиторий TanStack. Тут хитрость в том, как GitHub хранит объекты форков - в общей object-store сети репозитория. npm при резолве github:owner/repo#<sha> запрашивает tarball по URL этого конкретного owner/repo. GitHub отдаёт объект, если SHA достижим в fork network, даже если коммит не в main-ветке и не merged - достаточно, чтобы он был запушен в любой форк этой сети. URL выглядит легитимно (указывает на upstream), но содержимое контролируется атакующим через форк. Социальная инженерия на уровне инфраструктуры - красиво, если бы не было так опасно.

В package.json фейкового пакета @tanstack/setup прописан prepare-хук: "prepare": "bun run tanstack_runner.js && exit 1". Конструкция && exit 1 срабатывает после успешного завершения payload (код 0): exit 1 роняет prepare-хук с ненулевым кодом, и установка пакета @tanstack/setup проваливается. Но поскольку он указан в optionalDependencies, npm не прерывает установку родительского пакета - payload к этому моменту уже отработал, а артефакт optional-пакета откатывается, скрывая следы. Зависимость "bun": "^1.3.13" тянет Bun runtime, через который запускается payload вместо Node.js - так обходятся детектирующие правила, заточенные под процесс node.

Второе изменение: внедрение файла router_init.js (2.3 МБ) в корень пакета, хотя поле files в package.json перечисляет только ["dist", "src"]. Файл, не указанный в files, но присутствующий в опубликованном тарболе - сам по себе индикатор компрометации, подтверждающий модификацию артефакта вне стандартного build-процесса.

В ранних вариантах Shai-Hulud (сентябрь 2025) дроппер был проще. По публичным разборам, функция updatePackage добавляла postinstall-скрипт в package.json и прикладывала файл bundle.js к архиву. Эволюция от postinstall к prepare через optionalDependencies - ответ на рост внимания к postinstall-хукам со стороны сканеров.

npm поддерживает несколько install-time lifecycle-хуков, которые выполняются автоматически при npm install: preinstall, install, postinstall, а также prepare (для git-зависимостей и в dev-контексте). Каждый исполняется с привилегиями текущего пользователя и доступом ко всем переменным окружения. На билд-серверах это означает прямой доступ к GitHub PAT, npm-токенам, облачным ключам.

Stage 2: payload - обфускация, credential harvesting, C2-коммуникация​

[Применимо: анализ малвари в sandbox-среде, air-gapped]

Вторая ступень - монолитный JavaScript-файл весом от 2 до 10 МБ. Обфусцирован в три слоя (по данным StepSecurity).

Слой 1: hex-кодирование. Все строковые литералы - пути к файлам, URL эндпоинтов, имена переменных окружения - заменены на hex-представление. Тривиально снимается, но эффективно против grep-based поиска по паттернам.

Слой 2: XOR-шифрование. Критические фрагменты (C2-адреса, логика эксфильтрации) зашифрованы XOR с ключом, генерируемым в runtime. Ищите циклы с String.fromCharCode и побитовым XOR (^).

Слой 3: AES-256-GCM. Финальные payload-модули расшифровываются в runtime из AES-256-GCM контейнера. Ключ извлекается из переменных окружения или вычисляется из fingerprint системы. Этот слой защищает от статического анализа даже после снятия первых двух - без ключа вы видите мусор.

После деобфускации раскрывается набор модулей. Credential harvesting перебирает файловую систему: ~/.npmrc, ~/.ssh/id_rsa, ~/.aws/credentials, ~/.config/gcloud/, ~/.kube/config, ~/.vault-token. Runner.Worker memory scraping - Python-скрипт, читающий /proc/<PID>/mem раннера GitHub Actions. Persistence - скрипт payload_1.sh устанавливает gh-token-monitor: на macOS через launchctl load, на GNU/Linux через systemctl --user enable, переживая перезагрузку. Дополнительно модифицируется settings.json VS Code. GitHub Actions workflow injection - создание ветки shai-hulud с workflow-файлом, перечисляющим все секреты и отправляющим их на webhook атакующего. По некоторым данным, в отдельных вариантах кампании присутствовал механизм "мёртвой руки": если эксфильтрация не удалась - payload пытался снести home-директорию пользователя, переводя атаку из шпионажа в деструктивную плоскость.

Реверс-инжиниринг малвари npm: пошаговая методология анализа вредоносного пакета​

1783465036222.webp

Требования к окружению​

  • ОС: GNU/Linux (Ubuntu 22.04+ / Kali 2024+) или macOS. Анализ на Windows возможен, но извлечение артефактов отличается
  • RAM: минимум 8 ГБ (16 ГБ рекомендуется при работе с AST-парсерами для файлов >5 МБ)
  • Инструменты: Node.js 18+ (деобфускация), jq, diff, strings, strace/dtrace, Babel parser (@babel/parser), prettier. Для сетевого анализа: Burp Suite или mitmproxy
  • Сеть: полностью изолированная или с перехватом трафика. Payload обращается к C2 при запуске
  • Контейнеризация: строго в Docker или VM. Shai-Hulud 2.0 при неудаче эксфильтрации выполняет rm -rf на home-директории - без изоляции рискуете остаться без домашней папки

Извлечение артефактов и поиск точки входа​

Первый шаг - получить чистую и подозрительную версии пакета. Скачиваем обе через npm pack <package>@<version> и распаковываем в отдельные директории. diff -rq ./clean/ ./suspect/ покажет добавленные и изменённые файлы. На что смотреть: появление файлов с "нейтральными" именами (router_init.js, opensearch_init.js, setup_bun.js), добавление optionalDependencies с github: URL на SHA-коммит (не semver-диапазон), увеличение размера ключевого JS-файла в 10-25 раз, присутствие файлов, не перечисленных в поле files.

В lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock) ищите зависимости с resolved на github: вместо registry.npmjs.org - для pnpm: grep -r "github:" pnpm-lock.yaml.

Деобфускация JavaScript и восстановление C2-логики​

Однострочный JavaScript-файл размером 2+ МБ нечитаем в принципе. Первый шаг - npx prettier --write suspect_file.js. После форматирования проявляется структура: массивы зашифрованных строк, функции-декодеры, точки вызова eval/Function.
JavaScript:
// Типовой паттерн обфускации Shai-Hulud (концептуальный)
const _0x3a = [0x2f,0x70,0x72,0x6f,0x63,0x2f];
const path = Buffer.from(_0x3a).toString(); // "/proc/"
// XOR-паттерн для C2-адреса
for (let i=0; i<enc.length; i++)
  result += String.fromCharCode(enc[i] ^ key[i % key.length]);
Hex-строки декодируются тривиально. XOR-паттерны ищутся по циклам с String.fromCharCode и побитовым ^. AES-256-GCM контейнеры требуют извлечения ключа - часто он захардкожен в соседней переменной.

AST-анализ через @babel/parser показывает вызовы eval, Function(), require('child_process').exec() как AST-ноды типа CallExpression. Паттерн Function(atob(...)) - надёжная сигнатура семейства Shai-Hulud.

Для восстановления дерева процессов запускаем strace -f -e 'trace=execve,connect,openat,clone' npm install <package>@<version> в контейнере. По данным StepSecurity, при установке скомпрометированного @opensearch-project/opensearch@3.8.0 процессное дерево выглядит так:
Код:
npm install (PID 2332)
 └─ node install.js           # Stage 1: установка Bun
     └─ bun opensearch_init.js    # Stage 2: payload
         ├─ gh auth token         # Кража GitHub token
         └─ python3 mem_scraper.py   # читает /proc/<Runner.Worker PID>/mem
Четыре исходящих сетевых соединения: два легитимных к registry.npmjs.org, два вредоносных - к C2-домену (по публикациям упоминаются домены, мимикрирующие под легитимные проекты) и api.github.com для распространения через украденные токены. Два соединения - нормальная работа npm, два - червь делает своё дело. В strace это видно сразу.

Package hijacking npm через SLSA provenance bypass: кейс TanStack​

1783465060175.webp

Компрометация TanStack вскрыла фундаментальный изъян модели доверия SLSA. Скомпрометированные пакеты @tanstack/* несут валидные SLSA-аттестации, выданные Sigstore (Fulcio + Rekor), привязанные к легитимному workflow release.yml репозитория TanStack/router. Провенанс указывает на корректный builder и invocation ID.

Атакующий использовал украденный OIDC-токен GitHub Actions (ACTIONS_ID_TOKEN_REQUEST_TOKEN), чтобы получить сертификат Fulcio и создать in-toto statement со стандартным GitHub Actions build type. По данным StepSecurity, это первый задокументированный случай npm-червя, порождающего пакеты с валидными SLSA Build Level 3 аттестациями. Формулировка StepSecurity точная: "SLSA provenance confirms which pipeline produced the artifact, not whether the pipeline was behaving as intended.""Происхождение данных в SLSA подтверждает, какой конвейер обработки данных создал артефакт, а не то, работал ли этот конвейер должным образом." Вдумайтесь: провенанс подтверждает, что артефакт собран на правильном конвейере - но не то, что конвейер не был скомпрометирован.

Цепочка атаки: (1) staging payload в форке через единственный коммит, (2) инъекция optionalDependencies и файла payload в тарбол, (3) публикация через легитимный workflow - украденный NPM_TOKEN из secrets для публикации в npm, ambient OIDC-токен (id-token: write) для получения валидного Sigstore-сертификата и SLSA-аттестации, (4) самораспространение через украденные токены.

Для реверс-инженера это значит: и npm audit signatures (ECDSA registry-подпись), и проверка provenance (npm view <pkg> --json | jq .dist.attestations, верификация через cosign/rekor) покажут "verified" для заведомо вредоносного пакета. Оба механизма подтверждают происхождение, но не чистоту кода. Без diff содержимого тарбола между версиями вы слепы.

Корневая причина - скомпрометированный GitHub-аккаунт сотрудника Red Hat, через который были отправлены orphan-коммиты в репозитории RedHatInsights. Payload технически схож с Shai-Hulud, что, по оценкам исследователей, подтверждает: после публикации исходного кода червя порог входа для воспроизведения атаки резко упал.

Обнаружение npm malware: артефакты компрометации и ограничения инструментов​

📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме


Ни один инструмент npm security анализа не закрывает всё. Private registry proxy (Verdaccio, Artifactory) с cooldown-периодом 24-72 часа для новых версий - дополнительный барьер, дающий время на обнаружение. Флаг --ignore-scripts полностью предотвращает Stage 1, но ломает пакеты с легитимным postinstall для компиляции нативных модулей. На практике нужен allow-list: разрешённые скрипты - явно перечислены, остальные заблокированы.

Самый недооценённый вектор обнаружения - мониторинг частоты публикации. Атакующие опубликовали десятки версий за считанные минуты. Большинство легитимных release-процессов так не работают (хотя монорепозитории с автоматическим versioning могут быть исключением). Простейший скрипт на npm view <package> time --json, сравнивающий интервалы между публикациями, поймает аномалию раньше любого статического анализатора.

Принципиальная проблема всех текущих механизмов защиты npm - они реактивны. SLSA-провенанс подтверждает происхождение артефакта, но не его чистоту - TanStack это доказал. npm audit работает по базе известных уязвимостей, а червь до момента обнаружения - zero-day по определению. Статические анализаторы ловят паттерны прошлых атак, но трёхслойная обфускация и Bun runtime вместо Node.js обходят сигнатурные правила.

Сдвиг в защите npm произойдёт, когда реестр начнёт применять cooldown-периоды и diff-аудит принудительно - на стороне инфраструктуры, а не потребителя. Компрометация @redhat-cloud-services через единственный GitHub-аккаунт - не аномалия, а новая норма. Те, кто анализирует supply chain attack npm профессионально, видят один сценарий с минимальными вариациями: скомпрометированный pipeline, lifecycle hook как точка входа, многослойная обфускация payload, GitHub как C2-канал. Меняются имена пакетов и названия кампаний.

Я ожидаю, что в ближайший год мы увидим переход от "штучных" кампаний к commodity-инструментарию: npm-червь как услуга, с кастомизируемым payload и выбором вектора initial access. Готовиться к этому стоит не очередным сканером, а изоляцией CI/CD-среды на уровне сетевого периметра и принудительным version pinning с ручным ревью каждого обновления критичных зависимостей. Если хочешь повторить шаги анализа JavaScript-payload в контролируемой инфре - Web-категория на HackerLab (https://hackerlab.pro) для этого подходит.
 
Последнее редактирование модератором:
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🧭 Навигатор · ИБ 2026
Не знаешь, какой трек твой?
5 направлений ИБ, реальные зарплаты и точка входа для каждого — в одном треде.
JuniorSenior+
100K → 600K+ ₽ /мес
Открыть навигатор →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab