Wednesday, October 7, 2026, 18:15 UTC. An email from Microsoft 365, severity High: "User NOREPLY@LUDOPROGRAMMING.COM has been restricted from sending messages outside the organization due to potential compromised activity."
No one had broken anything. No password had been stolen, no server had been compromised. Simply, the contact form on the site had done its job 109 times — exactly as programmed, and exactly as we had never imagined.
The trick: reversing the fields
The contact form sent two emails for each valid message. One to us, with the content of the message. And one to the person who filled out the form, a polite confirmation that started with "Hello, {name}!".
The second email was the problem, and it took an attacker to show it to us.
A bot filled out the form like this:
- The "name" field:
Вам перевод 172144 руб. забрать тут https://5d58cc63.sslip.io/…— translated, "You have a transfer of 172,144 rubles, pick it up here", with a link. - The "email" field: the address of some victim. In 96 out of 109 cases, a Gmail address.
- The "message" field: random tokens —
roY0RVe A4BJ tOXXCkW— written specifically to pass filters.
The result: our server sent the scam to the victim, signed from our domain, with valid SPF and DKIM. A phishing email with impeccable reputation, delivered free by our infrastructure.
This is not spam on the contact form. It is using the form as an email relay. The difference is that in the first case you annoy a person — in the second, you become the attacker, from everyone else's point of view.
The numbers
All form messages are saved in the database, including those stopped by filters. That decision, made months earlier for another reason — to check if the filter mistakenly blocks real clients — proved to be the only source of truth during the incident.
- 167 messages in three days, of which 109 reached sending.
- None were real. All 167, without exception, were automated.
- Constant rate: about 10 messages per hour, day and night, without pause.
- 41 distinct IP addresses for the 109 sent messages.
- 93 of them had a link in the name field — a field that never contains a web address.
Where it came from
The question "from which country" has a disturbing answer: from no particular country. We searched public registries for each IP address in the attack, and the pattern is uniform:
3.114.220.22— Amazon Web Services, Tokyo region132.145.80.230— Oracle Cloud, United States154.90.62.70— Lightnode, South Korea193.29.139.132— Greenhost, Netherlands149.22.87.204and38.91.107.49— Cogent Communications, United States8.218.195.61— APNIC block, Asia-Pacific
No residential address. No infected computer of some retiree. All are rented virtual machines, at reputable cloud providers, probably paid with stolen cards and discarded after a few days of use.
Here is the uncomfortable lesson for anyone relying on IP-based restrictions: the origin of a modern attack is not a country, it is a credit card. The cost of a new IP address has dropped below the cost of a coffee, and defenses that assume an attacker returns from the same address have become decorative.
Where the links led
The truly elegant part of the attack — and I say this without irony — was how they avoided registering a domain. The links used sslip.io and nip.io, two public wildcard DNS services that turn an IP address into a hostname. The name 5d58cc63.sslip.io is not random: 5d58cc63 is the IP address written in hexadecimal, which translates to 93.88.204.99.
No registered domain, no registrar to file a complaint with, no certificate to buy. The destination server was hosted in a Dutch block, and a second link from the same campaign led to a machine in Microsoft Azure. The attackers use the same platforms as us, for the same reasons: they are cheap, fast, and reliable.
A methodological note, because I almost made a mistake: the first DNS resolution I did gave an address from Romania. It was not true — it was my internet provider, which automatically redirects nonexistent names to its own page. With a public resolver, the real answer appeared immediately. If you publish an assignment, check it from two different paths, otherwise you accuse an innocent party.
Why existing defenses didn’t work
The form was not unprotected. It had four layers, and it is instructive to see how the bot bypassed each one.
Honeypot field — an invisible field that only a robot fills out. It caught 2 messages out of 167. Serious bots haven’t fallen for it for years.
Minimum completion time — three seconds, because a human does not type faster. It caught 50 messages. Then the bot started waiting, and it stopped catching anything.
Rate limit per IP — maximum three messages per hour from the same address. Useless against 41 rotating addresses. Moreover, the counter lived in application memory, so it disappeared at each restart and was not shared between instances.
Structural filter — the part that judges the shape of the text, not the vocabulary: linguistically impossible words, digits buried in words, the same number repeated in different fields. It caught 6 messages. It missed the rest for two reasons we understood only after reading the real tasks.
First: the analysis worked only on the Latin alphabet. Cyrillic text was, for the filter, empty text. Second: tokens like roY0RVe were broken into short pieces at each digit, and the remaining pieces were too short to seem suspicious.
How we countered it
1. We removed the amplifier
The confirmation email to the address in the form was completely eliminated. It was the first and most important decision, because no filter improvement changes a structural reality: a form that sends email to any unknown address is, by definition, an open relay. The difference is only how hard it is to find.
If you want confirmation for real people, the only sure form is double opt-in — the address must prove its existence before receiving the second message. Until then, silence is better.
2. We wrote rules from real tasks, not imagination
Having all 109 saved messages, the new rules were not guessed, but read directly from the attack:
- Link in the name field — blocks on its own. A name never contains a web address, and this rule covered 93 of 109 messages.
- Name longer than 70 characters — a name is not a sentence.
- Cyrillic alphabet — deliberately below the blocking threshold. Alone, it stops nothing: a Bulgarian or Russian client is a client. Together with any other signal, it becomes decisive.
- Random tokens with mixed letters and digits — four or more in a form have no innocent explanation.
- Words with case switched four times —
KsWGvdjBWBGYcDmOAQ. The threshold is four alternations precisely so McDonald and DeLuca pass unhindered.
Each rule has a weight, and blocking is done on a cumulative score. A single signal does not condemn anyone — just like before, because the real risk of any filter is not letting spam through, but cutting off a client.
3. We measured on the real attack
The part most skip. We ran the new filter over all 109 messages that had slipped through: 108 blocked. The only one left is an SEO offer written by a human, in clean English, structurally identical to a legitimate request — and which, honestly, we don’t even want to block.
Without the saved corpus, "we tightened the filter" would have remained an impression. With it, it is a number.
4. We checked in production, not in tests
The deployment finished at 06:41:40 UTC. The bot struck every eight minutes, so the proof came by itself, four minutes later:
06:45:47 filtered spam (score 17: digits_in_name, link_in_name,
name_too_long, cyrillic, random_tokens)
Score 17 at a threshold of 5. Since then, no message from the campaign has reached sending.
The part no one tells: the aftermath
Fixing the cause did not restart emails. The account remained restricted, and every attempt returned 550 5.1.8 Access denied, bad outbound sender. Three things learned live:
"200 OK" does not mean "delivered". Our email service responded successfully, and the application marked the message as sent. Microsoft rejected it a few seconds later, elsewhere in the chain. Monitoring that stops at the HTTP code is blind exactly where it matters.
The restriction applies to the sender, not the recipient. We assumed internal messages would pass. They don’t: a restricted account no longer sends anywhere, not even to a colleague in the next office.
Our own notifications went outside the organization. Form messages reached a Gmail address, not a domain mailbox. So during the attack, not 109 external emails were sent, but 218 — confirmations to victims and notifications to us together fed the Microsoft detector.
What to check on your side, today
If you have a contact form on a site, the questions that matter are these:
- Does the form send any email to the address filled in by the visitor? If yes, you have an open relay. Stop it or put double opt-in.
- Where do your notifications go? If the recipient is outside the domain, every spam message consumes your sender reputation.
- Do you save what the filter blocks? Without a corpus, you cannot measure or improve — and you don’t find out if you are losing clients.
- Is the rate limit per IP? Then it doesn’t protect you from anything serious. Move it to something else, or accept that it’s just a cap against accidents.
- What happens if your email provider blocks you tomorrow? Order confirmations, password resets, invoices — all go the same way.
What we would have done differently
The code was not careless. It had filters, limits, a honeypot field, audit saving, and comments explaining why each threshold is where it is. It lost to an attacker who read the application behavior more carefully than we read our own assumptions.
The mistake was not a line of code, but an unwritten assumption: that people fill out the form for themselves. From the moment a visitor-controlled field ends up in the body of an email sent to another address controlled by the same visitor, you no longer have a contact form. You have a message sending service, offered for free, to anyone.
The incident lasted one day. The fix took three hours. Regaining sender reputation takes longer than both combined — and that is the only cost that really matters.