Przejdź do treści
Bezpieczeństwo·~31 min czytania

Hardening usług systemd dla Nginx, PHP-FPM i MariaDB

Izolacja procesów krok po kroku: mapa uprawnień usługi, drop-in zamiast pliku pakietu, trzy różne profile oraz test i wycofanie zmiany.

Autor: Zespół LinuxLab

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

  1. Czego systemd nie zastępuje
  2. Krótki słownik
  3. Mapa uprawnień usługi
  4. Sklep pod / i blog pod /news/
  5. Inwentaryzacja przed zmianą
  6. Drop-in, nie plik pakietu
  7. Wspólna warstwa sandboxa
  8. Profil Nginx
  9. Profil PHP-FPM
  10. Profil MariaDB
  11. Dyrektywy bez kopiowania w ciemno
  12. Restart i limity zasobów
  13. Wdrożenie, test i wycofanie
  14. Ograniczenia tego podejścia
  15. 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ługaCo robi w stosieGranice warte ograniczenia
NginxPrzyjmuje 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-FPMUruchamia 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.
MariaDBPrzechowuje 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-address bazy;
  • 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=yes daje 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ługaZwykle czytaZwykle zapisujeKomunikuje się przezCzęste pułapki
Nginxkonfigurację, certyfikaty, pliki statycznecache, logi, pliki tymczasowe, PIDTCP 80/443, socket PHP-FPM lub lokalne TCPniestandardowy katalog cache, uploady, proxy do innej usługi, PCRE JIT
PHP-FPMkod aplikacji, sekrety, pliki biblioteksesje, uploady, cache, pliki aplikacji, logisocket Unix, MariaDB, SMTP/API/Redis przez TCPwiele pul w jednej jednostce, mail(), OPcache JIT, katalog strony pod /home
MariaDBkonfigurację, certyfikaty replikacji, danekatalog danych, socket i PID, binlogi, logi, pliki tymczasowesocket Unix, TCP dla klientów i replikacjiniestandardowy 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;
}
ElementCo robiPo co jest i przed czym chroni
upstream fastcgi_backendNadaje 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 = /newsDopasowuje 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*.phpZwracają 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ówWykrywa 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 PHPSprawdza 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_controllerPró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.sampleDodaje 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

AplikacjaKatalogi zwykle wymagające zapisuCo lepiej generować poza PHP-FPM
MagentoWybrane 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.
WordPresswp-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ń:

  1. Które katalogi usługa zapisuje po pełnym restarcie?
  2. Czy któryś socket, PID albo plik tymczasowy leży w /tmp, /var/tmp, /home lub /run/user?
  3. Czy PHP uruchamia lokalny program pocztowy przez mail()?
  4. Czy Nginx ma włączony PCRE JIT, a PHP - OPcache JIT?
  5. Czy baza używa replikacji, nietypowego katalogu danych, szyfrowania, backupu wykonywanego przez proces bazy albo niestandardowego pluginu?
  6. 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
OperacjaCo robiCzy włącza nowy sandbox?
daemon-reloadWczytuje definicje jednostek do systemd.Nie.
reloadProsi działający program o przeładowanie własnej konfiguracji, o ile ten to wspiera.Nie.
restart lub try-restartZatrzymuje 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.

DyrektywaCo dokładnie robiPrzed czym chroniNa co uważać
ProtectSystem=fullMontuje 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=yesTworzy 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=yesDaje 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=yesUdostę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=yesOdmawia 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=yesBlokuje 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=yesUdostę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=yesBlokuje 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=yesUż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=yesOdmawia 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=yesBlokuje 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=yesOdmawia 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=yesBlokuje 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=nativeDopuszcza 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
DyrektywaZnaczenie w NginxWarunek wdrożenia
NoNewPrivileges=yesProces 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=yesBlokuje 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.
CapabilityDlaczego bywa potrzebneRyzyko
CAP_NET_BIND_SERVICEWiązanie portu uprzywilejowanego, na przykład 80 lub 443.Bez niego master nie uruchomi listenera na takim porcie.
CAP_SETUIDPrzejście workerów na użytkownika nieuprzywilejowanego.Pozwala zmieniać UID, więc zostawiamy je tylko wtedy, gdy jest naprawdę potrzebne.
CAP_SETGIDUstawienie grup workerów.Pozwala zmieniać GID i grupy.
CAP_KILLMaster sygnalizuje workery działające pod innym UID.Pozwala wysyłać sygnały do procesów, więc zakres wymaga kontroli.
CAP_CHOWNInicjalizacja 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_OVERRIDENiekiedy 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:

UstawienieCo dajeDlaczego potrafi zepsuć PHP-FPM
NoNewPrivileges=yesBlokuje 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=yesUtrudnia 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=yesUkrywa /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=strict nie naprawiamy błędu wpisem ReadWritePaths=/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.

UstawienieKiedy ma sensCo sprawdzamy wcześniej
ProtectHome=yesGdy 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=yesGdy 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_ADMINGdy 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.

DyrektywaCo kontrolujeDlaczego 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

  1. Utrzymujemy otwartą działającą sesję administracyjną i zapewniamy sobie konsolę awaryjną.
  2. Robimy kopię konfiguracji albo commit w systemie zarządzania konfiguracją.
  3. Wykonujemy inwentaryzację opisaną wyżej.
  4. Przygotowujemy konkretny test: adres HTTP zdrowia, stronę dynamiczną, logowanie, upload, zadanie PHP i zapytanie do bazy.
  5. 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:

AplikacjaMinimalny 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

  1. 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.
  2. 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.
  3. Brak ProtectHome=yes, NoNewPrivileges=yes albo MemoryDenyWriteExecute=yes nie 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.
  4. Lista capability Nginx wymaga utrzymania. CAP_DAC_OVERRIDE jest szerokie: jeżeli jest potrzebne, zapisujemy dlaczego, a jeżeli nie, usuwamy je po teście.
  5. Wersja systemd jest częścią polityki. Starszy systemd potrafi zgłosić nieznaną dyrektywę i ją zignorować. CI albo playbook powinien wykonywać systemd-analyze verify na docelowej wersji systemu, a po wdrożeniu czytamy journal.
  6. Wynik systemd-analyze security nie jest celem samym w sobie. Maksymalnie niski wynik uzyskany kosztem backupu, poczty, replikacji lub aplikacji jest porażką, a nie hardeningiem.
  7. 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

  1. Znamy faktyczne nazwy jednostek i wszystkie istniejące drop-iny.
  2. Znamy katalogi odczytu i zapisu, gniazda oraz wymagane połączenia każdej usługi.
  3. 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.
  4. Jeżeli obie aplikacje dzielą master PHP-FPM, świadomie zaakceptowaliśmy sumę ich ReadWritePaths= albo zaplanowaliśmy osobne jednostki.
  5. Surowa inwentaryzacja, sekrety, adresy i nazwy klientów zostają w zespole.
  6. Użyliśmy drop-inu w /etc/systemd/system i nie zmieniliśmy pliku pakietu.
  7. Wykonaliśmy daemon-reload oraz systemd-analyze verify.
  8. Zrestartowaliśmy jedną działającą usługę i przeczytaliśmy jej journal.
  9. Sprawdziliśmy rzeczywistą funkcję: HTTP/HTTPS, PHP, upload i sesję oraz odczyt z bazy.
  10. Mamy sprawdzony sposób wycofania dokładnie tego pliku, który dodaliśmy.
  11. Monitoring obserwuje endpoint aplikacji i pętle restartów, nie tylko port TCP.
  12. 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.