Скрытые секреты в Docker-образах: как их найти

Обложка статьи о скрытых секретах в Docker-образах

Начну с ситуации, которую видел не раз. Разработчик пишет Dockerfile, добавляет секрет для настройки чего-либо, затем удаляет файл несколькими строками ниже и пушит образ. С точки зрения контейнера файл исчез. Но секрет по-прежнему находится в образе — навсегда — и любой, у кого есть доступ к образу, может его извлечь с помощью tar и нескольких команд оболочки.

В этой статье мы воспроизведём именно такой сценарий с нуля, изучим внутреннее устройство образа и самостоятельно извлечём секрет. К концу вы поймёте, почему это происходит и как этого избежать.

Что потребуется

Перед началом убедитесь, что у вас есть:

  • Установленный и запущенный Docker

  • Базовое знакомство с Dockerfile

  • Утилита jq (apt install jq / brew install jq)

  • Терминал

Больше ничего не нужно.

Настройка демонстрации

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

FROM alpine
RUN echo "supersecret_api_key_123" > secret.txt
ENV DB_PASSWORD=Str0ngP@ssw0rd
RUN rm secret.txt
ENTRYPOINT ["sh", "-c", "sleep 60"]

Собираем образ:

$ docker build -t myapp:1.0 .

Запускаем контейнер и проверяем, есть ли там файл с секретом:

$ docker run --rm myapp:1.0 cat secret.txt
cat: can't open 'secret.txt': No such file or directory

Отлично — изнутри контейнера файл исчез. Всё выглядит чисто.

Но давайте покопаемся глубже.

Что показывает docker history

Первое, что стоит сделать с любым образом, — запустить docker history. Эта команда показывает каждую инструкцию, использованную при сборке:

$ docker history myapp:1.0
IMAGE          CREATED       CREATED BY                                      SIZE
a3f9d2c81b4e   2 min ago     ENTRYPOINT ["sh" "-c" "sleep 60"]               0B
<missing>      2 min ago     RUN /bin/sh -c rm secret.txt # buildkit         0B
<missing>      2 min ago     ENV DB_PASSWORD=Str0ngP@ssw0rd                  0B
<missing>      2 min ago     RUN /bin/sh -c echo "supersecret..." > secre…   28B
<missing>      5 weeks ago   CMD ["/bin/sh"]                                 0B
<missing>      5 weeks ago   ADD alpine-minirootfs-...tar.gz / # buildkit    8.45MB

Внимательно посмотрите на столбец SIZE.

Слой RUN rm secret.txt занимает 0 байт — он ничего не добавил в файловую систему, а лишь создал маркер удаления. Слой RUN echo выше него занимает 28 байт — это и есть наш файл с секретом, лежащий ровно там, где мы его оставили.

Строка ENV DB_PASSWORD тоже видна открытым текстом прямо в выводе истории.

Как устроены слои Docker

Чтобы понять, почему файл никуда не делся, нужно разобраться, как Docker хранит слои.

Каждая инструкция в Dockerfile, изменяющая файловую систему, создаёт новый неизменяемый слой (read-only layer). Docker складывает эти слои друг на друга с помощью объединённой файловой системы (union filesystem; overlayfs в Linux). Когда контейнер запускается, все слои объединяются и представляются как единая файловая система.

Главное, что нужно понять: слои неизменяемы и только добавляют данные (immutable and additive). Когда вы удаляете файл в следующем слое, Docker не трогает предыдущий. Вместо этого в новый слой добавляется специальный файл-маркер — вайтаут (whiteout), файл с именем .wh.<имя_исходного_файла>. Объединённая файловая система видит этот маркер и скрывает исходный файл от работающего контейнера.

Исходный файл по-прежнему находится в своём слое — полностью нетронутым.

Извлечение секрета

Теперь давайте на практике вытащим секрет. Никаких специальных инструментов не нужно — только docker save и tar.

Шаг 1 — Экспортируем образ в tar-архив:

$ docker save myapp:1.0 -o myapp.tar
$ mkdir myapp-extracted && tar xf myapp.tar -C myapp-extracted/
$ ls myapp-extracted/
blobs/  index.json  manifest.json  oci-layout  repositories

Шаг 2 — Читаем манифест, чтобы увидеть список слоёв:

$ cat myapp-extracted/manifest.json
[{
  "Config": "blobs/sha256/a3f9d2c81b4e...",
  "RepoTags": ["myapp:1.0"],
  "Layers": [
    "blobs/sha256/29df493baa13...",
    "blobs/sha256/779700a0de6f...",
    "blobs/sha256/05093f0d1704..."
  ]
}]

Массив Layers перечисляет слои в порядке их создания — том же порядке, в каком следуют инструкции Dockerfile. Слой 0 — это alpine, слой 1 — наш RUN echo, слой 2 — наш RUN rm.

Шаг 3 — Просматриваем содержимое каждого слоя файловой системы:

$ tar tf blobs/sha256/779700a0de6f...
secret.txt
$ tar tf blobs/sha256/05093f0d1704...
.wh.secret.txt

Флаг t выводит содержимое архива без извлечения файлов, а f указывает tar читать из файла. Вот оно — слой 1 содержит secret.txt, слой 2 содержит .wh.secret.txt. Префикс .wh. — это маркер вайтаута, скрывающий файл во время выполнения. Оригинал так и не был затронут.

Шаг 4 — Читаем секрет:

$ tar xOf blobs/sha256/779700a0de6f... secret.txt
supersecret_api_key_123

Флаг x извлекает файл, O (заглавная буква O) выводит результат в stdout вместо записи на диск, f читает из архива. Секрет — вот он. Извлечён менее чем за минуту, без специальных инструментов и повышенных привилегий.

Переменная ENV извлекается ещё проще

Чтобы найти DB_PASSWORD, нам даже не пришлось лезть в слои. Переменные среды, заданные через ENV, хранятся в конфигурации образа — их можно прочитать напрямую через docker inspect, без всякой распаковки архивов:

$ docker inspect myapp:1.0 --format '{{json .Config.Env}}' | jq .
[
  "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "DB_PASSWORD=Str0ngP@ssw0rd"
]

Если вы работаете с экспортированным tar-архивом (например, кто-то передал вам файл образа без доступа к Docker), конфигурационный блоб внутри архива — это файл, указанный в поле "Config" в manifest.json. В некоторых версиях Docker он экспортируется как gzip-сжатый бинарный файл, а не как обычный JSON, поэтому cat может выдавать ошибки кодировки. Надёжный способ — доверить разбор jq:

$ jq '.config.Env' blobs/sha256/a3f9d2c81b4e...

В любом случае — извлекать слои не нужно. Любой, у кого есть доступ к образу, может прочитать все значения ENV за секунды.

Кто может это сделать?

Любой, у кого есть доступ на чтение к образу. На практике это означает:

  • Каждый разработчик, способный выполнить docker pull из вашего реестра

  • Ваша CI/CD-система, если она хранит образы как артефакты сборки

  • Любой, кто получил .tar-экспорт образа

  • Все без исключения, если образ опубликован на Docker Hub как публичный

Правильный способ работы с секретами

Теперь, когда мы разобрались с проблемой, поговорим о том, как её решить.

1. Используйте монтирование секретов BuildKit на этапе сборки

Если секрет нужен во время сборки — например, для аутентификации в приватном реестре пакетов — используйте --mount=type=secret из BuildKit. Секрет становится доступен как временный файл только внутри этого единственного шага сборки и никогда не записывается ни в один слой:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=api_key \
    cat /run/secrets/api_key | ./setup.sh
$ docker build --secret id=api_key,src=./api_key.txt -t myapp:1.0 .

2. Передавайте секреты во время выполнения, а не во время сборки

Самый чистый подход — вообще не помещать секреты в образ. Передавайте их при запуске контейнера:

$ docker run -e DB_PASSWORD=$(vault kv get -field=password secret/db) myapp:1.0

Либо используйте Docker Secrets (режим Swarm) или Kubernetes Secrets, монтируемые в под как файлы.

3. Проверяйте уже существующие образы

Если вы не уверены, содержат ли ваши текущие образы секреты, такие инструменты, как Trivy и Trufflehog, умеют сканировать слои образов на наличие паттернов, характерных для учётных данных. Стоит прогнать их по своему реестру, прежде чем считать всё чистым.

4. Объединение слоёв как крайняя мера

Squash-сборка объединяет все слои в один, удаляя промежуточную историю:

$ docker build --squash -t myapp:1.0 .

Это смягчение проблемы, а не её решение. Секрет всё равно существовал в реальном слое в момент сборки и мог быть перехвачен тогда. Используйте этот подход только тогда, когда изменить процесс сборки невозможно.

Итог

Итоговая схема: секрет навсегда остаётся в слое Docker-образа

Правило простое: если данные когда-либо были записаны в слой, их можно прочитать обратно. Единственный надёжный подход — не записывать секреты туда вообще.

© 2026 meganuke