monstertov@php2shell

CVE-2026-109618 · CVE-2026-109619 · CVE-2026-115560

· monster research · monstertov · tov.monster

Introduction

er kwam een video voorbij waarin met een php exploit commando's op een webserver werden uitgevoerd. mijn eerste gedachte was dat het wel weer zo'n proof-of-concept zou zijn die alleen werkt als alles precies goed staat ingesteld. die zie je wel vaker. maar dit ging niet om een lekke wordpress-plugin of een foutje in één website. de kwetsbaarheid zat in php zelf.

dus ik ging verder kijken. een paar uur later begonnen allerlei websites offline te gaan. en niet alleen een paar kleine sites. het NCSC haalde de eigen website uit de lucht, zoals ook door de overheid werd gemeld. ook diensten als DigiD, MijnOverheid en Overheid.nl werden geraakt door preventieve maatregelen. zelfs de website van de Tweede Kamer ging tijdelijk offline. de rijksoverheid bevestigde de voorzorgsmaatregelen.

en toen kwamen de ziekenhuizen erbij. ikazia legde uit waarom de website tijdelijk niet bereikbaar was en ZGT publiceerde een eigen update. in de berichtgeving kwamen ook maastricht UMC+, het jeroen bosch ziekenhuis en bovenij voorbij. dit overzicht van de ziekenhuiswebsites laat zien hoe breed de onrust was.

op dat moment zag het eruit als serious shit. overheidsdiensten, ziekenhuizen en zelfs de nationale cybersecurityorganisatie haalden websites offline vanwege hetzelfde php-probleem. ik dacht serieus even dat we aan het begin stonden van iets dat het halve internet zou raken.

om int0x80 te quoten: "all the cool stuff happens in memory", eens kijken of dat hier klopt. het interessante zat namelijk niet in een opvallende url of een ingewikkeld loginformulier, maar in hoe php intern met geheugen omgaat.

wel een belangrijk verschil: die websites gingen grotendeels uit voorzorg offline, terwijl beheerders de situatie onderzochten en updates doorvoerden. offline gaan betekent nog niet dat er al iets mis was. het leek meer op een paniekreactie dan op echt bewijs dat er werkende aanvallen plaatsvonden. dat onderscheid lijkt een grote rol te spelen in de nasleep van dit alles.

Waar het oorspronkelijke onderzoek vandaan komt

de oorspronkelijke kwetsbaarheden, inmiddels bekend als PHP2Shell, zijn ontdekt en gemeld door andere securityonderzoekers. Wiz Research schreef een technische analyse en Calif publiceerde uitgebreid eigen onderzoek. het werk van calif hing samen met OpenAI's Patch the Planet-initiatief. wiz kwam onafhankelijk van die route ook bij de problemen uit. samen met de php-ontwikkelaars en andere partijen werd de disclosure en het patchproces gecoördineerd.

op 21 oktober 2026 verschenen noodupdates voor CVE-2026-109618 en CVE-2026-109619. beide kregen een kritieke score van 9.2. het gaat om fouten in de interne request state en verwijzingen naar geheugen dat al is vrijgegeven. onder de juiste omstandigheden kan dat leiden tot het uitlezen van geheugen of remote code execution (rce), zonder dat een aanvaller eerst hoeft in te loggen.

twee dagen later, op 23 oktober, kwam daar CVE-2026-115560 bij. dit probleem heeft ook met upload-progress en sessies te maken, maar vereist een hele specifieke combinatie van serialisatie-instellingen. de noodupdates van 21 oktober verhelpen die derde cve niet. volgens de melding van het php-project stond een oplossing voor een latere reguliere bugfixrelease gepland.

het doel van deze write-up is niet om die oorspronkelijke ontdekkingen te claimen. het gaat om het namaken van de verschillende chains op eigen labservers, uitzoeken waarom sommige tot rce leiden en vooral zien wat er overblijft zodra de configuratie iets afwijkt.

De testomgeving en de resultaten

het onderzoek groeide uit tot een toolkit met tien chains. elke chain werd getest in gecontroleerde dockeromgevingen, met vaste php-versies en reproduceerbare webserverconfiguraties. daarnaast zijn op eigen labservers acht extra productieachtige profielen gebouwd. daarin verschilden juist de builds, workers en instellingen, om te zien of de resultaten overdraagbaar waren. alle tests vonden plaats op eigen systemen.

de korte samenvatting: alle tien de chains zijn onder hun eigen labvoorwaarden getest; chain j bleef onbevestigd. drie werkten op gepatchte php-releases, mits de benodigde kwetsbaarheid in de applicatie ook aanwezig was. één chain maakte het mogelijk om na de eerste setup meerdere commando's uit te voeren zonder nieuwe workercrashes.

maar toen dezelfde verzameling chains tegen de acht productieachtige labprofielen werd getest, leverde dat geen werkende rce op zonder de eerdere, specifieke kalibratie. dat verschil is mischien nog wel interessanter dan de geslaagde tests.

Gebruikte labomgevingen

omgevingphpserver / sapiconfiguratiedoel
stock-control8.4.26apache mod_php, één workerimage defaultschains reproduceerbaar controleren
prod-control8.4.26apache mod_phpphp.ini-productionproductieachtige php-instellingen
patched-control8.4.27apache mod_php, één workergelijk aan stockde vendorfix controleren
wall-747.4.33apache mod_phpimage defaultsgrens bij oudere versies
wall-82/81/838.2.34 / 8.1.31 / 8.3.xapache mod_phpimage defaultsgeheugenleks testen op andere versies
unserialize-lab8.5.11apache mod_phpaparte testapplicatieunserialize-chain
115560-lab8.5.12, zelf gecompileerdapache mod_phpvijf profielen, inclusief negatieve controlesverwarring tussen serializers
fpm-lab8.4.4php-fpm + nginxrequest buffering uitstall-uaf-primitive

alle dockerimages stonden vast op hun digest. zo verandert een test niet opeens omdat een upstream image een nieuwe build krijgt. de composeomgeving start met docker compose up -d.

Wat er nou precies misgaat in PHP

de oorspronkelijke cves gaan over onderbroken initialisatie of cleanup en achtergebleven globale state. het scenario met upload-progress-tellers hieronder is één onderzocht pad, geen volledige uitleg van beide cves. php heeft sinds versie 5.4 de feature session.upload_progress. hiermee kan een applicatie bijhouden hoe ver een bestandsupload is. in een standaardconfiguratie staat de functie aan:

session.upload_progress.enabled = On
session.upload_progress.cleanup = On
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = "1"

als een multipart post een veld met de naam PHP_SESSION_UPLOAD_PROGRESS bevat vóór het bestand, kan php de voortgang gaan opslaan voordat de applicatiecode zelf wordt uitgevoerd. daarvoor houdt de rfc1867-parser pointers bij naar tellers in een session-hashtable:

progress->post_bytes_processed = zend_hash_str_find(
    Z_ARRVAL(progress->data), "bytes_processed", ...);
progress->current_file_bytes_processed = zend_hash_str_find(
    Z_ARRVAL(progress->current_file), "bytes_processed", ...);

die bewaarde pointers zouden ongeldig worden als de opslag met de tellers wordt vrijgegeven terwijl de parser er nog naar verwijst. een normale sessie-update bewijst op zichzelf niet dat dit gebeurt, omdat de progress-data een referentie behoudt. de volgorde hieronder illustreert de onderzochte voorwaarde, geen bevestigd uitvoeringsverloop.

FILE_START      bewaar adressen van counter-zval*
FILE_DATA       werk tellers bij in geldige slots
update()        initialiseer / vervang sessiedata
next FILE_DATA  gebruik oude pointers nadat opslag is vrijgegeven

een verdacht stukje broncode betekent niet meteen dat je ook echt een exploit hebt. de cruciale vraag is of precies het arData-blok met zo'n teller daadwerkelijk wordt vrijgegeven en daarna opnieuw wordt aangeschreven. daarom draaiden de tests om waarneembaar gedrag, niet alleen om wat de code op het eerste gezicht lijkt te doen.

De tien chains, van A tot en met J

Chain A: Session Injection + LFI

php zet de naam van een geüpload bestand in de sessiedata. normaal wordt die naam gewoon als tekst behandeld. maar als een applicatie daarnaast een local file inclusion (lfi) bevat waarmee het sessiebestand als php wordt ingeladen, kan die bestandsnaam uitvoerbare code worden. in het lab werd hiervoor <?php system('id'); ?> als bestandsnaam gebruikt.

