Введение

Всем привет! Меня зовут Максим Чиненов, я работаю в компании Swordfish Security, где занимаюсь внедрением, развитием и исследованием инструментов и процессов связанных с практиками Cloud & Container Security.

Сегодня мы разберем работу инструмента OCI-image-compliance-scanner, разработанного в нашей компании для покрытия задач по аудиту образов на лучшие практики компоновки.

Composition/Compliance Scan

Данный инструмент относится к классу решений Composition/Compiance Scanning. Из названия следует, что он может включать любой набор необходимых проверок: как конфигурации параметров запуска, заложенных разработчиком при сборке образа, так и содержимого его файловой системы.

Самым распространенным вариантом использования такого инструмента, является случай, при котором вам нужно проверить образы из публичных источников на соответствие вашим внутренним ИБ-стандартам и лучшим практикам. Также данный класс решений применятся при получении образов от внешнего разработчика в составе программно-аппаратного комплекса.

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

Обычно в организациях подобный процесс в той или иной степени формализирован внутренними документами. Зачастую требования к образам описаны в «Программе и методике испытаний» или «ПМИ» — это технический документ, который формализует этап тестирования продукции. ПМИ предназначен для выявления параметров, обеспечивающих соответствие различным требованиям, которые могут включать набор лучших отраслевых практик и любые другие внутренние требования компании.

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

А какие есть OpenSource решения?

Изучив доступные проекты на github, мы не нашли решение, которое бы полностью покрывало набор требований наших заказчиков именно при анализе на компоновку уже готовых артефактов.

Частично под наши параметры подходят:

Dockle

Известный Open Source инструмент, включающий некоторые проверки образов на компоновку.

У данного решения более широкий функционал: анализ на секреты, проверки по CIS, а так же линтер Dockerfile. Но нам важны проверки именно для образов, а их там как раз недостаточно.

Chef Inspec

Chef Inspec — фреймворк для тестирования Docker-образов на основе инструмента
комлаенс-анализа.

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

LinPEAS

LinPEAS
LinPEAS

Linux Privilege Escalation Awesome Script — набор скриптов для аудита хостовой операционной системы на возможность повышения привилегий.

Это мощный инструмент аудита Linux-систем, включающий множество проверок. К сожалению, большинство из них не применимы к контейнерным окружениям и для нас будут избыточными.

Trivy

Комплексный инструмент для сканирования на уязвимости, поиск секретов, анализа IAC манифестов и т.д.

Имеет возможность расширения функционала, в том числе через добавление плагинов, которые, по сути, являются отдельными файлами, вызываемыми через CLI Trivy. Например, командой trivy plugin_name --args
Здесь так же потребуется разработка необходимых нам проверок.

Зачем нам свой инструмент

Первая цель – покрыть требования наших заказчиков к некоторым образам, которые берутся из OpenSource. Поскольку наша компания тоже является поставщиком и разработчиком ИБ-решений, нам необходимо проводить аудит образов в том числе и на компоновку/комплаенс перед передачей заказчику.

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

Так как нам нужен был инструмент для анализа именно образов, было принято решение не дорабатывать доступные инструменты, а написать свой необходимый набор проверок и упаковать его в нужном нам виде для использования в CI/CD.

Выдвигаем требования к разрабатываемому сканеру

Проверки проверками, но для начала нужно определить, как будет выглядеть сам сканер и какие функции он должен выполнять. Для этого мы выделили следующие функциональные требования:

  1. Полная автоматизация сканирования
    Инструмент должен принимать на ввод путь до образа и проводить все необходимые проверки.

  2. Интеграция в CI/CD пайплайн
    Для этого все скрипты упаковываются в образ для его запуска в пайплайне, а так же добавляется возможность конфигурации параметров запуска через переменные окружения.

  3. Работа с удалённым registry
    Использование утилит Podman, Docker или Scopeo для аутентификации в приватном репозитории и загрузки образа в наш контейнер для сканирования. Так как запуск контейнера из сканируемого образа для проведения проверок не нужен, то решения в виде Dind или его аналогов с пробросом сокета контейнерного рантайма нам не потребуются. Остановились на использовании Podman для загрузки образов, как на самом простом варианте. Можно использовать Skopeo, но в этом случае пришлось бы писать дополнительные условия в зависимости от типа реестра. В случае с Podman нам нужно просто выполнить команду podman pull

  4. Возможность выставления Security Gate в зависимости от результата проверки При наличии дефектов в образе или любых ошибок на этапе сканирования должен возвращаться ненулевой exit code.

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

  6. Вывод результатов в человекочитаемом виде
    Должен присутствовать вывод в читабельном формате для его первичного анализа.

Что будем проверять

Прежде, чем реализовывать сами проверки, давайте разберемся, что же такое образ контейнера и из чего он состоит:

Манифест

Кроме набора файлов, которые представлены в образе, каждый образ имеет манифест и описание его конфигурации. Данные сущности регулируются спецификацией OCI Image Manifest Specification, которая призвана стандартизировать формат образа контейнера. Благодаря ей один и тот же образ может быть запущен в различных контейнерных средах, которые поддерживают OCI-стандарт.

Посмотрим, какие метаданные содержит образ alpine:latest

$ podman image inspect alpine:latest
[
    {
        "Id": "05455a08881ea9cf0e752bc48e61bbd71a34c029bb13df01e40e3e70e0d007bd",
        "Digest": "sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b",
        "RepoTags": [
            "docker.io/library/alpine:latest"
        ],
        "RepoDigests": [
            "docker.io/library/alpine@sha256:6457d53fb065d6f250e1504b9bc42d5b6c65941d57532c072d929dd0628977d0",
            "docker.io/library/alpine@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b"
        ],
        "Parent": "",
        "Comment": "",
        "Created": "2024-01-27T00:30:48.743965523Z",
        "Config": {
            "Env": [
                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
            ],
            "Cmd": [
                "/bin/sh"
            ]
        },
        "Version": "20.10.23",
        "Author": "",
        "Architecture": "amd64",
        "Os": "linux",
        "Size": 7671366,
        "VirtualSize": 7671366,
        "GraphDriver": {
            "Name": "overlay",
            "Data": {
                "UpperDir": "/home/chin/.local/share/containers/storage/overlay/d4fc045c9e3a848011de66f34b81f052d4f2c15a17bb196d637e526349601820/diff",
                "WorkDir": "/home/chin/.local/share/containers/storage/overlay/d4fc045c9e3a848011de66f34b81f052d4f2c15a17bb196d637e526349601820/work"
            }
        },
        "RootFS": {
            "Type": "layers",
            "Layers": [
                "sha256:d4fc045c9e3a848011de66f34b81f052d4f2c15a17bb196d637e526349601820"
            ]
        },
        "Labels": null,
        "Annotations": {},
        "ManifestType": "application/vnd.docker.distribution.manifest.v2+json",
        "User": "",
        "History": [
            {
                "created": "2024-01-27T00:30:48.624602109Z",
                "created_by": "/bin/sh -c #(nop) ADD file:37a76ec18f9887751cd8473744917d08b7431fc4085097bb6a09d81b41775473 in / "
            },
            {
                "created": "2024-01-27T00:30:48.743965523Z",
                "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
                "empty_layer": true
            }
        ],
        "NamesHistory": [
            "docker.io/library/alpine:latest"
        ]
    }
]

Нас интересуют следующие поля:

  • Config: описывает параметры, которые применятся для контейнера запущенного из данного образа. Содержит такие поля, как [User, ExposedPorts, Env, Cmd, Volumes, Workdir, Entrypoint, и т.д ...];

  • History: содержит историю сборки каждого слоя;

  • GraphDriver: cодержит метаданные о слоях и используемом storage-драйвере.

Слои

Любой образ состоит из одного и более слоев. При запуске контейнера для работы с его файловой системой по умолчанию используется драйвер OverlayFS (но необязательно), который позволяет объединить все слои и предоставить их в виде общей структуры контейнеру.

Работа с образом при использовании storage драйвера "OverlayFS" на примере Docker
Работа с образом при использовании storage драйвера «OverlayFS» на примере Docker

Слои образа доступны только в режиме Read-only. Все операции записи в файловую систему контейнера в процессе его работы осуществляются в отдельном слое, который привязывается к каждому контейнеру и является уникальным.

Давайте посмотрим, как слои хранятся в Podman:

$ podman image inspect c8d36223ec8c | jq .[].GraphDriver
{
  "Name": "overlay",
  "Data": {
    "LowerDir": "/home/chin/.local/share/containers/storage/overlay/112a4d2b01d132d767dd308bf84cb1056ac9e9c69e20363e6f4e0408f6a9093c/diff:/home/chin/.local/share/containers/storage/overlay/8db3188b70bb36654ff2bd898e80b10755c11edd07594656eaf282006ff77d7a/diff:/home/chin/.local/share/containers/storage/overlay/3d3aed3edc819c2f725ac40628fe1a2be94f857eb4761d12493d0161a18dc6da/diff:/home/chin/.local/share/containers/storage/overlay/31f3b94693eedf746f21c88fded37fb678da561c6a0a504cda548ebb7faf649b/diff:/home/chin/.local/share/containers/storage/overlay/58f1e7fffb3a66240446f0a4902ae32f157782c01a0c3a5e81323e70b9ed4d17/diff:/home/chin/.local/share/containers/storage/overlay/fdf3ed5c79ccdf9fda551031d00e9068a46a586410f21f97401c77c49d642f8d/diff",
    "UpperDir": "/home/chin/.local/share/containers/storage/overlay/5922742b05d547c802ab6eb1a4129282baf33c0e73c4ee6d59c218e0f0e91ba3/diff",
    "WorkDir": "/home/chin/.local/share/containers/storage/overlay/5922742b05d547c802ab6eb1a4129282baf33c0e73c4ee6d59c218e0f0e91ba3/work"
  }
}

Видим, что используется драйвер OverlayFS, а также:

  • LowerDir: содержит список директорий разделенных «:», они соответствуют тем самым RO-слоям, из которых состоит образ;

  • UpperDir: writable-слой, используемый контейнером для операций записи в процессе его работы;

  • MergedDir: служебный каталог для внутреннего использования. Результат наложения LowerDir и UpperDir. Если два каталога с одинаковым именем встречаются в LowerDir и UpperDir, то MergedDir задействуется при монтировании, как результат их объединения;

  • WorkDir: обязательный служебный каталог. Используется для работы OverlayFS.

Мы будем проверять содержимое файловой системы в LowerDir, где содержатся компоненты, представленные в самом образе.

Рассматриваемые векторы атаки

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

Давайте рассмотрим, какие в данном случае есть возможности:

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

    Каждый процесс в Linux имеет свои собственные атрибуты, которые включают UID, GID, RUID, RGID, EUID, EGID.

    Вкратце рассмотрим каждый из них:

    UID и GID

    Они же пользователь и группа. Данные атрибуты указываются в системном файле "/etc/passwd" для каждого пользователя в системе.

    user:x:1000:1000:user,,,:/home/user:/bin/bash

    В данном случае UID и GID при инициализации оболочки "bash" от имени пользователя "user" будут иметь значения 1000:1000

    RUID и RGID

    Каждый дочерний процесс, который запускается в текущей оболочке унаследует атрибуты учетной записи пользователя, т.е будет иметь те же значения RUID и RGID.

    Запустим простую команду, чтобы это продемонстрировать:

    $ sleep 3600 & ps aux | grep 'sleep'

    И проверим UID, GID, RUIG, RGID, EUID, EGID этого процесса:

    $ ps -p $PID -o pid,euid,ruid,suid,egid,rgid,sgid,cmd
        PID  EUID  RUID  SUID  EGID  RGID  SGID CMD
     229878  1000  1000  1000  1000  1000  1000 sleep 3600

    Как мы видим, все значения в данном случае идентичны.

    EUID и EGID

    Чтобы понять отличие EUID и EGID от RUID и RGID рассмотрим для примера всем известную утилиту passwd.

    $ ls -l $(which passwd)
    -rwsr-xr-x 1 root root 59976 Feb  6 15:54 /usr/bin/passwd

    Владельцем и группой для исполняемого файла являются root:root. Это связано с тем, что утилите passwd необходимо модифицировать файл /etc/shadow, который требует для записи root привилегий.

    Возникает вопрос: «А как тогда мы можем использовать passwd от любого пользователя для изменения пароля, если у нас нет root привилегий?»
    Обратите внимание на букву «s» вместо «x» в част и разрешения файла, относящейся к владельцу. Это специальный бит разрешения, который известен как setuid.

    Вот тут-то раскрывается назначение атрибутов EUID и EGID. При выполнении passwd процесс изменит свой EUID со значения по умолчанию на владельца исполняемого файла. В данном случае — root. Затем ядро операционной системы принимает решение, разрешить ли запись в файл /etc/shadow, просматривая EUID процесса. Поскольку теперь EUID указывает на root, операция записи будет разрешена.

    Мы рассмотрели механизм повышения привилегий в Linux связанный с SUID битом.
    Т.к все привилегии в контейнере наследуются от процесса с PID 1, который не обязательно будет запущен от root, то возможности повышения привилегий в основном связаны с использованием SUID, SGID битов на исполняемых фай лах.

    Получается, что необходимо сканировать файловую систему образов на наличие битов SUID, SGID на файлах внутри наших образов.

  2. Удаленный доступ
    Различные компоненты позволяют реализовать сценарии для удаленного доступа в контейнере. Это могут быть как клиентские, так и серверные приложения. Если злоумышленник попал в контейнер, в котором, например, также есть ssh-клиент, он получает еще одну возможность к расширению атаки.

  3. Компиляторы
    В зависимости от конфигурации среды исполнения, ядра и других условий, можно придумать различные варианты развития: от компиляции утилит для пентеста прямо внутри контейнера до сборки модуля ядра или eBPF-программы и ее загрузки в ядро хостовой ОС.

  4. Мисконфигурации образа
    Здесь выделим запуск контейнера по умолчанию от root и отсутствие непривилегированного пользователя.

  5. Мисконфигурации самого приложения и специфичные ему риски ИБ
    Сюда можно отнести уязвимости в коде и используемых библиотеках, небезопасная работа с конфиденциальными данными, отсутствие обеспечения целостности образа и многое другое. Для покрытия всех возможных рисков используются другие инструменты и подходы, применяемые на разных этапах SSDLC: SAST, DAST, IAC, SecretScan, CodeReview, SupplyChain, RuntimeSecurity, Observability, Benchmarking и т.д.

Примеры и разбор полученных проверок

Давайте разберем, что в итоге у нас получилось как по самим проверкам, так и по их реализации. Здесь сразу оговорюсь, что я не являюсь разработчиком и на оптимальную реализацию с точки зрения кода не претендую.

1. Образ не должен иметь тег :latest

Описание:
Образ должен фиксироваться указанием конкретной версии для идемпотентности наших IAC манифестов.

Проверка:
Анализ тега образа

2. Образ не должен запускаться с правами суперпользователя

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

Проверка:
В манифесте образа в параметрах запуска должен быть явно прописан пользователь, отличный от суперпользователя, аналогично и для группы. Здесь проверяем вхождения
на значения «root» или «0», т.к. пользователя и группу можно задать как по username, так и по UID. Также проверяем случаи, когда пользователь вообще не определён. Для анализа манифеста на все описанные вхождения получается следующий блок кода:

           if 'User' in config:
               user_group = config['User']
               if not user_group:
                   result["Severity"] = "Critical"
                   result["Pass"] = False
                   result["Description"] = f"Проверка не пройдена. Пользователь не определён."  
               user = user_group.split(":")[0]
               try:
                   group = user_group.split(":")[1]
               except IndexError as e:
                   group = None
               if user == "root" or user == "0":
                   result["Severity"] = "Critical"
                   result["Pass"] = False
                   result["Description"] = f"Проверка не пройдена. Пользователь по умолчанию {user}."  
               elif not group:
                   result["Severity"] = "Critical"
                   result["Pass"] = False
                   result["Description"] = f"Проверка не пройдена. Группа по умолчанию не определена."   
               elif group == "root" or user == "0":
                   result["Severity"] = "Critical"
                   result["Pass"] = False
                   result["Description"] = f"Проверка не пройдена. Группа по умолчанию {group}."
               else:
                   result["Severity"] = "Informational"
                   result["Pass"] = True
                   result["Description"] = f"Проверка пройдена. Пользователь по умолчанию {user}, группа по умолчанию {group}."
           else: 
               result["Severity"] = "Critical"
               result["Pass"] = False
               result["Description"] = f"Проверка не пройдена. Пользователь не определён."
           return result

