Примечание переводчика. Статья является переводом. ​​Автор оригинала: Гийом Валадон (Guillaume Valadon) — исследователь в области кибербезопасности в GitGuardian. Имеет докторскую степень в области сетевого взаимодействия, любит изучать данные и создавать пакеты. Гийом — со-мейнтейнер Scapy. И он всё ещё помнит, что означает AT+MS=V34! Дальше идет текст автора. 

Зомби-слои Docker — это неиспользуемые слои образов, которые остаются в хранилище образов контейнеров (далее — реестр) даже после удаления из манифестов. В этой практической статье рассмотрим, как так получается, что эти слои остаются в реестрах, и почему крайне важно сразу же аннулировать раскрытые секреты.

В GitGuardian мы любим разбираться в том, как всё устроено, и выискивать разгадки в самых неожиданных местах. Эта история об одном разговоре за чашечкой кофе, который начался с любопытства — вопросов вроде «Что?», «Как?» и «А что, если?» — и привёл к удивительным открытиям.

TL;DR

  • Слои образов Docker остаются в реестре после удаления из манифеста, становясь «зомби-слоями».

  • Зомби-слои могут неделями оставаться в реестре, прежде чем будут зачищены сборщиком мусора.

  • Они могут стать серьёзной угрозой, если в них хранятся конфиденциальные данные, например секреты, а злоумышленник постоянно следит за конкретным реестром.

  • В AWS ECR неизменяемость тегов предотвращает перезапись манифестов, но слои всё равно успевают попасть в реестр до того, как система отклонит манифест, что создаёт зомби-слои.

Что внутри у Docker-образа?

Образ Docker описывается Dockerfile’ом, который содержит набор команд, последовательно применяемых во время сборки, и идентифицируется именем и тегом (версией). Типичный Dockerfile содержит команды FROM, RUN и COPY, как показано в примере ниже. Если его применить, получим образ с файлами /root/app.sh и /root/mongodb.txt внутри:

$ cat Dockerfile
FROM ubuntu:24.10

RUN echo "connection_uri = 'mongo://z0:kHR@192.0.2.28:773'" > /root/mongodb.txt

ENV MESSAGE="Hello World!"

COPY app.sh /root/

Теперь из директории с этим Dockerfile выполните следующие команды, чтобы создать образ с именем blogpost-image и тегом original, запустить его, вывести содержимое скрипта app.sh и выполнить его:

$ docker build -t blogpost-image:original .         

$ docker run --rm -it blogpost-image:original
root@5831ef8de3d5:/# cat /root/app.sh 
echo $MESSAGE
root@5831ef8de3d5:/# /bin/bash /root/app.sh 
Hello World!

На этом простом примере можно изучить структуру Docker-образа. Он состоит из нескольких файлов:

  • JSON-манифеста, описывающего содержимое Docker-образа, включая его слои;

  • нескольких слоёв в tar-архивах, содержащих файлы вроде /bin/bash и /root/app.sh.

Следующая команда выведет любопытную информацию о новом манифесте:

$ docker inspect blogpost-image:original | jq '.[] |.Id, .Config.Env, .RootFS'
"sha256:e8efac89ff2d5df926d69fbe35b4805880c38ed5a35c981ee4e080533ae21da7"
[
  "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "MESSAGE=Hello World!"
]
{
  "Type": "layers",
  "Layers": [
    "sha256:35de1435b273af9899f19dc9185e579c3553b03a285397e01ad580f3ed607250",
    "sha256:31dbac5402efe38f23f5ff87c464b867c3f300a8b0788fd7de28220ce3f2713b",
    "sha256:b6783ff925260fba1ab9f52863b7dd647c18e472e2b083e014846d2fa961b785"
  ]
}
  • Id — хэш SHA256 JSON-файла конфигурации образа.

  • Env — переменные окружения, используемые образом, включая MESSAGE, которые мы указали в Dockerfile.

  • Layers — список SHA256-хэшей tar-архивов, содержащих файлы образа.

Для дальнейшего изучения можно воспользоваться инструментом skopeo. Сначала извлечём содержимое образа:

$ skopeo copy docker-daemon:blogpost-image:original dir:original_content

$ ls original_content 
31dbac5402efe38f23f5ff87c464b867c3f300a8b0788fd7de28220ce3f2713b
b6783ff925260fba1ab9f52863b7dd647c18e472e2b083e014846d2fa961b785
manifest.json
35de1435b273af9899f19dc9185e579c3553b03a285397e01ad580f3ed607250
e8efac89ff2d5df926d69fbe35b4805880c38ed5a35c981ee4e080533ae21da7
version

Видим три слоя и файл с таким же именем, как ID образа. Это текстовый файл, также содержащий историю сборки и слои, которые успешно собрались. Обратите внимание, что docker history --no-trunc blogpost-image:original даёт аналогичный результат:

$ cat original_content/e8efac89ff2d5df926d69fbe35b4805880c38ed5a35c981ee4e080533ae21da7 |jq '..history, .rootfs'
[
  {
    "created": "2024-09-13T03:45:45.267601999Z",
    "created_by": "/bin/sh -c #(nop)  ARG RELEASE",
    "empty_layer": true
  },
  {
    "created": "2024-09-13T03:45:45.298845055Z",
    "created_by": "/bin/sh -c #(nop)  ARG LAUNCHPAD_BUILD_ARCH",
    "empty_layer": true
  },
  {
    "created": "2024-09-13T03:45:45.322690413Z",
    "created_by": "/bin/sh -c #(nop)  LABEL org.opencontainers.image.ref.name=ubuntu",
    "empty_layer": true
  },
  {
    "created": "2024-09-13T03:45:45.34650769Z",
    "created_by": "/bin/sh -c #(nop)  LABEL org.opencontainers.image.version=24.10",
    "empty_layer": true
  },
  {
    "created": "2024-09-13T03:45:47.796572396Z",
    "created_by": "/bin/sh -c #(nop) ADD file:09509f4e7d531b71ff20f83a8fdb1fd7fafd2621a6c0d5cf35bee26ddf03028a in / "
  },
  {
    "created": "2024-09-13T03:45:48.030541078Z",
    "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/bash\"]",
    "empty_layer": true
  },
  {
    "created": "2024-10-01T12:06:18.789793084Z",
    "created_by": "RUN /bin/sh -c echo \"connection_uri = 'mongo://z0:kHR@192.0.2.28:773'\" > /root/mongodb.txt # buildkit",
    "comment": "buildkit.dockerfile.v0"
  },
  {
    "created": "2024-10-01T12:06:18.825235917Z",
    "created_by": "ENV MESSAGE=Hello World!",
    "comment": "buildkit.dockerfile.v0",
    "empty_layer": true
  },
  {
    "created": "2024-10-01T12:06:18.825235917Z",
    "created_by": "COPY app.sh /root/ # buildkit",
    "comment": "buildkit.dockerfile.v0"
  }
]
{
  "type": "layers",
  "diff_ids": [
    "sha256:35de1435b273af9899f19dc9185e579c3553b03a285397e01ad580f3ed607250",
    "sha256:31dbac5402efe38f23f5ff87c464b867c3f300a8b0788fd7de28220ce3f2713b",
    "sha256:b6783ff925260fba1ab9f52863b7dd647c18e472e2b083e014846d2fa961b785"
  ]
}

Некоторые команды в Dockerfile — например, ENV, — не создают слои. Другие — FROM, RUN и COPY — делают это. Из предыдущего вывода видно, что файл 35de...7250 является базовым образом, созданным оператором FROM (то есть ubuntu:24.10), файл 31db...713b содержит mongodb.txt, созданный RUN, и, наконец, файл b678...b785 содержит app.sh, созданный командой COPY. Давайте проверим содержимое этих двух последних слоёв с помощью команды tar:

$ tar tf original_content/31dbac5402efe38f23f5ff87c464b867c3f300a8b0788fd7de28220ce3f2713b 
root/
root/mongodb.txt

$ tar tf original_content/b6783ff925260fba1ab9f52863b7dd647c18e472e2b083e014846d2fa961b785 
root/
root/app.sh

После сборки Docker-образ обычно отправляется в реестр с помощью команды docker push. Пример ниже показывает, что происходит при публикации образа в реестре Docker Hub после добавления тегов: загружаются три слоя; их краткие хэши выводятся вместе с ID образа.

$ docker tag blogpost-image:original example/blogpost-image:v0.1.0

$ docker push example/blogpost-image:v0.1.0                  
The push refers to repository [docker.io/gggvaladon/blogpost-image]
b6783ff92526: Pushed 
31dbac5402ef: Pushed 
35de1435b273: Pushed 
v0.1.0: digest: sha256:ddfa644ea64486150c8b97775a8b00c407c2c32212519cc22cc80d155b39a295 size: 943

Что произойдет, если удалить слой из образа?

Предположим, что после пуша образа в реестр Docker Hub обнаружилось, что учётные данные MongoDB были опубликованы по ошибке. Обратите внимание, что правильное поведение в этом случае — отозвать старые учётные данные и выпустить новые. Но для целей этой статьи предположим, что мы ограничились удалением соответствующего слоя.

Есть несколько способов удалить слой, содержащий учётные данные (то есть 31db...713b). Очевидно, можно вручную отредактировать файлы manifest.json и e8ef..1da7, удалить все ссылки на слой, а затем пересобрать образ с помощью skopeo. Менее утомительный способ — воспользоваться инструментом layeremove, разработанным Жеромом Петаццони. Он автоматизирует ручные действия. При использовании этих двух способов ID образа и его дайджест изменятся из-за изменения манифеста, а хэш слоя останется прежним, поскольку соответствующие tar-архивы не менялись.

Воспользуемся самым простым способом: закомментируем команду RUN и пересоберём образ, пометив его как изменённый. В хэшах слоёв появилось кое-что интересное: хэш слоя COPY изменился, в то время как его содержимое — нет!

$ docker inspect blogpost-image:altered |jq '.[].RootFS'
{
  "Type": "layers",
  "Layers": [
    "sha256:35de1435b273af9899f19dc9185e579c3553b03a285397e01ad580f3ed607250",
    "sha256:abdd7e4717f42cafbdf90a9c5fbeeda575361d2c3704b37266c3140761e1216d"
  ]
}

Помните, что слои Docker — это архивы tar? Заголовок tar содержит временную метку. Поскольку два слоя COPY собирались один за другим, временные метки отличаются, а следовательно, и хэши SHA256.

При пуше нового образа с тем же именем и тегом в репозиторий поедет только новый слой RUN (то есть abdd..216d), поскольку другие там уже есть.

$ docker tag blogpost-image:altered example/blogpost-image:v0.1.0

$ docker push example/blogpost-image:v0.1.0                     
The push refers to repository [docker.io/gggvaladon/blogpost-image]
abdd7e4717f4: Pushed 
35de1435b273: Layer already exists 
v0.1.0: digest: v0.1.0: digest: sha256:6a2ddb202bfdec2066db7b9bf8489d70f1ab90e309d936a5fb4f28e5bf7ddb45 size: 736

Что происходит со слоем, который был удалён?

В новом, изменённом манифесте образа нет ссылок на слои RUN и COPY из исходного пуша. Давайте попробуем извлечь их из Docker registry.

Docker registry — это веб-сервис, который реализует чётко определённый протокол. Чтобы получить содержимое образа, будем использовать три эндпоинта. Нам будет нужно:

  • получить токен (он понадобится на других шагах);

  • извлечь манифест образа;

  • скачать слои.

Покажем, как работать с Docker registry на примере Docker Hub. Обратите внимание, что эндпоинты аутентификации для других реестров могут отличаться, но остальные эндпоинты должны быть такими же. Сначала получим токен для доступа к реестру:

