— Artikel — № 001

001 —Security

Legacy PHP herbesmetting: de .htaccess-muur die houdt

De WordPress van een klant werd om de paar dagen opnieuw besmet. Bestanden opschonen hielp niet. Dit is het .htaccess-patroon dat het stopte.

Messing hangslot op papieren kaart met lakzegel, geteerd touw en rood lint op eiken werkbank.
Hero · gestileerd stilleven№ 001

De klant belde op dinsdag. Zijn WordPress was zondag opgeschoond — alle bestanden vervangen vanuit een schone tarball, admin-wachtwoorden geroteerd, wp-config salts opnieuw gegenereerd, het gebruikelijke werk. Dinsdagmiddag had Google Safe Browsing het domein alweer gemarkeerd. Dezelfde payload: een Japanse SEO-spaminjectie die wp_options.siteurl herschreef voor niet-ingelogde bezoekers met een specifieke User-Agent.

Dit was de derde opschoning in twee weken. De eigenaar wilde weten of hij het hele ding maar niet opnieuw moest bouwen op een "moderne stack". Dat is bijna nooit het antwoord, en hier ook niet. Het antwoord was dat de opschoning symptomen aanpakte terwijl de voordeur open bleef staan.

De herbesmettingsloop

De meeste herbesmettingen op een verouderde site volgen hetzelfde patroon. Je ruimt de zichtbare rommel op — de geïnjecteerde header.php, de frauduleuze wp-includes/class-wp-xyz.php, de base64-blobs. Je mist één bestand. Dat bestand is een dropper: een kleine, onschuldig ogende uploader die een POST accepteert en schrijft wat hem wordt opgedragen. Een week later komt de aanvaller terug en bouwt de hele kit in veertig seconden weer op.

In dit geval zat de dropper op wp-content/uploads/2021/08/.cache.php. 1,4 KB groot. Hij was acht maanden vóór de eerste zichtbare besmetting geüpload, via een kwetsbare versie van een contactformulier-plugin die de site niet meer gebruikte maar ook nooit had verwijderd. De map van de plugin stond nog op disk. Het kwetsbare endpoint was nog bereikbaar.

Dit is de access log-regel die de besmetting op maandag 19:04 UTC opnieuw opbouwde:

203.0.113.88 - - [19/Apr/2026:19:04:11 +0000] "POST /wp-content/uploads/2021/08/.cache.php HTTP/1.1" 200 412 "-" "Mozilla/5.0"

Een POST naar een PHP-bestand binnen uploads/. Die request hoort niet te bestaan. Er is geen enkele legitieme reden waarom een bestand onder wp-content/uploads PHP zou moeten uitvoeren. Hetzelfde geldt voor wp-content/cache, het grootste deel van wp-includes als je thema geen rare dingen doet, en de volledige map van elke verlaten plugin.

Elke dropper vinden voordat je de deur sluit

Voordat je één regel Apache-config schrijft, moet je weten wat er werkelijk op disk staat. Opschonen wat je kunt zien is niet genoeg; je moet vinden wat je niet kunt zien.

find wp-content/uploads -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.php5" -o -name "*.phar" \) 2>/dev/null
find wp-content/uploads -type f -name "*.*.php*" 2>/dev/null
find . -type f -name "*.php" -newer wp-config.php -not -path "./wp-content/plugins/*" 2>/dev/null

Het derde commando is het commando dat mensen overslaan. Het toont elk PHP-bestand dat nieuwer is dan wp-config.php, dat op de meeste sites al maanden niet is aangeraakt. Wat er verschijnt is ofwel een legitieme plugin-update die je tegen de repo kunt verifiëren, ofwel precies waar je naar zoekt.

Op deze site gaf het eerste commando negen bestanden terug. Twee daarvan waren de dropper en een secundaire shell. Zeven waren oudere injecties die sinds de laatste opschoning sluimerend waren geweest omdat de aanvaller tussen ze rouleerde.

De muur

Zodra de boom schoon is, verhinder je dat PHP wordt uitgevoerd op plekken waar PHP niets te zoeken heeft. In Apache doe je dat met een kleine .htaccess die je in de uploads-map zelf plaatst:

# wp-content/uploads/.htaccess
# Block PHP execution in the uploads tree.

<FilesMatch "\.(php|phtml|php3|php4|php5|php7|php8|phar|pht|inc)$">
    Require all denied
</FilesMatch>

# Also block handler-based execution (some hosts route via AddHandler).
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule \.(php|phtml|phar|pht)$ - [F,L,NC]
</IfModule>

# Deny if someone tries a double extension like image.php.jpg.
<FilesMatch "\.ph(p[3457]?|tml|ar|t)\.">
    Require all denied
