Client-Side Security

From XSS to Session Hijack: A Complete Exploitation Chain

Why I stopped treating XSS as a popup bug

When I first learned about XSS, I thought the goal was to make a browser show an alert box. It took me a while to realize that the alert box is only a proof of concept. The real impact comes from what a script can do inside the victim's session. A stored XSS can steal tokens, change account settings, call APIs, and even create persistent access.

This post is my personal study note on turning XSS into a session hijack. I am writing it because the chain is often reported poorly. Testers find the injection point and stop. The better approach is to think about the full path from the injected script to the action that the attacker wants to perform.

The types of XSS and where they live

XSS comes in three main forms. Reflected XSS is injected into the request and reflected in the response. Stored XSS is saved by the application and rendered for other users. DOM-based XSS never leaves the browser; the payload modifies the DOM using client-side data.

The injection context matters more than the type. The payload must work in the context where the data is rendered. If the data is placed inside an HTML tag, I need to close the tag. If it is placed inside a JavaScript string, I need to break out of the string. If it is placed inside a URL attribute, I need to escape the attribute.

Stored XSS is the most valuable for session hijacking because it reaches every user who views the stored data. A comment field, a profile name, a document title, or a support ticket can be a persistence point. I look for fields that are stored and then rendered in an administrative view or a shared page.

Finding the injection point

The injection point is found by sending a unique marker and looking for the marker in the response. I use a random string instead of a common payload, because the random string is easier to locate. If the marker appears inside a specific HTML context, I know where to build the payload.

The next step is to test whether the application encodes the output. If the marker is escaped, the payload will be displayed as text. If the marker appears without encoding, the context is injectable. I test each field in every context where it is rendered.

The browser is my main tool for this testing. I use the developer console to inspect the DOM and the network requests. The response in the source view is not the same as the rendered DOM, and the DOM is what the script actually executes in.

Building a payload that does something real

The payload should do something that matters. The first action is often reading the session token. If the token is in a cookie without HttpOnly, document.cookie can read it. If the token is in localStorage, any script on the origin can read it. I exfiltrate the token to a server I control and use it to confirm the hijack.

If the token cannot be read, the script can still act on the user's session. It can send requests with the user's cookies and perform actions on their behalf. Changing the password, changing the email, adding an attacker-controlled phone number, or creating a new API token are all possible through the session.

The payload should be small and focused. A large payload is harder to deliver and easier to detect. I build the payload in stages and test each stage separately.

Stealing the session token

The most direct session hijack is token theft. I create a payload that reads the token and sends it to my server. The server logs the token, the user agent, and the time. I then use the token from a different browser to confirm that it is valid.

The confirmation step is important. A stolen token that cannot be used is not a full hijack. I test the token by making a request that returns data from the victim's account. If the request succeeds, the token is valid.

HttpOnly cookies make token theft harder but not impossible. The script cannot read the cookie, but it can still make requests with the cookie. I can call an endpoint that returns account data, or I can call an endpoint that returns a token in the response. The chain does not end with document.cookie.

Using CSRF tokens

Modern applications often include CSRF tokens in forms and requests. A script running on the same origin can read those tokens. This means XSS can bypass CSRF protection completely. The script can call a state-changing endpoint with the correct token and perform the action.

The combination of XSS and CSRF is powerful. I can change the victim's email address, add a new device, or approve a transaction. Each of these actions requires a token that the script can read from the page or from an API response.

The test should be careful. I do not want to change the victim's account during a real engagement. I use a test account or a controlled environment and document the potential impact instead.

Persistence through service workers

Service workers are one of the most interesting persistence mechanisms. A service worker registered from a script on the origin can intercept future requests on that origin. It can modify responses, inject scripts, and redirect the user to different content.

If an XSS payload registers a service worker, the access can survive after the original page is closed. The service worker runs in the background and can affect every future visit. This is much more dangerous than a one-time script execution.

Not every application allows service worker registration, and the behavior depends on the browser and the secure context. I test whether the origin allows it and whether the application clears the registration on logout. If it does not, the persistence is a real risk.

Persistence through profile data

The stored data itself can be a persistence mechanism. If the application renders profile data on every page, an XSS payload stored in the profile will execute on every page load. This is why stored XSS is so dangerous. The attacker does not need to convince the victim to click a link.

