Two minutes: how our invisible captcha failed — and what holds instead
The last report had a paragraph we were a little proud of: the invisible captcha in the contact form of our estate agent website. The browser computes in the background, the human notices nothing, the robot computes itself tired. Plus a trap field and a minimum fill-in time — three quiet guards. That same evening we sat down and attacked the form the way somebody would who does not mean well by us.
It held for two minutes.
What was possible in two minutes
No exploit, no gap in the framework — only a small script that does exactly what the browser does too. The result, without instructions, but honest:
- Leads out of nowhere. Invented names, invented addresses, addresses without an at sign — everything landed as a lead in the CRM. An afternoon of scripting and the pipeline would have been full of dead files.
- Other people's mailboxes. The form offers “send a copy by e-mail”. Enter somebody else's address and our mail server dutifully sends a message to a person who never asked — with our sender. That is called backscatter, and it is the fastest way to get a sender account onto blocklists.
- Text where none belongs. Line breaks in a name field are harmless until they land in a mail header. Then one name becomes two recipients.
- No brake. Nothing stopped the script from doing all of that a thousand times.
And the three guards? A script simply leaves the trap field empty. It waits out the fill-in time. And the computing puzzle — that was the real lesson.
The calculation we had not done
A proof-of-work captcha lives on a claim: for the human the computing task is short, for the attacker it is expensive in total. We worked the difficulty through — and the claim does not hold. Every level a human on a phone still tolerates costs a graphics card nothing. You can make the task so hard that it slows a spammer down; then the honest visitor waits a minute for the send button. There is no setting at which both are true.
Behind that lies a more general insight, and it is the core of this report: everything that runs in the browser belongs to the attacker. Puzzle, trap field, timer, cookie, even a third party's image guessing — they are hurdles the client is supposed to clear. But in an attack the client is not our browser, it is their script. It can rebuild every hurdle because it can see it.
What it cannot rebuild: opening a letter that was put into a mailbox that does not belong to it.
The only factor that remains
So we turned the form around. No longer “prove that you are human”, but “prove that this address belongs to you”. Whoever makes an inquiry receives a six-digit code at exactly the address they entered, and types it in. Only then does the inquiry go on. That is not new — every registration on the net does it that way — but it is the only thing that holds against a script. Invented addresses get no code. Other people's addresses get a code the attacker cannot read. The copy mail only goes to a confirmed mailbox.
So that it does not become tiresome: a confirmed address is remembered for a few hours, in a signed cookie the browser can neither read nor forge. Whoever inquires about three properties in a row types the code once. And the memory applies only to exactly that address — enter a different one after confirming and you start over.
Plus a cap: only a few codes are open per address at a time, so nobody can bombard someone else's mailbox with our mails. And because the code belongs in a mail the agent wants to design, it is a template of the mail room like any other — with placeholders, language variants and archive.
A service that knows nothing
The part that gave us the most pleasure is invisible. The confirmation service knows nothing about leads, exposés or forms. It can do exactly two things: “confirm the identity behind address X” and “here is the code, is it right?”. How it confirms — today by e-mail — is an exchangeable channel the rest sees nothing of. The form is only one caller among several.
The proof came immediately: the registration of a visitor account now runs through the same service. Previously it created the account and logged it in without anyone ever checking whether the address belonged to the registrant — you could have created accounts on other people's addresses. Now the code comes first, and only the confirmed address gets an account. No new logic, no second route, just a second caller.
What belongs around it
The code is the core, but not the whole answer. Three things flank it:
- A brake at the edge. The contact form accepts two submissions per minute per sender address on the network, login and registration five. Whoever wants more gets a polite refusal with a waiting time. Deliberately not evaluated are headers with which a sender could give themselves a different address — the website speaks directly with the network, so the real connection counts.
- Validation on both sides. The website checks every field before passing it on: exactly one clean address, no line breaks in single-line fields, length limits. And the application server checks the same again, because the website is not the last authority.
- The sender address in the right place. It used to hang on the property component of a menu entry. Now it belongs to the website itself — because codes, inquiries and future mails have nothing to do with properties.
Two things we deliberately did not build. The login gets no second factor; whoever knows address and password gets in, and the brake suffices against guessing. And the three quiet guards have been removed completely — not switched off, but deleted. A protection that does not protect is not protection, it is reassurance.
The lesson
Yesterday's report was not wrong: the form did what it should, the leads arrived, the templates worked. It was simply not thought through to the end. We had built the hurdle before we had played the attacker — and the attacker needs two minutes to climb a hurdle they can see.
That is why the order is reversed now: attack first, then build. And while building, ask the question that makes all the difference — not “how do we make it harder”, but “what can the attacker fundamentally not do”. The answer was a mailbox. It will be one again at the next form.
The mail room has meanwhile sent its first confirmation mail. The second came a few seconds later — to the same address, with a different code, because a tester did not want to believe the first one was really burned. It was.