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 пакета
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: пошаговая методология анализа вредоносного пакета
Требования к окружению
- ОС: 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]);
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
Компрометация 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) для этого подходит.
Последнее редактирование модератором: