Извлечение токенов из памяти CI-runner'а
- title
- Извлечение токенов из памяти CI-runner'а
- type
- concept
- summary
- Код на CI-runner'е читает память процесса worker'а для извлечения OIDC-токенов и прямой публикации в реестр
- parent
- supply-chain-security
- tags
- security, github-actions, ci-cd, oidc
- created
- 2026-05-12
- updated
- 2026-05-12
- lang
- ru
- translation_of
- ci-runner-token-extraction
- source_updated
- 2026-05-12
- translated
- 2026-09-01
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Класс атак, при котором вредоносный код, получивший возможность исполнения на CI-runner'е, напрямую читает память процесса хоста runner'а для извлечения OIDC-токена - а затем использует этот токен для аутентификации во внешнем сервисе (npm, PyPI, AWS через федеративную идентификацию) в обход объявленных в workflow шагов публикации.
Это то самое связующее звено, которое превращает "исполнение кода на runner'е" в "запись в продакшен-реестр пакетов".
Как это работает (специфика GitHub Actions)
На хостящихся у GitHub runner'ах (GitHub-hosted runners) работает процесс Runner.Worker, который отвечает за выполнение workflow. Когда в workflow объявлено id-token: write, а какой-либо шаг запрашивает OIDC-токен (getIDToken() в @actions/core, неявный вызов внутри шагов с поддержкой Trusted Publishing и т. д.), runner лениво выпускает JWT и держит его в памяти.
Если код атакующего оказывается на той же машине - и работает под тем же пользователем, что и worker (а по умолчанию так и есть), - он может:
- Найти PID процесса
Runner.Workerчерез/proc/*/cmdline - Прочитать
/proc/<pid>/maps, чтобы узнать структуру адресного пространства worker'а - Прочитать
/proc/<pid>/mem, чтобы сделать дамп этой памяти - Найти JWT по шаблону (префикс base64
eyJ...служит быстрым ориентиром) - Отправить JWT POST-запросом на endpoint доверенной OIDC-публикации (trusted publisher) реестра
Токен остаётся валидным до истечения срока действия - обычно несколько минут - и любая публикация в течение этого окна неотличима от публикации, сделанной легитимным шагом Publish Packages. Реестр видит запрос на публикацию от workflow с валидной привязкой и не имеет возможности проверить происхождение запроса на уровне конкретного шага.
Один и тот же скрипт для дампа памяти (буквально один в один, включая комментарий с указанием авторства) был замечен в:
- Компрометации tj-actions/changed-files, март 2025 - первоначальная публикация через force-push тега в action без фиксации версии.
- Компрометации TanStack
@tanstack/*, май 2026 - см. tanstack-npm-supply-chain-postmortem.
Авторы скрипта называют эту технику тем, [от чего реестр защитить не может], - и они правы. Защита должна быть на стороне workflow.
Почему шаг Publish Packages в workflow не имеет значения
Распространённое ложное чувство безопасности: "Для шага публикации у нас настроено ручное подтверждение / отдельное окружение / подпись аппаратным ключом". Всё это не имеет значения, если OIDC-токен можно выпустить из любого job'а в workflow с id-token: write. Атакующему не нужно проникать в ваш шаг публикации. Он выпускает токен сам и шлёт POST-запрос напрямую в реестр.
Инцидент с TanStack - наглядный пример. Легитимный шаг Publish Packages был пропущен, поскольку упали тесты. Но публикации всё равно произошли - из фазы тестирования и очистки, где бинарники атакующего выполнились после восстановления отравленного кэша.
Способы защиты
Надёжное решение - разделение исполнения кода и владения токеном.
Перенести публикацию в job, не выполняющий код участников проекта.
- Типичная безопасная схема: job 1 собирает проект и создаёт артефакт (загружаемый через
actions/upload-artifact). Job 2 имеетid-token: write, не запускает сторонних зависимостей, скачивает артефакт, сверяет его с манифестом хэшей, подписанным в другом месте, и публикует. Если в окружении job 2 нет ничего, что могло бы исполнить код атакующего, добраться до токена в памяти невозможно.
Ограничить id-token: write только одним job'ом.
- Задайте
permissions: {}на уровне workflow и добавляйтеid-token: writeтолько в job публикации. Принцип "запрещено по умолчанию" для каждого job'а.
Не восстанавливать кэши в job'е публикации.
- Job публикации не должен вызывать
actions/cache@v...для путей, в которые мог записать данные код атакующего. Загружайте зависимости заново в этом job'е или восстанавливайте их из изолированной области кэша (cache scope), в которую не мог писать запуск из PR. Это разрушает связку github-actions-cache-poisoning -> извлечение токена.
Фиксировать все action'ы по SHA коммита.
- Плавающий тег
@v...у стороннего action'а сам по себе служит вектором извлечения (как в случае с tj-actions/changed-files). Инструментpinactи режимpinnedв Dependabot автоматизируют этот процесс.
Проверка источника происхождения (на стороне реестра, в масштабе экосистемы).
- Структурное решение - научить реестры фиксировать путь к файлу workflow и ID шага, выполнившего публикацию, и дать авторам пакетов возможность указывать, какому именно шагу разрешено запрашивать токен. Это обсуждается, но пока не внедрено. До тех пор защита целиком держится на мерах со стороны издателя пакета, описанных выше.
См. также
- supply-chain-security
- long-lived-keys - Trusted Publishing идёт в правильном направлении; эта концепция описывает оставшуюся брешь
- pwn-request-pattern - распространённая точка входа, дающая атакующему возможность исполнять код
- github-actions-cache-poisoning - мост между выполнением одного workflow и окружением другого
- tanstack-npm-supply-chain-postmortem - разбор цепочки атаки в действии
- Разбор случая с tj-actions от StepSecurity: https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised