126 —Security
Contact form spam: a 30-minute incident walkthrough
23:41 on a Tuesday. A legacy WordPress contact form just became an open relay and the host has fifteen minutes of patience. Here is the thirty-minute IR.
23:41 on a Tuesday. A Loom comes in from an agency partner: their client, a Dutch staffing firm running WordPress 4.9 with a custom theme from 2017, is being threatened with suspension by their hosting provider. Outbound mail queue: 11,000 messages in twenty minutes, all addressed to obvious throwaway domains. The contact form on /contact/ is still happily accepting input. The agency lead has a release going out at 09:00 and asks if we can keep the box alive until morning.
This is the most common contact form spam pattern on a legacy site that has not been touched in three years: a header injection vulnerability in a hand-rolled mail handler, or a deprecated PHPMailer version inside a theme, or both. The mechanics have not changed since 2009. The fix takes about half an hour if you grep in the right order.
The first ninety seconds
Before touching anything, you want three facts: how much mail is queued, how fast it is going out, and what user the PHP process runs as. SSH in and run:
mailq | tail -1
postqueue -p | grep -c "^[0-9A-F]"
ps aux | grep -E "php|www-data|apache" | head -5
On Postfix the first line gives you the queue summary. On a hosting panel the second is more reliable. The third tells you which user owns the spamming process, which matters when you go to revoke its outbound SMTP credentials.
You also want the attacker's source IP. Pull it from the access log so you know what is hitting /contact/ and at what rate:
tail -2000 /var/log/apache2/access.log | grep -E "POST.*contact" \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -10
If the top result is a single IP at 400 requests, an IP block at the WAF or in .htaccess is your second tourniquet. If it is fifty IPs at twenty submissions each, you have a botnet and the form-level block is the only thing that holds. Either way the timestamps tell you when the bleeding started, which you will want for the abuse-desk mail in the morning.
Now stop new submissions. The fastest tourniquet is a one-line block at the web server, not at WordPress, because the form handler may live outside the WP rewrite stack:
# .htaccess, top of file
<FilesMatch "(contact|sendmail|mailer|formhandler)\.php">
Require all denied
</FilesMatch>
This returns 403 before PHP runs. The queue stops growing while you investigate. If the form is a WP admin-ajax endpoint, deny the action instead:
RewriteCond %{QUERY_STRING} action=(send_contact|cf7_submit) [NC]
RewriteRule ^wp-admin/admin-ajax\.php$ - [F,L]
Reading the mail log
On Debian and Ubuntu hosts the mail log is at /var/log/mail.log. On RHEL family it is /var/log/maillog. The last hundred lines tell you whether mail is going via a local sendmail, a relay, or a smarthost like SendGrid:
tail -200 /var/log/mail.log | grep -E "from=|to=" | head -50
You want to see two things. First, the envelope sender. A legitimate site shows from=www-data@yourdomain.com or from=<wordpress@yourdomain.com>. A compromised form often shows the user-submitted address, because the handler is using it as the Sender:
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 the giveaway. PHP's mail() function should produce nrcpts=1 on a normal contact form submission. Anything above three is suspicious. Above ten is mail header injection, full stop. The taxonomy lives in OWASP CRLF Injection.
The Subject line is the other tell. A normal staffing-firm form submission looks like 'New application from Jan'. A compromised one looks like a base64 hash, a pharmacy keyword stack, or a Cyrillic header that did not survive the queue's encoding. Pipe a few queued messages through postcat and read the headers before you delete:
mailq | awk 'NR > 1 {print $1}' | grep -E '^[A-F0-9]' | head -5 \
| xargs -I{} postcat -q {} | grep -E "^(Subject|From|To):" | head -30
Two minutes of reading saves you from deleting a real customer's mail in the panic that follows.
Second, the X-PHP-Originating-Script header. Postfix logs it in the queue file but you can read it cleanly from a sample message:
postcat -q 5B7K0w0g028392 | head -30
The header that names the file
When PHP submits via the sendmail binary, the runtime injects an X-PHP-Originating-Script header that names the exact script file. It looks like this:
X-PHP-Originating-Script: 33:contact-handler.php
The leading 33 is the UID of the process. The path is relative to the docroot or to the include path. If your docroot is /var/www/wp and the header says wp-content/themes/old-theme/inc/mailer.php, that is the file calling mail(). No more guessing.
If the header is missing it usually means the script is using a custom SMTP path (PHPMailer with isSMTP() set), not the local sendmail wrapper. In that case grep is your friend. More on that in a moment.
The PHPMailer line to grep
You now have two angles: the file from the header, and the broader codebase. The grep that finds 90% of legacy contact form vulnerabilities is this one:
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
You are looking for the pattern where unsanitized request data flows into a header argument or into PHPMailer's addAddress. The classic vulnerable line 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);
If the attacker can submit an email field that contains a literal CRLF followed by Bcc: list@..., PHP happily appends it to the header block. The form is now an open relay.
The PHPMailer angle is older but still alive on themes that bundled their own copy. CVE-2016-10033 (Sender argument injection, fixed in 5.2.18 and properly closed in 5.2.22) lets an attacker pass shell metacharacters into the Sender field, which sendmail interprets as command-line flags. If you grep and find class.phpmailer.php with VERSION below 5.2.22, replace the whole library before doing anything else:
grep -r "VERSION\s*=\s*['\"]5\." wp-content/ \
--include="class.phpmailer.php"
Two other patterns are worth knowing. wp_mail() can be hooked through the phpmailer_init action, and a malicious must-use plugin can rewrite the Sender there without ever touching the theme. Check wp-content/mu-plugins/ and any plugin under wp-content/plugins/ for files containing add_action('phpmailer_init'). The second pattern is Contact Form 7 with an additional-headers field that reads from-tag-value [your-email]. CF7 sanitises by default since 5.0.4 but plenty of legacy sites froze on 4.x; check the wpcf7_mail_components filter for any custom override that re-introduces the unsafe pattern.
Patching the handler
The minimal correct fix on a hand-rolled handler is to validate the address with filter_var and to never put user input into a header verbatim:
$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 rejects CR/LF, so Reply-To is safe.
mail($to, '[contact] ' . substr($subject, 0, 80), $body, $headers);
This is not a hardening exercise, it is the floor. Real hardening means swapping the form to a managed handler with an SPF-aligned envelope sender and a CAPTCHA at the door. The floor stops the bleeding so you can sleep.
Sealing the queue
The vulnerable script is patched. The queue still has 9,000 messages in it. Flushing them sends them. You almost never want to flush. You want to delete:
# Postfix: delete all queued mail
postsuper -d ALL
# Or, more carefully, only the bad sender's mail
mailq | awk '/throwaway\.ml/ {print $1}' | tr -d '*!' \
| xargs -n1 postsuper -d
Then rotate the outbound SMTP credentials if the site relays through SendGrid, Mailgun, or Postmark. The attacker has them in process memory; they should be treated as public. The same goes for any SMTP password stored in wp_options under wp_mail_smtp or a similar key. Update the option row, do not just change the panel.
Restart the web server and unblock the form path in .htaccess once you are confident the patch holds. Submit a test message from your phone on cellular, watch the mail log show nrcpts=1, and breathe out.
What to leave running overnight
The release goes out at 09:00. You have six hours. Three things are worth leaving in place.
First, a queue size watch. A one-line cron that pages you if the queue grows fast:
# /etc/cron.d/queue-watch, runs every 2 minutes
*/2 * * * * root [ $(postqueue -p | grep -c "^[0-9A-F]") -gt 200 ] \
&& curl -fsS https://ntfy.sh/your-private-topic -d "queue spike"
Second, an outbound rate limit at Postfix. Add to /etc/postfix/main.cf:
smtp_destination_rate_delay = 2s
smtp_destination_concurrency_limit = 2
default_destination_recipient_limit = 5
This will not stop a determined attacker, but it caps the blast radius at a few hundred messages per hour, comfortably below the threshold where most European hosts (TransIP, Hetzner, Strato) auto-suspend the account. Apache's mod_ratelimit can do the inbound side if the form itself is being hammered.
Third, a snapshot of the mail log and queue state at the moment of the patch. If the host bills by bandwidth or per outbound message, or if a customer asks tomorrow whether their legitimate submission got through, you want a paper trail you can read instead of guess at:
cp /var/log/mail.log /root/incident-$(date +%F)-mail.log
postqueue -p > /root/incident-$(date +%F)-queue.txt
The morning after
Send the agency lead three artifacts: the patched file (diff, not whole file), the postcat output that names the X-PHP-Originating-Script path, and the queue-watch cron. Recommend they replace the form entirely on the next maintenance window. Hand-rolled mail handlers from 2017 are a category, not a bug.
Email the abuse desk at the host before they email you. A short note with the queue size at the spike, the patched file path, the CVE if PHPMailer was involved, and the timestamp the bleeding stopped is the difference between 'we noted your incident' and 'your account is on a watch list for thirty days'. TransIP, Hetzner, and Strato all have abuse@ addresses that read during business hours; a 09:00 mail beats a 23:00 panic ticket every time.
When we built Pier, the macOS app we use for editing this kind of legacy site, the incident loop above was one of the recurring shapes we kept hitting at 23:00. The way we ended up handling it: every file edit lands in version history by default, so the patched contact-handler.php sits next to the pre-incident copy one click away, and the wp_options row you touched in the MySQL editor when rotating the SMTP password is there too. If the morning shows that the patch broke a legitimate submission path, you revert without rummaging through backups.
The smallest thing to do today, before the next incident: open your three oldest client sites and run the two greps above against wp-content. If either returns a hit, you have an evening's work scheduled for you. Better that than a Loom at 23:41.
— Questions —
Why does the mail log show nrcpts above 1 for a single form submission?
Because the handler is interpolating user input into the headers and the user input contains CRLF plus Bcc:. Every Bcc: address in the injected header counts as a recipient on the same envelope.
Will deleting the Postfix queue lose legitimate mail?
Yes, anything queued at that moment is gone. Filter by sender domain with postsuper -d if you can identify the bad batch cleanly; otherwise accept the loss. The alternative is sending the spam yourself.
How do I tell whether PHPMailer or a hand-rolled mail() call is the source?
Read X-PHP-Originating-Script in a queued message with postcat -q. If it points at class.phpmailer.php you have a library issue. If it points at theme code, the theme handler is the issue.
How long until the host suspends the account during the incident?
Most European providers suspend somewhere between 200 and 1,000 messages per minute. You have ten to fifteen minutes once the spike starts. The .htaccess block at the top buys you the rest of the half hour.