W LinuxLab hardening usług traktujemy jako zmianę produkcyjną, a nie jako gotowy plik do skopiowania. Najpierw ustalamy, co dana usługa naprawdę czyta, zapisuje i z czym się łączy. Dopiero wtedy dokładamy ograniczenia, po jednej usłudze i z przygotowanym sposobem wycofania.
W tym artykule ograniczamy trzy usługi typowego stosu LEMP: Nginx, PHP-FPM i MariaDB. Pokazujemy inwentaryzację poprzedzającą pierwszy drop-in, wspólną warstwę sandboxa i znaczenie każdej dyrektywy, a następnie ustawienia warunkowe osobno dla serwera WWW, dla interpretera PHP i dla bazy. Przykłady prowadzimy na jednym umownym hoście, na którym pod adresem example.test/ działa sklep Magento, a pod example.test/news/ blog WordPress. Nazwy jednostek, wersję PHP oraz ścieżki danych ustalamy przed wdrożeniem we własnej konfiguracji, zamiast przepisywać je z gotowego przykładu.
Spis treści
- Czego systemd nie zastępuje
- Krótki słownik
- Mapa uprawnień usługi
- Sklep pod / i blog pod /news/
- Inwentaryzacja przed zmianą
- Drop-in, nie plik pakietu
- Wspólna warstwa sandboxa
- Profil Nginx
- Profil PHP-FPM
- Profil MariaDB
- Dyrektywy bez kopiowania w ciemno
- Restart i limity zasobów
- Wdrożenie, test i wycofanie
- Ograniczenia tego podejścia
- Checklista wdrożenia
Co ogranicza systemd, a czego nie zastępuje
Systemd potrafi zmniejszyć skutki przejęcia procesu przez błąd w Nginx, PHP-FPM lub MariaDB. Nie naprawia luki w aplikacji, ale utrudnia użycie przejętego procesu do zmiany konfiguracji systemu, sięgnięcia po urządzenia, modyfikacji ustawień jądra albo zbudowania kolejnych granic uprawnień.
Nie istnieje przy tym jeden poprawny plik hardeningu dla wszystkich trzech usług. Każda z nich ma inne dane, inne ścieżki zapisu i inne wymagania sieciowe. Bez ich rozpoznania mocniejsza konfiguracja potrafi po prostu wyłączyć stronę, kolejkę zadań albo bazę.
Zakładamy system Debian lub Ubuntu z systemd jako menedżerem usług, a polecenia wykonujemy z uprawnieniami administratora. W kontenerze, w którym systemd nie jest procesem PID 1 albo nie może tworzyć przestrzeni nazw, część mechanizmów nie zadziała i trzeba to sprawdzić na konkretnym hoście.
| Usługa | Co robi w stosie | Granice warte ograniczenia |
|---|---|---|
| Nginx | Przyjmuje ruch HTTP/HTTPS, czyta konfigurację i pliki statyczne, łączy się z backendem. | Zapis do konfiguracji systemu, dostęp do urządzeń, ustawień jądra i nieużywanych rodzin gniazd. |
| PHP-FPM | Uruchamia kod aplikacji PHP, zwykle dla wielu pul i wielu stron naraz. | Zapis poza katalogami aplikacji, dostęp do urządzeń i jądra, tworzenie plików SUID/SGID. |
| MariaDB | Przechowuje dane, prowadzi własne pliki tymczasowe, udostępnia socket lub port TCP. | Zmiany w systemie poza katalogiem danych, dostęp do katalogów użytkowników, urządzeń i jądra. |
Sandbox systemd obejmuje proces usługi i jego potomków. Jeżeli Nginx przekazuje żądanie do PHP-FPM, ograniczenia Nginx nie dotyczą już procesu PHP: każda usługa potrzebuje własnego profilu.
Hardening jednostek nie zastępuje przy tym:
- aktualizacji systemu, Nginx, PHP, MariaDB oraz samej aplikacji;
- poprawnych uprawnień do plików i uprawnień kont bazodanowych;
- ograniczenia portów przez firewall i właściwego
bind-addressbazy; - kopii zapasowych oraz sprawdzonego odtwarzania;
- ochrony aplikacji przed SQL injection, zdalnym wykonaniem kodu, błędami autoryzacji i wyciekiem sekretów;
- monitoringu rzeczywistego działania aplikacji.
Granica bywa myląca przy dyrektywach o nazwach sieciowych. RestrictAddressFamilies= zabrania procesowi tworzenia gniazd określonego rodzaju, ale nie rozstrzyga, do którego adresu IP Nginx lub PHP może się połączyć. To zadanie firewalla, segmentacji sieci i konfiguracji aplikacji.
Krótki słownik
- Jednostka (unit) - opis obiektu zarządzanego przez systemd. Usługa Nginx jest zwykle jednostką
nginx.service. - Plik dostarczony przez pakiet - definicja jednostki w katalogu takim jak
/usr/lib/systemd/system/albo/lib/systemd/system/. Aktualizacja pakietu może ją zmienić. - Drop-in - mały dodatkowy plik w katalogu
/etc/systemd/system/nazwa.service.d/, nadpisujący wybrane ustawienia bez kopiowania całej definicji usługi. - Cgrupa - grupa procesów, której systemd przypisuje limity i właściwości. Usługa wraz z procesami potomnymi zwykle mieści się w jednej cgrupie.
- Przestrzeń nazw (namespace) - prywatny widok części systemu. Na przykład
PrivateTmp=yesdaje usłudze własne katalogi tymczasowe. - Capability - pojedyncze uprawnienie jądra wyjęte z szerokiego uprzywilejowania roota, na przykład możliwość wiązania portów poniżej 1024.
- Sandbox - zestaw ograniczeń procesu. Nie jest maszyną wirtualną ani kontenerem i nie odcina procesu od wszystkich widocznych dla niego danych.
Mapa uprawnień przed pierwszym drop-inem
Zanim powstanie choć jeden plik, odpowiadamy na pytanie, czego proces potrzebuje po uruchomieniu. Poniższa tabela jest częstym punktem wyjścia, a nie opisem konkretnego serwera.
| Usługa | Zwykle czyta | Zwykle zapisuje | Komunikuje się przez | Częste pułapki |
|---|---|---|---|---|
| Nginx | konfigurację, certyfikaty, pliki statyczne | cache, logi, pliki tymczasowe, PID | TCP 80/443, socket PHP-FPM lub lokalne TCP | niestandardowy katalog cache, uploady, proxy do innej usługi, PCRE JIT |
| PHP-FPM | kod aplikacji, sekrety, pliki bibliotek | sesje, uploady, cache, pliki aplikacji, logi | socket Unix, MariaDB, SMTP/API/Redis przez TCP | wiele pul w jednej jednostce, mail(), OPcache JIT, katalog strony pod /home |
| MariaDB | konfigurację, certyfikaty replikacji, dane | katalog danych, socket i PID, binlogi, logi, pliki tymczasowe | socket Unix, TCP dla klientów i replikacji | niestandardowy datadir, tmpdir w /tmp, backup lub replikacja |
Dwa fakty z tej tabeli decydują o kształcie całej polityki. Po pierwsze, jednostka phpX.Y-fpm.service ogranicza wszystkie pule uruchomione przez ten master PHP-FPM i nie izoluje pojedynczej strony; rozdział stron wymaga osobnych jednostek albo innego modelu uruchamiania. Po drugie, ProtectSystem=full nie czyni katalogu /var/www tylko do odczytu - chroni między innymi /usr i /etc, ale nie cały system plików. Do zabezpieczenia kodu aplikacji potrzebne jest ProtectSystem=strict wraz z wąską, sprawdzoną listą ReadWritePaths=.
Przypadek: sklep pod / i blog pod /news/
Dalsze przykłady prowadzimy na jednym publicznym hoście. Sklep Magento działa pod https://example.test/, a blog WordPress pod https://example.test/news/. Każda z aplikacji ma własną bazę lub własny schemat oraz osobne konto MariaDB.
To jest jeden vhost Nginx, nie dwa, a prefiks URL /news/ nie tworzy granicy bezpieczeństwa w systemie operacyjnym. Dlatego aplikacje dostają osobne katalogi, osobne konta bazy i - jeżeli poziom ryzyka tego wymaga - osobne mastery oraz jednostki PHP-FPM. WordPress nie powinien mieć dostępu ani do tabel sklepu, ani do jego katalogów runtime.
Układ katalogów jest umowny i niezależny od adresów URL:
/srv/www/example.test-magento/
├── app/ kod Magento - tylko do odczytu w runtime
├── generated/ artefakty generowane podczas wdrożenia
├── pub/ katalog publiczny wskazywany przez nginx.conf.sample
│ ├── media/ media produktów - zapis aplikacji
│ └── static/ artefakty statyczne - generowane przy wdrożeniu
└── var/
├── cache/
├── log/
├── page_cache/
├── session/
└── view_preprocessed/
/srv/www/example.test-wordpress/
├── wp-admin/ kod WordPress - tylko do odczytu w runtime
├── wp-content/cache/ cache wtyczki, jeżeli jest używany
├── wp-content/uploads/ obrazy i załączniki redakcji
└── wp-includes/ kod WordPress - tylko do odczytu w runtime
Katalog bloga nie trafia do /srv/www/example.test-magento/pub/. Rozdzielone drzewa ograniczają przypadkowe usunięcie bloga podczas wdrożenia sklepu i pozwalają nadać obu aplikacjom węższe prawa zapisu. Adres URL łączy się z drugim katalogiem przez dyrektywę alias.
Powyższa lista nie wyczerpuje katalogów każdego wdrożenia. Wtyczka cache, moduł płatności, integracja magazynowa albo nietypowy moduł Magento potrafią używać dodatkowych ścieżek. Faktyczny zapis zbieramy więc z konfiguracji, logów i testów, a potem dopisujemy jeden mały wyjątek. Podobnie z zależnościami: sklep może wymagać osobnej usługi cache, wyszukiwania albo kolejki, a blog cache obiektowego. Każdy taki komponent trafia do mapy połączeń, zanim ograniczymy rodziny adresów PHP-FPM.
Osobny katalog pod prefiksem /news/
Poniższa konfiguracja jest specjalizowanym vhostem, a nie fragmentem do doklejenia do dowolnego pliku. Sekcja upstream należy do kontekstu http { ... }, reszta do jednego bloku server { ... }. Certyfikat, logi i listen pominięto celowo - pozostają takie jak w działającym vhoście. Magento zachowuje przy tym własny plik nginx.conf.sample, ponieważ producent dostarcza w nim routing aplikacji oraz reguły ograniczające wykonanie niebezpiecznych plików.
# Kontekst http {}. Nazwa fastcgi_backend musi odpowiadać nazwie,
# której używa nginx.conf.sample danej wersji Magento.
upstream fastcgi_backend {
server unix:/run/php/php8.3-fpm-store-web.sock;
}
server {
# Istniejące listen, TLS, certyfikaty i access_log pozostają tutaj.
server_name example.test;
set $MAGE_ROOT /srv/www/example.test-magento;
# Jeden kanoniczny adres bloga: końcowy ukośnik jest obowiązkowy.
location = /news {
return 308 /news/$is_args$args;
}
# Ten blok przejmuje cały prefiks /news/ przed globalnymi regexami Magento.
location ^~ /news/ {
alias /srv/www/example.test-wordpress/;
index index.php;
# Magento sample może definiować własną stronę 404/403 na poziomie server.
error_page 404 = @wordpress_news_404;
error_page 403 = @wordpress_news_403;
# Zagnieżdżony exact dziedziczy lokalną obsługę błędów powyżej.
# Plik zawiera dane dostępowe WordPressa; nie może trafić do klienta.
location = /news/wp-config.php {
return 404;
}
location = /news/wp-config-sample.php {
return 404;
}
# Ten regex musi stać przed ogólną obsługą PHP poniżej.
location ~* ^/news/wp-content/uploads/.*[.](?:php(?:[0-9]+)?|phtml|phar|phps)$ {
return 404;
}
# Blokuje .env, .git, .user.ini, .htaccess i inne ukryte składniki.
location ~ ^/news/(?:.+/)?[.] {
return 404;
}
# Jest zagnieżdżony celowo: dziedziczy alias z bloku /news/.
location ~ ^/news/(?<wp_script>.+[.]php)$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param DOCUMENT_ROOT /srv/www/example.test-wordpress;
fastcgi_param SCRIPT_FILENAME /srv/www/example.test-wordpress/$wp_script;
fastcgi_param SCRIPT_NAME /news/$wp_script;
fastcgi_pass unix:/run/php/php8.3-fpm-news-web.sock;
}
# Plik i katalog mają pierwszeństwo; reszta idzie do front controllera.
try_files $uri $uri/ @wordpress_news_front_controller;
}
# Nazwana lokalizacja nie pasuje do zwykłego URI; wejście jest tylko z try_files.
location @wordpress_news_front_controller {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /srv/www/example.test-wordpress/index.php;
fastcgi_param SCRIPT_NAME /news/index.php;
fastcgi_param DOCUMENT_ROOT /srv/www/example.test-wordpress;
fastcgi_pass unix:/run/php/php8.3-fpm-news-web.sock;
}
# Nazwane lokalizacje nie mają publicznego URI. Przy domyślnym
# recursive_error_pages off ich return kończy odpowiedź bez pętli.
location @wordpress_news_404 {
return 404;
}
location @wordpress_news_403 {
return 403;
}
# Routing Magento i jego reguły bezpieczeństwa dla całej pozostałej ścieżki.
include /srv/www/example.test-magento/nginx.conf.sample;
}
| Element | Co robi | Po co jest i przed czym chroni |
|---|---|---|
upstream fastcgi_backend | Nadaje nazwę połączeniu FastCGI dla Magento; wiersz server unix:... wskazuje socket puli sklepu, a nie port widoczny w sieci. | Plik nginx.conf.sample Magento odwołuje się do tej nazwy. Osobny socket pozwala nie wysyłać kodu sklepu do puli WordPressa. Dokładną nazwę porównujemy z używanym plikiem sample i konfiguracją puli. |
set $MAGE_ROOT ... | Ustawia zmienną, przez którą plik sample odnajduje kod i katalog pub/. | Pozwala zostawić oficjalny routing Magento bez przepisywania jego treści do własnego pliku. |
location = /news | Dopasowuje wyłącznie dokładny adres bez ukośnika i odsyła go kodem 308 do wersji kanonicznej; $is_args dodaje ? tylko wtedy, gdy $args nie jest puste. | Eliminuje dwa adresy tego samego bloga i problemy z linkami względnymi. Kod 308 zachowuje metodę HTTP i wymaga współczesnego Nginx; w starszym, sprawdzonym środowisku dla żądań GET zostaje 301. |
location ^~ /news/ | Wybiera najdłuższy prefiks i nie pozwala później uruchomić globalnych bloków regex z pliku Magento. | Bez tego reguła Magento dla pliku CSS, obrazu lub PHP mogłaby przejąć żądanie bloga. Modyfikator ^~ nie wyłącza bloków regex zagnieżdżonych wewnątrz, dlatego obsługa PHP stoi niżej, a nie na poziomie server. |
alias /srv/www/example.test-wordpress/ | Zamienia prefiks URL na wskazany katalog: /news/wp-login.php odpowiada plikowi /srv/www/example.test-wordpress/wp-login.php. | Katalog aplikacji pozostaje poza drzewem Magento. Końcowe ukośniki po obu stronach są istotne dla sklejania ścieżki; alias różni się tym od root, które dokleja cały URI. |
Blokady wp-config*.php | Zwracają 404, więc klient nie dostaje ani pliku konfiguracyjnego, ani próbki z informacjami o instalacji. | wp-config.php zawiera dane połączenia z bazą i klucze aplikacji, a 404 dodatkowo nie potwierdza istnienia pliku. Bloków nie przenosimy na poziom server: dokładne dopasowanie wygrałoby wtedy z /news/ i ominęło jego lokalną obsługę błędów. |
Blok dla wp-content/uploads/ | Zanim zadziała ogólna reguła PHP, odrzuca w uploadach rozszerzenia .php, .phpN, .phtml, .phar i .phps bez względu na wielkość liter. | Przejęty panel redakcyjny ani podatna wtyczka nie powinny móc przesłać i uruchomić skryptu PHP jako pliku multimedialnego. Kolejność obu regexów jest częścią zabezpieczenia. |
| Blok dla ukrytych składników | Wykrywa segment ścieżki zaczynający się od kropki i zwraca 404. | Chroni między innymi .env, katalog .git, .user.ini i .htaccess. Uzasadniony publiczny plik z kropką dostaje wąski wyjątek po teście, a nie zdjęcie całej blokady. |
error_page 404 = @... i 403 = @... | Zastępują odziedziczoną obsługę błędów dla bloga; znak = zachowuje kod odpowiedzi, a @... wskazuje nazwaną lokalizację. | Plik sample Magento potrafi zamieniać 404 i 403 na własny kontroler. Bez lokalnego nadpisania brakujący skrypt bloga lub zablokowany upload pokazałby stronę sklepu. |
Zagnieżdżony blok ...(?<wp_script>.+[.]php)$ | Obsługuje wyłącznie URI kończące się na .php w wybranym już prefiksie; nazwany fragment wp_script zawiera część adresu po /news/, a blok dziedziczy alias rodzica. | Zagnieżdżenie jest konieczne - przeniesiony obok location ^~ /news/ blok zostałby pominięty. Jednocześnie reguła nie dotyczy PHP sklepu. |
try_files $uri =404 w bloku PHP | Sprawdza istnienie pliku, zanim żądanie trafi do PHP-FPM. | Ogranicza przekazywanie do FPM nieistniejących nazw skryptów i jest prostą ochroną przed błędnym mapowaniem ścieżki. Tej kontroli nie zdejmujemy po to, aby naprawić błąd 404. |
fastcgi_param SCRIPT_FILENAME ... | Przekazuje FPM pełną ścieżkę pliku WordPressa zbudowaną z nazwanego wyniku dopasowania. | PHP wykonuje plik bloga, a nie ścieżkę z katalogu Magento. W tym układzie zamiana wartości na $document_root$fastcgi_script_name dodałaby prefiks /news/ po raz drugi. |
fastcgi_param SCRIPT_NAME ... | Przekazuje PHP publiczną nazwę skryptu, nadal z prefiksem /news/; w nazwanej lokalizacji ma stałą wartość /news/index.php. | Zapobiega sytuacji, w której aplikacja uznaje, że jej skrypt działa pod /index.php w korzeniu hosta. |
try_files $uri $uri/ @...front_controller | Próbuje kolejno pliku i katalogu, a na końcu przekazuje żądanie do nazwanej lokalizacji, zachowując parametry zapytania. | To reguła ładnych adresów WordPressa. Nazwana lokalizacja omija pułapkę alias: zwykłe wewnętrzne przekierowanie do /news/index.php mogłoby zgubić mapowanie prefiksu na osobny katalog. |
include .../nginx.conf.sample | Dodaje oficjalne reguły Magento dla wszystkiego poza /news/. | Zachowuje routing i ochrony sklepu. Ponieważ prefiks bloga omija jego globalne regexy, własne blokady uploadów i plików ukrytych są po stronie WordPressa konieczne. |
Adres bloga ustawiamy też w samej aplikacji, inaczej będzie generowała linki do korzenia hosta zamiast do prefiksu:
define('WP_HOME', 'https://example.test/news');
define('WP_SITEURL', 'https://example.test/news');
Po zmianie sprawdzamy nie tylko stronę główną, ale też logowanie do /news/wp-admin/, permalink wpisu, obraz po uploadzie, formularz, REST API i wygenerowane przekierowanie. Oglądamy również nagłówki Set-Cookie: wtyczka używająca wspólnej nazwy cookie lub ścieżki / potrafi kolidować z sesją sklepu mimo rozdzielonych aplikacji.
Warto zauważyć, co ten układ oznacza dla automatyzacji. Typowy szablon vhosta ma jeden katalog root i jedną globalną obsługę PHP, więc nie modeluje drugiej aplikacji pod prefiksem URL. Taki vhost renderujemy jako osobny, zarządzany szablon albo rozszerzamy model o deklaratywną aplikację ścieżkową. Ręczna edycja pliku wygenerowanego przez automatyzację zniknie przy kolejnym uruchomieniu. Jeżeli osobny vhost HTTP tylko wydaje certyfikat ACME i przekierowuje ruch na HTTPS, pełny routing /news/ renderujemy wyłącznie do vhosta HTTPS.
Co naprawdę musi być zapisywalne
| Aplikacja | Katalogi zwykle wymagające zapisu | Co lepiej generować poza PHP-FPM |
|---|---|---|
| Magento | Wybrane podkatalogi var/ oraz pub/media/. | generated/ i pub/static/ - kompilację i wdrożenie uruchamia osobne konto lub pipeline, nie proces obsługujący żądanie HTTP. |
| WordPress | wp-content/uploads/ oraz katalog cache używanej wtyczki. | Rdzeń, wtyczki i motywy. Aktualizacje plików idą przez kontrolowany proces wdrożeniowy, nie przez panel WWW na produkcji. |
Nie otwieramy zapisu do całego katalogu aplikacji tylko dlatego, że aktualizacja z panelu tego oczekuje. Taki wyjątek znosi ochronę kodu przed przejętym procesem PHP. Jeżeli proces wdrożeniowy musi zapisać nowy kod, uruchamiamy go jako osobną, świadomie uprzywilejowaną operację, a nie jako worker PHP-FPM.
Czym skutkuje wspólny master PHP-FPM
Jeżeli sklep i blog działają w jednym php8.3-fpm.service, to ProtectSystem=strict oraz ReadWritePaths= obejmują sumę ich potrzeb:
[Service]
ProtectSystem=strict
ReadWritePaths=/run/php
ReadWritePaths=/srv/www/example.test-magento/var/cache
ReadWritePaths=/srv/www/example.test-magento/var/log
ReadWritePaths=/srv/www/example.test-magento/var/page_cache
ReadWritePaths=/srv/www/example.test-magento/var/session
ReadWritePaths=/srv/www/example.test-magento/var/view_preprocessed
ReadWritePaths=/srv/www/example.test-magento/pub/media
ReadWritePaths=/srv/www/example.test-wordpress/wp-content/cache
ReadWritePaths=/srv/www/example.test-wordpress/wp-content/uploads
Powyższy blok pokazuje kształt polityki, a nie kompletną konfigurację obu aplikacji. Przed zastosowaniem potwierdzamy socket FPM, logi PHP, konkretną konfigurację sesji i cache oraz wymagania dodatków.
Wspólny master oznacza wspólny sandbox. Podatność w WordPressie pozwala wtedy zapisywać także do dozwolonych katalogów Magento, ponieważ oba workery odziedziczyły ograniczenia jednego mastera. Osobne pule poprawiają rozdział użytkowników Unix i socketów, ale nie tworzą osobnego sandboxa systemd.
Jeżeli rozdział aplikacji jest wymaganiem bezpieczeństwa, docelową architekturą są oddzielne mastery i jednostki systemd dla sklepu oraz bloga, albo dobrze zaprojektowane kontenery. Każdy unit dostaje wtedy wyłącznie własne ReadWritePaths=. To istotniejsza poprawa niż dalsze obniżanie wyniku systemd-analyze security.
Inwentaryzacja przed zmianą
Najpierw ustalamy nazwy jednostek. W opisywanym systemie są to nginx.service, php8.3-fpm.service i mariadb.service, ale wersja PHP oraz kanoniczna nazwa jednostki bazy bywają inne.
sudo systemctl list-unit-files --type=service 'php*-fpm.service'
sudo systemctl status nginx.service
sudo systemctl status php8.3-fpm.service
sudo systemctl status mariadb.service
Pierwsze polecenie wypisuje zainstalowane mastery PHP-FPM, kolejne trzy pokazują, czy wskazana usługa działa. Następnie odczytujemy pełną konfigurację razem z istniejącymi drop-inami:
sudo systemctl cat nginx.service
sudo systemctl cat php8.3-fpm.service
sudo systemctl cat mariadb.service
sudo systemctl show nginx.service -p FragmentPath -p DropInPaths -p User -p Group -p ExecStart
sudo systemd-delta --type=extended
systemctl cat znaczy tu więcej niż obejrzenie samego pliku pakietowego, ponieważ pokazuje także lokalne nadpisania. FragmentPath podaje główny plik definicji, a DropInPaths wszystkie dodatkowe pliki, które systemd wczyta. Surowego wyniku tych poleceń nie przenosimy do zgłoszeń ani dokumentacji dostępnej szerzej niż zespół: zawiera nazwy hostów, ścieżki, konta, adresy i parametry uruchomieniowe.
Przed zmianą zapisujemy również wynik pomocniczej oceny, żeby mieć punkt odniesienia:
sudo systemd-analyze security --no-pager nginx.service
sudo systemd-analyze security --no-pager php8.3-fpm.service
sudo systemd-analyze security --no-pager mariadb.service
Polecenie ocenia wyłącznie ochrony implementowane przez systemd. Niższa liczba exposure jest wskazówką, a nie dowodem bezpieczeństwa - nie mówi, czy aplikacja ma podatność, czy konto bazy ma zbyt szerokie uprawnienia i czy firewall jest poprawny.
Do inwentaryzacji dopisujemy odpowiedzi na sześć pytań:
- Które katalogi usługa zapisuje po pełnym restarcie?
- Czy któryś socket, PID albo plik tymczasowy leży w
/tmp,/var/tmp,/homelub/run/user? - Czy PHP uruchamia lokalny program pocztowy przez
mail()? - Czy Nginx ma włączony PCRE JIT, a PHP - OPcache JIT?
- Czy baza używa replikacji, nietypowego katalogu danych, szyfrowania, backupu wykonywanego przez proces bazy albo niestandardowego pluginu?
- Czy baza ma być dostępna tylko lokalnie, czy również przez prywatną sieć?
Brak odpowiedzi oznacza, że etap wdrożenia jeszcze się nie zaczyna.
Drop-in zamiast pliku pakietu
Pliku z /usr/lib/systemd/system/ ani /lib/systemd/system/ nie edytujemy. Należy do pakietu, więc aktualizacja może nadpisać zmianę albo wprowadzić nową ważną dyrektywę, której kopia starego pliku nie dostanie. Zamiast tego używamy katalogu lokalnego:
/etc/systemd/system/nginx.service.d/60-hardening.conf
/etc/systemd/system/php8.3-fpm.service.d/60-hardening.conf
/etc/systemd/system/mariadb.service.d/60-hardening.conf
Katalog /etc ma wyższy priorytet niż katalogi pakietowe, a drop-iny o różnych nazwach czytane są alfabetycznie. Nazwa 60-hardening.conf jest konwencją: sygnalizuje lokalną politykę i zostawia miejsce na bardziej szczegółowy plik przed nią lub po niej.
Najprostszy sposób ręcznego utworzenia drop-inu to sudo systemctl edit nginx.service - edytor zapisze plik override.conf we właściwym katalogu. W infrastrukturze zarządzanej przez Ansible, Puppet lub inny system konfiguracji wygodniej jest zarządzać jednym nazwanym plikiem przez ten system. Obu metod nie mieszamy dla tej samej usługi bez ustalenia kolejności plików.
Wspólna warstwa sandboxa
Ustawienia sandboxa proces zakłada podczas startu, dlatego samo wczytanie definicji nic jeszcze nie zmienia:
sudo systemctl daemon-reload
| Operacja | Co robi | Czy włącza nowy sandbox? |
|---|---|---|
daemon-reload | Wczytuje definicje jednostek do systemd. | Nie. |
reload | Prosi działający program o przeładowanie własnej konfiguracji, o ile ten to wspiera. | Nie. |
restart lub try-restart | Zatrzymuje i uruchamia proces ponownie; try-restart nie podniesie usługi celowo zatrzymanej. | Tak. |
Dlatego hardening wdrażamy w oknie serwisowym i po jednej usłudze. Składnia pliku bywa poprawna, a aplikacja i tak potrzebuje dostępu, którego w nim nie uwzględniono.
Poniższa warstwa jest kandydatem na pierwsze wdrożenie po wykonanej inwentaryzacji. Nie ogranicza sieci do konkretnych adresów, nie zmienia użytkownika usługi i nie narzuca limitów pamięci ani CPU, co czyni ją rozsądnym etapem początkowym dla długo działających usług sieciowych:
[Service]
ProtectSystem=full
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
Wklejenie tego bloku do trzech usług nie zwalnia z testu. PrivateTmp=yes nie pasuje do bazy, która celowo wystawia socket w /tmp, a PrivateDevices=yes do usługi korzystającej ze sprzętowego modułu kryptograficznego.
| Dyrektywa | Co dokładnie robi | Przed czym chroni | Na co uważać |
|---|---|---|---|
ProtectSystem=full | Montuje dla usługi /usr, katalogi rozruchowe i /etc tylko do odczytu. | Przejęty demon nie zmieni binariów systemu ani jego konfiguracji. | Nie chroni całego /var, a więc zwykle także kodu w /var/www. Wartości strict nie włączamy bez listy wymaganych zapisów. |
PrivateTmp=yes | Tworzy prywatne /tmp i /var/tmp, niewidoczne dla procesów spoza usługi. | Ogranicza podmianę i odczyt plików tymczasowych przez inne procesy. | Proces uruchomiony poza usługą nie zobaczy tych plików. Sprawdzamy integracje używające wspólnego pliku tymczasowego albo socketu w /tmp. |
PrivateDevices=yes | Daje minimalny prywatny widok /dev z urządzeniami pseudo, takimi jak /dev/null i /dev/urandom. | Zmniejsza możliwość sięgnięcia przejętym procesem po dyski, urządzenia blokowe i specjalny sprzęt. | Nie pasuje, gdy proces rzeczywiście potrzebuje urządzenia: HSM, USB albo własnego urządzenia znakowego. |
ProtectKernelTunables=yes | Udostępnia wrażliwe interfejsy jądra, w tym część /proc/sys i /sys, tylko do odczytu. | Utrudnia zmianę parametrów jądra i ustawień systemu przez proces usługi. | Nie blokuje pośredniej zmiany ustawień przez uprzywilejowaną usługę IPC i nie zastępuje ograniczenia capability. |
ProtectKernelModules=yes | Odmawia jawnego ładowania i usuwania modułów jądra. | Przejęta usługa webowa nie wpłynie na moduły jądra. | Nie stosujemy do usługi, która zgodnie z projektem zarządza modułami lub nietypowymi systemami plików. |
ProtectKernelLogs=yes | Blokuje dostęp do pierścienia logów jądra. | Ogranicza odczyt wrażliwych informacji z dmesg i zapis do niego. | Nie blokuje zwykłego logowania usługi do journald. Starszy systemd może nie znać tej dyrektywy, dlatego zawsze uruchamiamy systemd-analyze verify. |
ProtectControlGroups=yes | Udostępnia hierarchię cgrup w /sys/fs/cgroup tylko do odczytu. | Proces nie zmieni limitów i organizacji innych procesów. | Nie pasuje do menedżera kontenerów. Samo ustawienie nie ustanawia limitu pamięci ani CPU. |
ProtectClock=yes | Blokuje zmianę zegara systemowego i sprzętowego, odbierając też związane capability. | Chroni czas systemowy przed manipulacją utrudniającą logowanie, TLS i analizę incydentu. | Nie synchronizuje zegara i nie poprawia NTP. Nie ustawiamy jej dla usługi zarządzającej czasem. |
ProtectHostname=yes | Używa prywatnej przestrzeni UTS i blokuje zmianę nazwy hosta oraz domeny systemowej. | Przejęty proces nie zmieni identyfikacji serwera. | Nie czyni nazwy hosta sekretem i nie zmienia konfiguracji DNS. |
RestrictRealtime=yes | Odmawia polityk planowania czasu rzeczywistego, takich jak SCHED_FIFO, SCHED_RR i SCHED_DEADLINE. | Ogranicza zagłodzenie CPU i lokalną odmowę usługi. | Nie ustawia limitu CPU i nie obniża zwykłego priorytetu procesu. |
RestrictNamespaces=yes | Blokuje tworzenie i przełączanie wszystkich typów przestrzeni nazw. | Utrudnia budowanie własnych środowisk kontenerowych i obejść opartych na namespace. | Nie izoluje usługi w kontenerze. Nie pasuje do programu, który celowo tworzy przestrzenie nazw. |
RestrictSUIDSGID=yes | Odmawia ustawiania bitów SUID i SGID na plikach oraz katalogach. | Przejęty proces nie zostawi pliku, który później podniesie uprawnienia innego użytkownika. | Nie blokuje samego wywołania setuid() przez proces, który już ma do niego uprawnienie. |
LockPersonality=yes | Blokuje zmianę domeny wykonawczej jądra przez personality(2). | Zamyka rzadko potrzebny mechanizm emulacji, który powiększa powierzchnię ataku. | Nie jest filtrem wywołań systemowych. |
SystemCallArchitectures=native | Dopuszcza tylko natywne ABI hosta, na przykład 64-bitowe na 64-bitowym serwerze. | Utrudnia obejście części filtrów seccomp przez alternatywne ABI. | Nie jest listą dozwolonych wywołań systemowych i nie zastępuje SystemCallFilter=. |
Większość ograniczeń plikowych opiera się na przestrzeni nazw montowań. Dokumentacja systemd zaznacza, że uprzywilejowany proces zdolny do montowania potrafi je obchodzić. Dlatego w dalszym etapie analizujemy capability i ewentualnie SystemCallFilter=~@mount, ale nie dokładamy ich bez testu.
Nginx: profil i ustawienia warunkowe
Pierwsza wersja pliku dla serwera WWW zawiera samą warstwę bazową:
# /etc/systemd/system/nginx.service.d/60-hardening.conf
[Service]
ProtectSystem=full
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
Nginx zwykle jest dobrym kandydatem do dalszego ograniczenia, ale dopiero po teście konfiguracji, ruchu HTTP/HTTPS, odnowienia certyfikatu, cache oraz każdego proxy_pass i fastcgi_pass. Drugi etap dla sprawdzonej jednostki może wyglądać tak:
[Service]
NoNewPrivileges=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_KILL CAP_CHOWN CAP_DAC_OVERRIDE
| Dyrektywa | Znaczenie w Nginx | Warunek wdrożenia |
|---|---|---|
NoNewPrivileges=yes | Proces i jego potomkowie nie zdobędą nowych uprawnień podczas execve(), na przykład przez plik setuid/setgid albo capability pliku. | Sprawdzamy moduły, skrypty pomocnicze i integracje uruchamiane przez Nginx. Dyrektywa nie odbiera uprawnień, które proces już ma. |
RestrictAddressFamilies=... | Pozwala tworzyć gniazda Unix, IPv4 i IPv6, blokując pozostałe rodziny. | Upewniamy się, że Nginx nie potrzebuje innej rodziny. To nie jest firewall: nie ogranicza adresów, portów ani gniazd przekazanych przez socket activation. |
MemoryDenyWriteExecute=yes | Blokuje pamięć jednocześnie zapisywalną i wykonywalną oraz zmianę istniejącej mapy pamięci na wykonywalną. | Testujemy razem z PCRE JIT, który generuje kod w pamięci i może przestać działać. Ochrona utrudnia część technik wykorzystania błędu, ale nie jest pełnym zabezpieczeniem przed wykonaniem kodu. |
CapabilityBoundingSet=... | Ustawia górną granicę capability dostępnych procesowi; nie dodaje żadnego uprawnienia. | Każda pozycja ma mieć dowód w zachowaniu usługi. Błędne ograniczenie potrafi uniemożliwić start albo obsługę ruchu. |
| Capability | Dlaczego bywa potrzebne | Ryzyko |
|---|---|---|
CAP_NET_BIND_SERVICE | Wiązanie portu uprzywilejowanego, na przykład 80 lub 443. | Bez niego master nie uruchomi listenera na takim porcie. |
CAP_SETUID | Przejście workerów na użytkownika nieuprzywilejowanego. | Pozwala zmieniać UID, więc zostawiamy je tylko wtedy, gdy jest naprawdę potrzebne. |
CAP_SETGID | Ustawienie grup workerów. | Pozwala zmieniać GID i grupy. |
CAP_KILL | Master sygnalizuje workery działające pod innym UID. | Pozwala wysyłać sygnały do procesów, więc zakres wymaga kontroli. |
CAP_CHOWN | Inicjalizacja katalogów tymczasowych, cache lub logów pod właściwego użytkownika. | Pozwala zmieniać właściciela plików dostępnych procesowi. |
CAP_DAC_OVERRIDE | Niekiedy potrzebne rootowemu masterowi do odczytu pliku lub inicjalizacji katalogu mimo zwykłych bitów dostępu. | Jest szerokie i pozwala omijać część kontroli uprawnień plików. Jeżeli test potwierdzi, że jest zbędne, usuwamy je. |
Lista capability bywa lepsza niż pozostawienie pełnego zbioru roota, ale nie jest automatycznie mała. CAP_DAC_OVERRIDE wymaga jawnego uzasadnienia zapisanego razem z konfiguracją.
PHP-FPM: profil i ustawienia warunkowe
PHP-FPM potrzebuje osobnego pliku dla każdej rzeczywistej jednostki. Jeżeli na hoście działają dwie wersje PHP, obie identyfikujemy i testujemy niezależnie:
# /etc/systemd/system/php8.3-fpm.service.d/60-hardening.conf
[Service]
ProtectSystem=full
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
Tutaj ostrożność jest największa, ponieważ kod aplikacji, rozszerzenia PHP i wszystkie pule dzielą konsekwencje sandboxa mastera. Poniższe ustawienia bywają dobrym pomysłem, ale nie należą do ślepego profilu początkowego:
| Ustawienie | Co daje | Dlaczego potrafi zepsuć PHP-FPM |
|---|---|---|
NoNewPrivileges=yes | Blokuje podniesienie uprawnień przez execve(). | Funkcja mail() może uruchamiać lokalny program pocztowy z bitem setuid i wysyłka przestanie działać. Dyrektywa nie blokuje natomiast zwykłego setuid(), którym master zrzuca uprawnienia na użytkownika puli. |
MemoryDenyWriteExecute=yes | Utrudnia wykonanie kodu zapisanego dynamicznie w pamięci. | OPcache JIT potrzebuje pamięci wykonywalnej tworzonej w czasie działania. Najpierw wyłączamy albo testujemy JIT oraz wszystkie rozszerzenia. |
ProtectHome=yes | Ukrywa /home, /root i /run/user przed usługą. | Złamie stronę, której kod, uploady, socket albo sekret leżą pod jednym z tych katalogów. |
RestrictAddressFamilies=... | Ogranicza tworzenie gniazd do Unix, IPv4 i IPv6. | Aplikacja może korzystać z nietypowego rozszerzenia lub lokalnej integracji. Sprawdzamy bazę, cache, SMTP, API i zadania asynchroniczne. |
CapabilityBoundingSet=... | Zmniejsza maksymalny zbiór capability. | Master FPM może potrzebować przejścia na użytkownika i grupę puli. Listy z Nginx nie kopiujemy - to inny model procesu. |
Po włączeniu
ProtectSystem=strictnie naprawiamy błędu wpisemReadWritePaths=/var. Taki wyjątek oddaje większość zyskanej ochrony. Zamiast tego wskazujemy małe katalogi przeznaczone na uploady, sesje i cache, a potem sprawdzamy każdy z nich osobno.
MariaDB: profil i ustawienia warunkowe
Baza danych jest wrażliwsza operacyjnie niż serwer WWW. Po złym restarcie potrafi długo wykonywać recovery, a błąd ścieżki zapisu blokuje start całej aplikacji. Dlatego MariaDB wdrażamy jako ostatnią z trzech usług, po pełnym teście Nginx i PHP-FPM.
# /etc/systemd/system/mariadb.service.d/60-hardening.conf
[Service]
ProtectSystem=full
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
Przed użyciem potwierdzamy katalog danych, katalog tymczasowy oraz lokalizację socketu, PID, logów, binlogów i plików replikacji. Jeżeli tmpdir albo socket znajdują się w /tmp, najpierw zmieniamy ich lokalizację lub odkładamy PrivateTmp=yes.
| Ustawienie | Kiedy ma sens | Co sprawdzamy wcześniej |
|---|---|---|
ProtectHome=yes | Gdy dane, backupy, klucze i żaden wymagany plik bazy nie leżą w /home, /root ani /run/user. | Rzeczywisty datadir, katalog backupu, skrypty startowe, pluginy i replikację. |
NoNewPrivileges=yes | Gdy proces bazy i jego skrypty pomocnicze nie uruchamiają programu podnoszącego uprawnienia przez execve(). | Pełny restart, backup, narzędzia pomocnicze i wszystkie pluginy. |
CapabilityBoundingSet=~CAP_SYS_ADMIN | Gdy zamiast zgadywania pełnej listy wolimy odebrać jedno bardzo szerokie capability. Utrudnia odwracanie ograniczeń opartych na przestrzeni nazw montowań. | Składnia z tyldą oznacza wszystkie oprócz wskazanych. Sprawdzamy rzeczywisty unit, storage i pluginy zamiast zakładać, że każdy serwer bazy wygląda tak samo. |
RestrictAddressFamilies=... | Gdy baza korzysta wyłącznie z gniazd Unix oraz TCP IPv4/IPv6. | Połączenia aplikacji, monitoringu, backupu, replikacji i ewentualnego DNS. |
Do bazy nie dokładamy automatycznie:
PrivateNetwork=yes, ponieważ baza może potrzebować połączeń TCP;DynamicUser=yes, ponieważ trwałe dane oczekują stabilnego właściciela plików;MemoryDenyWriteExecute=yes, dopóki nie przetestujemy używanej wersji, pluginów i bibliotek;- pełnej listy dozwolonych
CapabilityBoundingSet=, dopóki nie znamy wymagań tej konkretnej jednostki, w tym blokowania pamięci i nietypowego storage; ProtectSystem=strict, dopóki nie mamy kompletnej, wąskiej listy katalogów zapisu.
Po odczytaniu systemctl cat mariadb.service i udanym teście pełnego restartu sensownym drugim krokiem jest:
[Service]
NoNewPrivileges=yes
CapabilityBoundingSet=~CAP_SYS_ADMIN
To nie jest pełna lista dozwolonych capability. Znak ~ odwraca znaczenie wpisu, więc sam taki wpis oznacza wszystkie capability z wyjątkiem CAP_SYS_ADMIN. Wartości z różnych drop-inów potrafią się łączyć, dlatego po zmianie sprawdzamy faktyczny wynik przez systemctl show. Taki krok jest mniej podatny na zgadywanie niż lista skopiowana z Nginx i odbiera capability szczególnie istotne dla ograniczeń montowania, ale nadal wymaga testu startu, recovery, backupu, replikacji i pluginów.
Docelowy, bardziej restrykcyjny etap wygląda następująco:
[Service]
ProtectSystem=strict
ReadWritePaths=/var/lib/mysql /run/mysqld
# Dopisujemy wyłącznie sprawdzone ścieżki, jeżeli są używane:
# /var/log/mysql
# katalog tmpdir, innodb_tmpdir lub aria_tmpdir
# katalog secure_file_priv
Ścieżki z przykładu odpowiadają typowemu układowi pakietowemu i nie zastępują sprawdzenia własnego serwera. Przy ProtectSystem=strict zwykła hierarchia jest dla usługi tylko do odczytu, ale PrivateTmp=yes nadal tworzy prywatne, zapisywalne /tmp i /var/tmp - tryb strict nie oznacza więc, że proces nie zapisze absolutnie niczego.
W inwentaryzacji bazy uwzględniamy również LOAD DATA INFILE, SELECT ... INTO OUTFILE oraz secure_file_priv. Prywatny /tmp usługi nie jest tym samym katalogiem co hostowy /tmp. Wydzielony katalog importu i eksportu, na przykład pod katalogiem danych bazy, jest czytelniejszy i łatwiejszy do jawnego dopuszczenia niż szeroki wyjątek.
Jeżeli baza ma pracować naprawdę wyłącznie lokalnie, osobnym i dokładnie przetestowanym etapem bywa:
[Service]
IPAddressDeny=any
IPAddressAllow=localhost
Taka polityka nie dotyczy gniazda Unix, ale potrafi zablokować połączenia aplikacji przez prywatną sieć, DNS, replikację, zdalny backup i monitoring. Jej działanie zależy też od obsługi cgrup i mechanizmów jądra, a poprawnego bind-address ani firewalla nie zastępuje.
Dyrektywy, których nie kopiujemy w ciemno
ProtectSystem=strict i ReadWritePaths=
ProtectSystem=strict montuje niemal całą hierarchię plików jako tylko do odczytu dla usługi. Skutecznie chroni kod aplikacji, ale wymaga jawnego odblokowania każdej poprawnej ścieżki zapisu:
[Service]
ProtectSystem=strict
ReadWritePaths=/dokladnie/sprawdzony/katalog
ReadWritePaths= nie tworzy katalogu i nie naprawia zwykłych uprawnień Unix - daje wyłącznie wyjątek od ograniczenia montowania. Nie wpisujemy tam /var, katalogu głównego ani szerokiego drzewa aplikacji po to, żeby usługa wystartowała. Zamiast tego odszukujemy w logu konkretny plik, ustalamy, dlaczego jest zapisywany, i wydzielamy dla niego mały katalog.
ProtectHome=
ProtectHome=yes czyni /home, /root i /run/user niedostępnymi dla procesu. Dla demonów sieciowych, które nie potrzebują prywatnych danych użytkowników, jest to sensowne wzmocnienie. W środowisku wielowitrynowym warunek często nie jest spełniony, bo docroot lub dane aplikacji bywają pod /home. Brak tej dyrektywy jest wtedy świadomym wyjątkiem, a nie usterką do ukrycia.
RestrictAddressFamilies=
Dyrektywa jest listą dozwolonych rodzin adresów dla wywołania socket(2). Częsty zestaw to:
[Service]
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
AF_UNIX pozwala między innymi na lokalne gniazdo PHP-FPM lub MariaDB, AF_INET odpowiada IPv4, a AF_INET6 IPv6. Ograniczenie zmniejsza powierzchnię ataku innych rodzin, na przykład AF_PACKET, ale nie filtruje celu połączenia i nie wpływa na gniazdo przekazane usłudze przez systemd.
MemoryDenyWriteExecute=
Ustawienie zabrania tworzenia pamięci jednocześnie zapisywalnej i wykonywalnej oraz zmiany pamięci na wykonywalną, co utrudnia część technik wstrzyknięcia kodu po wykorzystaniu błędu. Gwarancji nie daje i potrafi kolidować z JIT, dlatego wcześniej sprawdzamy PCRE JIT w Nginx oraz OPcache JIT w PHP. Dokumentacja wskazuje ponadto, że ochrona wymaga dodatkowych działań, jeżeli proces ma zapisywalny i wykonywalny system plików albo może użyć memfd_create(). To osobny, testowany etap, a nie pozycja domyślnego szablonu.
NoNewPrivileges=
Włączone NoNewPrivileges=yes oznacza, że ani proces, ani jego potomkowie nie zdobędą nowych uprawnień przez execve(). Chroni więc przed wykorzystaniem pliku setuid/setgid albo capability pliku uruchomionego po przejęciu procesu. Nie jest to ogólny zakaz zmiany UID: master PHP-FPM, który już działa z odpowiednimi uprawnieniami, nadal użyje setuid() do uruchomienia workera pod użytkownikiem puli. Realnym ryzykiem zgodności pozostaje program pocztowy setuid uruchamiany przez mail().
CapabilityBoundingSet=
Dyrektywa ustala maksymalny zbiór capability, które może posiadać proces. Wymaga dobrej znajomości jednostki: użytkownika, ExecStart, przejścia na użytkownika workerów, plików certyfikatów i katalogów runtime. Capability nie kopiujemy między usługami i nie traktujemy tej listy jako nadania uprawnień. Najpierw analizujemy jednostkę, uruchamiamy ją w środowisku testowym i obserwujemy logi, a potem odbieramy po jednym capability. Przy problemie cofamy tylko ostatnią zmianę i zapisujemy, dlaczego dany wyjątek jest potrzebny.
ProtectProc= i ProcSubset=
ProtectProc= ukrywa część procesów widocznych w /proc, a ProcSubset= ogranicza widok tej hierarchii. Brzmi to jak proste wzmocnienie, lecz uniwersalnym dodatkiem nie jest: rootowy master nie zyskuje pełnej korzyści z ukrywania procesów, a monitoring i diagnostyka bywają zależne od widoku /proc. Najpierw oddzielamy usługę od roota i odbieramy zbędne capability, dopiero potem oceniamy te dyrektywy na rzeczywistym hoście.
SystemCallFilter=
SystemCallFilter= jest filtrem seccomp dla wywołań systemowych i działa jako lista dozwolona albo zakazana. Zbyt wąski filtr kończy proces sygnałem SIGSYS, także na rzadkiej ścieżce błędu, a bezpiecznej uniwersalnej listy dla całego stosu LEMP nie ma. Dokumentacja rekomenduje łączenie ograniczeń montowania z odebraniem CAP_SYS_ADMIN albo z filtrem blokującym grupę @mount, ale i to wymaga testu. Zanim powstanie polityka, warto obejrzeć lokalną definicję grupy:
systemd-analyze syscall-filter @mount
User=, DynamicUser=, PrivateNetwork= i IPAddressAllow=/Deny=
W drop-inie nie zmieniamy User= ani DynamicUser= dla usługi pakietowej bez pełnego projektu migracji własności plików; przy PHP-FPM i MariaDB z trwałymi danymi jest to szczególnie ryzykowne. PrivateNetwork=yes odcina usługę od zwykłej sieci hosta, co dla Nginx i typowej bazy jest zwykle błędem. IPAddressAllow= oraz IPAddressDeny= przydają się tylko wtedy, gdy dokładnie znamy wszystkie wymagane kierunki ruchu, i nie zastępują przeglądu firewalla.
Restart i limity zasobów
Automatyczny restart poprawia dostępność, ale hardeningiem nie jest. Poniższy fragment dla Nginx lub PHP-FPM oznacza: uruchom usługę ponownie po błędzie i odczekaj pięć sekund między próbami.
[Service]
Restart=on-failure
RestartSec=5s
Restart=on-failure nie podniesie procesu po poprawnym zakończeniu z kodem zero, a RestartSec=5s ogranicza tempo prób. Ustawienia limitu liczby startów, StartLimitIntervalSec= i StartLimitBurst=, należą do sekcji [Unit] i zatrzymują pętlę restartów po przekroczeniu progu; konkretne wartości zależą od aplikacji i polityki monitoringu.
MariaDB wymaga osobnej decyzji operacyjnej. Bezrefleksyjny restart po niepowodzeniu potrafi wydłużyć recovery albo ukryć pierwotny błąd. Przed jego włączeniem ustalamy, kto analizuje alert, ile trwa typowe odtwarzanie i jak odróżniamy brak odpowiedzi od zaplanowanego serwisu.
sudo systemctl show nginx.service -p ActiveState -p SubState -p NRestarts -p Result
Usługa bywa chwilowo w stanie auto-restart, więc nie zawsze pojawi się od razu w systemctl --failed. Monitoring powinien sprawdzać zarówno stan jednostki, jak i działający endpoint aplikacji.
| Dyrektywa | Co kontroluje | Dlaczego nie ma tu wartości przykładowej |
|---|---|---|
MemoryHigh= | Próg, po przekroczeniu którego jądro zaczyna odzyskiwać pamięć i spowalniać cgrupę usługi. | Zbyt mała wartość pogarsza opóźnienia, zanim wystąpi OOM. Traktujemy ją jako pierwszy, obserwowany próg, a nie losową liczbę. |
MemoryMax= | Twardy maksymalny limit pamięci cgrupy usługi. | Za mała wartość kończy proces podczas większego ruchu albo recovery bazy. |
TasksMax= | Maksymalną liczbę procesów i wątków. | PHP-FPM i MariaDB używają wielu procesów lub wątków, a zbyt niski limit wygląda jak losowa awaria. |
CPUQuota= | Długookresowy limit czasu CPU. | Zamienia skok ruchu w długie opóźnienia, choć proces formalnie działa. |
Wdrożenie, test i wycofanie
Najbezpieczniejsza kolejność to Nginx, potem każda jednostka PHP-FPM, a na końcu MariaDB. Trzech usług nie zmieniamy naraz.
1. Przygotowanie zmiany
- Utrzymujemy otwartą działającą sesję administracyjną i zapewniamy sobie konsolę awaryjną.
- Robimy kopię konfiguracji albo commit w systemie zarządzania konfiguracją.
- Wykonujemy inwentaryzację opisaną wyżej.
- Przygotowujemy konkretny test: adres HTTP zdrowia, stronę dynamiczną, logowanie, upload, zadanie PHP i zapytanie do bazy.
- Wybieramy jedną usługę i wyłącznie bazową warstwę ograniczeń.
Dla sklepu i bloga test nie kończy się na kodzie HTTP 200. Przed zmianą ustalamy bezpieczne testy biznesowe:
| Aplikacja | Minimalny test po zmianie sandboxa |
|---|---|
Magento pod example.test/ | Strona główna i karta produktu, obraz produktu, dodanie produktu do koszyka oraz nieprodukcyjny test sesji lub checkoutu zgodny z polityką sklepu. Do tego zadania cron i kolejki, jeżeli są używane. |
WordPress pod example.test/news/ | Aktualność otwarta po bezpośrednim adresie, logowanie do /news/wp-admin/ na koncie testowym, zapis szkicu i wgranie niesekretnego pliku testowego. Do tego cache oraz zaplanowana publikacja lub WP-Cron. W kontrolowanym środowisku potwierdzamy również, że /news/wp-config.php i tymczasowy plik PHP w wp-content/uploads/ zwracają 404; po teście plik usuwamy. |
Nie wykonujemy przy tym prawdziwych płatności ani nie publikujemy danych testowych na produkcji tylko po to, żeby sprawdzić drop-in.
2. Walidacja pliku przed restartem
sudo systemctl daemon-reload
sudo systemd-analyze verify nginx.service
sudo systemctl cat nginx.service
sudo systemctl show nginx.service -p ProtectSystem -p PrivateTmp -p PrivateDevices -p NoNewPrivileges -p CapabilityBoundingSet
systemd-analyze verify szuka błędów definicji, w tym nieznanych dyrektyw. systemctl cat potwierdza, że właściwy drop-in został faktycznie wczytany, a show pokazuje wartości, które widzi systemd. Walidacja składni nie testuje jednak zachowania aplikacji i nie zastępuje restartu ani testu funkcjonalnego.
3. Restart wyłącznie działającej usługi
sudo nginx -t
sudo systemctl try-restart nginx.service
sudo systemctl is-active --quiet nginx.service
sudo journalctl -u nginx.service -b --no-pager -n 100
nginx -t sprawdza konfigurację przed restartem, a try-restart nie podniesie usługi celowo zatrzymanej. Po restarcie wykonujemy rzeczywisty test HTTP/HTTPS, a nie tylko test portu.
sudo php-fpm8.3 -t
sudo systemctl try-restart php8.3-fpm.service
sudo journalctl -u php8.3-fpm.service -b --no-pager -n 100
Nazwa binarna PHP-FPM bywa inna w używanej dystrybucji. Sekretów ani hasła do bazy nie wpisujemy przy tym w historii powłoki.
sudo systemctl try-restart mariadb.service
sudo mariadb-admin ping
sudo mariadb -NBe 'SELECT 1'
sudo journalctl -u mariadb.service -b --no-pager -n 100
Przed restartem bazy sprawdzamy jej narzędziem konfigurację dla konkretnej dystrybucji i planujemy krótki przestój. Dwa środkowe polecenia zakładają lokalny sposób uwierzytelnienia administratora; przy innym modelu stosujemy jego bezpieczny odpowiednik i nie podajemy hasła w linii poleceń.
4. Wycofanie bez kasowania cudzych drop-inów
Na hoście zarządzanym automatycznie cofamy najpierw zmianę w systemie konfiguracji. Ręczne wycofanie dotyczy wyłącznie pliku dodanego w tym kroku:
sudo mv -- /etc/systemd/system/nginx.service.d/60-hardening.conf /etc/systemd/system/nginx.service.d/60-hardening.conf.disabled
sudo systemctl daemon-reload
sudo systemctl try-restart nginx.service
sudo systemctl cat nginx.service
Zmiana rozszerzenia sprawia, że systemd nie traktuje pliku jako drop-inu. systemctl revert stosujemy dopiero po analizie, ponieważ potrafi usunąć także inne lokalne nadpisania tej jednostki. Jeżeli po cofnięciu usługa nadal nie startuje, przyczyna nie musi leżeć w sandboxie: czytamy pełny journal, porównujemy systemctl cat sprzed i po zmianie oraz sprawdzamy zwykłą konfigurację aplikacji.
Ograniczenia tego podejścia
- Nginx i PHP-FPM korzystają z tego samego mechanizmu drop-inów, ale baza wymaga osobnego procesu wdrożenia. Polityki webowej nie rozciągamy na MariaDB tylko dlatego, że również ma jednostkę systemd. Trwałe dane i recovery podnoszą koszt błędu.
- Automatyczny restart nazywamy dostępnością, nie bezpieczeństwem. Dla Nginx i PHP-FPM bywa użyteczny, lecz bez monitoringu zamienia błąd w niewidoczną pętlę restartów.
- Brak
ProtectHome=yes,NoNewPrivileges=yesalboMemoryDenyWriteExecute=yesnie zawsze jest zaniedbaniem. Bywa uzasadnionym wyjątkiem: docroot pod/home, lokalny MTA z bitem setuid albo JIT. Wyjątek opisujemy, testujemy i okresowo oceniamy ponownie. - Lista capability Nginx wymaga utrzymania.
CAP_DAC_OVERRIDEjest szerokie: jeżeli jest potrzebne, zapisujemy dlaczego, a jeżeli nie, usuwamy je po teście. - Wersja systemd jest częścią polityki. Starszy systemd potrafi zgłosić nieznaną dyrektywę i ją zignorować. CI albo playbook powinien wykonywać
systemd-analyze verifyna docelowej wersji systemu, a po wdrożeniu czytamy journal. - Wynik
systemd-analyze securitynie jest celem samym w sobie. Maksymalnie niski wynik uzyskany kosztem backupu, poczty, replikacji lub aplikacji jest porażką, a nie hardeningiem. - Sandbox jednostki PHP-FPM nie rozdziela stron. Gdy jedna jednostka obsługuje wiele pul, podatność w jednej z nich pozostaje w granicach możliwości całego mastera. To osobny temat architektury usług.
Checklista wdrożenia
- Znamy faktyczne nazwy jednostek i wszystkie istniejące drop-iny.
- Znamy katalogi odczytu i zapisu, gniazda oraz wymagane połączenia każdej usługi.
- Dla sklepu rozdzieliliśmy katalogi runtime od kodu wdrażanego przez pipeline, a panelowi WWW bloga nie daliśmy prawa nadpisania rdzenia, wtyczek ani motywów.
- Jeżeli obie aplikacje dzielą master PHP-FPM, świadomie zaakceptowaliśmy sumę ich
ReadWritePaths=albo zaplanowaliśmy osobne jednostki. - Surowa inwentaryzacja, sekrety, adresy i nazwy klientów zostają w zespole.
- Użyliśmy drop-inu w
/etc/systemd/systemi nie zmieniliśmy pliku pakietu. - Wykonaliśmy
daemon-reloadorazsystemd-analyze verify. - Zrestartowaliśmy jedną działającą usługę i przeczytaliśmy jej journal.
- Sprawdziliśmy rzeczywistą funkcję: HTTP/HTTPS, PHP, upload i sesję oraz odczyt z bazy.
- Mamy sprawdzony sposób wycofania dokładnie tego pliku, który dodaliśmy.
- Monitoring obserwuje endpoint aplikacji i pętle restartów, nie tylko port TCP.
- Po aktualizacji systemd lub pakietu ponownie porównamy
systemctl cat, wynik walidacji i testy aplikacji.
Przy każdej zmianie wracamy do dokumentacji upstream: systemd.exec(5) opisuje środowisko wykonawcze i sandboxing, systemd.unit(5) jednostki oraz drop-iny, systemd.service(5) zachowanie restartu, systemd.resource-control(5) limity zasobów, a systemd.directives(7) zawiera indeks wszystkich dyrektyw. Na serwerze otwiera je man 5 systemd.exec i odpowiedniki pozostałych stron. Wyjątki zależne od aplikacji potwierdzamy w dokumentacji PCRE JIT i obsługi location w Nginx, konfiguracji OPcache JIT w PHP, opisie nginx.conf.sample po stronie Magento, zaleceniach WordPressa dla instalacji w osobnym katalogu oraz opisie LOAD DATA INFILE w MariaDB.
Hardening jednostek jest tanią warstwą, która działa dopiero wtedy, gdy nie łamie usługi. Dlatego jego wartość bierze się nie z długości pliku, lecz z inwentaryzacji przed zmianą, jednej usługi na raz i sprawdzonego sposobu wycofania. Sandbox ogranicza skutki błędu, którego i tak trzeba szukać w aplikacji, uprawnieniach i firewallu.
Chcesz ograniczyć skutki przejęcia procesu na serwerze?
Robimy inwentaryzację usług, przygotowujemy drop-iny systemd i wdrażamy je pojedynczo, z testem funkcjonalnym i gotowym sposobem wycofania.
Powiązany artykuł
WordPress na Nginx + PHP-FPM + MariaDB + Certbot
Stos, którego jednostki ograniczamy w tym poradniku.
Zobacz też