May 14, 2026
The Story of 45+ Stored XSS Bugs
The simple methodology that allowed me to discover dozens of XSS bugs…
He4am
5 min read
During a bug bounty on a popular open-source CMS platform, I identified over 45 distinct Stored XSS vulnerabilities — all originating from the same 4 root causes.
Because most of these stemmed from the same underlying mistakes, I consolidated them into a handful of reports grouped by context and vulnerable code pattern rather than submitting 45 individual findings.
In this write-up, I want to share the methodology that let me track down so many bugs I would've otherwise missed, and break down the 4 insecure code patterns behind them.
If you're a bug hunter or penetration tester, this might help you find more bugs with less effort. If you're a developer, it'll show you how output encoding quietly fails in complex applications — and what to watch out for.
The Core Problem: Output-Based Sanitization
The fundamental architectural flaw in the target application was its reliance on Output-Based Sanitization rather than Input Validation.
When an application allows arbitrary HTML to be stored in the database, the burden of security shifts entirely to the "sinks" (the places where the data is rendered). If a single user input is rendered in 20 different places across the application, the developers must remember to escape that output exactly 20 times. If they forget even once, it results in a Stored XSS vulnerability.
The "Secondary Context" Trap
Because of this architectural choice, the most common place I found XSS was in what I call a "Secondary Context".
When a developer builds a primary page (like an "Edit Profile" page), they thoroughly test it and ensure the user's "Company Name" is properly sanitized. However, months later, a different developer builds an "Admin Dashboard" that lists all users in a data table. They pull the "Company Name" from the database, assume it is safe, and render it without escaping it.
The Methodology: Passive Payload Tracking
Because the application had so many sinks, actively testing every single input against every single output would have taken forever. Instead, I used a passive hunting strategy combined with white-box source code review.
While my primary goal was hunting for high impact flaws, I wanted to passively check for XSS at the same time. I avoided noisy payloads like <script> or <img src=x>. Instead, whenever I found a new input in the application, I named it using a simple HTML tag containing its exact source path.
I didn't pick the tag blindly. I had already noticed that the application allowed italic (<i>) tags without stripping them. If the app blocked <i>, I would have found another tag that worked (like <b> or <u>) and used that instead. Always analyze the application behavior first.
For example, if the input is in the profile settings page in the bio field, I'd use something such as:
<i>Settings_Profile_Bio</i><i>Settings_Profile_Bio</i>As I navigated through the application testing other features, if I suddenly saw italic text rendered on the screen, I immediately knew two things:
- I had found a vulnerable sink where HTML was executing.
- The input text told me exactly which input field the data originated from.
Patterns of Vulnerable Code
Through my code review, I identified four common coding mistakes that caused the application to fail at output encoding in these secondary contexts.
Pattern 1: Bypassing Template Auto-Escaping (The |raw and |md Filters)
The target is using the Twig template engine. Modern template engines are designed to be secure by default; they automatically escape HTML characters so that if a user submits <script>, it gets safely rendered as text.
However, developers sometimes need to render legitimate HTML (e.g., styled text). To do this, template engines provide filters that tell the engine to avoid escaping the input. The most obvious is the |raw filter:
{# The "|raw" filter disables auto-escaping #}
<span>{{ input|raw }}</span>{# The "|raw" filter disables auto-escaping #}
<span>{{ input|raw }}</span>Another dangerous filter I found was |md (markdown). Developers used it to render markdown input, forgetting that standard markdown parsing also allows raw HTML tags to be rendered natively.
Standard markdown parsers, including the one used here (|md), pass raw HTML through without escaping it. So a user submitting <script>alert(1)</script> in a markdown field doesn't need to bypass anything. When user-controlled data was passed into these filters without validation, it disabled the built-in defenses.
Bonus: A "Hidden" Sink Found Along the Way
While grepping the codebase for other dangerous uses of |raw and |md, I came across a feature that allowed users to create custom elements with different types (like text, number, or date) — and found something unexpected: a template block handling a column type called html that was never exposed in the UI:
{% elseif col.type == 'html' %}
<td class="...">{{ value|raw }}</td>{% elseif col.type == 'html' %}
<td class="...">{{ value|raw }}</td>The UI never gave me the option to select html as a type. However, because the backend didn't validate if the submitted type was allowed, I intercepted the HTTP request during element creation and manually changed the type parameter to html.
By manipulating this variable, I forced the application into a configuration that intentionally disabled sanitization and trusted my input blindly.
This is technically a separate vulnerability class — Improper Server-Side Validation — where the application trusted the client to enforce a constraint that only existed in the UI.
Pattern 2: Backend HTML Generation via Html::tag()
While many XSS vulnerabilities stem from frontend templates or JavaScript, the backend PHP code can also be responsible. The target application is built on top of the Yii framework, which provides a helper method called Html::tag() to programmatically generate HTML elements in PHP.
// "Html::tag()" does not encode the content by default
return Html::tag('div', $userControlledInput, ['class' => 'user-bio']);// "Html::tag()" does not encode the content by default
return Html::tag('div', $userControlledInput, ['class' => 'user-bio']);A common misconception among developers is that helper methods like Html::tag() automatically HTML-encode the inner content. However, in Yii, Html::tag() only encodes the HTML attributes (like the class array above), but it intentionally leaves the $userControlledInput raw to allow nesting other HTML tags.
When developers passed unvalidated user input directly into the content parameter of Html::tag(), this caused a Stored XSS before the data even reached the frontend template. This backend pattern was the root cause for several other XSS vulnerabilities.
Pattern 3: Client-Side Frameworks (v-html in Vue.js)
The target application heavily utilized Vue.js on the frontend. Like Twig, Vue.js escapes HTML by default when using standard data binding.
However, when developers needed to render pre-formatted text, they used the v-html directive. This tells Vue.js to insert the raw HTML into the DOM, completely bypassing the framework's XSS protections.
<div class="user-bio" v-html="userProfile.bio"></div><div class="user-bio" v-html="userProfile.bio"></div>Because the backend didn't sanitize the input, passing it straight into v-html caused execution.
Pattern 4: Client-Side JavaScript String Concatenation
While the backend template engine (Twig) handled most rendering, some sections of the application's dashboard utilized Vanilla JavaScript to build widgets dynamically.
Instead of using native DOM creation methods or framework-provided escaping functions, the developers constructed the HTML using raw string concatenation:
// user-controlled "value.name" is directly concatenated
return '<span class="status">' + value.name + '</span>';// user-controlled "value.name" is directly concatenated
return '<span class="status">' + value.name + '</span>';Unlike server-side patterns, this kind of sink is invisible to template engine audits — you have to read the JavaScript directly.
The Exploit Cycle
Whenever I found one of these vulnerabilities using the passive tracking method, I didn't just stop and report the bug.
Instead, I went straight into the application's source code, identified the exact vulnerable pattern that caused it (like the ones shown above), and used grep to search the entire codebase for every other occurrence of that specific pattern.
Find a vulnerability → Identify the vulnerable pattern → Search source code for that pattern → Discover similar bugs → Repeat.
That feedback loop is what turned a single finding into a systematic sweep of the entire codebase — resulting in over 45 reports.
Conclusion
Finding XSS isn't just about throwing a payload list at a wall and seeing what sticks. By mapping data flows and understanding the underlying architecture, you can find the root causes — whether they are secondary context oversights, unsafe macros, or hidden sinks — and systematically hunt down every instance of that flawed pattern.
In the next write-up, I explained how I turned one of these XSS findings into a full database exfiltration — without ever stealing a cookie along with other interesting escalation techniques:
XSS to Database Exfiltration How to escalate a standard XSS vulnerability into critical impact - bypassing HttpOnly, escalating admin privileges…