Приветствую всех! В своей прошлой и по совместительству первой статье я рассказывал про упаковку приложения в докер контейнеры (ссылка на нее находится в конце этой статьи). В комментариях мне сделали замечание, что я не упомянул про защиту приложения и запуск от non-root. Что ж, исправлюсь и сделаю это в отдельной статье. Напомню, что я написал простое приложение для голосование за лучший ресторан и попытался по простому объяснить, как произвести его контейнеризацию. Также уточню, что упор я делаю именно на упаковку приложения в докер контейнеры, а не на бизнес-логику и UI.
Есть несколько релизов:
https://github.com/codyRhett/restaurantVote/tree/release/1.0.2 - версия проекта для запуска локально на компьютере. Для запуска необходимо установить postgresql и создать БД
https://github.com/codyRhett/restaurantVote/tree/release/1.0.1 - версия для запуска в контейнерах
Но! конкретно в этой статье речь пойдет о версии:
https://github.com/codyRhett/restaurantVote/tree/release/1.0.3 - версия для запуска в контейнерах от непривилегированного пользователя
Запуск контейнера от непривилегированного пользователя (non-root) — это критически важный слой защиты от эксплойтов. Разберем, как это работает, на примерах и технических деталях. DeepSeek дает вот такое лаконичное определение эксплоиту:
Эксплоит — это программный код, скрипт или метод, который использует уязвимость в системе, приложении или сети для выполнения несанкционированных действий. Это может быть кража данных, получение контроля над устройством, нарушение работы системы и т.д.
Но мы же понимаем, что лучше один раз увидеть, чем семь раз услышать. Возьмем кейс, который в идеале никогда не должен произойти в вашем приложении. Для этого вернемся к предыдущей версии проекта, а именно клонируем релиз 1.0.1 (https://github.com/codyRhett/restaurantVote/tree/release/1.0.1)
Обратите внимание на REST контроллер UserRestController, а конкретнее на его тестовый endpoint:
@GetMapping("/execute")
public String executeCommand(@RequestParam("cmd") String cmd) {
log.debug("executeCommand");
try {
Process process = Runtime.getRuntime().exec(cmd);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
return reader.lines().collect(Collectors.joining("\n"));
} catch (IOException e) {
return "Error: " + e.getMessage();
}
}
В качестве RequestParam передается строка cmd, которая преобразуется в исполняемую команду. И злоумышленник может передать туда, например, команду rm -rf с указанием пути на системные файлы и спокойно удалить их, если контейнер запущен от привилегированного пользователя - root. Повторюсь:
... что ни один нормальный разработчик не сделает такого уязвимого эндпоинта.
Это я сделал чисто для примера. А способов выполнить данный скрипт много, в том числе просто зайти в контейнер через docker exec и выполнить эту команду там.
Как же это будет выглядеть?
Собираем war файл через mvn package. Далее исполняем docker-compose up и ждем пока создадутся контейнеры. После этого выполняем классический набор запросов в командной строке:

docker ps - получаем список запущенных контейнеров
docker exec -it 49d672f4e359 /bin/bash - заходим в командную строку нашего основного запущенного контейнера
cd ../ - переходим в основную директорию
делаем GET запрос в эндпоинт:
curl http://localhost:8080/api/user/execute?cmd=rm%20-rf%20/tmp/%20--no-preserve-root
Кстати, этот же запрос можно кинуть через браузер. Этот GET запрос передает команду rm -rf /tmp --no-preserve-root, которая удаляет папку tmp вместе с ее содержимым.
Далее исполняем команду ls и убеждаемся, что папки tmp больше нет. Тоже самое можно проделать с остальными папками. Думаю, не надо объяснять, почему это плохо. И вообще с большинством системных папок можем делать, что угодно - удалять, перезаписывать и т д. И все это потому, что контейнер запущен от root.
Как обезопасить себя от такого поведения?
Клонируем себе релиз 1.0.3 (https://github.com/codyRhett/restaurantVote/tree/release/1.0.3). Отличие от предыдущих релизов заключается в двух файлах - dockerfile и docker-compose.yml. Я их переписал для безопасного запуска контейнеров, чтобы усложнить задачу злоумышленников взломать наш сервис.
Начнем с того, что необходимо немного модифицировать dockerfile, чтобы наглядно продемонстрировать как работают привилегии. Приведенный ниже код демонстрирует конфигурацию для запуска от non-root:
FROM adoptopenjdk/openjdk11:ubi
# Создаем системного юзера и группу с явным UID/GID
RUN useradd -r -u 1001 appuser && \
# создаем директорию app
mkdir /app && \
# Настройка прав доступа к директории /app \
# 7 (владелец): Чтение + запись + выполнение (rwx).
# 5 (группа): Чтение + выполнение (r-x).
# 0 (остальные): Нет прав (---).
chmod 750 /app && \
# Назначение владельца директории /app
chown appuser:appuser /app && \
mkdir -p /app/data/logs && \
chmod -R 750 /app/data/logs && \
chown -R appuser:appuser /app/data/logs
# укзываем рабочую дирректорию
WORKDIR /app
# Укажите переменную окружения для пути к логам
ENV LOG_DIR=/app/data/logs
# Копируем файлы с правами
ARG WAR_FILE=target/restaurantVote-1.0-SNAPSHOT.war
# Копирование файлов с назначением владельца и группы
COPY --chown=appuser:appuser ${WAR_FILE} /app/application.war
COPY --chown=appuser:appuser src/main/webapp /app/webapp
# Явное переключение пользователя и рабочей директории
# Используем UID вместо имени. Запуск от имени нового созданного юзера
USER 1001
ENTRYPOINT ["java","-jar","/app/application.war"]
Что же тут нового и необычного?
Мы создаем группу и пользователя appuser и присваиваем ему идентификатор 1001
Даем права папкам app, где лежит исполняемый файл и app/data/logs, куда будем складывать логи (chmod - дает права доступа к файлам и директориям, chown - смена владельца папок, потому что по умолчанию владельцами папок является root)
Копируем файлы программы в новые папки с назначением владельца и группы
Явно указываем от какого пользователя осуществляем запуск контейнера USER 1001
Не буду расписывать тут подробно про то, какие бывают права. Если вкратце, то наши права зашифрованы числом 750. Первая цифра - права на владельца, вторая - на группу, третья на всех остальных. В нашем случае - 1. все права, 2. чтение+выполнение и 3. нет прав соответственно. В докер файле я привел просто пример, как можно использовать права непривилегированного пользователя. Можно создавать папки, файлы и настраивать к ним такие права доступа, какие пожелаем.
Следующий этап - это уже настройка непосредственно контейнера
Создание DOCKER-COMPOSE
Приведенный ниже код демонстрирует docker-compose.yml для безопасного запуска контейнеров
version: '2'
services:
app:
image: 'restaurant'
build: .
security_opt:
- no-new-privileges
cap_drop:
- ALL
volumes:
- ./host_logs:/app/data/logs # папка на хосте -> контейнер
- app_volume:/app/data/logs
user: "1001:1001"
container_name: app
ports:
- "8080:8080"
depends_on:
- db
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5433/restaurant
- SPRING_LIQUIBASE_URL=jdbc:postgresql://db:5433/restaurant
- SPRING_DATASOURCE_USERNAME=postgres
- SPRING_DATASOURCE_PASSWORD=1234
- SPRING_JPA_HIBERNATE_DDL_AUTO=update
db:
image: 'postgres:latest'
container_name: db
build: .
user: "999:999"
security_opt:
- no-new-privileges
cap_drop:
- ALL
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=1234
- POSTGRES_DB=restaurant
- PGPORT=5433
volumes:
app_volume:
На что тут стоит обратить внимание?
security_opt:
- no-new-privileges
Эта опция запрещает процессам внутри контейнера повышать свои привилегии (например, через sudo или su). Даже если злоумышленник получит доступ к контейнеру, он не сможет стать root.
cap_drop:
- ALL
Эта опция отключает все привилегии ядра Linux для контейнера.
Изменить владельца файлов (CAP_CHOWN).
Работать с сетевыми настройками (CAP_NET_ADMIN).
Монтировать файловые системы (CAP_SYS_ADMIN).
user: “1001:1001”
Эта опция говорит о том, что контейнер запускается от пользователя с идентификатором 1001 (которого мы создали на предыдущем шаге)
Теперь проделываем те же действия, как и для релиза 1.0.1. НО! Обязательно выполнить билд без использования кэша через команду:
docker compose build --no-cache
Это делаем для того, чтобы не наступать на те же грабли, что и я. В кэше сохранился мой предыдущий образ, и для создания контейнера использовался именно он, и я долго не мог понять, что происходит.
После этого запускаем контейнеры, заходим в restaurant и через команду ls -l можем видеть владельцев файлов и директорий в корне контейнера. Также через whoami можно посмотреть пользователя, от которого запущен контейнер.

Далее заходим в bash контейнера через docker exec и кидаем GET запрос на удаление папки tmp:
curl http://localhost:8080/api/user/execute?cmd=rm%20-rf%20/tmp/%20--no-preserve-root

Что же мы видим? Папка tmp не удалилась, потому что владельцем этой папки является root, а контейнер запущен от пользователя appuser, у которого мы сильно урезали права. Также попробуем напрямую удалить файлы из папки app.

Видим, что мы успешно удалили файл application.war, потому что владельцем папки является пользователь appuser, от которого и запущен контейнер.
Собственно, это все!
Итого: Если злоумышленник и зайдет в контейнер, то он сможет удалить только то, что не очень важно для нас (благодаря гибкой настройке прав). Например, как в данном случае, не сможет удалить системные файлы, владельцем которых является root. В зависимости от ваших потребностей вы можете ставить свои ограничения, но запуск контейнера ВСЕГДА должен осуществляться НЕ от Root!
Вы спросите, а какой смысл, если пользователь, например, зайдет в контейнер из под root и вообще возможно ли такое? А я отвечу - это проблемы инфраструктуры и такого в принципе не должно быть. А запуск не от привилегированного пользователя - просто необходимая ступень защиты, которая должна усложнить жизнь злоумышленникам. И эту защиту можно обойти, но на это нужно время.
Использование appuser подразумевает, что по умолчанию в контейнере нет пользователя root, а все процессы запущены с минимальными привилегиями.
Моя предыдущая статья, где подробно на примерах рассказываю, что такое контейнеризация и как упаковывать приложения в докер контейнеры:
Docker для начинающих: простое развертывание приложения за несколько шагов