— Artikel — № 019

019 —Security

Gehackte WordPress herkennen: de 5-minuten audit

Je hebt FTP, het databasewachtwoord en misschien vijf minuten voordat de klant wakker wordt. Dit is de audit die je tot een oordeel brengt, ongeveer in deze volgorde.

Messing schuifmaat op crèmekleurig papier met potloodstreepjes en rood wasmerk, op donker eikenhouten oppervlak.
Hero · gestileerd stilleven№ 019

De Slack-melding komt binnen om 23:41. De WordPress-site van een klant krijgt een Google Safe Browsing-waarschuwing, de agency lead zit om 06:00 in een vliegtuig, en jij hebt de FTP-credentials en het databasewachtwoord in 1Password staan. Tijd om de site lokaal te klonen heb je niet. Je hebt misschien vijf minuten voordat de klant gaat appen.

Dit is de audit die ik draai, ongeveer in deze volgorde, op elke WordPress-site waarvan ik vermoed dat hij is gecompromitteerd. Hij gaat uit van shell- of FTP-toegang, MySQL-toegang en verder niets. Geen Wordfence-abonnement, geen actieve malwarescanner, geen verse back-up. Alleen de live installatie en een terminal.

De twee bestanden die aanvallers als eerste aanraken

Begin bij het bestandssysteem. In mijn eigen opschoonwerk laten verreweg de meeste WordPress-compromitteringen sporen achter op twee plekken: wp-config.php en .htaccess. Lees beide. Niet greppen, gewoon van boven naar beneden lezen.

In wp-config.php zoek je naar alles boven de openings-<?php-tag (PHP die draait vóór de standaardconfig), naar elke require van een bestand buiten de WordPress-installatie, en naar elke base64_decode, gzinflate, eval of str_rot13. Een schone wp-config heeft daar geen enkele van. Zie je bovenaan een one-liner met obfuscated payload, dan heb je je antwoord.

In .htaccess is het patroon meestal een conditionele RewriteRule die alleen afgaat bij verwijzingen vanuit zoekmachines of specifieke user agents. De klassieke versie ziet er zo uit:

RewriteCond %{HTTP_REFERER} (google|bing|yahoo|yandex) [NC]
RewriteCond %{HTTP_USER_AGENT} !(bot|crawl|spider) [NC]
RewriteRule .* http://malicious-domain.tld/redirect.php [R=302,L]

De site oogt prima als je hem direct bezoekt. Alleen bezoekers die binnenkomen via een zoekresultaat worden omgeleid. Die asymmetrie is precies het doel van de aanval: voor de eigenaar lijkt de site gezond, terwijl het SEO-verkeer wordt verzilverd. De mod_rewrite-documentatie van Apache legt de conditionele syntax uit als je wilt nakijken wat elke directive precies doet.

Vier SQL-queries op de live database

Nu de database. Open MySQL en draai deze vier queries op het WordPress-schema. Elke query richt zich op een ander trucje waarmee aanvallers persistentie behouden.

1. Admin-gebruikers die jij niet hebt aangemaakt

SELECT ID, user_login, user_email, user_registered
FROM wp_users
ORDER BY user_registered DESC
LIMIT 20;

Let op accounts die in de laatste 30 dagen zijn geregistreerd en die niemand in het team zich herinnert aangemaakt te hebben. Kruisverwijs met wp_usermeta voor gebruikers van wie de wp_capabilities-metawaarde administrator bevat. Aanvallers maken vaak een tweede admin aan, zodat het intrekken van de eerste (of het roteren van het oorspronkelijke admin-wachtwoord) ze niet buitensluit.

2. Actieve plugins en geplande taken

SELECT option_value FROM wp_options WHERE option_name = 'active_plugins';
SELECT option_value FROM wp_options WHERE option_name = 'cron';

Beide resultaten zijn geserialiseerde PHP-arrays. Loop de lijst met actieve plugins door. Alles wat je niet herkent, alles met een generieke naam als wp-cache-helper of seo-tools, alles in een directory die niet matcht met een plugin in de officiële WordPress.org plugin directory, krijgt een kritische blik. Kijk daarna naar wp-content/plugins/ op schijf: de meeste legitieme plugins komen met een readme.txt, een languages-map en een build-directory. Eén anoniem PHP-bestand in een eigen slug-mapje is een rode vlag.

De cron-option somt elke geplande hook op die WordPress kent. Aanvallers zijn dol op wp-cron omdat ze er een payload mee kunnen inplannen die elk uur afgaat, zonder zichtbare admin-UI. Hooks als wp_update_check_v2 of namen die je niet kunt herleiden tot een plugin die je herkent, zijn verdacht tot het tegendeel bewezen is.

