Summary

Disclosure note: This was found during an authorized, client-commissioned penetration test. The target platform’s domain, endpoints, employee identities, tokens, and session values below have been anonymized/fictionalized to protect the client — none of the identifiers or values shown are real. TL;DR A one-time password (OTP) meant to act as a second authentication factor was returned directly in the body of the HTTP response that triggered its own generation. No interception, no phishing, no SIM-swap — just reading the API’s own response told you the code needed to log in as someone else. Combined with a trivial account-enumeration step earlier in the flow, this chain let an unauthenticated tester identify a valid administrator account and log into it using nothing but the platform’s own API responses. Severity: Critical (CVSS 3.1: 9.8 ) Class: API2:2023 / A07:2021 — Broken Authentication, Identification and Authentication Failures (OWASP) Background Let’s start with the basics, because this bug is a great excuse to revisit them: why do we even bother with OTPs? The whole point of a one-time password is to prove you have access to something an attacker with just your stolen password shouldn’t have — your inbox, your phone, an authenticator app. It’s the “something you have” half of two-factor authentication, and it’s supposed to be the safety net that catches you even after your password has already leaked somewhere. That safety net only works if the code takes exactly one path: server generates it, server ships it down the side channel (email, SMS, push), and nowhere else. The second a server gets a little too chatty — echoing that code back in an HTTP response, a log line, or anywhere a client can read it directly — the “something you have” factor quietly turns into “something anyone watching the network traffic has too.” That’s exactly the trap this target fell into, and it’s a more common mistake than you’d hope. Recon: finding valid accounts before touching login Testing started with no credentials. Reviewing the platform’s client-side JavaScript bundle (a routine step — production bundles frequently ship more than intended) turned up several email addresses hardcoded or referenced inside a static asset: https://app.targetapp.example.com/static/js/main.[hash].js Four candidate addresses tied to the target organization turned up this way. Not all of them were necessarily live accounts — bundles can reference test data, internal tooling, or stale fixtures — so the next step was to check which ones the platform itself recognized. alice.doe@novacorp.com infosec@novacorp.com appsecurity@novacorp.com bob.smith@novacorp.com Step 1: confirming a real account exists (and leaking its internal ID for free) The password-reset/login flow exposed an endpoint that, given an email address, tells you whether it belongs to a registered user — and, in doing so, hands back that user’s internal record ID: POST /Mail/Validation_Email HTTP/1.1 Host: prod-app-api.targetapp.example.com Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIs… Origin: https://app.targetapp.example.com {“option”:1,“email”:“admin.user@targetapp.example.com”} Response: 200 OK 5741 That’s it — just the internal idPersonnal for the account, returned in plaintext with no ambiguity about whether the email was valid. Of the four candidate addresses recovered from the JS bundle, two returned a valid ID this way, confirming two real, registered accounts — one of which turned out to belong to an administrator. This alone is a (smaller) account-enumeration issue: a generic “if this email exists, we’ll send instructions” response would have avoided handing out a definitive yes/no plus an internal ID. But it’s what happened next that turned this into something critical. Step 2: the OTP, mailed to nobody but returned to everybody With a confirmed email and its idPersonnal , the next step in the normal login flow requests that the platform send a one-time code to that user’s email: POST /Mail/Insert_Rel_EmailCode HTTP/1.1 Host: prod-app-api.targetapp.example.com Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIs… Origin: https://app.targetapp.example.com {“email”:“admin.user@targetapp.example.com”,“idPersonnal”:5741} Response: 200 OK {“email”:“admin.user@targetapp.example.com”,“code”:“085327”,“idPersonnal”:5741,“id”:0} Read that response again: the endpoint’s entire job is to email a one-time code to the user. Instead, it emails the code and hands it straight back in its own HTTP response body — to whoever made the request, regardless of whether they have any access to that inbox at all. Get Sid’s stories in your inbox Join Medium for free to get updates from this writer. At this point, no email account, no SMS access, and no interception of anything was required. The code needed to complete login as admin.user@targetapp.example.com was sitting in plain JSON, in the same response that triggered the request. Step 3: reusability turns a leak into a takeover The obvious follow-up question: does the platform at least treat that code as single-use, short-lived, and bound to the session that requested it? Testing showed no on all three counts. The captured OTP (085327 ) was submitted as the second factor to complete login for the account — successfully, with no additional restriction — granting an authenticated session for an account carrying administrative privileges. The OTP was not bound to:

  • the session or device that initiated the request,
  • a short, enforced expiry window, or
  • single use (no invalidation after a successful or failed attempt). Impact This chain — email enumeration → OTP leaked in response → OTP freely reusable — collapses the entire second factor down to zero real protection:
  • Confidentiality: any of the organization’s accounts discoverable through the enumeration step (not just this one) can have their OTP retrieved the same way.
  • Integrity / Account Takeover: because the compromised account carried administrative privileges, an attacker gains the ability to perform critical actions across the platform — not just read access to one user’s data.
  • Reproducibility: every step used only the platform’s own public API with a generic authenticated session — no race condition, no timing attack, no social engineering. This is trivially scriptable end-to-end. Root cause The OTP-issuing endpoint conflated “generate and dispatch the code” with “tell the caller what the code is.” The correct behavior is for the server to generate the OTP, persist it server-side (bound to the user, session, and a short TTL), send it exclusively through the out-of-band channel (email/SMS), and return only a generic acknowledgment ({“status”:“sent”} ) to the HTTP caller — never the code itself. On top of that, even if the leak were fixed, the missing session/device binding and lack of expiry meant a captured code had a wide window and no restrictions on reuse. Remediation
  1. Never return the OTP in the API response. The response to an OTP-send request should carry no more information than “a code was sent” — nothing that lets the caller distinguish success from the code’s actual value.

Wrong

return {“email”: email, “code”: otp, “idPersonnal”: user.id}

Right

store_otp(user_id=user.id, code=otp, ttl_seconds=90) send_email(user.email, otp) return {“status”: “sent”} 2. Bind the OTP to context. Tie every generated code to the specific user ID, session ID, and (where feasible) device fingerprint that initiated the request, so a code obtained through any other channel can’t be replayed against a different session. 3. Short, enforced expiry and single use. 30–120 seconds is plenty for a real user; invalidate immediately after one use or after expiry, whichever comes first. 4. Generic responses on account-lookup endpoints. The email-validation step should not distinguish “this account exists” from “this account doesn’t” with a bare internal ID versus an error — return the same generic response either way, and rely on the OTP delivery itself (received or not) as the real signal to the legitimate user. 5. Rate-limit and monitor. Both the email-validation and OTP-issuing endpoints should be throttled per account/IP, with alerting on repeated lookups or code requests — the exact pattern this enumeration and leak would produce at scale. 6. Audit client-side bundles before shipping. The initial email addresses came from a production JS bundle. Build pipelines should scan for hardcoded credentials, emails, and internal identifiers before deployment. Conclusion Multi-factor authentication is only as strong as the channel carrying the second factor. Here, the “channel” leaking the OTP wasn’t a phishing page or a compromised inbox — it was the API itself, handing the code back to whoever asked for it, on top of a lookup flow that made finding valid (including administrative) accounts trivial in the first place. Treat OTP generation and delivery as two separate, one-way operations: the server issues and stores the code, the out-of-band channel delivers it, and the HTTP response in between should never know — or say — what that code was.

By Sid

Original Article