open atlas
↑ К треку
Go с нуля до senior GO · 11 · 03

Минимальные контейнерные образы для Go: лестница площади атаки от ubuntu до scratch через multi-stage

Каждый бинарь в образе — инструмент для атакующего после RCE. Multi-stage компилирует полным тулчейном, затем COPY статического бинаря в scratch или distroless — ни шелла, ни пакетного менеджера, нечего показать сканеру. CA-сертификаты и nonroot добавляют явно.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Алерт обнаружения вторжений сработал на исходящем соединении к майнинг-пулу, и дежурный приготовился к привычной чистке криптомайнера. Вебшелл был настоящим: непропатченный эндпоинт обработки изображений позволил атакующему записать дроппер в записываемую временную директорию, и payload был хрестоматийным — скачать майнер через curl, сделать исполняемым, форкнуть под именем процесса, мимикрирующим под приложение. Только логи показывали одни и те же три строки, повторяющиеся в цикле и падающие каждый раз: sh: not found, curl: not found, chmod: not found. Сервис работал в образе scratch — один статически слинкованный Go-бинарь, ни шелла, ни busybox, ни пакетного менеджера, ни libc. У дроппера была опора внутри процесса, но не было второй стадии для запуска, потому что каждый инструмент, к которому он тянулся, был удалён на этапе сборки самим фактом того, что его не добавили. У атакующего было исполнение кода — и нечего исполнять. Инцидент понизился с «майнер в проде» до «заблокированная попытка эксплойта», и сильнейшим контролем пост-мортема оказалась строка, которую никто не написал: шелл, которого не было в образе.

Аргумент площади атаки

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

Контейнерный образ — не виртуальная машина; это файловая система, которую видит ваш процесс. Всё исполняемое в ней — /bin/sh, apt, curl, wget, python, весь GNU coreutils — это возможность, доступная любому, кто добился исполнения кода внутри контейнера. Большинство реальных атак на контейнеры двухстадийны: уязвимость приложения (десериализация, SSRF — подделка серверных запросов, запись через path-traversal) даёт опору, а затем атакующий живёт за счёт земли, используя уже присутствующие инструменты, чтобы скачать payload, повысить привилегии и закрепиться. Шелл — универсальная вторая стадия; пакетный менеджер — сервис доставки софта, вручённый незваному гостю; curl и wget — каналы эксфильтрации. Тезис минимального образа прямолинеен: нельзя эксплуатировать инструмент, которого нет. Это защита отсутствием, и она необычайно надёжна, потому что не зависит от корректного выполнения правила в рантайме — возможности просто не существует.

Go — язык, для которого это работает лучше всего, потому что тулчейн Go может выдать единственный статически слинкованный бинарь без рантайм-зависимости от libc хоста или интерпретатора. Это значит, что исполнимая поверхность Go-сервиса может быть ровно одним файлом: вашим бинарём. Больше ничему не нужно быть в образе, чтобы программа работала.

Лестница, ступень за ступенью

Думайте о базовых образах как о лестнице, обменивающей удобство на площадь атаки:

ubuntu / debian дают полный userland — удобно для отладки и полноценный набор для незваного гостя. alpine ужимается до ~5–10 МБ на busybox и musl, но в нём всё ещё есть шелл (busybox sh — настоящая вторая стадия), а musl — не glibc: сборки с cgo могут наткнуться на тонкие отличия libc, а разрешение DNS исторически вело себя достаточно иначе, чтобы вызывать прод-инциденты. distroless (образы Google) убирает шелл и пакетный менеджер целиком, сохраняя glibc-базу, CA-сертификаты и /etc/passwd — около 20 МБ, с вариантом :nonroot и вариантом :debug, добавляющим busybox только когда он явно нужен. scratch — пустой образ: ноль байт базы, так что образ — это только ваш бинарь. Он требует дисциплины статической линковки, и несколько вещей, которые полноценная ОС давала бесплатно, придётся добавить руками.

Сборка: multi-stage — это механизм

Сборке нужен весь тулчейн; рантайму — ничего из него. Multi-stage-сборки выражают ровно это разделение — компилируют в жирной стадии-билдере, затем COPY только артефакт в минимальную финальную стадию:

# --- стадия сборки: полный тулчейн Go ---
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 заставляет собрать статический бинарь без зависимости от libc,
# что только и делает возможной финальную стадию на scratch.
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server

# --- финальная стадия: ничего, кроме нужного бинарю ---
FROM scratch
# TLS к любому HTTPS-эндпоинту требует набора CA; у scratch его нет.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
# Запуск под не-root UID; в scratch нет useradd, поэтому задаём числом.
USER 65534
ENTRYPOINT ["/app"]

Четыре вещи, которых scratch не даёт, и как их поставить: CA-сертификаты (ca-certificates.crt, иначе программа не сможет валидировать ни один TLS-сертификат — классическое «x509: certificate signed by unknown authority» в проде); tzdata, если делаете арифметику часовых поясов (или вшейте импортом Go time/tzdata); /etc/passwd, если инструменту нужно разрешить имя пользователя (числовой USER обходит это); и nonroot UID, потому что по умолчанию это root, а root-процесс плюс побег из контейнера — событие уровня хоста. Соедините USER с read-only корневой ФС (--read-only в рантайме) — и вы убрали ещё и цели для записи дропперов. Distroless — тот же рецепт с уже включёнными сертификатами и /etc/passwd; выбирайте его вместо scratch, когда хотите эти батарейки в комплекте или нужен debug-вариант наготове.

Викторина

Go-сервис локально работает, но в деплое из scratch-образа каждый исходящий HTTPS-вызов падает с 'x509: certificate signed by unknown authority'. Чего не хватает и почему?