3. Geïnjecteerde post-inhoud

SELECT ID, post_title FROM wp_posts
WHERE post_content LIKE '%<iframe%'
   OR post_content LIKE '%display:none%'
   OR post_content LIKE '%eval(base64%';

Geïnjecteerde spamlinks zitten vaak in gepubliceerde posts, verstopt achter een display:none-div of een iframe buiten beeld. Voor een menselijke bezoeker leest de post normaal; alleen zoekmachines indexeren de verborgen ankerteksten.

De mu-plugins-map en de uploads-map

Twee mappen waaruit WordPress code uitvoert en die de meeste eigenaren vergeten: wp-content/mu-plugins/ en (bij verkeerde configuratie) wp-content/uploads/.

Must-use plugins laden automatisch en hebben geen vermelding in de admin-UI. Er is geen "deactiveer"-knop. Als mu-plugins/ bestaat en jij hebt er geen bestand neergezet, lees dan elk PHP-bestand erin. Het is het schoonste persistentie-mechanisme dat een aanvaller kan kiezen.

De uploads-map hoort alleen media te bevatten. Vind je een .php-bestand onder wp-content/uploads/, dan klopt er iets niet. Draai dit vanaf de WordPress-root via SSH:

find wp-content/uploads -name "*.php" -type f

Een schone installatie geeft niets terug. Elke output is óf een kwetsbaarheid (een upload-formulier dat geen bestandsextensie valideerde) óf een al geplaatste web shell. Als noodgreep zet je een .htaccess-bestand in de uploads-map met daarin php_flag engine off, en onderzoek je daarna pas waar de aanvaller binnenkwam.

Integriteit van core-bestanden in 30 seconden

Staat WP-CLI op de server, dan vertelt dit ene commando je of er een core-bestand is gewijzigd ten opzichte van wat WordPress.org heeft uitgeleverd:

wp core verify-checksums

Het commando vergelijkt elk bestand in wp-admin/ en wp-includes/ met de officiële checksums. Een schone installatie meldt Success: WordPress installation verifies against checksums. Een gecompromitteerde installatie laat precies de bestanden zien die niet kloppen. Die output, samen met wat je vond in wp-config.php en .htaccess, is meestal genoeg om te weten of je gaat opschonen of terugzet vanaf een back-up.

Wat te doen met de bevindingen

Vijf minuten brengt je tot een oordeel, niet tot een fix. Komen twee of meer checks vies terug, dan is de site gecompromitteerd en is de enige veilige route een back-up terugzetten van vóór de vroegste verdachte timestamp, en daarna dichten waarmee ze binnenkwamen. Komt alles schoon terug en wordt de site nog steeds geflagd, dan is het de moeite waard om de mailqueue van de server en de laatste 48 uur aan access logs te lezen voordat je aanneemt dat Google het mis heeft.

Bij het bouwen van Pier, de chat-editor die we maken voor elke legacy site, kwamen we deze flow vaak genoeg tegen dat we de audit rechtstreeks in de chat hebben gedraad: wijs hem op een FTP- en MySQL-verbinding, vraag of de site is gecompromitteerd, en hij draait de bestandschecks en SQL-queries hierboven op de live installatie. Elk bestand dat hij aanraakt belandt in de version history, en dezelfde MySQL editor laat je een rij of een bestand diffen tegen de staat van vorige week als je toch gaat opschonen.

Het kleinste dat je vandaag kunt doen, ook op een site die je vertrouwt: lees je eigen wp-config.php en je eigen .htaccess één keer van boven naar beneden. Herken je niet elke regel, dan heb je huiswerk.

— Vragen —

Kan ik deze audit draaien zonder shell-toegang?

Het grootste deel wel. De bestandschecks werken via SFTP, en de SQL-queries werken in phpMyAdmin of Adminer. Alleen de wp-cli checksum-stap heeft shell nodig, of het equivalent bij een managed host.

Hoe vers moet de back-up zijn?

Ouder dan de vroegste verdachte timestamp die je vond. Als wp_users een rogue admin laat zien die op 1 mei is geregistreerd en je enige back-up is van 5 mei, dan zet je een gecompromitteerde site terug.

Checksums kloppen, maar Google flagt de site nog steeds. Wat nu?

Bekijk de mailqueue en de laatste 48 uur aan access logs. De compromittering kan in een plugin zitten (niet in core), of de flag kan een achtergebleven Safe Browsing-vermelding zijn van een eerder incident.