Linux Hardening Docker nie jest piaskownicą. 15 błędów, które regularnie widzę na produkcyjnych serwerach
Linux Hardening: Docker nie jest piaskownicą. 15 błędów, które regularnie widzę na produkcyjnych serwerach
Jest taki moment, który powtarza się zaskakująco często. Administrator spędza godzinę na zabezpieczaniu Ubuntu – wyłącza logowanie roota przez SSH, konfiguruje UFW, sprawdza AppArmor i instaluje Fail2Ban. Potem wpisuje jedno polecenie:
Bash
docker run -d nginx
Kilka minut później serwer działa. Strona odpowiada. Wszystko wygląda poprawnie.
Tylko że właśnie otworzyliśmy zupełnie nową warstwę systemu, która rządzi się własnymi zasadami bezpieczeństwa.
Największy problem z Dockerem polega na tym, że przez lata narosło wokół niego kilka niebezpiecznych mitów. Najbardziej popularny brzmi: „kontener jest odizolowany od hosta, więc nic złego się nie stanie”.
To wygodne uproszczenie.
I właśnie ono prowadzi do większości błędów.
Pierwsza rzecz, którą sprawdzam na obcym serwerze
Kiedy dostaję dostęp do serwera z Dockerem, nie zaczynam od docker ps.
Najpierw interesuje mnie, jak Docker został uruchomiony.
Bash
docker info
To jedno polecenie potrafi zdradzić więcej niż kilkadziesiąt minut przeglądania konfiguracji.
Szukam przede wszystkim:
- wersji Dockera,
- sterownika storage,
- włączonych mechanizmów bezpieczeństwa,
- rootless mode,
- AppArmor,
- seccomp.
Dopiero później patrzę na kontenery.
Dlaczego?
Bo źle skonfigurowany host pozostaje źle skonfigurowany niezależnie od liczby kontenerów.
Błąd numer jeden: --privileged
Jeżeli miałbym wskazać jedną opcję, której nadużywanie widuję najczęściej, byłoby to właśnie:
Bash
docker run --privileged
Działa.
Rozwiązuje problemy.
Przestają pojawiać się komunikaty o braku uprawnień.
I właśnie dlatego jest tak niebezpieczna.
Wyobraź sobie różnicę.
Normalny kontener:
Host
│
└── Kontener
├── ograniczone capability
├── seccomp
├── AppArmor
└── namespaces
Kontener uruchomiony z --privileged:
Host
│
└── Kontener
├── prawie pełne capability
├── szeroki dostęp do urządzeń
└── znacznie słabsze granice izolacji
To nie jest niewielka zmiana.
To zmiana filozofii bezpieczeństwa.
Jeżeli jakaś aplikacja wymaga --privileged, najpierw chcę zrozumieć dlaczego.
Root w kontenerze nie oznacza tego samego
Tutaj pojawia się kolejny mit.
„Przecież to tylko root w kontenerze.”
To zależy.
Domyślnie proces wewnątrz kontenera często działa jako UID 0.
Sprawdźmy.
Bash
docker exec app id
Możemy zobaczyć:
uid=0(root)
To nie oznacza automatycznie pełnego roota hosta.
Ale oznacza, że warto ograniczyć to tam, gdzie aplikacja tego nie potrzebuje.
Znacznie lepiej wygląda:
dockerfile
USER app
To jedna linijka.
A potrafi znacząco zmniejszyć skutki kompromitacji aplikacji.
Rootless Docker zmienia sposób myślenia
Jedną z najciekawszych zmian ostatnich lat jest rozwój Rootless Docker.
Tutaj Docker sam działa bez pełnych uprawnień roota.
Schemat wygląda inaczej.
Standard
root
│
Docker
│
Kontenery
Rootless
Użytkownik
│
Docker
│
Kontenery
To nie rozwiązuje wszystkich problemów.
Ale zmniejsza konsekwencje niektórych scenariuszy kompromitacji.
Porty publikowane na oślep
To klasyczny przypadek.
Bash
docker run -p 5432:5432 postgres
Działa.
Ale czy PostgreSQL naprawdę powinien być publicznie dostępny?
Często wystarczy:
Internet
│
▼
Nginx
│
▼
Aplikacja
│
▼
PostgreSQL
Baza nie potrzebuje własnego publicznego portu.
Im mniej wystawionych usług, tym prostszy model bezpieczeństwa.
Sieć Dockera to nie magia
Docker tworzy własne sieci.
Wiele osób nigdy do nich nie zagląda.
A warto.
Bash
docker network ls
Potem:
Bash
docker network inspect bridge
Nagle okazuje się, że kilka aplikacji widzi się wzajemnie.
Czasem zupełnie niepotrzebnie.
Lepiej budować oddzielne sieci dla różnych usług.
Przykład.
Frontend
│
▼
frontend_net
Backend
│
▼
backend_net
Database
│
▼
db_net
To prostsze niż późniejsze tłumaczenie, dlaczego kontener testowy widział bazę produkcyjną.
Obrazy nie powinny być wieczne
To bardzo częsty problem.
Ktoś buduje obraz.
Uruchamia.
I zapomina.
Miesiąc później.
Rok później.
Dwa lata później.
Kontener nadal działa.
Problem polega na tym, że podatności nie przestają istnieć tylko dlatego, że aplikacja działa poprawnie.
Regularne przebudowy obrazów są równie ważne jak aktualizacje systemu.
latest nie jest wersją
Nigdy nie lubiłem:
YAML
image: nginx:latest
Dlaczego?
Bo za miesiąc może oznaczać coś innego.
Znacznie bezpieczniej:
YAML
image: nginx:1.27
Albo jeszcze lepiej – konkretny digest obrazu.
Przewidywalność jest częścią bezpieczeństwa.
Capability są ważniejsze niż większość osób myśli
Linux posiada mechanizm capabilities.
Docker pozwala nimi zarządzać.
Zamiast dawać wszystko:
ALL
można zostawić tylko to, czego aplikacja naprawdę potrzebuje.
Przykładowo.
YAML
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
To znacznie bardziej świadome podejście.
Seccomp działa, nawet jeśli go nie widzisz
Jednym z najbardziej niedocenianych mechanizmów Dockera jest seccomp.
Większość administratorów nigdy go nie dotyka.
A mimo to korzysta z niego codziennie.
Domyślny profil ogranicza wiele potencjalnie niebezpiecznych wywołań systemowych.
Można sprawdzić konfigurację:
Bash
docker inspect app
i poszukać informacji o profilu bezpieczeństwa.
To kolejna warstwa, która działa „po cichu”.
AppArmor nie kończy się na hoście
Jeżeli host korzysta z AppArmor, kontenery również mogą z niego korzystać.
To bardzo ciekawe połączenie.
Host ogranicza proces.
Docker uruchamia proces.
AppArmor nadal kontroluje zachowanie procesu.
To pokazuje, że warstwy bezpieczeństwa nie konkurują ze sobą.
One się uzupełniają.
Wolumeny potrafią zdradzić więcej niż aplikacja
To kolejna pułapka.
YAML
volumes:
- /:/host
Tak.
Ludzie naprawdę to robią.
Jeżeli kontener nie potrzebuje całego systemu plików, nie powinien go widzieć.
Lepiej:
/app/data
niż:
/
Minimalizacja dostępu pozostaje jedną z najważniejszych zasad hardeningu.
Tajemnice nie powinny mieszkać w obrazie
Czasami widzę:
dockerfile
ENV PASSWORD=SuperSecret123
To nie jest miejsce na hasła.
Znacznie lepiej korzystać z:
- Docker Secrets,
- zmiennych środowiskowych dostarczanych podczas wdrożenia,
- zewnętrznych systemów zarządzania sekretami.
Obraz powinien być możliwie neutralny.
Logi potrafią zapełnić dysk
To problem bardziej praktyczny niż spektakularny.
Kontener działa.
Loguje wszystko.
Po kilku tygodniach:
No space left on device
Dlatego ograniczam rozmiar logów.
Przykład.
YAML
logging:
options:
max-size: "10m"
max-file: "5"
To drobiazg.
Ale właśnie takie drobiazgi odróżniają środowisko testowe od produkcyjnego.
Docker Compose to dokumentacja
Coraz częściej traktuję docker-compose.yml jako dokument architektury.
Dobry plik Compose pozwala zrozumieć:
- które usługi istnieją,
- które porty są publiczne,
- które sieci są prywatne,
- które wolumeny przechowują dane.
To znacznie lepsze niż odtwarzanie konfiguracji z historii poleceń.
Aktualizacja nie kończy się na docker pull
Wielu administratorów wykonuje:
Bash
docker pull nginx
i uznaje temat za zamknięty.
Nowy obraz nie zastąpi działającego kontenera.
Trzeba go jeszcze wdrożyć.
To oczywiste.
A jednak często o tym zapominamy.
Jak wygląda mój minimalny standard produkcyjny?
Jeżeli uruchamiam nową usługę, chcę zobaczyć mniej więcej taki obraz.
INTERNET
|
▼
NGINX
|
frontend_net
|
▼
APPLICATION
|
backend_net
|
▼
POSTGRESQL
Dodatkowo:
- brak
--privileged, - użytkownik niebędący rootem,
- ograniczone capability,
- domyślny seccomp,
- AppArmor,
- osobne sieci,
- wersjonowane obrazy,
- kontrolowane logi.
To nie jest maksymalnie restrykcyjna konfiguracja.
To konfiguracja, którą da się utrzymać przez lata.
Najważniejsza lekcja z Dockera
Największy błąd nie polega na używaniu złego polecenia.
Polega na traktowaniu kontenera jak magicznej piaskownicy.
Docker jest świetnym narzędziem.
Ale bezpieczeństwo nie wynika z samego faktu uruchomienia aplikacji w kontenerze.
Bezpieczeństwo wynika z decyzji administratora:
- jakie capability zostawić,
- jakie porty wystawić,
- jakie wolumeny udostępnić,
- jakiego użytkownika uruchomić,
- jak zbudować sieć,
- jak zarządzać sekretami.
Kontener nie zastępuje hardeningu.
Jest kolejną warstwą, którą również trzeba świadomie zabezpieczyć.