chain a werkte op 14 php-versies, van 7.4.33 tot en met 8.5.12, dus ook op versies met de securitypatches. de belangrijke voorwaarde is die extra lfi. alleen een bestandsnaam kunnen meesturen is niet genoeg. dit is precies waarom een bijgewerkte php-versie niet automatisch betekent dat een kwetsbare applicatie veilig is.

timing speelt ook mee. met session.upload_progress.cleanup ingeschakeld verwijdert php de progress-entry zodra de upload klaar is. de inclusion moet daarom plaatsvinden terwijl de payload nog aanwezig is, of de opstelling moet een andere voorwaarde hebben die deze bewaart.

Chain B: Unserialize UAF

voor chain b was een applicatie nodig die unserialize(user_input) uitvoert en daarna een property schrijft. daarbij kon een resize van de hashtable arData vrijgeven terwijl de verwerking nog bezig was.

in de labtest werd een crash oracle gebruikt. na ongeveer 2.200 requests waren de heap- en librarybases te achterhalen. vervolgens stuurde een vervalste datastructuur een destructor aan richting system().

op php 8.5.11, van vóór de fixes voor CVE-2026-109618 en CVE-2026-109619 in php 8.5.12, gaf de chain uid=33 terug. daarmee was rce in die labopstelling bevestigd. het ging wel om een applicatie met een onveilige unserialize-route, niet om willekeurige websites op dezelfde php-versie.

Chain C: auto_prepend_file zonder steeds te crashen

bij veel memory corruption-pocs lukt één commando en ligt daarna de worker eruit. chain c liet zien dat het ook anders kan.

na een eerste uaf-trigger werd PG(auto_prepend_file) aangepast zodat deze naar een php-shell onder /tmp wees. bij latere requests laadde php dat bestand via zijn eigen prepend-mechanisme. de setup veroorzaakte nog steeds één crash, maar daarna konden de volgende commando's achter elkaar draaien:

uname -a
whoami
ls /

op php 8.4.26 werkte dat zonder nieuwe workercrash tussen de commando's. daarmee was het resultaat stabieler dan een eenmalige rce-poc.

Chain D: Serializer Confusion, CVE-2026-115560

deze chain is bijzonder omdat de kwetsbare applicatieconfiguratie ook kan bestaan op versies die op 21 oktober al gepatcht waren.

upload progress schrijft beïnvloedbare data naar de sessie volgens het ingestelde serialisatieformaat. de applicatie moet starten met php_binary of php_serialize, maar tijdens runtime vóór session_start() overschakelen naar php. die laatste parser ziet | als scheiding tussen sessierecords. daardoor kunnen dezelfde bytes anders worden geïnterpreteerd dan toen ze werden opgeslagen. met een geschikte __destruct()-gadget in de applicatie kan dit uitmonden in object injection en uiteindelijk een webshell.

op php 8.5.12 gaven beide kwetsbare serializerprofielen uid=33 terug. daarnaast zijn twee negatieve controles uitgevoerd: één zonder upload progress en één zonder omschakeling van de serializer. in beide gevallen werkte de exploit niet. dat ondersteunt juist de specifieke oorzaak van de chain.

per 23 oktober 2026 beschreef php deze route als onwaarschijnlijk in normale configuraties, omdat de combinatie nogal ongebruikelijk is. het advies was om upload progress uit te zetten op systemen waar die combinatie toch voorkomt, totdat er een fix is. dat is dus niet hetzelfde als zeggen dat elke bijgewerkte php-site makkelijk te kraken is.

Chain E: Fiber UAF

fibers zitten sinds php 8.1 in de taal en kunnen code tijdelijk onderbreken en later hervatten. dat maakt de levensduur van objecten en verwijzingen net iets ingewikkelder.

bij een specifieke fiberconstructie in de applicatie kon een class-string callable te vroeg worden vrijgegeven. er bleef dan een ongeldige verwijzing achter. chain e werkte onder die voorwaarden op php 8.2.35, een gepatchte release. zonder het bijbehorende gebruik van fibers in de applicatie gaat die route niet zomaar op.

