June 12, 2026
The Print Button That Handed Me Your Account: Stored XSS to Full ATO on a “Secure” Email Platform
TL;DR
Itachix0f
3 min read
The target is a secure-email / encrypted-communication platform. Its main web app carefully neutralizes injected HTML in the conversation view — but I found a second, forgotten rendering path that re-injected the exact same stored message body into the DOM with no sanitization at all.
That gap let me demonstrate arbitrary JavaScript execution in the app origin. From there it wasn't a popup-and-go-home alert: I was able to show that the victim's OAuth refresh token could be read from sessionStorage, exchanged for a fresh high-privilege access token, and used to plant a delegation backdoor that survives a password change.
Why this target?
This isn't a random blog. It's a secure-email solution trusted by large organizations in highly regulated, high-stakes sectors — including major players in energy and nuclear, finance, healthcare and government. When confidentiality is the entire product, a client-side bug isn't cosmetic: the people using it are betting their most sensitive communications on the assumption that it's safe.
So I went looking for the gap between "looks safe" and "is safe."
The shape of the bug: two views, one source of truth
The interesting bugs tend to live where there are two render paths and only one of them got the security review.
I noticed the platform stored message bodies and could accept HTML content. In the main conversation view, it applied client-side mitigations — injected links were neutered and the content stayed inert. On its own, that's a low-severity "HTML injection" most hunters would walk past.
The interesting part was a second view: a print/export page that re-rendered the same stored message body straight into the DOM via innerHTML, without any of the mitigations the main view applied. Same stored bytes, completely different — and unsafe — rendering. The print page had been treated as a trusted internal surface. It wasn't.
What I demonstrated
Working against my own test account, I confirmed that HTML stored through the normal message flow was kept verbatim, with no server-side sanitization. In the main view it rendered inert, exactly as designed.
When the same conversation was opened in the print view, the stored content was injected through innerHTML and executed automatically on load. My proof was the classic harmless marker:
alert(document.cookie)alert(document.cookie)Execution in the app origin, triggered simply by the page rendering. That was the foothold — and the point where a "stored HTML injection" became a real stored XSS.
From a popup to account takeover
A document.cookie alert looks dramatic but the session cookies were HttpOnly, so they weren't the prize. The real question for any in-origin script is: what can it actually reach?
In this case, the answer was sessionStorage — where the platform kept an OAuth refresh token. Any JavaScript running in the origin can read that. To demonstrate the consequence safely, I showed that an in-origin script could locate the refresh token and send it to a collector I controlled — conceptually:
// illustrative only — locate the token in web storage and report it
const token = findRefreshTokenInSessionStorage();
report(token);// illustrative only — locate the token in web storage and report it
const token = findRefreshTokenInSessionStorage();
report(token);I then showed that a refresh token obtained this way could be exchanged, through the platform's own authentication endpoint, for a fresh and valid access token:
POST /api/authentication/login
grant_type=refresh_token
refresh_token=<obtained-via-XSS>POST /api/authentication/login
grant_type=refresh_token
refresh_token=<obtained-via-XSS>The issued token came back with mfa_required: false and a scope set that, for a confidentiality product, is alarming — sending and receiving messages, writing the profile, even changing the password. Refresh token → access token → authenticated as the victim, with no further interaction. Because the exchange goes through the platform's legitimate flow, it looks like an ordinary login — there's nothing exotic to flag.
Persistence: surviving a password reset
Takeover that ends when the victim rotates a password is weak. The platform had a delegation feature, and I demonstrated that the access token obtained through the chain could add an attacker-controlled address as a delegate.
The important detail: a password change does not revoke a delegate. So the access outlives the victim's instinctive "I think I was compromised — let me reset my password" reaction. On a platform built for confidential communication, persistent access to someone's encrypted messages is about as serious as a client-side bug gets.
Impact
Chaining it together, I was able to demonstrate:
- Arbitrary JavaScript execution in the app origin (stored, auto-triggered by the print view).
- Reading the OAuth refresh token from
sessionStorage, sidestepping theHttpOnlycookie protection entirely. - Exchanging it for a valid high-privilege access token via the platform's own endpoint — an effective authentication bypass.
- Acting as the victim: messages, attachments, recipients, confidential data.
- A delegation backdoor that persists across password changes.
For a secure-communications product trusted by organizations in energy, finance and government, that defeats the core promise.
Takeaways
- The second render path is where the depth is. When one view is hardened, the export, print or preview page is often where the security review didn't reach.
HttpOnlyis not "XSS doesn't matter." If the real secret lives insessionStorage, the cookie flag is a speed bump, not a wall. The question is always: what does in-origin script actually have access to?- Go past
alert(). Demonstrating execution gets a shrug; demonstrating the full consequence — token, session, persistence — is what shows the true severity.