Спасибо Фрэнку Герберту, что воспитал поколение хакеров
Спасибо Фрэнку Герберту, что воспитал поколение хакеров

npm install — такая привычная многим из читателей команда, но за последние пару месяцев она обернулась сущим кошмаром для инженеров по безопасности. И ладно бы всё сводилось к проверке 5 пакетов из package.json, но у каждой зависимости по 10 своих зависимостей, а у тех ещё по 10. В итоге мы тянем 2000, а не 5 пакетов, и тут, кажется, уже руками не проверишь. И именно на этой боли всех безопасников, поддерживающих JS-проекты, сыграла команда TeamPCP. В этой статье я хочу подробно, от А до Я, разобраться, в чём опасность, почему так произошло и как от этого защититься.

Небольшая теоретическая справка

Перед тем как погружаться в тему, предлагаю освежить в голове основные понятия, на которых и строится вся атака.

Зачем нам пакеты?

Большую часть того, что мы можем придумать, уже давно изобрели до нас. Поэтому, например, для того чтобы распарсить дату, не нужно писать логику с нуля — мы просто берём готовую библиотеку и используем её. В результате имеем 2 строчки кода, а не 100.

npm (Node.js Package Manager) — самый крупный склад пакетов для JavaScript/Node.js. Является публичным реестром, т. е. любой может опубликовать и скачать пакет.

Зависимости, package.json и node_modules

Если один проект использует чужой пакет, то этот пакет — зависимость проекта. В Node.js список зависимостей хранится в файле package.json:

{
  "name": "Тестовый стенд",
  "dependencies": {
    "@tanstack/react-router": "^1.160.0"
  }
}

npm использует этот файл, чтобы тянуть из реестра нужные пакеты, когда ты используешь команду npm install.

Lockfile — фиксация точных версий

В примере выше мы использовали ^1.160.0. Эта запись значит, что мы берём версию 1.160.0 и выше в рамках мажорной версии. И при запуске npm install, если он увидит более новую версию в рамках этого правила, то её подтянет. Но в пакетном менеджере это, к счастью, предусмотрено: создаётся lockfile — файл package-lock.json, в котором зафиксировано, какие сейчас используются версии каждого пакета.

В противовес npm install есть команда npm ci (clean install), которая ставит только те версии, которые зафиксированы в lockfile.

Lifecycle-хуки — дырка, в которую ползет червь

Пакеты в package.json могут содержать скрипты жизненного цикла (lifecycle scripts) — команды, которые npm выполняет в заданные относительные моменты времени:

{
  "scripts": {
    "preinstall":  "node before.js",
    "postinstall": "node after.js",
    "prepare":     "node build.js"
  }
}

Они были созданы в благих целях: билдить нативные модули, тянуть нужные бинари под специфическую ОС и т. п. Но стали для злоумышленников путём запуска их кода на наших тачках. Для особо тревожных разработчиков на этот случай был добавлен ключ --ignore-scripts, к которому мы ещё вернёмся.

Секреты в GitHub

В наших CI мы используем секреты — особо чувствительные значения (пароли, токены, ключи). Закидываем в настройках репозитория новое значение, а потом раннер сам тянет его как переменную окружения. И, казалось бы, GitHub маскирует их в логах, но есть нюанс: в памяти процессора на самом раннере они лежат в открытом виде.

Токены npm

Чтобы разработчику опубликовать новую версию какого-то пакета, он должен подтвердить, что публикует доверенное лицо, — для этого существует токен публикации. Они могут быть классическими, гранулярными, автоматизационными, но нас интересует конкретный вид — токены с обходом 2FA. Если вы видите bypass_2fa: true, значит, перед вами именно такой токен. Это отличное решение для автоматизации и гроб для безопасности.

OIDC и Trusted Publishing — «логин без пароля»

Умные инженеры как-то посидели, подумали и решили, что хранить сам токен неудобно, да и не очень безопасно, и придумали OIDC (OpenID Connect) и Trusted Publishing.

Идея до безумия проста: мы не говорим «эти изменения публикует доверенный автор», мы говорим: «эти изменения публикуются из доверенного репозитория, доверенной ветки». В ходе исполнения CI у нас просто создаётся токен, который перестаёт действовать после его завершения, и этому токену доверяет npm.

