Przejdź do treści

Gdzie siedzi interpreter – model wdrożenia a bezpieczeństwo aplikacji

W pierwszej części cyklu opisałem PHP2Shell – dwie luki w silniku PHP, przez które urzędy w Polsce i za granicą wyłączały swoje strony. Najważniejszy szczegół techniczny tej historii łatwo przeoczyć: ścieżka ataku prowadziła przez kod, który PHP wykonuje przy parsowaniu żądania, zanim skrypt aplikacji w ogóle wystartuje. Można było mieć kod napisany wzorowo, z walidacją, zapytaniami parametryzowanymi i testami – i nie miało to żadnego znaczenia.

To przesuwa pytanie o bezpieczeństwo z „jak napisałem aplikację” na „gdzie i jak ją uruchomiłem”. Ono zwykle umyka, bo na tanim hostingu nikt go nie zadaje – i właśnie to jest jednocześnie największa zaleta PHP i jego największe ryzyko. Prześledźmy więc jedno żądanie HTTP przez cztery modele wdrożenia i sprawdźmy w każdym, co stoi między internetem a kodem, kto decyduje o wersji interpretera i co w ogóle może zostać wywołane z zewnątrz.

Model 1: interpreter wewnątrz serwera WWW

Tak do dziś wygląda większość tanich hostingów – i tak sam konfigurowałem PHP choćby w serii o serwerze poczty na FreeBSD: Apache z modułem mod_php. Interpreter PHP jest wczytany do procesu serwera WWW. Żądanie przychodzi do Apache’a, Apache widzi rozszerzenie .php i oddaje plik interpreterowi, który działa w tym samym procesie.

To jest niesamowicie wygodne. Wrzucasz plik kontakt.php przez FTP i pod adresem /kontakt.php od razu działa formularz. Żadnego serwera aplikacji, żadnej konfiguracji tras, żadnego restartu. Dlatego PHP wygrał sieć.

Z punktu widzenia bezpieczeństwa ten model ma jednak trzy cechy, które warto nazwać wprost:

  1. Każdy plik .php w katalogu publicznym jest punktem wejścia. Trasy aplikacji wyznacza system plików, nie kod. Jeśli napastnikowi uda się w jakikolwiek sposób zapisać plik .php w katalogu dostępnym z sieci – przez źle zabezpieczony upload zdjęć, dziurawą wtyczkę, wykradzione hasło do FTP – ten plik od razu staje się działającym programem. Tak właśnie działa klasyczny web shell, od lat najpopularniejszy sposób utrzymywania się na przejętej stronie.
  2. Interpreter przetwarza żądanie wcześniej niż aplikacja. Parsowanie formularzy, plików, ciasteczek sesji i śledzenie postępu uploadu to funkcje silnika, włączane globalnie w php.ini. Aplikacja, która nigdy nie przyjmuje plików, i tak ma aktywny parser plików – bo to nie ona o nim decyduje.
  3. O wersji interpretera decyduje ktoś inny. Na hostingu współdzielonym PHP jest częścią serwera, nie aplikacji. Wersję wybierasz z listy w panelu, a hostingodawca trzyma na liście stare wersje, bo inaczej połowa klientów przestałaby działać. Aplikacja zostaje na PHP 7.4, bo „przecież działa”, i nikt tego nie widzi – bo tej informacji nie ma w żadnym pliku projektu.

Klasyczna obrona przed punktem pierwszym to wyłączenie wykonywania PHP w katalogu na pliki użytkowników:

# Apache: katalog uploads/ – pliki są tylko danymi, nie programami
<Directory "/var/www/example/uploads">
    php_admin_flag engine off
    <FilesMatch "\.(php|phtml|phar)$">
        Require all denied
    </FilesMatch>
</Directory>

Działa, ale zwróć uwagę, jak wygląda ta obrona: to wyjątek dopisany do konfiguracji, którą trzeba znać, pamiętać i powtórzyć przy każdej migracji. Domyślnie wszystko jest wykonywalne, a bezpieczeństwo trzeba dołożyć. To dokładnie ten sam układ, który opisywałem przy SQL Injection: kiedy najprostsza droga jest zarazem najgroźniejsza, problem będzie wracał, niezależnie od tego, ilu ludzi zna poprawną odpowiedź.

Model 2: PHP-FPM i front controller

Nowoczesne PHP wygląda inaczej. Serwer WWW (najczęściej nginx) nie wykonuje PHP, tylko przekazuje żądanie do osobnej puli procesów PHP-FPM. Pule mogą działać na osobnych użytkownikach systemowych, z osobnymi limitami i ustawieniami – jedna aplikacja nie widzi plików drugiej.