</FilesMatch>

Drie lagen, omdat hosts verschillen. FilesMatch met Require all denied is het primaire blok op moderne Apache (2.4+). De RewriteRule vangt handler-gebaseerde uitvoering op bij shared hosting waar AddHandler application/x-httpd-php hoger in de keten is gedeclareerd en een simpele Deny die niet onderbreekt. De dubbele-extensie-matcher handelt de klassieke payload.php.jpg-truc af, waarbij de server uitvoert op de eerste matchende extensie.

Zet hetzelfde bestand in wp-content/cache/.htaccess, en in elke plugin-map die je hebt uitgeschakeld maar nog niet kunt verwijderen. Voor Drupal zijn de equivalente locaties sites/default/files/ en sites/*/files/. Voor Magento 1 is dat media/ en var/. Voor Joomla images/ en tmp/.

Controleer of de muur werkt voordat je het ticket sluit:

echo '<?php echo "executed"; ?>' > wp-content/uploads/_test.php
curl -i https://example.com/wp-content/uploads/_test.php
# Expect: HTTP/1.1 403 Forbidden
rm wp-content/uploads/_test.php

Krijg je 200 OK met het woord executed, dan heeft je directive niet gewerkt. Controleer of AllowOverride in de vhost FileInfo en Limit toestaat, of zet het blok direct in de vhost met een <Directory>-stanza.

Wat de muur niet doet

Hij lost de kwetsbaarheid die de dropper binnenliet niet op. Hij maakt de dropper nutteloos, wat vaak voldoende is — aanvallers gaan door naar zachtere doelen zodra hun toolkit zichzelf niet meer kan reconstrueren. Maar het onderliggende gat zit er nog steeds tot je de verlaten plugin verwijdert, de levende plugins bijwerkt, en audit wat voor custom upload-handler de vorige ontwikkelaar van de site in 2017 heeft geschreven.

Hij helpt ook niet tegen besmettingen die via de database binnenkomen — via SQL-injectie toegevoegde admin-gebruikers, herschrijvingen van wp_options, stored XSS in post-content. Die vragen om een andere ronde: audit wp_users, audit wp_usermeta op wp_capabilities-entries die je niet hebt aangemaakt, diff wp_options.siteurl en wp_options.home tegen wat ze zouden moeten zijn.

Na de muur

Op de site van deze klant ging de .htaccess er dinsdagavond in. De volgende reconstructiepoging kwam woensdag om 02:17 UTC. Het access log laat elf POST-requests naar vier verschillende dropper-paden zien, allemaal met 403. Vrijdag was de aanvaller ermee gestopt. De site is sindsdien schoon.

De cleanup-playbook die we voor deze klant schreven werd het sjabloon dat we nu bij elke herbesmettingszaak gebruiken: zoek elk PHP-bestand onder door gebruikers schrijfbare paden, diff alles wat nieuwer is dan wp-config.php, plaats de muur, verifieer met curl, en ga vervolgens op je gemak de oorspronkelijke kwetsbaarheid jagen.

Toen we Pier bouwden liepen we telkens aan tegen de wrijving van dit soort audits over SFTP in het ene venster, find over SSH in een ander, peuteren in de MySQL editor in een derde, en het spoor bijster raken van wat we hadden aangepast als de klant terugbelde. Zoals we het uiteindelijk hebben opgelost: de bestandsboom, de database en een volledige versiegeschiedenis van elke bewerking blijven in één venster, zodat een incident-walkthrough van vijf minuten ook een walkthrough van vijf minuten blijft.

Wat je vandaag kunt doen

Open een SSH- of SFTP-sessie naar je oudste klantsite. Draai het eerste find-commando hierboven tegen wp-content/uploads. Wat dat teruggeeft is het gesprek dat je hierna voert.

— Vragen —

Breekt het blokkeren van PHP in wp-content/uploads WordPress?

Nee. WordPress core voert geen PHP uit vanuit de uploads-map. Het enige dat kapot kan gaan zijn verkeerd geconfigureerde caching-plugins of custom code die uitvoerbare PHP naar uploads schrijft, en dat laatste is op zichzelf al een rood vlaggetje.

Werkt dit op Nginx?

Nginx negeert .htaccess. Het equivalent is een location-blok in je server-config: location ~* /wp-content/uploads/.*\.php$ { deny all; } geplaatst binnen het server-blok, gevolgd door een reload.

Wat als mijn host LiteSpeed gebruikt?

LiteSpeed respecteert .htaccess-directives die compatibel zijn met Apache, inclusief FilesMatch en Require all denied, dus hetzelfde bestand werkt. De RewriteRule-fallback wordt eveneens ondersteund.