В самом образе в файле «/etc/passwd» должен фактически присутствовать пользователь, отличный от root, который прописан в блоке USER. Если его не будет, то, очевидно, контейнер не запустится:

   $ podman inspect ubuntu:test | jq .[].Config.User
   "user"
   $ podman run -ti ubuntu:test bash
   Error: unable to find user user: no matching entries in passwd file

Тут важно заметить, что зачастую, почти все линтеры Dockerfile не учитывают проверку на группу. При этом, если группа не прописана явно в параметрах запуска и для данного пользователя будет задана группа root в «/etc/passwd», то контейнер запустится с правами user:root, что во многом будет почти эквивалентно суперпользователю.

Например:

   $ podman inspect ubuntu-rootless:latest | jq .[].Config.User
   "user"
   $ podman run -ti ubuntu-rootless:latest bash
   user@814ec3598430:/$ id
   uid=1001(user) gid=0(root) groups=0(root)''

3. Отсутствие файлов с возможностью повышения привилегий

Описание:
Уменьшение возможностей повышения привилегий для предполагаемого злоумышленника. Некоторые из вас могут заметить, что для контейнера можно ограничить возможность повышения/изменения привилегий путем правильно сконфигурированного "securityContext". Однако необходимо учитывать, что команда эксплуатации может не указать нужные параметры для ограничения таких действий в самом runtime. Поэтому дополнительная проверка на этапе анализа образа не является излишней.

Проверка:
Поиск в файловой системе образа бинарных файлов su, sudo, исполняемых файлов с битами SUID, SGID

4. Отсутствие файлов, через которые возможно получить удаленный доступ

Описание:
По аналогии проверяем известные утилиты, которые могут обеспечить удаленный доступ.

Проверка:
Поиск в ФС образов исполняемых файлов ssh, sshd, nc, netcat, socat и др....

5. Отсутствие компиляторов в образе

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

Проверка:
Поиск исполняемых файлов известных компиляторов в образе. Здесь мы собрали данные о самых распространенных компиляторах и названиях их исполняемых файлов. Получился список из ~250 файлов компиляторов, которые мы будем искать в образе.
За полноту списка, как и за 100%-ую уверенность, что все указанные компиляторы обязательно помогут злоумышленнику развить атаку, ручаться не буду.
Ниже вы видите часть из полученного списка:

       "gccgo":	"Go compiler, based on the GCC backend",
       "gccgo-9":	"GNU Go compiler",
       "gccgo-10":	"GNU Go compiler",
       "gccgo-11":	"GNU Go compiler",
       "gccgo-12":	"GNU Go compiler",
       "gcl":	"GNU Common Lisp compiler",
       "gdc":	"D compiler (language version 2), based on the GCC backend",
       "gdc-9":	"GNU D compiler (version 2)",
       "gdc-10":	"GNU D compiler (version 2)",
       "gdc-11":	"GNU D compiler (version 2)",
       "gdc-12":	"GNU D compiler (version 2)",
       "gfortran":	"GNU Fortran 95 compiler",
       "gfortran-9":	"GNU Fortran compiler",
       "gfortran-10":	"GNU Fortran compiler",
       "gfortran-11":	"GNU Fortran compiler",
       "gfortran-12":	"GNU Fortran compiler",
       "go":	"Go programming language compilermetapackage",
       "gobjc":	"GNU Objective-C compiler",
       "gobjc++":	"GNU Objective-C++ compiler",
       "gobjc++-9":	"GNU Objective-C++ compiler",
       "gobjc++-10":	"GNU Objective-C++ compiler",
       "gobjc++-11":	"GNU Objective-C++ compiler",
       "gobjc++-12":	"GNU Objective-C++ compiler",
       "gobjc-9":	"GNU Objective-C compiler",
       "gobjc-10":	"GNU Objective-C compiler",
       "gobjc-11":	"GNU Objective-C compiler",
       "gobjc-12":	"GNU Objective-C compiler",
       "golang":	"Go programming language compilermetapackage",
       "golang-1.13":	"Go programming language compilermetapackage",
       "golang-1.13-go":	"Go programming language compiler, linker, compiled stdlib",
       "golang-1.17":	"Go programming language compilermetapackage",
       "golang-1.17-go":	"Go programming language compiler, linker, compiled stdlib",
       "golang-github-googleapis-gnostic-dev":	"compiler for OpenAPI specificationlibrary",
       "golang-github-gopherjs-gopherjs-dev":	"Go to Javascript compiler",
       "golang-github-wellington-go-libsass-dev":	"Go wrapper for libsass, the only Sass 3.5 compiler for Go",
       "golang-github-yuin-gopher-lua-dev":	"virtual machine and compiler for Lua in Go",

6. Получение информации о производителе

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

Проверка:
Анализ манифеста и вывод информации LABEL, MAINTAINER для получения данных заложенных разработчиком/производителем.

7. Получение информации о исполняемом файле в образе

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

