— Artikel — № 038

038 —Security

Valse plugin-updates in wp-content: zeven audit-signalen

Praktische audit voor valse plugin-updates en supply-chain malware in wp-content. Zeven signalen, met grep en SQL die je direct kunt draaien.

Bovenaanzicht van manilamap, grep-spiekbriefje, pluginlijst, SQL-blad, messing sleutel en rode lakzegel op linnen.
Hero · gestileerd stilleven№ 038

Een freelance developer opende op zondag om 23:14 de map wp-content/plugins/woocommerce/ van een klant, omdat de staging-replay in Hotjar liet zien dat de cart-pagina JavaScript draaide dat niemand uit het team geschreven had. De versie-string van de plugin stond op 8.6.1, exact gelijk aan de WordPress.org-repo. Maar twee bestanden in includes/ waren vier dagen ná elk ander bestand in die map aangepast. Geen van beide kwam terug in de changelog. De vorm van een valse plugin-update is meestal precies dit: een echte release, grotendeels intact, met een of twee extra bestanden die niemand verstuurd heeft.

Dat is het nieuwe gezicht van de WordPress supply-chain aanval: geen brute-force, geen bekende CVE, geen gestolen FTP-wachtwoord, maar een valse plugin-update die er legitiem genoeg uitziet om een blik van vijf seconden te doorstaan. Hij komt binnen via een gecompromitteerd maintainer-account, een typosquatted plugin-kloon, of een betaalde plugin die stilletjes verkocht is aan een SEO-spammer. De zeven signalen hieronder zijn de dingen die we daadwerkelijk controleren, in de volgorde waarin we ze controleren, op een verouderde site die we net hebben overgenomen.

Waarom wp-content de standaard landingsplek werd

Drie dingen kwamen samen. WordPress core werd lastiger aan te vallen: automatische updates sloten de long tail van installs op 4.x af, en moderne hardening-guides hebben de meeste bureaus zover gekregen om directe PHP-execution onder wp-includes te blokkeren. De plugin-economie draait ondertussen op duizenden kleine, onbetaalde maintainers, waar een 2FA-reset op het privé-mailadres van een auteur vaak de hele aanvalsketen is. En het plugin-update-mechanisme zelf vertrouwt blind op wat de .org-repo serveert, die op zijn beurt vertrouwt op wat een maintainer pusht. Resultaat: wp-content is nu zowel de makkelijkste plek om code te droppen als de plek waar beheerders het laatst kijken.

De zeven signalen hieronder zijn gerangschikt op hoe goedkoop ze zijn om te controleren. Loop ze van boven naar beneden af op een site die je verdenkt, of zet ze in een wekelijkse cron op een site die je net hebt overgenomen. Geen van hen vereist een betaalde scanner.

Signaal 1: mtime-mismatch binnen één plugin

De duidelijkste verklikker is timing. Een echte plugin-update raakt elk bestand in de bundel binnen dezelfde seconde aan, want onder de motorkap is het een unzip-operatie. Een valse plugin-update die ná die unzip is geïnjecteerd laat precies de vingerafdruk achter die je wilt zien: één enkel bestand dat uren of dagen na de rest is aangepast.

cd wp-content/plugins/woocommerce
find . -type f -newer readme.txt -mtime -30 -ls | sort -k 8

Lees de output op datum. Driehonderd bestanden met datum 2026-05-12 en één bestand met datum 2026-05-16 is de vorm waar je naar zoekt. De uitschieter is je startpunt. Als de hosting bij deploy de timestamps strip (sommige managed-WordPress providers doen dat), val dan terug op checksums vergelijken tegen een verse download van dezelfde plugin-versie uit de .org-repo.

Signaal 2: obfuscated PHP in een bestand dat leesbaar zou moeten zijn

Plugin-source hoort leesbaar te zijn. Echte PHP heeft zelden eval() rond base64_decode() nodig om zijn werk te doen, en de legitieme uitzonderingen (een vendored Composer-dependency, een inline CSS-compiler) duiken bijna nooit onaangekondigd op in de includes/-map van een plugin.

grep -rEn --include="*.php" \
  "(eval\(base64_decode|eval\(gzinflate|eval\(gzuncompress|str_rot13\(base64)" \
  wp-content/