Круто! Мы избавились от userpass, но появилась новая боль: в стандартной реализации мы проверяем только, откуда пришло обновление, а не что именно в этом обновлении, и этой дыркой червяк ещё воспользуется.

SLSA-провенанс и Sigstore

Напоследок в этом душном первом модуле глянем на самое современное подписание новой публикации.

Провенанс — это справка, которая, в отличие от прошлой, говорит нам: «где собирался этот артефакт и как он собирался». Стандарт называется SLSA (уровни Build Level 1–3: чем выше — тем строже гарантии неподделываемости сборки). Для подписывания используется sigstore — подпись без приватного ключа: мы просто получаем одноразовый сертификат по нашей OIDC-личности вместо приватного ключа.

Однако есть один интересный момент: атака TeamPCP впервые в истории npm обошла и эту защиту. Выпущенные ими пакеты были с валидным провенансом Build Level 3.

На этом наша небольшая теоретическая часть завершена, и предлагаю перейти к самому интересному — самой атаке.

Хронология событий

Коротко по фактам (даты — по отчётам Endor Labs, Wiz, Socket, StepSecurity, Akamai, Snyk):

  • 11 мая 2026 TeamPCP запустили волну, начав с экосистемы TanStack. За пять часов — 400+ зловредных версий в 172 пакетах. Зацепило Mistral AI, OpenSearch, UiPath, Guardrails AI.

  • 12 мая они выложили червя в открытый доступ и анонсировали на BreachForums «соревнование по supply-chain атакам». Поехали копикэты.

  • 19 мая — следующий залп: 300+ версий в 323 пакетах за 22 минуты, полностью на автомате.

  • К началу июня дотянулись до npm-пакетов Red Hat с ~80k загрузок в неделю.

Говорящее имя

Shai-Hulud — гигантские песчаные черви из вселенной «Дюны» Фрэнка Герберта. Название подобрано не случайно, так как здесь мы имеем дело с самораспространяющимся вредоносом. Он заражает одного мейнтейнера и расползается дальше по всем его пакетам и связанным с ними.

Помимо Shai-Hulud злоумышленники используют тематику «Дюны» и в других местах. Так, например, ветки, через которые они подкидывают червей, могут называться fremen, harkonnen или sandworm.

Изначально песчаный червь был не особо умён. И пайплайн атаки выглядел примерно так:

  1. Крадём npm-токен разработчика.

  2. Находим все пакеты, которые можно релизнуть с этим токеном.

  3. Дописываем вредоносный код.

  4. Публикуем

  5. Повторяем на новых тачках. В результате на машинах жертв селился TruffleHog, который собирал секреты и сливал их в публичную Git-репу прямо в аккаунте жертвы. Звучит страшно, но всё-таки шаг с получением токена не тривиален, и вот именно тут TeamPCP шагнули вперёд и прокачали червя до очень опасного злодея.

Удар №1: точкой входа стал не токен, а кривой workflow

Раньше, чтобы опубликовать версию, нужен был npm-токен, но TeamPCP решили зайти через другую уязвимость. Они воспользовались тем, что в GitHub Actions есть возможность отправлять кэш из PR:

  1. Злоумышленник создаёт PR из своего форка.

  2. CI имеет права на общий кэш сборки и после его запуска он прокидывает код в этот самый кэш.

  3. Спустя время мейнтейнер запускает CI с новым релизом и из кэша забирает вредоносный код.

  4. Вредоносный код исполняется в CI, который считается доверенным.

Результат: никто не пытался взломать вашу учётную запись, в логах npm нет ничего подозрительного. А это всё последствия плохо настроенного проекта, в котором с целью «главное, что работает» были слишком широко настроены права.

Удар №2: маскировка секретов не значит ничего

После того как вредоносный код пролез, он исполняется по lifecycle-хуку. Однако там же есть средства мониторинга, которые через --require перехватывают лишние скрипты. Но TeamPCP и это не остановило: они просто используют Bun — аналог Node.js, для которого никакого мониторинга нет.

Дальше самое весёлое. Червь, находясь на раннере, находит процесс GitHub Actions и через /proc/<pid>/mem забирает все секреты. И несмотря на то, что в логах CI они все замаскированы, в памяти они лежат в открытом виде. И вот тут важно запомнить для себя, что любой код, который крутится на раннере, может видеть секреты в открытом виде. Не стоит вестись на эти *** в логах.

