I'm Hossam Hussein, a security researcher and bug bounty hunter. In this write-up I'll walk you through a Stored Cross-Site Scripting (XSS) vulnerability I found in an ERP / procurement platform — and how, after it was fixed, I broke it a second time by bypassing the patch.
> ⚠️ The real program is private, so throughout this article the target is referred to asfree-palestine.com. Every payload, endpoint and behaviour is real — only the hostname has been changed.
Target Information
Program: Private (referred to here as free-palestine.com)
An ERP (Enterprise Resource Planning) system is the software a company runs its operations on — purchasing, invoicing, approvals, suppliers, and the people who use all of it. Each customer (organisation) has its own users and roles: regular employees, approvers, and administrators.
One small, friendly feature drew my attention: an organisation administrator can configure a**"Welcome text"** — a banner that is displayed to every user of that organisation on their home dashboard. Whenever an application lets one user provide content that is then rendered as HTML to other users, it deserves a very close look.
Summary
A Stored XSS vulnerability existed in the organisation Welcome text feature. The banner was stored and served as raw HTML to all users of the organisation, allowing an attacker (a malicious or compromised admin) to execute arbitrary JavaScript in other users' authenticated sessions.
After my initial report was accepted and fixed, I revisited the patch and found it was a bypassable denylist, not a real sanitizer — so I achieved Stored XSS on the very same feature a second time. This article covers both: the original bug, the fix, and the fix bypass.
Proof of Concept (PoC)
🧭 Part 1 — Understanding the feature
As an organisation admin, I set a Welcome text. On the dashboard it doesn't render inline — instead the front-end loads it into an whose src points at a dedicated endpoint:
GET /web/api/home/welcome-html/
That endpoint returns the stored banner as a full HTML document:
The banner is served as text/html — not JSON, not escaped text.
The page's CSP allowed 'unsafe-inline' for scripts.
Inside the dashboard the iframe is sandboxed, so JS there is neutered. But the raw endpoint — https://free-palestine.com/web/api/home/welcome-html/ — opened directly as a top-level page, is same-origin, unsandboxed,text/html, withunsafe-inlineCSP. A perfect place for code to run.
💥 Part 2 — The first Stored XSS (before the fix)
A plain was rejected, but the field happily accepted an whose src was an HTML-entity-encoded javascript: URI:
Decoded, that src is simply:
javascript:alert(document.cookie)
An admin stores it once → it is served from welcome-html → any user who opens that endpoint runs attacker-controlled JavaScript in their own authenticated session.
✅ Reported → Accepted → Fixed. Most stories end here. This one doesn't.
🔧 Part 3 — The fix
After remediation, the field gained a server-side guard. Anything script-like was rejected with:
{ "messages": ["Scripts are not allowed in the welcome text. Remove the script to save the text."] }
It looked solid. It rejected , every _on=_* handler I tried, javascript: (even entity-encoded / tab-obfuscated), srcdoc, mutation-XSS payloads, / — and even the exact old payload.
But a filter that must enumerate "everything dangerous" is playing a game it cannot win. It's a denylist, not a sanitizer. So I went looking for what it forgot.
💥 Part 4 — Bypassing the fix (the second Stored XSS)
A few probes revealed the filter's nature: a substring denylist that decodes HTML entities first, then matches a fixed set of keywords — regardless of context. Three independent gaps broke it.
Gap 1 — an incomplete event-handler list. The platform keeps adding new handlers; these were not blocked:
onpagereveal — fires automatically on page load (zero interaction)
onbeforematch — fires when a scroll-to-text fragment reveals hidden="until-found" content
onsecuritypolicyviolation — fires on a CSP violation
Gap 2 —alertwas "fuzzy-blocked, so I stopped spelling it.** I generated the string at runtime so the letters never appear in the payload:
(8680439).toString(30) === "alert"
So top[(8680439).toString(30)] is just top.alert — with no "alert" in the source.
Gap 3 —cookiewas blocked, so I rebuilt it:
document['coo'+'kie'] // === document.cookie, but "cookie" never appears literally
Final payloads:
Zero-click — fires the instant the endpoint is opened:
XSS
One-click, broader support — delivered with a scroll-to-text fragment:
💥 The victim opens the link → the browser finds xsspoc, reveals the hidden=until-found element, fires beforematch, and the handler executes alert(document.cookie) in the victim's authenticated origin. Confirmed live in the browser. Same feature, same impact — the fix was bypassed.
Impact
Stored XSS — arbitrary JavaScript runs in a victim's authenticated session on the platform.
Session / token theft and the ability to act as the victim via the application's own APIs.
Admin → user compromise across an organisation (the banner is shown to every org user).
Privilege escalation potential if an administrator views the page.
The patched version was bypassed, re-introducing the same risk after it was believed fixed.
Key Takeaways
A denylist is not a sanitizer. New HTML event handlers ship every year, and JavaScript can rebuild any blocked string ((8680439).toString(30), 'coo'+'kie', template literals). Use an allowlist HTML sanitizer or store/render the value as plain text.
Fix the output context, consistently. Serve user-influenced HTML with a restrictive CSP (no 'unsafe-inline'), a non-renderable content type, and sandbox it everywhere — never as a directly-navigable top-level page.
When a bug is "fixed," test the fix. Some of the best findings live one layer past Resolved.
Report Timeline
Reported — original Stored XSS (entity-encoded javascript: iframe in the welcome banner).
Accepted & Fixed — "the fix for this is live!"
Re-tested the fix — found it was a bypassable denylist.
Reported the fix bypass — zero-click onpagereveal / one-click onbeforematch payloads. Same feature, broken again.