A Public Form with Three Vulnerabilities in One UI
CAPTCHA bypass, phishing relay, and blind SSRF, all from one unauthenticated endpoint
We keep coming back to this one because of how little it took[cite: 6]. No auth, no account, no clever bypass of anything resembling real security engineering[cite: 6]. Just a public contact form, three separate requests, and we were able to walk away with an unauthenticated bulk-messaging primitive, a phishing relay wearing a trusted domain's identity, and a backend that would fetch any URL we handed it[cite: 6]. None of these required chaining a zero-day[cite: 6]. They required reading what the form actually did versus what it claimed to do[cite: 6].
The short version: a "no-code form builder" turned into an unauthenticated abuse platform because three independent server-side checks were either never implemented or implemented against the wrong thing[cite: 6]. Here's how each one falls, and how to go looking for the same pattern elsewhere[cite: 6].
Start by asking what the CAPTCHA actually verifies
Most form-builder platforms that experience a spam problem bolts on a CAPTCHA widget and calls it done[cite: 6]. The widget itself, usually a "I'm not a robot" checkbox, is client-side theater[cite: 6]. The actual security control is a second, invisible step: the server takes the token the widget generates and calls the CAPTCHA provider's verification API to confirm it's real[cite: 6]. Skip that second step, and the widget is nothing more than decoration[cite: 6].
So the first thing we test on any form with a visible CAPTCHA isn't the widget[cite: 6]. It's whether the backend actually calls out to verify the token, or just checks that something arrived in that field[cite: 6].
Capture a legitimate submission first, so you have a real token to compare against[cite: 6]:
- Submit the form normally through a browser, proxying the request[cite: 6].
- Note the full CAPTCHA response token, these are typically several hundred characters[cite: 6].
- Replay the request with a truncated or stale version of that token[cite: 6].
- Replay again with a fabricated three-character string that has nothing to do with a real token[cite: 6].
- Replay with an empty string[cite: 6].
On the platform we tested, steps 3 and 4 both came back 201 Created[cite: 6]. A three-character junk string was accepted identically to a genuine token[cite: 6]. Only the empty string was rejected[cite: 6]. That's the tell: the server checks for non-empty, not valid[cite: 6]. There's no call to the verification API happening at all, or if there is, its result is never checked[cite: 6].
The practical consequence: any script can now submit that form as fast as the rate limiter allows, no CAPTCHA-solving service, no delay, nothing[cite: 6]. On our tested platform that ceiling was 1,500 submissions per rate-limit window[cite: 6]. Whatever that endpoint does on submission, an attacker can now trigger it 1,500 times an hour for free[cite: 6].
This is CWE/OWASP A04:2021, Insecure Design, not a subtle bug[cite: 6]. It's a security control that either was never wired up server-side or broke silently at some point with no regression test catching it[cite: 6]. If you're auditing any form with a CAPTCHA, this is a five-minute test and it's worth running on every single one you encounter, because it's shockingly common for the check to be missing entirely rather than merely weak[cite: 6].
Then ask what happens to the data after you submit it
Once you've confirmed you can submit at volume, the next question is: what does a submission actually do[cite: 6]? Contact forms often trigger an autoresponder, a confirmation email sent back to whatever address the submitter provided[cite: 6]. That's normal[cite: 6]. What's not normal is when the platform never stops to ask "does this recipient belong to the person filling out the form?"[cite: 6]
On the platform we tested, it didn't ask[cite: 6]. The recipient field was just another free-text input, no verification, no ownership check, nothing tying it to an authenticated session (there wasn't one) or to the domain of the page the form was embedded on[cite: 6]. Put a third party's email address in that field instead of your own, and the confirmation email goes to them, not you[cite: 6].
Now look at what else lands in that email[cite: 6]. Every form has some number of free-text fields, comment boxes, "what can we help with," that kind of thing[cite: 6]. Those values get dropped into the autoresponder body, and on the platform I tested, with no sanitization at all: bare URLs went through untouched, no encoding, no plain-text stripping[cite: 6].
Put those two things together and you get a mechanism, not just a bug: pick any victim's email address, put a malicious link or message in the free-text fields, and submit[cite: 6]. The victim receives an email that appears to come from the legitimate organization's domain, arrives as a normal-looking auto-reply, and very likely passes SPF/DKIM/DMARC, because it's genuinely being sent from that organization's actual mail infrastructure[cite: 6]. The victim has no relationship to the attacker and no way to distinguish this from real communication[cite: 6]. This is functionally identical to the CRM-form-relay abuse pattern that's been reported against other major platforms: any public, unauthenticated form with an unsanitized autoresponder is a phishing-as-a-service endpoint waiting to be found[cite: 6].
Combine this with the CAPTCHA gap above and you don't have "someone could phish one person by filling out a form."[cite: 6] You have "someone can script up to 1,500 brand-impersonating phishing emails per hour, to any recipient list they want, from infrastructure the legitimate organization owns and that spam filters already trust."[cite: 6]
Don't stop at email. Check whether free-text fields get fetched
This is the one that surprised us most, because it wasn't something we set out to test, it fell out of testing something else[cite: 6]. We checked whether the free-text fields did any content validation at all, so we dropped a bare domain name (no https://, no scheme, just a hostname) into one of the fields and pointed it at an out-of-band interaction service[cite: 6].
Within about ninety seconds, the collaborator log lit up: DNS queries from several major cloud-provider IP ranges, followed by actual HTTP requests from a separate IP[cite: 6]. Something server-side had taken that string, decided it looked like a domain, resolved it, and fetched it, despite the field being a plain short-text input with no indication it was ever meant to hold a URL, let alone be treated as one[cite: 6].
That's a blind SSRF primitive (CWE-918)[cite: 6]. The likely culprit is a link-preview or outbound-link security-scanning feature, something that inspects submitted content for links to flag or preview them, and does its own domain detection on raw free text rather than only acting on fields explicitly typed and validated as URLs[cite: 6]. Whatever the intent, the effect is that the platform's own backend infrastructure will make network requests to any destination we choose, on every submission, with no authentication required to trigger it[cite: 6].
We didn't push further to test whether this reaches internal-only infrastructure, link-local addresses, RFC 1918 ranges, or a cloud metadata endpoint like 169.254.169.254, that's the obvious next step and we flagged it as unconfirmed rather than claiming it[cite: 6]. But the primitive itself, "attacker picks a destination, platform backend fetches it," is confirmed end to end, and it's the kind of finding that turns into a metadata-service credential leak if the escalation path is real and nobody's tested it yet[cite: 6].
Combined with the CAPTCHA gap, this isn't a one-off curiosity, it's a scriptable SSRF probe you can fire 1,500 times an hour against any infrastructure you want to fingerprint[cite: 6].
Why all three exist at once
These aren't three unrelated bugs that happened to land on the same form[cite: 6]. They're three separate instances of the same failure: trusting client-supplied data to mean what it claims to mean, without verifying it server-side[cite: 6].
- The CAPTCHA token is trusted to mean "a human solved this," without confirming it with the provider that issues it[cite: 6].
- The recipient field is trusted to mean "this is the submitter's own address," without any check that it is[cite: 6].
- The free-text field is trusted to mean "this is just a string," until some other part of the pipeline decides it's actually a URL and fetches it, without validating that assumption against the field's declared type[cite: 6].
Every one of these is server-side code taking user input at face value instead of validating it against what the input is supposed to represent[cite: 6]. That's the pattern worth internalizing, not "check CAPTCHAs" or "sanitize email fields" as isolated checklist items, but the general habit of asking, for every field on every form: what does the backend actually do with this, and did anyone verify it does what it claims?[cite: 6]
How to actually fix it
CAPTCHA: implement real server-side verification against the provider's verification endpoint on every submission[cite: 6]. Reject on missing, empty, expired, or failed verification[cite: 6]. Add a regression test that submits a fabricated token and asserts rejection, this is exactly the kind of check that silently rots without one[cite: 6].
Phishing relay: sanitize free-text fields before they ever reach an outbound email body, at minimum render as plain text and strip or encode URLs[cite: 6]. Consider whether an autoresponder should be allowed to fire to arbitrary third-party addresses at all without some form of recipient verification[cite: 6].
SSRF: treat every free-text field as untrusted, non-fetchable data by default[cite: 6]. If a feature needs to preview or scan links, route it through an isolated proxy with a strict egress allowlist that explicitly blocks link-local, private (RFC 1918), and cloud metadata ranges, and disable automatic redirect-following to disallowed destinations[cite: 6]. Validate fields against their declared type rather than silently auto-detecting URLs in plain-text input[cite: 6].
If you operate a form on a platform like this and don't control the backend: disable the autoresponder immediately if it's not business-critical[cite: 6]. That one setting change kills the phishing-relay path outright while you wait on the platform vendor, and it's worth doing the moment you find this pattern, not after the vendor responds[cite: 6].
What we want other researchers to take from this
Public, unauthenticated form endpoints are underrated attack surfaces[cite: 6]. They look boring, they don't require an account, and everyone assumes the vendor handled the basics[cite: 6]. Test the CAPTCHA the way we described above, it's a five-minute check with a real hit rate[cite: 6]. Test whether the autoresponder recipient is verified against anything[cite: 6]. And test what the backend actually does with every free-text field, not just whether it renders safely on the frontend, but whether anything server-side treats it as a domain, a URL, or a template variable[cite: 6]. The most damaging finding in this set wasn't the one anyone was looking for, it fell out of testing something else entirely[cite: 6]. That's usually how the good ones show up[cite: 6].
Glossary: Terms used in this post
- CAPTCHA / reCAPTCHA: The "I'm not a robot" widget shown on a form[cite: 6]. It generates a token meant to prove a human, not a script, filled out the form[cite: 6]. The widget itself proves nothing on its own; it only works if the server checks that token with the provider that issued it[cite: 6].
- CAPTCHA verification API / siteverify: The server-to-server call a website is supposed to make after a CAPTCHA is submitted, sending the token to the CAPTCHA provider (e.g. Google) to ask "was this real?"[cite: 6] If a site skips this call, the CAPTCHA becomes decorative, it collects a token but never checks whether that token means anything[cite: 6].
- Token: A piece of text generated by the CAPTCHA widget and sent along with the form submission[cite: 6]. Real tokens are long (several hundred characters) and expire quickly[cite: 6]. A "fabricated" or "junk" token is just a short made-up string used to test whether the server is actually checking the token's validity or merely checking that the field isn't blank[cite: 6].
- Rate limiter / rate-limit window: A control that caps how many requests a given source can send in a given time period (e.g. "1,500 per hour")[cite: 6]. It's meant to slow down abuse, not eliminate it[cite: 6]. If nothing else blocks automation (like a working CAPTCHA check), the rate limit becomes the only remaining ceiling on how fast an attacker can act[cite: 6].
- Autoresponder: An automatic confirmation email a form sends back after someone submits it[cite: 6]. Normal and expected behavior for a contact form, the problem in this post isn't that it exists, it's that nothing verifies the recipient is actually the person who filled out the form[cite: 6].
- Free-text field: Any form input where the submitter can type whatever they want (a comment box, a "message" field), as opposed to a dropdown or a field restricted to a specific format[cite: 6]. These fields are risky because backend code sometimes makes assumptions about what kind of content will show up in them, without actually enforcing those assumptions[cite: 6].
- Sanitization: The process of cleaning up user-submitted content before displaying it or including it elsewhere (like in an email), stripping or encoding anything that could be interpreted as a clickable link, executable code, or formatting rather than plain text[cite: 6]. "Unsanitized" means that the cleanup step didn't happen[cite: 6].
- SPF / DKIM / DMARC: Three email authentication standards that receive mail servers and spam filters check to decide whether an email claiming to be "from" a given domain is actually authorized to be sent from that domain's infrastructure[cite: 6]. An email that passes all three looks legitimate to almost every spam filter, even if its content is malicious, because the check only verifies the sender's infrastructure, not the sender's intent[cite: 6].
- SSRF (Server-Side Request Forgery): A vulnerability where an attacker can trick a server into making network requests (DNS lookups, HTTP requests) to a destination the attacker chooses, instead of the server only ever talking to destinations its developers intended[cite: 6]. "Blind" SSRF means the attacker can't see the response directly, they can only confirm it happened by watching for the server's request to arrive somewhere they control (see: out-of-band interaction service, below)[cite: 6].
- CWE-918: The standard catalog number (Common Weakness Enumeration) for Server-Side Request Forgery[cite: 6]. Used as a shorthand reference so other researchers or engineers immediately know which class of bug is being described[cite: 6].
- OWASP A04:2021 (Insecure Design): A category from OWASP's Top 10 list of common web application risks, covering flaws that come from how a feature was designed or specified in the first place, rather than a simple coding mistake[cite: 6]. A missing server-side verification step (like the CAPTCHA gap in this post) is a textbook example: no line of code is technically "wrong," a necessary check was just never designed in[cite: 6].
- Out-of-band (OOB) interaction service: A tool (Burp Collaborator is one well-known example) that gives you a unique, disposable domain name and then shows you every DNS lookup or HTTP request that domain receives[cite: 6]. It's how you prove a server made a request you can't otherwise observe, you plant the unique domain somewhere, then watch the tool's log for a hit[cite: 6].
- Link-local / RFC 1918 (private) address ranges: Blocks of IP addresses reserved for use inside a private network rather than the public internet (e.g. addresses starting with 10., 172.16-31., or 192.168.) or for a device to talk to itself/its local network segment[cite: 6]. A server that can be tricked into fetching addresses in these ranges may expose internal systems that were never meant to be reachable from outside[cite: 6].
- Cloud metadata endpoint (169.254.169.254): A special address that cloud providers (AWS, Google Cloud, Azure, etc.) expose to virtual machines running in their infrastructure, used by the machine itself to fetch its own configuration, and sometimes temporary security credentials[cite: 6]. If an SSRF bug can reach this address, it can potentially steal credentials belonging to the server itself, one of the most severe outcomes an SSRF can lead to[cite: 6].
- Egress allowlist: A rule that only allows outbound network traffic to a pre-approved list of destinations, blocking everything else by default[cite: 6]. Recommended as a fix for SSRF because it stops a server from being able to reach unexpected or internal destinations even if something upstream tries to make it[cite: 6].
- Regression test: An automated test that re-checks a specific behavior every time code changes, specifically to catch cases where a fix or feature that used to work silently breaks later[cite: 6]. Recommended here because a missing CAPTCHA check is exactly the kind of thing that can pass unnoticed for a long time without one[cite: 6].