Проверка:
Анализ манифеста и вывод информации из CMD, ENTRYPOINT

8. Образ должен иметь минимально возможное количество слоев

Описание:

Хорошей практикой является сжатие образов в один результирующий слой. Иногда встречаются образы, состоящие из 50 и более слоев, что вызывает некоторые вопросы к их компоновке и дальнейшему использованию в инфраструктуре.

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

Проверка:
Анализ манифеста и вывод информации о количестве слоёв в образе

9. Образ должен состоять из минимального количества компонентов

Описание:

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

Наш образ должен иметь только те файлы, которые фактически используются в процессе работы нашего приложения. Почти всегда это подразумевает отсутствие пакетных менеджеров, системных утилит, зачастую shell и т. д. Такие образы будут собраны, что называется, «From scratch». Однако при анализе следует помнить, что есть определенный минимальный набор компонентов, который вы встретите почти всегда:

  • Набор сертификатов для работы TLS;

  • Корневые библиотеки, например glibc или musl, для языков, в которых статическая компиляция невозможна;

  • Интерпретаторы для некоторых языков, например Python, Node;

  • Системные файлы и каталоги, необходимые для корректной работы библиотек, например «/etc/passwd» и «/tmp».

Хорошим примером для ознакомления c концепцией distroless будет ее реализация в образах от chainguard или google.

Google и Chainguard Distroless
Google и Chainguard Distroless

Ниже приведен пример структуры каталога «/usr/bin» для одного из образов, позиционируемого как образ для «python». Мы взяли его из всеми известного Docker Hub.