Сканирование, честно

Сканеры образов (Trivy, Grype, встроенный в реестр) подавляюще хороши в одном: сопоставлении версий установленных пакетов ОС с базами CVE. Эта рамка — ключ к чтению их вывода на минимальных образах. Образ на базе debian с 200 пакетами покажет длинный, в основном шумовой список CVE — многие в пакетах, которые ваш Go-бинарь никогда не вызывает. У scratch-образа нет пакетов ОС, поэтому скан уровня ОС чист по построению — не потому что сканер снисходителен, а потому что в его компетенции нечего найти. Это настоящее структурное преимущество, и именно здесь важна честность: чистый скан scratch не означает безопасный сервис. Уязвимости ваших Go-модулей по-прежнему существуют (это работа govulncheck из прошлого урока), логику приложения по-прежнему можно эксплуатировать, а ваш собственный статический бинарь инструменты пакетов ОС не сканируют. Минимальные образы ужимают площадь атаки базы почти до нуля; о коде, который вы отгрузили, они не говорят ничего. Два контроля композируются: govulncheck для вашего Go-кода, минимальные образы для всего под ним.

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

Почему «сканер ничего не нашёл» — сигнал слабее, чем ощущается? Потому что сканеры пакетов ОС отвечают на «совпадает ли установленный пакет с известной CVE?» — а на scratch множество установленных пакетов пусто, так что ответ тривиально «нет». Риск — принять этот пустой результат за справку о здоровье всего сервиса. Защитимое прочтение узкое и верное: базовый образ не вносит известно-уязвимых пакетов. Всё над базой — ваши зависимости, ваш код, ваша конфигурация — требует своих контролей. Минимальные образы облегчают работу сканеру, удаляя весь его домен, — это настоящий выигрыш и частичный.

Сторона издержек, прямо

Минимальные образы — не бесплатное удобство. Шелл, который вы удалили, — это шелл, в который вы не сможете kubectl exec, чтобы отладить живой инцидент; ваша история отладки переезжает в структурные логи, метрики, эфемерные debug-контейнеры (сайдкар, разделяющий namespace процессов) или distroless-вариант :debug, держащийся на полке. Статическая линковка ограничивает: cgo-зависимые библиотеки (некоторые драйверы SQLite, отдельные крипто-биндинги) либо требуют CGO_ENABLED=1 и, стало быть, libc в финальном образе (distroless, не scratch), либо чисто-Go-альтернативы. И числа — реальная мотивация: переход с ~120 МБ debian-базы на ~10 МБ финальный образ режет время pull и латентность холодного старта на автоскейлящихся нодах и ужимает хранение на образ по всему флоту — заметно, когда реестр держит тысячи тегов. Берите самую нижнюю ступень, которую реально позволяют зависимости: scratch для чисто-Go статических бинарей, distroless для cgo или комплектности, alpine — только когда есть конкретная причина хотеть его шелл и принять musl.

Викторина

Почему multi-stage-сборка с финальной стадией scratch снижает площадь атаки так, как 'просто apt-get remove' на debian-образе надёжно не повторяет?

Вспомните перед уходом
  1. 01
    Объясни аргумент площади атаки для минимальных образов и почему multi-stage-сборки реализуют его надёжнее, чем удаление пакетов.
  2. 02
    Чего scratch не даёт, что Go-сервису часто нужно, как поставить каждое и что на самом деле доказывает чистый скан образа?
Итог

Контейнерный образ — это файловая система, в которой живёт ваш процесс, и каждый исполняемый файл в нём — возможность, вручённая любому, кто добился исполнения кода внутри контейнера. Поскольку реальные атаки двухстадийны — опора, затем жизнь за счёт земли уже присутствующими шеллом, пакетным менеджером и curl, — самый стойкий контроль — это удаление через невключение: эксплуатированный процесс в scratch-образе тянется к /bin/sh и не находит ничего, ровно как заблокированный майнер из приёма-крючка. Go подходит для этого уникально хорошо: CGO_ENABLED=0 выдаёт один статически слинкованный бинарь без зависимости от libc, так что исполнимая поверхность схлопывается в единственный файл. Лестница базовых образов меняет удобство на поверхность — полный userland ubuntu, busybox-и-musl alpine (шелл остаётся, а cgo натыкается на причуды musl), glibc-без-шелла distroless, пустота scratch — и multi-stage-сборки это механизм: компилировать в жирной стадии-билдере, COPY только артефакт в минимальную финальную стадию, которая стартует с пустоты и потому не может оставить забытый шелл, как apt-get remove. Scratch опускает четыре вещи, которые вы ставите явно — CA-сертификаты (иначе каждый TLS-вызов падает с x509-unknown-authority), tzdata, /etc/passwd, числовой nonroot USER, в идеале с read-only rootfs, — а distroless это тот же рецепт с включёнными батарейками плюс debug-вариант на инциденты. Сканирование — место, где важна честность: сканеры пакетов ОС не находят на scratch ничего, потому что в их домене ничего нет, — настоящий структурный выигрыш, который тем не менее не говорит ничего о CVE ваших модулей, вашей логике или вашем бинаре. Минимальные образы ужимают базу почти до нуля; govulncheck и безопасность приложения владеют всем над ней, и вы берёте самую нижнюю ступень, которую реально позволяют зависимости. Теперь, когда видишь в Dockerfile жирный базовый образ с пакетами, которые Go-бинарь никогда не трогает, — знаешь что делать: multi-stage, CGO_ENABLED=0, COPY в scratch, CA-сертификаты и nonroot — и вручаешь атакующему пустой ящик с инструментами.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.