Krótko: Cykl życia żądania PHP ma dwa poziomy. Raz na proces PHP ładuje swoje rozszerzenia (faza MINIT) i na końcu je zwalnia (MSHUTDOWN). Dla każdego pojedynczego żądania istnieje osobna faza startu i sprzątania (RINIT i RSHUTDOWN). W pracy webowej worker obsługuje wiele żądań po kolei, dlatego PHP musi między dwoma żądaniami niezawodnie resetować cały stan żądania. Właśnie na tej granicy leżała CVE-2026-109619, druga luka PHP2Shell.
Dwa poziomy: proces i żądanie
Dla osób nietechnicznych pomocny jest obraz: worker PHP jest jak okienko w urzędzie. Okienko jest rano raz urządzane, z formularzami, pieczątkami i komputerem. Potem przychodzi po kolei wielu interesantów. Po każdym interesancie biurko musi być puste, aby następny nie zobaczył cudzych dokumentów.
PHP działa tak samo. Przy starcie procesu ładuje rozszerzenia i ustawienia, które są takie same dla wszystkich żądań. Każde żądanie dostaje wtedy własną pamięć, własne zmienne i własne dane żądania, takie jak $_GET, $_POST czy $_FILES. Na końcu żądania PHP zwalnia tę pamięć. PHP Internals Book ujmuje to krótko: PHP zapomina pamięć żądania na końcu każdego żądania.
Fazy cyklu życia żądania PHP w skrócie
Nazwy pochodzą z wnętrza PHP; każde rozszerzenie może się podpiąć pod te fazy.
| Faza | Kiedy się wykonuje | Jak często | Typowe zadanie |
|---|---|---|---|
| MINIT (Module Init) | przy starcie procesu | raz na proces | rejestracja rozszerzeń, zakładanie wpisów ini, dane trwałe |
| GINIT | przy zakładaniu zmiennych globalnych | na proces, przy wątkach na wątek | przygotowanie zmiennych globalnych rozszerzenia |
| RINIT (Request Init) | na początku każdego żądania | na żądanie | przygotowanie pamięci i stanu żądania |
| Wykonanie | pomiędzy | na żądanie | działa Państwa kod PHP |
| RSHUTDOWN | na końcu każdego żądania | na żądanie | zwolnienie zasobów żądania |
| PRSHUTDOWN | po RSHUTDOWN | na żądanie, rzadko używany | ostatnie prace porządkowe |
| MSHUTDOWN | przy zakończeniu procesu | raz na proces | zwolnienie danych trwałych |
Ważne zdanie z PHP Internals Book dla twórców rozszerzeń: zmienne globalne nie są automatycznie czyszczone po każdym żądaniu. To, co ma być świeże dla każdego żądania, należy zresetować w RINIT. Ta reguła pokazuje, dlaczego granica między dwoma żądaniami jest miejscem krytycznym dla bezpieczeństwa.
Na końcu żądania kolejność jest również ustalona: najpierw wykonują się funkcje shutdown zarejestrowane za pomocą register_shutdown_function(), potem destruktory, następnie opróżniany jest bufor wyjścia. Dopiero po tym następuje RSHUTDOWN.
Dlaczego workery są używane ponownie
W wierszu poleceń proces obsługuje dokładnie jedno żądanie i kończy się. W pracy webowej byłoby to zbyt wolne. PHP-FPM, Apache z mod_php, LiteSpeed i FrankenPHP utrzymują więc workery, które obsługują wiele żądań jedno po drugim. MINIT wykonuje się wtedy raz, a RINIT i RSHUTDOWN tysiące razy. Jak FPM steruje swoimi workerami, wyjaśnia strona PHP-FPM wyjaśniony.
W PHP-FPM pm.max_requests określa, po ilu żądaniach worker jest restartowany. Domyślnie jest to 0, czyli bez ograniczenia. Podręcznik podaje to ustawienie jako środek przeciw wyciekom pamięci w bibliotekach podmiotów trzecich:
; pula FPM: restartować workera po 500 żądaniach (przeciw wyciekom pamięci)
pm = dynamic
pm.max_children = 20
pm.max_requests = 500
Jeszcze dalej idzie tryb workera we FrankenPHP: tam cała aplikacja uruchamia się raz i pozostaje w pamięci. Stan aplikacji przetrwa wtedy także na poziomie PHP. Przykład pokazuje różnicę:
<?php
function anfrageZaehler(): int
{
static $zaehler = 0; // w klasycznej pracy FPM dla każdego żądania znów 0
return ++$zaehler; // w trybie workera wartość liczy dalej między żądaniami
}
echo anfrageZaehler();
W trybie klasycznym wywołanie zawsze wypisuje 1, ponieważ PHP odrzuca pamięć żądania. W trybie workera wartość rośnie, dopóki worker żyje. Dokumentacja FrankenPHP radzi więc świadomie resetować stan związany z żądaniem między żądaniami.
Gdzie uderza CVE-2026-109619
CVE-2026-109619 nie dotyczy kodu Państwa aplikacji, lecz samego PHP. Według php.net wewnętrzny stan należący do żądania mógł przetrwać zwolnienie swojej pamięci. Późniejsze żądanie w tym samym workerze mogło go użyć, zanim inicjalizacja go zresetowała. W obrazie: na biurku w okienku został leżeć świstek, który faktycznie był już sprzątnięty. Szczegółów wykorzystania świadomie nie publikujemy; ocena znajduje się pod hasłem CVE-2026-109619.
Środki przejściowe php.net dotyczące tej luki opierają się na potwierdzonych drogach i nie są pełną ochroną:
session.upload_progress.enabled=0w php.ini, puli FPM lub konfiguracji serwera, potem zrestartować wszystkie workery; nie przezini_set()ani.user.ini.- W PHP 8.5 dodatkowo
fatal_error_backtraces=0i restart. - Usunąć nieprawidłowe lub przestarzałe ustawienia dla każdego żądania, które przed inicjalizacją generują komunikaty. Samo ukrywanie komunikatów według php.net nie pomaga.
- Naprawić w aplikacji błędy krytyczne i przekroczenia czasu, które można wywołać z zewnątrz, także w callbackach oraz przy przetwarzaniu przesłanych plików i importu.
Właściwym lekarstwem jest aktualizacja do PHP 8.5.12, 8.4.27, 8.3.36 lub 8.2.35. Ponieważ błąd tkwi w działającym workerze, działa ona dopiero po restarcie wszystkich procesów; kroki opisano w artykule Restart PHP-FPM i wszystkich workerów.
Po restarcie proszę sprawdzić za pomocą ps -eo pid,lstart,cmd | grep php, czy wszystkie procesy PHP wystartowały po momencie aktualizacji.
Co cykl życia oznacza dla eksploatacji i programowania
Z tego modelu można wyprowadzić jasne reguły. Dla administratorów: aktualizacja jest skuteczna dopiero wtedy, gdy nie działa już żaden proces ze starym kodem. Każdy tryb pracy z długowiecznymi workerami potrzebuje udokumentowanego restartu. Dla programistów: kod działający w trybach workera nie może zostawiać stanu związanego z żądaniem w zmiennych statycznych ani singletonach, na przykład danych użytkownika lub uprawnień. Dla obu grup obowiązuje: przechowywać logi od 20.10.2026, aby później móc ocenić, czy przed poprawką coś się wydarzyło.
Jak konkretnie pomaga PHP SOS?
Za pomocą usługi poprawek PHP wdrażamy ten model w praktyce:
- Ustalenie trybu pracy: Stwierdzamy, czy działa PHP-FPM, mod_php, LiteSpeed, FrankenPHP czy php-cgi i jak zarządza się workerami.
- Aktualizacja i restart: Instalujemy poprawioną wersję i kontrolowanie restartujemy każdego workera.
- Kontrola procesów: Na podstawie czasów startu i wersji dowodzimy, że nie pozostał żaden stary proces.
- Sprawdzenie trybów workera: W długo działających aplikacjach sprawdzamy, czy stan związany z żądaniem jest czysto resetowany.
Częste pytania
Czy PHP uruchamia nowy proces dla każdego żądania?
W wierszu poleceń tak, w pracy webowej zazwyczaj nie. PHP-FPM, Apache z mod_php, LiteSpeed czy FrankenPHP pozwalają jednemu procesowi obsługiwać wiele żądań po kolei. Między żądaniami PHP sprząta stan żądania.
Jaka jest różnica między MINIT a RINIT?
MINIT wykonuje się raz, gdy startuje proces PHP, i zakłada trwałe struktury. RINIT wykonuje się na początku każdego pojedynczego żądania i przygotowuje to, czego potrzebuje tylko to żądanie.
Czy pm.max_requests pomaga przeciw CVE-2026-109619?
php.net nie podaje częstszych restartów workerów jako środka przejściowego. To ustawienie ogranicza tylko, ile żądań worker obsłuży, zanim zostanie zrestartowany. Przeciw luce pomaga aktualizacja z następującym po niej restartem wszystkich workerów.
Czym jest tryb workera we FrankenPHP?
W trybie workera aplikacja uruchamia się raz i pozostaje w pamięci. Oszczędza to czas, ale zmienne statyczne, zmienne globalne i cache zostają zachowane między żądaniami. Według dokumentacji FrankenPHP stan związany z żądaniem trzeba więc świadomie resetować.