├── usr
│   ├── bin
│   │   ├── [
│   │   ├── addpart
│   │   ├── apt
│   │   ├── apt-cache
│   │   ├── apt-cdrom
│   │   ├── apt-config
│   │   ├── apt-get
│   │   ├── apt-key
│   │   ├── apt-mark
│   │   ├── arch
│   │   ├── awk -> /etc/alternatives/awk
│   │   ├── b2sum
│   │   ├── base32
│   │   ├── base64
│   │   ├── basename
│   │   ├── basenc
│   │   ├── bash
│   │   ├── bashbug
│   │   ├── captoinfo -> tic
│   │   ├── cat
│   │   ├── chage
│   │   ├── chattr
│   │   ├── chcon
│   │   ├── chfn
│   │   ├── chgrp
│   │   ├── chmod
│   │   ├── choom
│   │   ├── chown
│   │   ├── chrt
│   │   ├── chsh
│   │   ├── cksum
│   │   ├── clear
│   │   ├── clear_console
│   │   ├── cmp
│   │   ├── comm
│   │   ├── cp
...

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

Проверка:
Вывод информации о базовой ОС образа, вывод дерева файловой системы образа для первичного визуального анализа.

Реализация проверок

Все проверки, написаны на Python и сводятся к следующему: определение класса Image и уже в нем набора методов, соответствующих каждой проверке.

Выглядит это примерно так:

class Image():  

    def __init__(self, name):
        self.name = name   

    def tagCheck(self):
        result = Output()   
        result["Title"] = f"Tag :latest"
        result["Mitigation"] = "The image must have a fixed tag to determine the version"
        n = self.name.split(':')
        tag = n[-1]
        if tag != "latest":
            result["Severity"] = "Informational"
            result["Pass"] = True
            result["Description"] = f"Image {self.name} has tag: {tag}"   
        elif not tag:
            result["Severity"] = "Critical"
            result["Pass"] = False
            result["Description"] = f"Image {self.name} tag not defined: {tag}"  
        else:
            result["Severity"] = "Critical"
            result["Pass"] = False
            result["Description"] = f"Image {self.name} has tag: {tag}" 
        _returnStdout(result)
        return result
  

    def labelCheck(self, data):
      ...

А сам порядок сканирования выглядит вот так:

def main(image): 
    # Auth in private repo
    auth_repo(auth_config)
  
    # Create/refrest report dir
    create_dir(report_dir)
  
    # Start scan
    # Add :latest if tag not specified
    if ':' not in image:
        image = f"{image}:latest"
    image_short = ('_'.join((image.split("/")[-1]).split(":")[-2::]))
    print(f"{colorCyan}Image scanning started {image}{colorDefault}")  
  
    # Loading image into podman   
    print("1. Pulling the image")  
    pull_image(image)
  
    # Getting image manifest JSON manifest
    print("2. Getting the image manifest")
    data = get_manifest(image)
    image_name = image
    image_obj = Image(image_name)
  
    # Launch checks   
    print("3. Launch of compliance checks")  
    results = []
    results.append(image_obj.tagCheck())
    results.append(image_obj.exposeCheck(data))
    results.append(image_obj.defaultUserCheck(data))
    results.append(image_obj.labelCheck(data))
    results.append(image_obj.layersCheck(data))
    ...

Так же задаем набор exit кодов и переменных окружения для их переопределения:

# EXIT CODES
INFORMATIONAL = os.environ.get("INFORMATIONAL_EXIT_CODE", 0)
CRITICAL = os.environ.get("CRITICAL_EXIT_CODE", 12)
HIGH = os.environ.get("HIGH_EXIT_CODE", 13)
MEDIUM = os.environ.get("MEDIUM_EXIT_CODE", 14)
LOW = os.environ.get("LOW_EXIT_CODE", 15)
CANT_PULL_IMAGE = 20
CANT_GET_MANIFEST = 21
NOT_DEFINED_IMAGE = 22
CANT_CREATE_REPORT_DIR = 23

Cборка образа и встраивание в пайплайн

Теперь упакуем всё в образ. Мы планируем использовать данный инструмент в рамках gitlab-ci, поэтому стремления получить distroless-образ не имеем, потому что gitlab в рамках инициализации job'ы требует наличие какого-либо shell внутри образа.
Это необходимо для конфигурации поля script внутри файла .gitlab-ci.yaml.

Dockerfile

FROM python:alpine as python

FROM docker.io/mgoltzsche/podman as podman
RUN printf '%s\n' > /etc/containers/registries.conf \
    [registries.search] \
    registries=[\'docker.io\']
COPY --from=python / /
RUN mkdir -p /podman/module
COPY scan.py /podman
COPY module/ /podman/module

FROM scratch
COPY --from=podman / /
LABEL org.opencontainers.image.authors="mchinenov@swordfishsecurity.ru"
WORKDIR /podman
ENTRYPOINT ["python3", "scan.py"]

Пример джобы на основе gitlab

Compliance Image Scan:
  tags:
    - appsec-team
  image:
    name: registry.swordfishsecurity.com/internal/image_compliance_scanner:v1
  variables:  
    # Стратегия Git
    GIT_STRATEGY: none

    # Для сканирования образов из приватных реестров
    # необходимо определить переменную DOCKER_AUTH_CONFIG

    # Для запуска сканирования необходимо указать
    # путь до сканируемого образа в переменной COMPLIANCE_IMAGE_FULL_REF
  
  script: 
     - python3 /home/nonroot/scan.py

  artifacts:
    # Указать в переменной COMPLIANCE_REPORTS_DIR, директорию, куда будет сохранен отчёт в формате .json, по умолчанию ./reports
    paths:
      - reports
    expire_in: 1 day

Запускаем и проверяем работу

Для запуска сканирования требуется указать в значении переменной COMPLIANCE_IMAGE_FULL_REF полный путь до сканируемого образа.
При использовании приватного репозитория необходимо передать аутентификационные данные в переменной DOCKER_AUTH_CONFIG

Вывод работы нашей джобы сканирования

Так же отчёт в формате JSON, как артефакт работы джобы

{
    "gcc:latest": [
        {
            "Title": "Tag :latest",
            "Severity": "Critical",
            "Pass": false,
            "Description": "Image gcc:latest has tag: latest",
            "Mitigation": "The image must have a fixed tag to determine the version"
        },
        {
            "Title": "Critical ports in the instructions EXPOSE",
            "Severity": "Informational",
            "Pass": true,
            "Description": "The image does not have critical ports in the EXPOSE statement",
            "Mitigation": "Make sure your containers only use protocols for remote connectivity when necessary."
        },
        {
            "Title": "Default user and group",
            "Severity": "Critical",
            "Pass": false,
            "Description": "User not defined",
            "Mitigation": "The default USER and GROUP must be explicitly defined in the USER statement and do not contain root or 0. For example, USER app:app"
        },
        {
            "Title": "Image LABEL metadata",
            "Severity": "Low",
            "Pass": false,
            "Description": "LABEL is not defined",
            "Mitigation": "The image must contain a set of labels specified by the developer in the LABEL instruction"
        },
        {
            "Title": "The number of layers in the image",
            "Severity": "Low",
            "Pass": false,
            "Description": "Number of layers in the image 7. It is necessary to minimize the number of layers if possible",
            "Mitigation": "A good practice would be to squash all layers in the resulting image into a single layer. See docker squash, multistage build"
        },
        {
            "Title": "Parameters CMD, ENTRYPOINT",
            "Severity": "Informational",
            "Pass": true,
            "Description": "The image has startup parameters set in CMD ['bash']",
            "Mitigation": "It is good practice to specify default launch parameters by developer. You must define parameters in a CMD or ENTRYPOINT statement"
        },
        {
            "Title": "Checking for a suid bit file",
            "Severity": "Critical",
            "Pass": false,
            "Description": "Found file",
            "Mitigation": "The image must not contain file(s) suid bit in the image",
            "Files": [
                {
                    "5da10afe97eba389e1dc9867f240e6e61672153700132447b4bbbc97b9b0d6ee": [
                        "/usr/lib/openssh/ssh-keysign"
                    ]
                },
                {
                    "072686bcd3db19834cd1e0b1e18acf50b7876043f9c38d5308e5e579cbefa6be": [
                        "/usr/bin/newgrp",
                        "/usr/bin/chfn",
                        "/usr/bin/passwd",
                        "/usr/bin/umount",
                        "/usr/bin/chsh",
                        "/usr/bin/gpasswd",
                        "/usr/bin/su",
                        "/usr/bin/mount"
                    ]
                }
            ]
        },
        {
            "Title": "Checking for a sgid bit file",
            "Severity": "Critical",
            "Pass": false,
            "Description": "Found file",
            "Mitigation": "The image must not contain file(s) sgid bit in the image",
            "Files": [
                {
                    "e8ef21fa16f7d8b718e156431313a530cd4aee22a23f2340a2030cd6cb5843ca": [
                        "/usr/local/share/fonts"
                    ]
                },
                {
                    "5da10afe97eba389e1dc9867f240e6e61672153700132447b4bbbc97b9b0d6ee": [
                        "/usr/bin/ssh-agent"
                    ]
                },
                {
                    "072686bcd3db19834cd1e0b1e18acf50b7876043f9c38d5308e5e579cbefa6be": [
                        "/var/mail",
                        "/var/local",
                        "/usr/sbin/unix_chkpwd",
                        "/usr/bin/chage",
                        "/usr/bin/expiry"
                    ]
                }
            ]
        },
        {
            "Title": "Checking for a sudo file",
            "Severity": "Informational",
            "Pass": true,
            "Description": "File(s) sudo not found",
            "Mitigation": "The image must not contain file(s) sudo in the image"
        },
        {
            "Title": "Checking for a su file",
            "Severity": "Critical",
            "Pass": false,
            "Description": "Found file",
            "Mitigation": "The image must not contain file(s) su in the image",
            "Files": [
                {
                    "072686bcd3db19834cd1e0b1e18acf50b7876043f9c38d5308e5e579cbefa6be": [
                        "/usr/bin/su"
                    ]
                }
            ]
        },
        {
            "Title": "Checking for a sshd file",
            "Severity": "Informational",
            "Pass": true,
            "Description": "File(s) sshd not found",
            "Mitigation": "The image must not contain file(s) sshd in the image"
        },
        {
            "Title": "Checking for a ssh client file",
            "Severity": "Critical",
            "Pass": false,
            "Description": "Found file",
            "Mitigation": "The image must not contain file(s) ssh client in the image",
            "Files": [
                {
                    "5da10afe97eba389e1dc9867f240e6e61672153700132447b4bbbc97b9b0d6ee": [
                        "/usr/bin/ssh"
                    ]
                }
            ]
        },
        {
            "Title": "Checking for a nc file",
            "Severity": "Informational",
            "Pass": true,
            "Description": "File(s) nc not found",
            "Mitigation": "The image must not contain file(s) nc in the image"
        },
        {
            "Title": "Checking for a netcat file",
            "Severity": "Informational",
            "Pass": true,
            "Description": "File(s) netcat not found",
            "Mitigation": "The image must not contain file(s) netcat in the image"
        },
        {
            "Title": "Checking for a socat file",
            "Severity": "Informational",
            "Pass": true,
            "Description": "File(s) socat not found",
            "Mitigation": "The image must not contain file(s) socat in the image"
        },
        {
            "Title": "Compilers in the image",
            "Severity": "Medium",
            "Pass": false,
            "Description": "Found file",
            "Mitigation": "The image should not contain compilers in the file system, except when they are necessary for the operation of the application. Make sure that compilers are actually needed during execution",
            "Files": [
                {
                    "baec4f93827486ff5ae81e5748f5c6d1523975ca0f0526e4a7b8f990ce70a4d4": [
                        "/usr/local/bin/g++",
                        "/usr/local/bin/gcc",
                        "/usr/local/bin/gccgo",
                        "/usr/local/bin/gfortran",
                        "/usr/local/bin/go"
                    ]
                }
            ]
        },
        {
            "Title": "Checking for OS type",
            "Severity": "Informational",
            "Pass": true,
            "Description": "The image gcc:latest is based on the OS ['Debian GNU/Linux', '12']",
            "Mitigation": "A good practice would be to use Distroless images to minimize the components in the image"
        }
    ]
}

Как видим, на примере анализа образа «gcc:latest» наш сканер отработал корректно.

В результате сканирования было выявлено:

  • образ имеет тег «latest»;

  • пользователь по умолчанию не был определен разработчиком;

  • имеются файлы с битами SUID, SGID, а так же бинарные исполняемые файлы «su», «ssh client»;

  • обнаружен набор компиляторов

Следует отметить, что обнаруженные файлы в отчете разделены по слоям:

                {
                    "e8ef21fa16f7d8b718e156431313a530cd4aee22a23f2340a2030cd6cb5843ca": [
                        "/usr/local/share/fonts"
                    ]
                },
                {
                    "5da10afe97eba389e1dc9867f240e6e61672153700132447b4bbbc97b9b0d6ee": [
                        "/usr/bin/ssh-agent"
                    ]
                },
                {
                    "072686bcd3db19834cd1e0b1e18acf50b7876043f9c38d5308e5e579cbefa6be": [
                        "/var/mail",
                        "/var/local",
                        "/usr/sbin/unix_chkpwd",
                        "/usr/bin/chage",
                        "/usr/bin/expiry"
                    ]
		}

Данное значение — не что иное, как хэш слоя, в котором найдены соответствующие файлы.

e8ef21fa16f7d8b718e156431313a530cd4aee22a23f2340a2030cd6cb5843ca

Что делать дальше?

Наверное, у многих из вас возникнет следующий вопрос: «Мы просканировали образ и видим, что он не проходит проверки. А можно ли что‑то с этим сделать?»

Действительно, давайте рассмотрим, какие действия мы можем предпринять, если образ нам жизненно необходим и просто отказаться от него мы не можем. Так же помним, что у нас нет исходников, в том числе исходного Dockerfile, что исключает возможность пересборки. В данном случае можно воспользоваться инструментом docker slim toolkit, он же slim toolkit.

Slimtoolkit

Slimtoolkit
Slimtoolkit

Данный инструмент по заявлениям разработчиков позволяет в несколько раз уменьшить образ контейнера.

При запуске он отслеживает контекст исполнения контейнера, а затем собирает новый образ, используя только те файлы, которые применялись в процессе работы. Чтобы освоить данную концепцию и понять, как это выглядит на практике, возьмем для демонстрации образ nginx.

Важно, чтобы для запуска контейнера были переданы необходимые параметры для правильного профилирования. Для нашего тестового образа они уже были заданы разработчиком в «Entrypoint» и «Cmd»:

# podman inspect nginx:latest | jq '.[].Config.Entrypoint, .[].Config.Cmd'
[
  "/docker-entrypoint.sh"
]
[
  "nginx",
  "-g",
  "daemon off;"
]

Запускаем сборку на основе нашего nginx, используя SlimToolkit. Инструмент запускает контейнер из указанного образа, отслеживает контекст исполнения и на его основе создаёт новый «slim» образ:

# slim build --target nginx:latest
cmd=build info=param.http.probe message='using default probe' 
cmd=build state=started
cmd=build info=params image-build-engine='internal' target.type='image' target.image='nginx:latest' continue.mode='probe' rt.as.user='true' keep.perms='true' tags='' 
cmd=build state=image.inspection.start
cmd=build info=image id='sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070' size.bytes='187659947' size.human='188 MB' 
cmd=build info=image.stack index='0' name='nginx:latest' id='sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070' 
cmd=build info=image.exposed_ports list='80/tcp'
cmd=build state=image.inspection.done
cmd=build state=container.inspection.start
cmd=build info=container status='created' name='slimk_394176_20240527152435' id='96a28be6951f926eb9465149d4184a11ab427b5bb5f7edf8dd5294a670384fd9' 
cmd=build info=container status='running' name='slimk_394176_20240527152435' id='96a28be6951f926eb9465149d4184a11ab427b5bb5f7edf8dd5294a670384fd9' 
cmd=build info=container message='obtained IP address' ip='172.17.0.2'
cmd=build info=cmd.startmonitor status='sent'
cmd=build info=event.startmonitor.done status='received' 
cmd=build info=container name='slimk_394176_20240527152435' id='96a28be6951f926eb9465149d4184a11ab427b5bb5f7edf8dd5294a670384fd9' target.port.list='32783' target.port.info='80/tcp => 0.0.0.0:32783' message='YOU CAN USE THESE PORTS TO INTERACT WITH THE CONTAINER'
cmd=build state=http.probe.starting message="WAIT FOR HTTP PROBE TO FINISH" 
cmd=build info=continue.after mode='probe' message='no input required, execution will resume when HTTP probing is completed'
cmd=build prompt='waiting for the HTTP probe to finish'
cmd=build state=http.probe.running
cmd=build info=http.probe.ports count='1' targets='32783'
cmd=build info=http.probe.commands count='1' commands='GET /'
cmd=build info=http.probe.call target='http://127.0.0.1:32783/' attempt='1' error='none' time='2024-05-27T15:24:45Z' status='200' method='GET'
cmd=build info=http.probe.summary total='1' failures='0' successful='1'
cmd=build state=http.probe.done
cmd=build info=http.probe.crawler page='0' url='http://127.0.0.1:32783/'
cmd=build info=probe.crawler.done addr='http://127.0.0.1:32783/'
cmd=build info=event message='HTTP probe is done'
cmd=build state=container.inspection.finishing
cmd=build state=container.inspection.artifact.processing
cmd=build state=container.inspection.done
cmd=build state=building message="building optimized image" engine=internal 
cmd=build state=completed
cmd=build info=results status='MINIFIED' by='14.12X' size.original='188 MB' size.optimized='13 MB'
cmd=build info=results image-build-engine='internal' image.name='nginx.slim' image.size='13 MB' image.id='sha256:7e525a9263886539a2b6c8bf883b8663885cc314c6360fae3192dbd312bfbd50' image.digest='sha256:f2dcc16aca76580c1d56c3cd4dd5328ff857a7c58bce2e5d25610823e9ffc15d' has.data='true'
cmd=build info=results artifacts.location='/tmp/slim-state/.slim-state/images/e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070/artifacts'
cmd=build info=results artifacts.report='creport.json'
cmd=build info=results artifacts.dockerfile.reversed='Dockerfile.reversed'
cmd=build info=results artifacts.seccomp='nginx-seccomp.json'
cmd=build info=results artifacts.apparmor='nginx-apparmor-profile'
cmd=build state=done
cmd=build info=commands message='use the xray command to learn more about the optimize image'
cmd=build info=report file='slim.report.json'

Посмотрим, как изменился размер полученного образа:

nginx.slim                     latest    7e525a926388   4 minutes ago    13.3MB
nginx                          latest    e784f4560448   3 weeks ago      188MB

Отмечу, что после такой «пересборки» всегда нужно тестировать полученное приложение. Не стоит полагаться на то, что инструмент решит все описанные ранее проблемы.

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

Выводы

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

Сканирование файловой системы осуществляется набором правил (сигнатур), поэтому нужно учитывать, что данный инструмент подходит скорее для первичной проверки или одного из этапов более комплексного аудита.

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

В заключение хотелось бы отметить, что безопасность ваших контейнерных окружений — комплексная и зачастую нетривиальная задача. Ее нужно рассматривать в первую очередь как процесс , а не набор каких‑либо инструментов ИБ. Необходимо выстроить этот процесс таким образом, чтобы защищать ваше приложение на всех этапах жизненного цикла, начиная с дизайна и разработки, заканчивая эксплуатацией в промышленных средах.

Дополнительные материалы

Инструкцию по использованию и исходники для сборки инструмента можно найти в нашем github по ссылке SwordFish Security OCI-image-compliance-scanner

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


  1. WondeRu
    16.07.2024 22:43

    Спасибо за статью. Перекликается с нашими внутренними практиками, но нашлись моменты, которые возьмем на заметку.