The payload can also be hidden. A profile name might be rendered in the HTML but not visible in the UI. The payload executes even if the user does not see it. I look for fields that are stored and rendered in multiple views, including admin views.

Defenses that break the chain

The defense against XSS is a stack, not a single control. The first layer is context-aware output encoding. Every value that is inserted into HTML, attributes, JavaScript, URLs, or CSS should be encoded for that context. The second layer is a content security policy that restricts which scripts can execute.

The third layer is cookie security. HttpOnly, Secure, SameSite, and short expiration make token theft harder. The fourth layer is server-side session validation. A session should be invalidated when the password changes, when the device changes, or when suspicious activity is detected.

The fifth layer is input validation. Input validation is not a complete defense, but it reduces the number of injection points. The combination of these layers is what makes a single XSS less likely to become a full session hijack.

Testing responsibly

The exploitation chain can be destructive. I always use a test account, a controlled server, and a minimal payload. I do not want to modify another user's account or send real credentials to an external server. The goal is to demonstrate the risk, not to cause harm.

The proof of concept should be reproducible. I record the injection point, the stored field, the payload, the callback, and the action performed. The report should explain the chain from XSS to the security impact.

Content security policy

A content security policy is one of the strongest defenses against XSS. The policy tells the browser which scripts are allowed to run. If the policy is strict, an injected script is blocked even when the injection succeeds.

I test the policy by looking at the Content-Security-Policy header and the default-src, script-src, and object-src directives. A policy that allows unsafe-inline is much weaker than a policy that uses nonces or hashes.

The policy is not always easy to bypass, but it is not a complete defense. I test the injection point even when the policy is present. A bypass can come from a JSONP endpoint, a base tag, a missing frame-ancestors directive, or a policy that is too broad.

The CSP also affects the payload design. A payload that uses an external script is blocked by a strict policy, but a payload that uses an existing script may still work. I adapt the payload to the policy.

Cookie flags and session design

The cookie flags are the second layer of the defense. The HttpOnly flag prevents JavaScript from reading the cookie. The Secure flag prevents the cookie from being sent over HTTP. The SameSite flag controls when the cookie is sent with cross-site requests.

The flags do not stop XSS, but they change the impact. An HttpOnly cookie cannot be stolen directly, but the script can still make requests with the cookie. The session is not protected by the flag alone.

The session design also matters. A session that is tied to the device, the IP, or the user agent is harder to hijack. A session that is invalidated after a password change is safer. A session that can be revoked by the user is safer.

I test the session behavior by using the stolen token from a different browser. If the token works, the session is not tied to the client. The finding is more severe when the token is the only factor.

Testing the full chain

The full chain test starts with the injection point and ends with the impact. I create a stored payload in a test account, visit the page as another account, and observe the callback. The callback shows that the script executed.

The next step is the action. I use the script to read a token, call an API, or change a setting. The action proves that the script can do more than display an alert.

The final step is the persistence. I check whether the payload survives a page reload, a logout, and a browser restart. The persistence determines whether the access is one-time or ongoing.

The chain should be documented with screenshots and request logs. The developer needs to see the injection point, the payload, and the impact in one place.

Common mistakes I make

My biggest mistake is stopping at the alert box. The alert proves that JavaScript runs, but it does not prove the impact. My second mistake is ignoring the injection context. A payload that works in one context will not work in another. My third mistake is forgetting to test stored XSS in multiple views.

The fourth mistake is using a payload that is too complex. The complexity hides which part of the payload is necessary. The fifth mistake is not documenting the session hijack confirmation. A stolen token is only a finding if I can show that it works.

My quick checklist

  1. Find every injection point and identify the rendering context.
  2. Test reflected, stored, and DOM-based XSS separately.
  3. Build a payload that reads a token or performs an action.
  4. Confirm the token works from a different browser.
  5. Test state-changing requests with CSRF tokens.
  6. Check whether service workers can be registered.
  7. Evaluate the full defense stack, not just output encoding.
  8. Document the complete chain from injection to impact.

What I would do next time

Next time I want to think about the impact before I choose the payload. The impact tells me which action to demonstrate and which data to collect. I also want to spend more time on service workers, because persistence is where a small XSS becomes a serious problem. XSS is not a popup. It is a script that runs in a trusted context, and the context is where the real damage happens.