Signaal 3: uitvoerbare PHP onder wp-content/uploads

De uploads-map hoort inert te zijn. Afbeeldingen, PDF's, af en toe een CSV-export. PHP hoort daar nooit uit te voeren. De meest voorkomende post-compromise persistentie-truc is een .php-bestand droppen (of een .png met PHP erin, geserveerd via een te tolerante handler) in een jaar-maand-folder waar niemand kijkt.

find wp-content/uploads -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.php5" -o -name "*.phar" \) -ls
find wp-content/uploads -type f -name "*.png" -size +50k \
  -exec grep -l "<?php" {} \;

De hardening hoort in wp-content/uploads/.htaccess:

<FilesMatch "\.(php|phtml|php5|phar)$">
  Require all denied
</FilesMatch>

Draait de site op Nginx, dan hoort het equivalent in de server-block, niet in een per-directory-bestand. De Apache-documentatie over .htaccess scope en AllowOverride is de moeite waard om door te nemen de eerste keer dat je er een schrijft, want een denied FilesMatch in een context waar AllowOverride uitstaat doet helemaal niets.

Signaal 4: mu-plugins-entries die niemand heeft toegevoegd

WordPress laadt elk PHP-bestand in wp-content/mu-plugins/ automatisch. Ze verschijnen niet op het Plugins-scherm in de admin. Ze kunnen niet vanuit het dashboard gedeactiveerd worden. Ze draaien op elke request. De officiële documentatie omschrijft ze als een deploy-gemak, wat ze ook zijn, en tegelijk als een perfecte schuilplaats voor malware, wat ze óók zijn.

ls -la wp-content/mu-plugins/ 2>/dev/null

Op een schone install bestaat deze map niet, of bevat hij precies één bestand dat je er zelf in hebt gezet. Op een gecompromitteerde site bevat hij vaak iets onschuldig ogends: wp-cache.php, index.php, db-health.php. Lees het regel voor regel. Heb jij het niet geschreven en heeft je hostingmaatschappij het niet geschreven, dan is dát de backdoor.

Signaal 5: een admin-user die niet via het dashboard is aangemaakt

Dit is een database-signaal in plaats van een filesystem-signaal. Zodra een aanvaller schrijfrechten op het filesystem heeft, is de volgende zet meestal persistentie via een tweede admin-account dat een plugin-herinstallatie overleeft. Het dashboard maakt deze users aan met een normale user_registered timestamp en een mailadres dat je zou herkennen. Een directe SQL-insert laat vaak een placeholder-mailadres achter, een datum dichtbij nul, of een login die op een echte lijkt (admln, wpadmin1, support_wp).

SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key LIKE '%_capabilities'
  AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;

Let op de table-prefix. Gebruikt de install wp_xy_users, dan is de meta_key wp_xy_capabilities. Draai de query in je MySQL editor naar keuze en lees de geregistreerde datums. Alles wat buiten kantooruren is aangemaakt, door niemand in het bijzonder, is de rij die je wilt onderzoeken. Kruisreferentie met wp_users.user_email: een aanvaller gebruikt vaak een gratis mailadres met een numeriek achtervoegsel, of een domein waar je nog nooit van gehoord hebt.

Signaal 6: autoloaded options met een serialized payload

De wp_options-tabel wordt bij elke request gelezen wanneer autoload op yes staat. Het is ook een populaire plek om een payload te verbergen, want de meeste developers queryen die tabel nooit. Een autoloaded rij van 200KB is niet automatisch malicious. Sommige cache-plugins slaan daar legitiem fragmenten op. Maar het is altijd de moeite waard om te openen.

SELECT option_name, LENGTH(option_value) AS bytes, autoload
FROM wp_options
WHERE autoload = 'yes'
ORDER BY bytes DESC
LIMIT 25;

Open de top vijf met de hand. Alles wat begint met a: of O: is een PHP-serialized array of object. Kun je het niet lezen, plak dan de eerste 200 karakters in een serialized-data unpacker en kijk wat erin zit. Een echt cache-fragment is saai en zelf-verklarend. Een malicious payload bevat meestal een URL, een base64-blob, of een lijst admin-mailadressen die klaargezet worden voor exfiltratie. De valse plugin-update gebruikt deze rij soms als zijn command-and-control-kanaal, in plaats van het filesystem überhaupt aan te raken.