Do tego dochodzi zmiana w samych aplikacjach. Symfony, Laravel i większość współczesnych frameworków ma jeden plik wejściowy, public/index.php (tzw. front controller), a trasy są zdefiniowane w kodzie. Konfiguracja serwera przekazuje do PHP tylko ten jeden plik:

# nginx: do PHP-FPM trafia wyłącznie front controller
location / {
    try_files $uri /index.php$is_args$args;
}

location = /index.php {
    fastcgi_pass unix:/run/php/php8.4-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}

# każdy inny plik .php: 404, nawet jeśli ktoś go podrzuci
location ~ \.php$ {
    return 404;
}

Przy takiej konfiguracji podrzucony web shell przestaje być programem – staje się zwykłym plikiem, którego serwer nie wykona. Punkt pierwszy z modelu 1 znika. Punkt trzeci słabnie, bo wersja PHP-FPM jest zwykle zapisana w konfiguracji serwera albo obrazie kontenera.

Punkt drugi zostaje. PHP-FPM nadal sam parsuje treść żądania, zanim przekaże ją do index.php – i dlatego PHP2Shell dotknął zarówno mod_php, jak i FPM. Model 2 to ogromny postęp, ale interpreter nadal jest pierwszą rzeczą, która dotyka danych od napastnika.

Model 3: serwer aplikacji za reverse proxy

Tak typowo uruchamia się aplikacje w Pythonie, Node.js czy Javie. Aplikacja to jeden długo działający proces (w Pythonie np. Gunicorn albo Uvicorn), który sam nasłuchuje na porcie lub gnieździe. Przed nim stoi reverse proxy, zwykle nginx, które jako jedyne rozmawia z internetem. Dokumentacja Gunicorna wprost zaleca taki układ.