$ curl 'https://auth.docker.io/token?scope=repository:gggvaladon/blogpost-image:pull&service=registry.docker.io' |jq .
{"token":"SGVsbG8gZnJvbSBHaXRHdWFyZGlhbiE=","expires_in":300,"issued_at":"2024-10-01T07:45:24.84317206Z"}
{
  "token":"SGVsbG8gZnJvbSBHaXRHdWFyZGlhbiE=",
  "access_token": "VGhpcyBpcyBub3QgdGhlIHRva2VuIHlvdSdyZSBsb29raW5nIGZvciEK",
  "expires_in": 300,
  "issued_at": "2024-10-07T08:26:37.10650739Z"
}

Теперь с помощью этого токена можно скачать манифест образа и посмотреть список содержащихся в нём слоёв:

$ curl --header "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/gggvaladon/blogpost-image/manifests/v0.1.0 |jq '.fsLayers[].blobSum' |sort -u
"sha256:5ed0c1e3b84c0b46d8f8294f077d20e83cf4cad3a0195d90ded59ee666730439"
"sha256:6f192fbb9b2ab30739ebcfa04665d365d4fb63abf3e4c9a796803829bfaec560"
"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"

Остановимся на мгновение и сравним три этих хэша с предыдущими. Ни один из них не соответствует тому, что показывают команды skopeo и docker! Что происходит? Оказывается, слои сжимаются реестром, в результате в манифесте меняются значения SHA256. Воспроизвести новый хэш локально можно с помощью Go-пакета compress/gzip, используя compress_stdin:

$ skopeo copy docker-daemon:blogpost-image:altered dir:altered_content

$ cat altered_content/abdd7e4717f42cafbdf90a9c5fbeeda575361d2c3704b37266c3140761e1216d |compress_stdin |shasum -a 256
5ed0c1e3b84c0b46d8f8294f077d20e83cf4cad3a0195d90ded59ee666730439 -

Примечательно, что новый слой RUN (то есть abdd..216d) теперь идентифицируется в реестре Docker Hub как 5ed0..0439. Давайте скачаем его и проверим содержимое:

$ curl --location --header "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/gggvaladon/blogpost-image/blobs/sha256:5ed0c1e3b84c0b46d8f8294f077d20e83cf4cad3a0195d90ded59ee666730439 -O

$ file sha256:5ed0c1e3b84c0b46d8f8294f077d20e83cf4cad3a0195d90ded59ee666730439   
sha256:5ed0c1e3b84c0b46d8f8294f077d20e83cf4cad3a0195d90ded59ee666730439: gzip compressed data, original size modulo 2^32 2560

$ tar tf sha256:5ed0c1e3b84c0b46d8f8294f077d20e83cf4cad3a0195d90ded59ee666730439 
root/
root/app.sh

Это содержимое слоя RUN. Можно убедиться, что хэш SHA256 несжатого слоя совпадает с локальным хэшем:

$ zcat -f sha256:5ed0c1e3b84c0b46d8f8294f077d20e83cf4cad3a0195d90ded59ee666730439 |shasum -a 256
abdd7e4717f42cafbdf90a9c5fbeeda575361d2c3704b37266c3140761e1216d  -

Теперь мы знаем, как хранятся слои и как их можно извлечь. Пришло время проверить, сохранился ли в реестре исходный слой RUN (то есть 10ca..9a1c), содержащий секрет.

$ curl --location --header "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/gggvaladon/blogpost-image/blobs/sha256:10ca0674a0c252b8081225eecff28b6612f6b2b47c79ed05b18dc8784c669a1c

$ tar tf sha256:10ca0674a0c252b8081225eecff28b6612f6b2b47c79ed05b18dc8784c669a1c 
root/
root/mongodb.txt

Как видите, секрет всё ещё в реестре. Его можно получить по хэшу SHA256 сжатого слоя. Переопределение тега не приводит к удалению ранее сохранённых слоёв. Для обозначения слоев Docker, которые хранятся в реестре, но на которые нет ссылок в манифесте, мы и придумали термин «зомби-слой».

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

Как долго зомби-слой остается в реестре?