Chains F, G en H: Stock, Pure en 78lab

deze drie memory chains pakten het probleem op ongeveer dezelfde manier aan. tijdens het verwerken van multipart data werd op een zorgvuldig gekozen moment een out-of-memory-fout veroorzaakt. daarbij kon PS(id) naar vrijgegeven geheugen blijven wijzen. een vervolgrequest op dezelfde worker probeerde vervolgens via de Set-Cookie-header heap- en libraryadressen te lekken. met die informatie konden vervalste structuren een destructor naar zif_system omleiden.

enginetestconfiguratieresultaat
stockstandaard image; 128m/8m limieten, display_errors=Off, meerdere workersphp 8.4.26, reproduceerbaar, uid=33
purephp.ini-productionphp 8.4.26, uid=33
78labop het lab afgestemde instellingenphp 8.4.26, geverifieerd

vervolgens zijn dezelfde lekpatronen op php 8.3.x, 8.2.34, 8.1.31 en 7.4.33 geprobeerd. elke versie kreeg meer dan 20 probes, zonder één succesvolle leak. vijf onafhankelijke implementaties liepen tegen dezelfde grens aan: wat voor php 8.4/8.5 was afgestemd, werkte niet zomaar op oudere versies.

dat betekent niet dat oude php-versies veilig zijn. alleen dat deze specifieke publieke memory chain daar niet op dezelfde manier aanslaat.

Chain I: Reverse-Shell Stager

bij één chain was er maar plek voor een command van 14 bytes. voor een normale reverse-shell-one-liner is dat te weinig.

de stager gebruikte daarom de tijdelijke file van een nog lopende upload. zolang dat request open bleef, kon een langer script in dat bestand blijven staan. de beperkte commandruimte hoefde vervolgens alleen naar die file te verwijzen:

bash</tmp/php*

dat is 14 bytes. de test draaide om de lengtebeperking en om hoelang zo'n tijdelijk uploadbestand beschikbaar blijft. het is geen bewijs dat een willekeurige upload op zichzelf command execution geeft.

Chain J: Stall-UAF op PHP-FPM

hier werd het pas echt interessant. de eerdere out-of-memory-chains hebben op php-fpm een fundamenteel probleem: bij die fout sterft de worker, en daarmee verdwijnt ook de geheugenstatus die de volgende stap nodig heeft.

chain j gebruikte geen geheugenuitputting. in plaats daarvan werd een gestreamde multipart upload even stilgezet. als die pauze lang genoeg duurde, kon er tijdens dezelfde upload nog een progress-update plaatsvinden. daarna werden opnieuw databrokken verstuurd.

de test draaide op php 8.4.4 met php-fpm achter nginx. daarvoor stond fastcgi_request_buffering off, zodat nginx de upload niet eerst helemaal opving en php de data in delen ontving.

phase 1 sent (6000B)
after stall:  bytes_processed=5308
chunk 1:     bytes_processed=10510   laatste zichtbare update / re-init
chunk 2:     bytes_processed=10510   geen nieuwe session update
chunk 3:     bytes_processed=10510
chunk 4:     bytes_processed=10510
after end:   bytes_processed=0       cleanup

chunk één activeerde de update. de volgende chunks werden verwerkt zonder nieuwe zichtbare sessie-update, terwijl de worker gewoon bleef draaien. deze tellers bewijzen op zichzelf geen stale-pointer write, omdat normale progress-updates worden begrensd door frequentie-instellingen.

voor volledige rce moet de ongeldige schrijfactie eerst worden bevestigd en moet de betreffende slab van 320 bytes door een gecontroleerde allocatie worden hergebruikt. die stap was nog niet voltooid. de metingen tonen dus nog geen bewezen primitive of complete php-fpm-rce aan.

ook grappig: in een referentie-poc zaten underscores in de session-id. php accepteert die tekens daar niet, waardoor die poc zijn eigen monitoring stuk maakte. met geldige ids was dat probleem uit de metingen verdwenen.

Waarom het op realistischere labservers misging

de tien chains zijn getest in de omgevingen waarvoor ze waren ingericht. vervolgens zijn ze tegen acht andere, productieachtige serverprofielen op eigen labservers gedraaid. die profielen gebruikten bewust andere builds, versies en instellingen, om niet stiekem dezelfde ideale situatie te herhalen.