Не стоит забывать, что червь очень жаден, поэтому помимо секретов из GitHub Actions он также тянет все возможные креды по путям: ~/.ssh/*, ~/.npmrc, ~/.docker/config.json, ~/.kube/config, ~/.aws/credentials. Также просматривается история оболочки, и динамически дёргается IMDS (169.254.169.254) за облачными ключами, а также выполняются обращения к локальному Vault. Ну и на десерт: ~/.claude.json и .claude/mcp.json. AI теперь стал нормой в разработке и может быть точно такой же точкой, на которую будут давить злоумышленники.

Удар №3: OIDC и провенанс — против вас

Самая неприятная часть. Мы все постепенно переходим с обычных токенов на Trusted Publishing через OIDC. И всё выглядит очень безопасно, ведь если нет токена, то и красть нечего. Но TeamPCP использует окружение релизного раннера, в котором npm сам отдаёт тот самый временный токен публикации в обмен на OIDC-переменные, которые червь успешно подхватил. И OIDC честно авторизует его, ведь всё было по правилам.

OIDC подтверждает, кто пришёл, а не что он делает. И если вредонос запускается в заверенном пайплайне, то OIDC будет на его стороне.

Как я уже говорил выше, TeamPCP удалось подделать и SLSA-провенанс Build Level 3. Червь из нужного окружения идёт в Fulcio и забирает оттуда валидные сертификаты на свои вредоносы. И тут опять игра по правилам, ведь SLSA подтверждает: да, собрано нужным пайплайном, а не то, что в пайплайне всё прошло так, как было задумано. Провенанс необходим, но, к сожалению, недостаточен.

Удар №4: скорость и тайники

После того как червь находит все токены с bypass_2fa: true, он запрашивает у реестра все пакеты этого разработчика и публикует в каждом из них заражённую версию. В результате на графике релизов это выглядит как двойной релиз, где две версии публикуются с разницей в минуту, обычно ночью, когда инженеры дремлют. Так TanStack и UiPath делили общие креды, и от одного перетекло к другому.

Чтобы обойти egress-фильтр, украденное летит не на эндпоинт злоумышленников, а в тайники: CDN мессенджера Session (filev2.getsession.org) и dead-drop в GitHub через GraphQL-коммиты в подконтрольные репозитории. Коммиты делаются от имени claude@users.noreply.github.com с сообщением chore: update dependencies, а ветки называют в формате dependabot/github_actions/format/<что-то из Дюны>. В итоге всё выглядит как обычный проект, если не вглядываться.

Для повторения добавляются разные варианты автоматизации: SessionStart в .claude/settings.json, задача folderOpen в .vscode/tasks.json, systemd-сервис gh-token-monitor, ну и .github/workflows/codeql_analysis.yml, который сливает toJSON(secrets).

Грабли, на которые я бы наступил сам

Самое коварное. В описании токена, который создаёт сам червь, есть строка IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner. И если вы попробуете отозвать токен, то в течение минуты прилетит rm -rf ~/.

Рефлекс «увидел плохой токен — удалил» приведёт к очень плачевным последствиям. Поэтому, если столкнётесь с подобным, обязательно сначала делайте снапшот.

Выводы

Мы познакомились с очень неприятным червём, который обошёл все методы, которым я действительно доверял. В заключение хочу подвести пару выводов, которые сделал для себя, чтобы не столкнуться с подобным на своих проектах.

Зона

Что делаем

Зависимости

По возможности пользуемся npm ci и --ignore-scripts.

Скорость атаки

Карантин новых версий: мы не отдаём её с прокси, пока ей не стукнет определённое количество дней.

GitHub Actions

permissions: contents: read ставим по умолчанию, id-token: write — точечно. Обработка PR и релиза — разные процессы, разносим их. Стараемся использовать эфемерные раннеры, которые живут одну джобу.

Токены/подписи

bypass_2fa не используем при публикации и везде 2FA.

Рантайм/сеть

Egress-allowlist на раннерах, runtime-детект /proc/mem и левого egress. Для eBPF-мониторинга отлично подходит Falco.

Реагирование

Сначала снапшот, потом всё остальное.

© 2026 ООО «МТ ФИНАНС»

Комментарии (1)


  1. d3d14
    29.06.2026 11:51

    Поделом. Надо было думать, когда создавали систему общественных репозиториев. Это касается не только npm. Это все закономерно.