Signaal 7: plugin header-versie tegenover de .org-repository

Het laatste signaal wordt het vaakst overgeslagen, omdat het te voor de hand liggend voelt. De header bovenaan het hoofdbestand van een plugin ziet er zo uit:

<?php
/**
 * Plugin Name: WooCommerce
 * Plugin URI:  https://woocommerce.com/
 * Version:     8.6.1
 * Author:      Automattic
 */

Open dezelfde plugin op WordPress.org. Zegt de .org-repo dat 8.6.1 is uitgebracht op een datum die weken na de eigen mtime van het bestand ligt, dan is de versie-string met de hand bewerkt om een oudere, kwetsbare kopie te vermommen. De header van de valse plugin-update is bijgewerkt om het getoonde versienummer gelijk te houden met de legitieme release, zodat de update-melding in het admin-dashboard nooit afgaat. Voor betaalde plugins: vergelijk tegen de gepubliceerde release notes van de developer. Een versienummer dat je niet kunt herleiden naar een release-post is een versienummer dat een aanvaller heeft geschreven.

De signalen combineren

Geen enkel signaal op zich is een doodsvonnis. Een mu-plugin kan een legitieme toevoeging van de hostingmaatschappij zijn. Een autoloaded option van 200KB kan een echt cache-fragment zijn. Een obfuscated string kan een slecht geschreven maar onschadelijke third-party library zijn. De kracht van de zeven zit hem in clustering. Een echte backdoor laat bijna altijd drie ervan tegelijk achter: een mtime-uitschieter, een eval-call, en óf een mu-plugins-entry óf een autoloaded option. Vind je twee onafhankelijke signalen in dezelfde audit, behandel het dan als waarschijnlijke compromittering en start een backup-en-diff-workflow tegen een schone kopie van elke plugin. Vind je er één, leg het vast en kijk volgende week opnieuw.

Het kleinste dat je vandaag kunt doen

Pak één site die je in meer dan zes maanden niet hebt aangeraakt. Draai de zeven commando's hierboven achter elkaar. De hele audit past in vijftien minuten als er niks aan de hand is, een uur als er wel iets is. De twee dingen die we het vaakst tegenkomen zijn een oude mu-plugin uit een hosting-migratie en een autoloaded option van een al lang verwijderde cache-plugin. Beide zijn makkelijk op te ruimen. Wat zelden voorkomt maar enorm veel uitmaakt is signaal 1: het enkele bestand met de verkeerde mtime. Dát is degene die een terugkerende kalenderherinnering waard is.

Toen we Pier bouwden, kwamen we precies deze vorm van probleem vaker tegen dan verwacht. Klanten vroegen ons om een gecompromitteerde site op te schonen, en die opschoning had zelf een vangnet nodig, want een valse plugin-update terugdraaien op een live webshop om 02:00 is niet het moment voor giswerk. Zoals wij het uiteindelijk hebben opgelost: elke wijziging die Pier op een remote bestand maakt wordt ingepakt in een version history snapshot, zodat het terugdraaien van een wijziging (van jou of van een aanvaller) één klik is in plaats van een database-restore.

— Vragen —

Wat is het verschil tussen een valse plugin-update en een CVE-exploit?

Een CVE-exploit gebruikt een bekende bug in een actuele versie. Een valse plugin-update vervangt of vult de code van de plugin zelf aan met PHP die de aanvaller controleert, meestal via een gecompromitteerd maintainer-account of een typosquatted kloon in de .org-repo.

Haalt het opnieuw installeren van WordPress core de wp-content malware weg?

Nee. De core-reinstaller raakt alleen wp-admin en wp-includes aan. Alles onder wp-content, mu-plugins, of autoloaded rijen in wp_options overleeft de herinstallatie volledig onaangeroerd.

Is een .htaccess deny-regel genoeg om PHP-execution in uploads te stoppen?

Op Apache wel, mits AllowOverride aan staat voor die map. Op Nginx hoort het equivalent in de server-block. Blokkeer in beide gevallen ook gevaarlijke extensies al op het moment van uploaden.