labprofieluitkomst
php 8.4.26 / apache mod_php, meerdere serverprofielenoffsets en configuratie weken af van de gekalibreerde chains
php 8.2.34 / apache mod_php op freebsdgeheugenlek kon niet worden overgenomen
php 7.4.x, verschillende profielenmemory chains kwamen niet goed op gang
wordpress / php-fpmout-of-memory-chains verloren hun status; voor de applicatiechains ontbraken passende sinks

in geen van die acht profielen kwam rce tot stand. niet omdat de eerdere labresultaten ineens ongeldig waren, maar omdat een exploit veel gevoeliger is voor zijn omgeving dan een video met een werkende poc doet vermoeden.

Apache mod_php en PHP-FPM zijn niet hetzelfde

met apache mod_php kan een worker in bepaalde situaties blijven bestaan na een mislukte request, waarna een volgende request de oude geheugenstatus tegenkomt. php-fpm kan de worker bij een out-of-memory-fout beëindigen. de bijbehorende dangling state is dan weg.

dat blokkeert de onderzochte out-of-memory-chains, maar is geen algemene bescherming tegen de onderliggende kwetsbaarheid. chain j liet bij streaming gedrag zien dat verder onderzoek verdient. calif beschrijft daarnaast volledige rce op php-fpm via een ander pad met achtergebleven decoder-state na de request-startup.

Een andere build, andere adressen

memory exploits hangen af van details zoals offsets van zif_system en zval_ptr_dtor, heapindeling, filler counts, spray shapes en de grootte van vervalste structs. zelfs tussen docker-builds en handmatig gecompileerde varianten waren andere instellingen nodig.

op een andere linuxdistributie, architectuur of compilerversie kan hetzelfde versienummer dus een andere geheugenlayout opleveren. de publieke chains waarmee is vergeleken hadden geen universele methode om die waarden automatisch op afstand opnieuw te bepalen.

ASLR en de juiste worker raken

address space layout randomization (aslr) maakt runtimeadressen onvoorspelbaar. een memory leak kan helpen om die adressen te vinden, maar dan moet de eerste stap natuurlijk wel werken.

bovendien moeten sommige chains hun voorbereidende en volgende request bij dezelfde apache-worker afleveren. bij een pool met bijvoorbeeld vijf workers kan dat, onder simpele aannames, een race van ongeveer één op vijf per poging worden. met één worker in een lab valt dat probleem nauwelijks op.

Waarom oudere PHP-versies niet automatisch makkelijker zijn

dit specifieke lek kon ik op de geteste oudere builds niet reproduceren. dat bewijst geen algemene grens bij php 8.4: publieke labrapporten beschrijven ook heap- en libraryleks op php 8.3.33. verschillen tussen builds en kalibratie blijven belangrijke beperkingen bij het vergelijken van deze resultaten.

natuurlijk blijven oudere php-versies om allerlei andere redenen riskant. maar een memory exploit die op 8.4 goed werkt, is niet meteen bruikbaar op de hele oude php-vloot.

De derde CVE is afhankelijk van de applicatie

ook CVE-2026-115560 is geen universele bypass. er moet worden begonnen met php_binary of php_serialize, daarna moet vóór session_start() naar php worden overgeschakeld, en upload progress moet aanstaan. de negatieve controles lieten precies zien waarom die voorwaarden ertoe doen.

Waren al die websites nou echt gehackt?

op 23 oktober 2026 moet je drie dingen uit elkaar houden: systemen die mogelijk kwetsbaar zijn, pogingen om een kwetsbaarheid te misbruiken en daadwerkelijk bevestigde hacks.

het NCSC onderzocht een melding over mogelijke exploitatie in het buitenland, maar succesvol misbruik was niet bevestigd. in het bijgewerkte NCSC-advies staat ook dat exploitatie complex is, terwijl patchen nog steeds dringend wordt aangeraden.

de laboratoriumresultaten passen in dat beeld. op een bekende build met de juiste instellingen was rce aantoonbaar. zet daar een onbekende build of een andere sapi tegenover, dan gaat een groot deel van de aannames onderuit. in de acht extra labprofielen lukte geen enkele rce.

