A neighbourhood association had a contact form on its website. It sent each message to the secretary using PHP's built-in mail() function, and it had no protection of any kind: no CAPTCHA, no rate limit, no check on what went into the fields. For three years that did not matter. Then a bot found it.
The bot began submitting thousands of messages, with the spammer's content inserted into the fields so that the server dutifully forwarded it to addresses chosen by the attacker. Within two days, the shared server's IP address was listed on several public blocklists, and the association's real emails, including replies to members and invoices from the treasurer, began to bounce or land in spam.
How a contact form becomes a spam gun
A form like this builds an email from the visitor's input. The simplest version takes a name, an email address and a message, and passes them to mail(), with the visitor's address placed in a From: or Reply-To: header so that the secretary can reply.
mail($to, $subject, $message, "From: " . $_POST['email']);
The flaw is that headers in an email are separated by line breaks. If the visitor's "email address" field contains a line break followed by more headers, the script treats them as real headers. An attacker who submits something like this can add recipients:
[email protected]%0ABcc: [email protected]%0A%0ABuy cheap pills...
That is header injection. The server obligingly sends the attacker's message to the attacker's list, with the association's server as the apparent source. Even without injection, a bot that fills the form fields with spam and submits them thousands of times does damage, because each submission is a real email delivered to the secretary and the sender reputation of the server takes the beating from the volume.
The first two days
On Saturday morning the treasurer sent an invoice to a supplier. Nothing seemed wrong. By Saturday evening the secretary noticed an unusually large number of form messages, hundreds of them, all nonsense, and assumed it was a one-off. On Sunday, three members said they had received no replies to their questions, and one forwarded a bounce message to the secretary.
The bounce was the clue. It said, in the long-winded way of these messages, that the sending IP address was listed on a blocklist and that delivery was refused. The same message arrived again, from another provider, on Monday morning.
The association's site sat on a shared server, so the blocklist entry was for an address that many other customers used too. That is part of what made the host's abuse team act quickly: they had a server-wide reputation to protect, and they suspended outgoing mail for this one account on the Monday afternoon. From the association's point of view, the damage now included legitimate messages as well.
Wrong turns
The first assumption was that the association's mailbox had been hacked, so the secretary changed her password, which made no difference. The second was that the email address on the domain was being impersonated, which does happen, and someone looked at SPF records. They were fine. The messages in question were really sent from the server, and the SPF check passed, which is the unfortunate point: authentication says who sent a message, not whether it was wanted.
The turning point was the host's abuse team forwarding a sample of the outgoing mail and the name of the script that had made the calls. The server's mail log tied each message to contact.php and to the account, and the PHP mail logging option showed the originating script and its folder.
exim -bpc # count messages in the queue
exim -bp | head # list a few
grep "cwd=" /var/log/exim_mainlog | grep public_html | head
The commands differ between mail systems and on shared hosting you may not have access to them at all, in which case the host's support team runs them. The principle is the same: find the script that is injecting the mail.
Cleaning up
The work was done in this order, and the order matters.
- Disable the form. Without this, anything else is undone by the next bot request.
- Fix the form (validation, a CAPTCHA or honeypot, a rate limit), or replace it with a maintained form plugin.
- Ask the host to clear the mail queue, so that the thousands of waiting messages are not delivered after the fix.
- Check the site for other changes. A form that is this open often means the rest of the site is not carefully maintained either.
- Request delisting from each blocklist that named the address. Each has its own process, and most will relist the address if the problem returns.
- Wait for reputation to recover, and tell members what happened.
Delisting took a day for some lists and several for others. Large mailbox providers keep their own internal reputation, and they recover more slowly still. The whole recovery took the best part of a week before mail flowed normally.
Sending through authenticated SMTP
The better design is to stop using the server's local mail function and send through an authenticated SMTP account instead. The script logs in with a mailbox's credentials, and the mailbox has limits: a maximum number of messages per hour, restrictions on which From address is allowed, and a log that can be examined. A bot that finds the form can then only do as much damage as that mailbox's limits allow, and the shared server's address is not involved.
| Way of sending | Who is accountable | What limits abuse |
|---|---|---|
PHP mail() to local sendmail | The server's IP address | Only the host's overall limits |
| Authenticated SMTP, own mailbox | The mailbox | Hourly cap, one From address |
| Transactional email service | The service account | Its own sending limits and monitoring |
Pair that with authentication records for the domain. The SPF builder and the DMARC builder help set them up, and reports from DMARC will show unexpected senders using your domain.
Talking to the people who were affected
The association wrote to members on the Wednesday, in two short paragraphs: what had happened, that nothing in the membership list had been taken, and that anything sent in the previous week might have gone astray, so a repeat was welcome. Several members replied that they had assumed the committee was ignoring them. The apology cost nothing and settled most of the irritation.
What would have caught it
- Validating the fields and rejecting line breaks in anything that ends up in a mail header.
- A honeypot field or a CAPTCHA, plus a rate limit per visitor and per hour overall.
- Sending through authenticated SMTP, which limits what a script can do.
- Looking at the outgoing mail queue now and then. A queue of thousands of messages is hard to miss once you look.
- Setting up SPF and DMARC with reports, so that unusual sending shows up in a report.
- An alert from the host or a monthly blocklist check on the sending address.