← Alle artikelen

Een kritiek PHP-lek zonder patch voor oudere versies. Hoe ik op het werk een hostingserver met tientallen WordPress-vhosts binnen een dag afschermde, patchte en weer openzette, en wat ik in mijn homelab deed.

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 -i schrijft een tijdelijk bestand in dezelfde map en faalde dus stil. Het script haalt nu de vlag van map en bestand, past aan, test met nginx -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 = Off en session.use_strict_mode = On als drop-in in conf.d, niet via ini_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

  1. Meet van buitenaf, op inhoud. Niet "de homepage werkt", maar: wat geeft een .php-pad, en wat geeft een POST?
  2. Denk in backends, niet in hostnamen. De aanvaller kiest de makkelijkste naam.
  3. 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.
  4. Leg het vast. Evidence met tijdstempels en checksums schreef ik tijdens het werk, niet achteraf.