en die websites die op 21 oktober verdwenen? de genoemde overheidsdiensten en ziekenhuizen gingen volgens de beschikbare berichten vooral preventief offline. dat zag er nogal heftig uit, maar het bewees niet dat al die organisaties waren binnengedrongen.

Wat beheerders nu moeten doen

voor de eerste twee kritieke cves kwamen op 21 oktober de volgende upstream php-fixes uit:

php-takgepatchte versie
php 8.58.5.12
php 8.48.4.27
php 8.38.3.36
php 8.28.2.35

oudere, end-of-life-versies hebben een betrouwbare vendor-backport, de uitzonderlijke php-broncodebackports voor 7.4, 8.0 en 8.1, of een overstap naar een ondersteunde release nodig. die broncodebackports herstellen de reguliere ondersteuning niet. per 23 oktober lossen deze releases CVE-2026-115560 nog niet op; voor dat specifieke configuratieprobleem gelden de tijdelijke aanwijzingen van het php-project.

als een site php-uploadprogress niet gebruikt, kan de functie in de daadwerkelijke webserverconfiguratie uit:

session.upload_progress.enabled = Off

die instelling kan in php.ini staan, maar ook in de apacheconfiguratie of een php-fpm-pool, bijvoorbeeld via php_admin_flag. herstart daarna de service die php echt uitvoert, zodat alle oude workers worden vervangen. een apache-restart herstart geen aparte fpm-pool.

controleer ook welke php-versie de website daadwerkelijk draait. php -v in een terminal kan prima naar een andere installatie wijzen dan de versie achter nginx of apache.

instellingen als session.upload_progress.cleanup = On en fastcgi_request_buffering on kunnen bepaalde aanvalspaden lastiger maken, maar zijn geen patch. kijk daarnaast naar applicatiecode met onveilige unserialize()-aanroepen, local file inclusion en aanpassingen aan de sessieserializer tijdens runtime.

via de reverse proxy kun je multipart requests beperken op routes waar helemaal geen uploads thuishoren. doe dat per route, zodat normale uploadfuncties blijven werken.

Conclusie: serieus, maar niet de hack-the-world-bug die ik verwachtte

toen die eerste video voorbij kwam, leek het vooral een interessante php-poc. een paar uur later stonden het NCSC, overheidswebsites en ziekenhuissites in het nieuws omdat ze offline gingen. op dat moment dacht ik echt even dat dit een enorm internetbreed probleem ging worden.

na tien chains in gecontroleerde omgevingen en nog acht productieachtige profielen op eigen labservers ziet het beeld er anders uit. nog steeds serieus, alleen niet zo simpel als het in die eerste uren leek.

twee kritieke cves kregen noodpatches. op 23 oktober verscheen er nog een derde, waarbij onder bijzondere applicatie-instellingen ook op bijgewerkte php-versies een probleem kan bestaan. meerdere chains leverden in het lab daadwerkelijk command execution op, en de php-fpm-tests laten zien dat niet alles al is uitgezocht.

maar dit is, op basis van wat ik kon reproduceren, niet die simpele hack-the-world-vulnerability waar ik aanvankelijk bang voor was. een exploit die perfect werkt op één bekende build is nog geen exploit die je op willekeurige servers kunt loslaten. aslr, verschillen tussen builds, workerselectie, php-versies en applicatiecode zitten allemaal in de weg.

dat is goed nieuws voor verdedigers. alleen sowieso geen reden om een kwetsbare php-installatie ongepatcht te laten.

wat ik vooral interessant vind, is wat er de komende dagen nog boven water komt. de oorspronkelijke onderzoekers gebruikten ai om oude problemen in php te onderzoeken en te bewijzen. nu zo veel mensen naar dezelfde code kijken, zou het me niet verbazen als er nog ernstigere varianten of nieuwe cves worden gevonden. dat is een mogelijkheid, geen voorspelling dat het internet alsnog omvalt.

voorlopig is het advies simpel: patch wat bekend is, zet ongebruikte functies uit en blijf de ontwikkelingen volgen. de interessantste uitkomst van dit onderzoek was uiteindelijk niet alleen uid=33 in een terminal. het was vooral ontdekken waar een werkende labexploit ophoudt betrouwbaar te zijn.

Bronnen en verdere technische uitleg