126 —Security
Spammend contactformulier: 30 minuten incidentrespons
23:41 op een dinsdag. Een oud WordPress-formulier werd een open relay en de hosting heeft vijftien minuten geduld. Hier is een IR van dertig minuten.
23:41 op een dinsdag. Er komt een Loom binnen van een agency-partner: hun klant, een Nederlands uitzendbureau dat draait op WordPress 4.9 met een custom theme uit 2017, dreigt door de hostingmaatschappij opgeschort te worden. Uitgaande mailqueue: 11.000 berichten in twintig minuten, allemaal gericht aan voor de hand liggende wegwerpdomeinen. Het contactformulier op /contact/ accepteert nog vrolijk input. De agency-lead heeft om 09:00 een release en vraagt of we de server tot de ochtend in de lucht kunnen houden.
Dit is het meest voorkomende spampatroon op een legacy site die in drie jaar niet is aangeraakt: een header injection in een zelfgebouwde mail handler, of een verouderde PHPMailer in een theme, of allebei. De mechaniek is sinds 2009 niet veranderd. De fix kost ongeveer een half uur als je in de juiste volgorde grept.
De eerste negentig seconden
Voordat je iets aanraakt wil je drie feiten: hoeveel mail er in de queue staat, hoe snel die uitgaat, en als welke user het PHP-proces draait. Log in via SSH en draai:
mailq | tail -1
postqueue -p | grep -c "^[0-9A-F]"
ps aux | grep -E "php|www-data|apache" | head -5
Op Postfix geeft de eerste regel je de queue-samenvatting. Op een hostingpaneel is de tweede betrouwbaarder. De derde vertelt je welke user het spammende proces bezit, en dat is van belang wanneer je de bijbehorende uitgaande SMTP-credentials wilt intrekken.
Je wilt ook het bron-IP van de aanvaller. Haal dat uit het access log zodat je weet wat /contact/ raakt en met welke snelheid:
tail -2000 /var/log/apache2/access.log | grep -E "POST.*contact" \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -10
Is het topresultaat één IP met 400 requests, dan is een IP-block op de WAF of in .htaccess je tweede stuwband. Zijn het vijftig IP's met twintig submissions per stuk, dan heb je een botnet en houdt alleen het block op formulierniveau stand. Hoe dan ook, de timestamps vertellen je wanneer het bloeden begon, en die heb je 's ochtends nodig voor de mail naar de abuse desk.
Stop nu nieuwe submissions. De snelste stuwband is een one-liner op de webserver, niet in WordPress, want de formulierhandler kan buiten de WP rewrite-stack leven:
# .htaccess, bovenaan het bestand
<FilesMatch "(contact|sendmail|mailer|formhandler)\.php">
Require all denied
</FilesMatch>
Dit geeft een 403 terug voordat PHP draait. De queue groeit niet meer terwijl je onderzoekt. Is het formulier een WP admin-ajax endpoint, blokkeer dan de action:
RewriteCond %{QUERY_STRING} action=(send_contact|cf7_submit) [NC]
RewriteRule ^wp-admin/admin-ajax\.php$ - [F,L]
Het mail log lezen
Op Debian en Ubuntu staat het mail log op /var/log/mail.log. Op de RHEL-familie is dat /var/log/maillog. De laatste honderd regels vertellen je of mail uitgaat via een lokale sendmail, een relay, of een smarthost zoals SendGrid:
tail -200 /var/log/mail.log | grep -E "from=|to=" | head -50
Je wilt twee dingen zien. Eerst de envelope sender. Een legitieme site toont from=www-data@yourdomain.com of from=<wordpress@yourdomain.com>. Een gecompromitteerd formulier toont vaak het door de gebruiker ingestuurde adres, omdat de handler dat als Sender gebruikt:
Jun 10 23:42:11 sv1 postfix/cleanup[28394]: 5B7K0w0g028392:
message-id=<a8f...@yourdomain.com>
Jun 10 23:42:11 sv1 postfix/qmgr[1102]: 5B7K0w0g028392:
from=<attacker@throwaway.ml>, size=4128, nrcpts=87
nrcpts=87 is het verklikkertje. PHP's mail()-functie hoort nrcpts=1 te produceren bij een normale contactformulier-submission. Alles boven de drie is verdacht. Boven de tien is mail header injection, punt. De taxonomie staat in OWASP CRLF Injection.
De Subject-regel is het andere signaal. Een normale submission van een uitzendbureau leest als 'Nieuwe sollicitatie van Jan'. Een gecompromitteerde leest als een base64-hash, een stapel pharmacy-keywords, of een Cyrillische header die de queue-encoding niet heeft overleefd. Pijp een paar queued berichten door postcat en lees de headers voordat je verwijdert:
mailq | awk 'NR > 1 {print $1}' | grep -E '^[A-F0-9]' | head -5 \
| xargs -I{} postcat -q {} | grep -E "^(Subject|From|To):" | head -30
Twee minuten lezen voorkomt dat je in de paniek die volgt de mail van een echte klant weggooit.
Ten tweede de X-PHP-Originating-Script header. Postfix logt die in het queue-bestand, maar je leest hem netjes uit een voorbeeldbericht:
postcat -q 5B7K0w0g028392 | head -30
De header die het bestand noemt
Wanneer PHP verzendt via de sendmail-binary voegt de runtime een X-PHP-Originating-Script header in die het exacte script-bestand noemt. Het ziet er zo uit:
X-PHP-Originating-Script: 33:contact-handler.php
De voorloop 33 is de UID van het proces. Het pad is relatief aan de docroot of aan het include path. Is je docroot /var/www/wp en zegt de header wp-content/themes/old-theme/inc/mailer.php, dan is dat het bestand dat mail() aanroept. Geen giswerk meer.
Ontbreekt de header, dan betekent dat meestal dat het script een eigen SMTP-pad gebruikt (PHPMailer met isSMTP() aan), niet de lokale sendmail-wrapper. In dat geval is grep je vriend. Daarover zo meer.
De PHPMailer-regel om naar te greppen
Je hebt nu twee invalshoeken: het bestand uit de header, en de bredere codebase. De grep die 90% van de kwetsbaarheden in oude contactformulieren vindt, is deze:
cd /var/www/wp
grep -rEn "mail\s*\(.*\\\$_(POST|GET|REQUEST)" \
wp-content/themes wp-content/plugins 2>/dev/null
grep -rEn "(setFrom|addAddress|addBCC)\s*\(.*\\\$_(POST|GET|REQUEST)" \
wp-content/ 2>/dev/null
Je zoekt naar het patroon waarin ongesanitiseerde request-data in een header-argument of in PHPMailer's addAddress terechtkomt. De klassiek kwetsbare regel is:
// wp-content/themes/old-theme/inc/contact.php
$headers = "From: " . $_POST['email'] . "\r\n";
$headers .= "Reply-To: " . $_POST['email'] . "\r\n";
mail($to, $subject, $body, $headers);
Kan de aanvaller een email-veld insturen dat een letterlijke CRLF bevat gevolgd door Bcc: list@..., dan plakt PHP dat vrolijk achter het header-blok. Het formulier is nu een open relay.
De PHPMailer-invalshoek is ouder, maar leeft nog in themes die hun eigen kopie meeleverden. CVE-2016-10033 (Sender argument injection, gefixt in 5.2.18 en pas echt dichtgetimmerd in 5.2.22) laat een aanvaller shell-metakarakters in het Sender-veld zetten, die sendmail vervolgens interpreteert als command-line vlaggen. Vind je via grep een class.phpmailer.php met VERSION lager dan 5.2.22, vervang dan de hele library voordat je iets anders doet:
grep -r "VERSION\s*=\s*['\"]5\." wp-content/ \
--include="class.phpmailer.php"
Twee andere patronen zijn het kennen waard. wp_mail() kan gehookt worden via de phpmailer_init action, en een kwaadaardige must-use plugin kan de Sender daar herschrijven zonder het theme aan te raken. Controleer wp-content/mu-plugins/ en elke plugin onder wp-content/plugins/ op bestanden die add_action('phpmailer_init') bevatten. Het tweede patroon is Contact Form 7 met een additional-headers veld dat from-tag-value [your-email] inleest. CF7 saniteert standaard sinds 5.0.4, maar genoeg legacy sites bleven hangen op 4.x; controleer het wpcf7_mail_components filter op een custom override die het onveilige patroon herintroduceert.
De handler patchen
De minimaal correcte fix op een zelfgebouwde handler is het adres valideren met filter_var en gebruikersinput nooit letterlijk in een header zetten:
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if (!$email) {
http_response_code(422);
exit('invalid sender');
}
$headers = "From: noreply@yourdomain.com\r\n";
$headers .= "Reply-To: " . $email . "\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
// FILTER_VALIDATE_EMAIL weigert CR/LF, dus Reply-To is veilig.
mail($to, '[contact] ' . substr($subject, 0, 80), $body, $headers);
Dit is geen hardening-oefening, dit is de vloer. Echte hardening betekent het formulier vervangen door een managed handler met een SPF-aligned envelope sender en een CAPTCHA aan de poort. De vloer stopt het bloeden zodat je kunt slapen.
De queue dichtdraaien
Het kwetsbare script is gepatcht. De queue heeft nog 9.000 berichten staan. Flushen verzendt ze. Je wilt bijna nooit flushen. Je wilt verwijderen:
# Postfix: verwijder alle queued mail
postsuper -d ALL
# Of, voorzichtiger, alleen de mail van de slechte verzender
mailq | awk '/throwaway\.ml/ {print $1}' | tr -d '*!' \
| xargs -n1 postsuper -d
Rouleer daarna de uitgaande SMTP-credentials als de site relayt via SendGrid, Mailgun of Postmark. De aanvaller heeft ze in process memory; behandel ze als publiek. Hetzelfde geldt voor elk SMTP-wachtwoord dat in wp_options staat onder wp_mail_smtp of een vergelijkbare key. Update de option-row, verander niet alleen het paneel.
Herstart de webserver en deblokkeer het formulier-pad in .htaccess pas wanneer je vertrouwen hebt dat de patch houdt. Stuur een testbericht vanaf je telefoon via 4G, kijk hoe het mail log nrcpts=1 toont, en adem uit.
Wat 's nachts kan blijven draaien
De release gaat om 09:00 de deur uit. Je hebt zes uur. Drie dingen zijn het waard om te laten staan.
Ten eerste een queue size watch. Een one-line cron die je pieept als de queue snel groeit:
# /etc/cron.d/queue-watch, draait elke 2 minuten
*/2 * * * * root [ $(postqueue -p | grep -c "^[0-9A-F]") -gt 200 ] \
&& curl -fsS https://ntfy.sh/your-private-topic -d "queue spike"
Ten tweede een outbound rate limit op Postfix. Voeg toe aan /etc/postfix/main.cf:
smtp_destination_rate_delay = 2s
smtp_destination_concurrency_limit = 2
default_destination_recipient_limit = 5
Dit stopt geen vastberaden aanvaller, maar het kapt de blast radius af op een paar honderd berichten per uur, ruim onder de drempel waarop de meeste Europese hosters (TransIP, Hetzner, Strato) automatisch het account opschorten. Apache's mod_ratelimit kan de inkomende kant doen als het formulier zelf wordt platgebombardeerd.
Ten derde een snapshot van het mail log en de queue-status op het moment van de patch. Rekent de host af per bandbreedte of per uitgaand bericht, of vraagt een klant morgen of zijn legitieme submission is doorgekomen, dan wil je een paper trail die je kunt lezen in plaats van raden:
cp /var/log/mail.log /root/incident-$(date +%F)-mail.log
postqueue -p > /root/incident-$(date +%F)-queue.txt
De ochtend erna
Stuur de agency-lead drie artefacten: het gepatchte bestand (de diff, niet het hele bestand), de postcat-output die het X-PHP-Originating-Script pad noemt, en de queue-watch cron. Adviseer ze het formulier in het eerstvolgende onderhoudsvenster volledig te vervangen. Zelfgebouwde mail handlers uit 2017 zijn een categorie, geen bug.
Mail de abuse desk bij de host voordat zij jou mailen. Een kort bericht met de queue-grootte op het hoogtepunt, het pad van het gepatchte bestand, de CVE als PHPMailer betrokken was, en de timestamp waarop het bloeden stopte: dat is het verschil tussen 'we hebben uw incident genoteerd' en 'uw account staat dertig dagen op een watchlist'. TransIP, Hetzner en Strato hebben allemaal een abuse@ adres dat tijdens kantooruren wordt gelezen; een mail om 09:00 wint het altijd van een paniekticket om 23:00.
Toen we Pier bouwden, de macOS-app waarmee we dit soort verouderde sites bewerken, was de incident-loop hierboven een van de terugkerende vormen die we om 23:00 bleven tegenkomen. Hoe we het uiteindelijk aanpakten: elke file edit landt standaard in de version history, dus de gepatchte contact-handler.php staat één klik weg van de pre-incident kopie, en de wp_options-row die je in de MySQL editor hebt aangeraakt bij het rouleren van het SMTP-wachtwoord staat daar ook. Als blijkt dat de patch een legitiem submission-pad heeft gebroken, draai je terug zonder door back-ups te wroeten.
Het kleinste wat je vandaag kunt doen, voordat het volgende incident komt: open je drie oudste klantsites en draai de twee greps hierboven tegen wp-content. Levert een van beide een hit op, dan staat er een avondje werk voor je in de agenda. Beter dat dan een Loom om 23:41.
— Vragen —
Waarom toont het mail log nrcpts boven 1 voor één formulier-submission?
Omdat de handler gebruikersinput in de headers interpoleert en die input een CRLF gevolgd door Bcc: bevat. Elk Bcc:-adres in de geïnjecteerde header telt als een ontvanger op dezelfde envelope.
Verlies ik legitieme mail door de Postfix-queue te verwijderen?
Ja, alles wat op dat moment in de queue staat is weg. Filter op verzenderdomein met postsuper -d als je de slechte batch netjes kunt identificeren; anders accepteer je het verlies. Het alternatief is dat je zelf de spam blijft verzenden.
Hoe weet ik of PHPMailer of een zelfgebouwde mail()-call de bron is?
Lees X-PHP-Originating-Script in een queued bericht met postcat -q. Wijst die naar class.phpmailer.php, dan heb je een library-probleem. Wijst die naar themecode, dan zit het probleem in de theme-handler.