Были протестированы четыре реестра: Docker Hub, Quay.io, GitHub Packages и AWS ECR. Используемый метод очень похож на тот, что был описан ранее: загружаем исходный образ Docker, удаляем слой, загружаем изменённый образ, извлекаем слой, на который более не ссылается манифест. Единственное отличие — это процесс получения токена, который отличается для разных реестров. Способ извлечения образа не меняется, поскольку все реестры основаны на одной и той же спецификации.

Если коротко, то через месяц зомби-слой всё ещё был виден во всех протестированных реестрах, но Quay.io удалил его через 17 дней.

В ходе эксперимента мы обнаружили, что AWS ECR работает удивительным образом. Фича под названием «неизменяемость тегов» предотвращает перезапись образа Docker, что и было сделано в данном эксперименте. В наших тестах всё работало так, как и ожидалось: новый манифест запушить не получилось. Однако мы очень удивились, обнаружив, что слои всё-таки успели попасть в реестр до того, как манифест был отклонён. То есть зомби-слои можно запушить в реестр даже несмотря на то, что сам манифест система отвергает!

Выводы

Зомби-слои хранятся неделями. Их легко извлечь, что представляет потенциальную опасность для конфиденциальной информации. Подобный вектор атаки известен давно. Если злоумышленник следит за количеством слоёв, то вполне может им воспользоваться, найти зомби-слой и получить к нему доступ.

Опять же, очень важно своевременно аннулировать все раскрытые секреты. Если оставить секреты в открытом доступе даже на короткое время, это может нанести значительный ущерб. Свести к минимуму потенциальные риски позволяют бдительный мониторинг и оперативные действия по устранению опасности. Так вы защитите активы и не позволите злоумышленникам воспользоваться инфраструктурой.

Если вы ещё не проверяете свои образы Docker на наличие секретов, предлагаем сделать это, подписавшись на GitGuardian и загрузив ggshield. Пройдите аутентификацию с помощью ggshield auth login и воспользуйтесь следующей командой для проверки наличия секретов перед отправкой образов:

$ ggshield secret scan docker --show-secrets --format json -o ggshield-scan-docker.json blogpost-image:original 

$ cat ggshield-scan-docker.json |jq '.entities_with_incidents[0].incidents[0].occurrences[0]'
Saving docker image... OK
Scanning Docker config
Scanning... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1 / 1
Skipping layer sha256:35de1435b273af9899f19dc9185e579c3553b03a285397e01ad580f3ed607250: already scanned
Skipping layer sha256:b6783ff925260fba1ab9f52863b7dd647c18e472e2b083e014846d2fa961b785: already scanned
{
  "match": "mongo://z0:kHR@133.251.16.252:773",
  "type": "connection_uri",
  "line_start": 48,
  "line_end": 48,
  "index_start": 1507,
  "index_end": 1539,
  "pre_line_start": 49,
  "pre_line_end": 49
}

P. S.

Читайте также в нашем блоге:

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


  1. SabMakc
    30.01.2025 13:30

    То, что по хешу слоя можно извлечь слой - это ожидаемое поведение.
    Что старые слои остаются доступными (как минимум на некоторое время), не смотря на перенос тегов - тоже ожидаемо.

    Но вот чего не понял - а откуда злоумышленник получает перечень зомби-слоев.
    Постоянно мониторит новые образы?

    P.S. у меня 2 слоя возвращается в запросе на манифест, причем формат ответа другой - нет fsLayers.


  1. slonopotamus
    30.01.2025 13:30

    Обратите внимание, что правильное поведение в этом случае — отозвать старые учётные данные и выпустить новые.

    Вот именно. И зачем тогда всё это...


  1. Keirichs
    30.01.2025 13:30

    Вывод получается один, не хранить какие либо секреты в доккер контейнерах, как я вижу из вашего вывода JSON с информацией о слоях, информация хранящаяся в аргументах - не отображается, следовательно чувствительные данные надо хранить в другом безопасном месте и передавать как аргументы в образ.