php2shell: afschermen, patchen en weer open in één dag
Op 20 oktober kondigde het PHP-project twee kritieke kwetsbaarheden aan (CVE-2026-109618/109619, bijgenaamd php2shell). Een aanvaller zonder inloggegevens kan daarmee code uitvoeren, via het verwerken van een multipart-upload nog vóórdat je applicatie draait. De patch kwam een dag later, maar alleen voor PHP 8.2 en hoger. Voor 8.1 en ouder komt er niets.
Werk: tientallen vhosts op één hostingserver
Een aantal WordPress-sites draaide op een gedeelde hostingserver met een beheerpaneel: elke site een eigen PHP-FPM-container, nginx ervoor, en allemaal op PHP 8.1. Patchen kon dus niet, en PHP uitzetten via het paneel ook niet.
Stap 1: de valkuil van "de site staat uit"
De sites waren offline gehaald en weer aangezet met een statische index.html. De homepage zag er goed uit, maar /wp-login.php gaf gewoon de WordPress-login. nginx kiest index.html alleen voor /. Elk pad dat op .php eindigt, ging nog steeds naar PHP, zelfs paden die niet bestonden.
Les één: test niet de homepage, test /wp-login.php, /wp-cron.php en een verzonnen /test.php.
Stap 2: afschermen in nginx
Eén rewrite op serverniveau, direct onder server_name:
rewrite ^/(?![.]well-known/acme-challenge/) /__onderhoud last;
location = /__onderhoud { default_type text/html; try_files /index.html =503; }
Rewrites op serverniveau draaien vóórdat nginx een location kiest. Daardoor komt location ~ \.php$ nooit aan de beurt, wat er verder ook in de config staat. De exacte doellocatie kan alleen index.html geven, of een 503, en dus nooit via try_files terugvallen op index.php. Het ACME-pad blijft vrij, zodat certificaten gewoon verlengd worden.
Een multipart-POST naar een .php-pad kreeg daarna een 405 Not Allowed van nginx zelf. De aanval bereikt PHP niet meer.
Stap 3: wat ik bijna miste
- Aliassen delen de backend. Elke site had naast de echte naam ook een paneelnaam (
site-xxxx.paneldomein). Die staat niet in de publieke DNS, maar is met een Host-header op het IP gewoon bereikbaar en gebruikt dezelfde PHP-container. Eén naam dichtzetten is niet genoeg: zet álle vhosts op dezelfde backend dicht. - Immutable configs. De vhost-bestanden én de map waren beschermd met
chattr +i, zodat het paneel eigen aanpassingen niet overschrijft.sed -ischrijft een tijdelijk bestand in dezelfde map en faalde dus stil. Het script haalt nu de vlag van map en bestand, past aan, test metnginx -t, herlaadt en zet de vlag terug. - Een verborgen header bewijst niets. Sommige vhosts hadden
fastcgi_hide_header X-Powered-By. Geen PHP-header betekent dan niet "geen PHP". Ik controleerde op inhoud: geeft elk pad de onderhoudspagina?
Stap 4: patchen en per backend openen
Na de server-update stonden PHP 8.2.35 tot en met 8.5.12 klaar. Elke site ging naar 8.3. Pas als de container aantoonbaar op de nieuwe versie draaide, ging de afscherming eraf, per backend en met een controle in het script zelf:
# per vhost: welke backend, en draait die container op een gepatchte versie?
ip=$(grep -ohE 'fastcgi_pass\s+[0-9.]+' "$vhost.conf" | awk '{print $2}')
[[ ${PHPVERSIE[$ip]} =~ ^ecp-php8[2-5]$ ]] && open "$vhost"
Drie containers bleven na het omzetten in het paneel toch op 8.1 draaien. Het script liet ze dicht; een tweede ronde in het paneel loste het op. 's Avonds draaide alles weer, op PHP 8.3.36.
Homelab: dezelfde vraag, kleinere schaal
Thuis draaien een paar PHP-applicaties achter mijn eigen reverse proxy: een cloudomgeving, een wachtwoordmanager, webmail en een WordPress-site. Wat ik daar deed:
- Tussenmaatregel overal:
session.upload_progress.enabled = Offensession.use_strict_mode = Onals drop-in inconf.d, niet viaini_set. Dat is de officieel aanbevolen mitigatie tot de gepatchte images er zijn. - Beheerinterfaces alleen vanaf het LAN. IP-adresbeheer en DNS-filter-admin waren via de reverse proxy van buiten bereikbaar. Dat is nu
allow <lan>; deny all;, via GitOps uitgerold en van buitenaf gecontroleerd op een 403. - WordPress-hardening op de proxy:
xmlrpc.php,readme.html, de users-API en author-enumeratie (?author=1) geven een 403. - De cloudomgeving bijgewerkt naar de nieuwste onderhoudsversie. De ingebouwde updater faalde op een certificaatfout bij de leverancier zelf; handmatig via de tarball, met de oude installatie als vangnet ernaast.
Wat ik meeneem
- Meet van buitenaf, op inhoud. Niet "de homepage werkt", maar: wat geeft een
.php-pad, en wat geeft een POST? - Denk in backends, niet in hostnamen. De aanvaller kiest de makkelijkste naam.
- Maak terugdraaien even makkelijk als afschermen. Elk script maakte een backup, en het terugzetscript vergelijkt eerst of het bestand precies het origineel plus de toegevoegde regels is.
- Leg het vast. Evidence met tijdstempels en checksums schreef ik tijdens het werk, niet achteraf.