# nginx: reverse proxy przed aplikacją w Pythonie
server {
    listen 443 ssl;
    server_name example.pl;

    # limit rozmiaru żądania egzekwowany, zanim cokolwiek dotrze do aplikacji
    client_max_body_size 10m;

    location / {
        proxy_pass http://unix:/run/gunicorn.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Co się zmienia w porównaniu z modelem 1:

  • Punktami wejścia są wyłącznie trasy zdefiniowane w kodzie. Plik shell.py wrzucony do katalogu z obrazkami pozostaje plikiem tekstowym. Python nie wykonuje plików dlatego, że leżą w katalogu – nie ma takiego mechanizmu. Żeby podrzucony kod się wykonał, trzeba by go zaimportować, a to zrobi tylko sama aplikacja.
  • Parsowanie żądania jest częścią aplikacji, nie środowiska. W Pythonie formularze i pliki parsuje framework albo jego biblioteka (Django, Werkzeug we Flasku, Starlette w FastAPI). Te biblioteki też miewają błędy, i to poważne – ale ich wersja jest zapisana w requirements.txt albo pyproject.toml, a aktualizacja to zwykły commit, który przechodzi przez testy jak każda inna zmiana.
  • Wersja interpretera jest zapisana w projekcie. W pliku Dockerfile, w .python-version, w konfiguracji CI. Jest widoczna w repozytorium, a jej podbicie przechodzi przez ten sam proces co każda inna zmiana kodu.
  • Proxy filtruje ruch, zanim dotrze do interpretera. Limit rozmiaru żądania, limit czasu, odrzucanie nieprawidłowego HTTP – to robi nginx, napisany w C i pilnowany przez inny zespół niż aplikacja. Napastnik musi przejść przez dwie różne implementacje, a nie jedną.

Ten model nie jest bezpieczniejszy dlatego, że Python jest lepszy od PHP. Jest bezpieczniejszy, bo każda decyzja jest jawna: co jest punktem wejścia, na jakich wersjach wszystko działa i co przechodzi przez proxy. Nie da się tak uruchomić aplikacji, nie wiedząc, jak jest uruchomiona.

Model 4: brak interpretera

Najmniej oczywisty model to ten, w którym w chwili obsługi żądania nie działa żaden kod aplikacji. Tak działa ten blog: przez lata stał na WordPressie, dziś jest statycznym serwisem na Astro i Cloudflare Workers. Kod wykonuje się raz, przy budowaniu, a serwer rozdaje gotowe pliki HTML.

Gdy wybuchł PHP2Shell, nie musiałem sprawdzać ani aktualizować niczego. Nie dlatego, że byłem przewidujący, tylko dlatego, że nie było czego atakować: brak PHP, brak bazy danych, brak panelu logowania. Nie da się wykorzystać luki w interpreterze, którego nie ma.

Oczywiście nie każda strona może być statyczna – sklep, system egzaminacyjny czy panel administracyjny potrzebują kodu, który działa przy każdym żądaniu. Ale zaskakująco wiele stron, które dziś stoją na WordPressie, to w praktyce ulotka: kilka podstron i formularz kontaktowy, aktualizowane dwa razy w roku. Dla nich model 4 usuwa całą kategorię problemów.

Zestawienie

Model 1: mod_phpModel 2: PHP-FPM + front controllerModel 3: serwer aplikacji + proxyModel 4: statyk
Co jest punktem wejściakażdy plik .phpjeden plik, trasy w kodzietrasy w kodziebrak kodu w trakcie żądania
Kto pierwszy parsuje żądanieinterpreter PHPinterpreter PHPproxy, potem frameworkserwer plików / CDN
Gdzie zapisana jest wersja runtime’upanel hostingukonfiguracja serwera lub obrazrepozytorium projektu–
Podrzucony plik z kodemwykonuje sięnie wykonuje się (przy dobrej konfiguracji)nie wykonuje sięnie wykonuje się
Próg wejścianajniższyśredniwyższyzależy od strony

Warto przeczytać tę tabelę od dołu: im niżej, tym mniej darmowych rzeczy dostaje napastnik i tym więcej trzeba wiedzieć na starcie. To jest właściwa cena łatwości PHP – nie sam język, tylko to, że domyślny sposób jego uruchamiania podejmuje za ciebie decyzje, które w innych modelach podejmujesz sam.

Sekcja uczciwości

Gdybym na tym skończył, wpis sugerowałby, że wystarczy zmienić język, żeby być bezpiecznym. To nieprawda, z trzech powodów.

Po pierwsze, PHP można uruchomić w modelu 3. Aplikacja w Symfony czy Laravelu, w kontenerze z PHP-FPM, z wersją przypiętą w Dockerfile, za nginx przepuszczającym tylko index.php, z wyłączonymi zbędnymi funkcjami silnika – to praktycznie ten sam profil ryzyka co aplikacja w Django. Jedyna istotna różnica, która zostaje, to fakt, że parser żądań jest częścią interpretera, a nie wersjonowaną biblioteką aplikacji.

Po drugie, Pythona da się wdrożyć katastrofalnie. Najlepszy przykład to tryb debugowania we Flasku. Debugger Werkzeuga pozwala z poziomu przeglądarki wykonać dowolny kod Pythona na serwerze, a dokumentacja wprost ostrzega, że nie wolno go używać na produkcji. Mimo to aplikacje z włączonym debugerem regularnie lądują w internecie:

# Flask – tak NIE uruchamiamy aplikacji na serwerze
app.run(host="0.0.0.0", debug=True)

Jedna linijka, a jeśli PIN debugera wycieknie albo zostanie odgadnięty, napastnik ma pełne zdalne wykonanie kodu. Model wdrożenia nie pomoże, jeśli ktoś wystawi do internetu narzędzie, które z definicji służy do wykonywania kodu.

Po trzecie, kontener też się starzeje. Wersja zapisana w Dockerfile jest widoczna, ale nie aktualizuje się sama. Przy PHP2Shell okazało się, że oficjalny obraz Dockera php dla wersji 8.1 i starszych w ogóle nie dostanie poprawki. Obraz zbudowany dwa lata temu i nigdy nie przebudowany to dokładnie ten sam problem co PHP 7.4 wybrane w panelu hostingu – tylko lepiej ukryty, bo „przecież mamy Dockera”.

Co z tego zostaje

PHP jest łatwy, bo ukrywa model wdrożenia. To była jego siła i powód, dla którego tylu z nas od niego zaczynało. Ale ukryty model to model, którego decyzji nikt nie podejmuje świadomie – a decyzje, których nikt nie podjął, nie są aktualizowane, nie są testowane i nie są przeglądane. Po latach utrzymywania aplikacji widzę, że to właśnie one, a nie błędy w kodzie, sprawiają najwięcej kłopotów przy takich wydarzeniach jak PHP2Shell.

Model 3 nie jest bezpieczny sam z siebie. Jest po prostu jawny: wszystko, co ma znaczenie dla bezpieczeństwa, jest zapisane w repozytorium i przechodzi przez przegląd, testy i historię zmian. W następnej części cyklu opiszę, jak to wyglądało u mnie – dlaczego, wychowany na PHP, piszę dziś głównie w Pythonie, i co z tamtych przyzwyczajeń zabrałem ze sobą.