基本情報リンクをコピーしました

問題文リンクをコピーしました

alert(flag)が実行できたら勝ち!

作業ログリンクをコピーしました

コードをダウンロードして展開。

.text
$tree alert-my-flag/ alert-my-flag/ ├── compose.yaml └── web    ├── Dockerfile    ├── index.js    ├── package-lock.json    └── package.json 2 directories, 5 files

DockerとJavaScriptを使ったシンプルなプロジェクト。

まずDocker関連のファイルを見る。

compose.yaml
services: web: build: context: ./web restart: unless-stopped init: true environment: - PORT=3000 - FLAG=Alpaca{REDACTED} ports: - ${PORT:-3000}:3000
web/Dockerfile
FROM node:25.2.1-bookworm-slim WORKDIR /app ENV PUPPETEER_SKIP_DOWNLOAD=true RUN apt update && apt install -y chromium RUN rm -rf /var/lib/apt/lists/* COPY package*.json ./ RUN npm i --omit=dev COPY . . USER node CMD [ "node", "index.js" ]

なんてことはない、ただのDockerファイルのようだ。

環境変数FLAGにフラグが設定されているのと、Puppeteer関連の環境変数設定やChromiumのインストールがあることから定番の「フラグを持ったBotがChromium経由でWebページを開くので、ページに細工をしてフラグをダッシュする」形式だと予想する。

package*.jsonは特に怪しい記述がなかったのでカット。

ソースコードを見てみる。

web/index.js
import express from "express"; import cookie from "cookie-parser"; import rateLimit from "express-rate-limit"; import puppeteer from "puppeteer"; const PORT = process.env.PORT ?? "1337"; const APP_URL = `http://localhost:${PORT}/`; const FLAG = process.env.FLAG ?? "Alpaca{fake_flag}"; const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const visit = async (url) => { console.log(`Start visiting: ${url}`); const browser = await puppeteer.launch({ headless: "new", pipe: true, executablePath: "/usr/bin/chromium", args: [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", '--js-flags="--noexpose_wasm"', ], }); let successful = false; try { await browser.setCookie({ "name": "flag", "value": FLAG, "domain": new URL(APP_URL).hostname, "path": "/", "httpOnly": true, }); const page = await browser.newPage(); page.on('dialog', async dialog => { const message = dialog.message(); const type = dialog.type() console.log(`Dialog message: ${message}`); console.log(`Dialog type: ${type}`); if(type === "alert" && message === FLAG) { successful = true; } await dialog.accept(); }); await page.goto(url, { timeout: 5000 }); await sleep(3000); await page.close(); } catch (e) { console.error(e); } await browser.close(); console.log(`End visiting: ${url}`); return successful; }; const app = express(); app.set("view engine", "ejs"); app.use(express.urlencoded({extended: false})) app.use(cookie()) app.get("/", async (req, res) => { const flag = req.cookies.flag ?? "fake_flag"; const username = req.query.username ?? "guest"; let result; if(username.includes("flag") || username.includes("alert")) { result = "<p>invalid input</p>"; } else { result = `<h1>Hello ${username}!</h1>` } const html = `<!DOCTYPE html> <html> <head> <script>const flag="${flag}";</script> </head> <body> ${result} <p>Try <a href="/?username=<i>admin</i>">this page?</a> <p>Was "alert(flag)" successful? <form action="/report" method="POST"><input hidden id="username" name="username"><button>Submit this page!</button></form></p> <script> document.getElementById("username").value = new URLSearchParams(location.search).get("username")</script> </body> </html>`; return res.send(html); }); app.use( "/report", rateLimit({ windowMs: 60 * 1000, max: 3, }) ); app.post("/report", async (req, res) => { const { username } = req.body; if (typeof username !== "string") { return res.status(400).send("Invalid username"); } const url = `${APP_URL}?username=${encodeURIComponent(username)}`; try { const result = await visit(url); return res.send(result ? FLAG : "Failed..."); } catch (e) { console.error(e); return res.status(500).send("Something wrong"); } }); app.listen(PORT, () => { console.log(`Listening on http://localhost:${PORT}`); });

コード内での処理を大きく3つに分けて重要なところだけ抜粋すると以下のようになる

  1. /エンドポイントでの処理の受け付け
    リクエストヘッダに含まれるCookieのflagを
    <script>const flag="${flag}";</script>で定数に埋め込む。
    GETの?username=パラメーターに"flag","alert"が含まれていない場合、
    <h1>Hello ${username}!</h1>を含むレスポンスを返す。

  2. /reportエンドポイントでの処理の受け付け
    POSTのusernameパラメータから文字列を受け取り、encodeURIComponent()を用いて組み立てたURLをBotにアクセスさせる。

  3. visitでのBotによるページアクセス
    /reportエンドポイントでの処理内で作成されたURLにBotがアクセスする。
    Cookieに最終奪取目標であるflagが仕込まれている。

実際にアクセスしてみるとこんな感じ。

ブラウザコンソールからalert(flag)を実行すると"fake_flag"(Cookieにフラグが埋め込まれていない場合の値)が出力される。

つまり、フラグをゲットするためには?username=のパラメーターに小細工をして、Botがアクセスするだけでalert(flag)が実行されるようなURLを作成した状態でSubmit this page!を押せば良い。手元で動作確認をしてそのままsubmitボタンを押せば良い良心的な設計。

どう考えてもこれは「XSS脆弱性を突け」という問題なのだが、少しばかりハードルを高くしている。

まず1つ目はCookieへのhttpOnly属性の付与だ。
javascriptからdocument.cookieなどを利用してcookieの値を読み出せないようにしている。

まぁこれは<script>const flag="${flag}";</script>でjavascriptから読めるflag変数にcookieの値が埋め込まれているのであまり意味がない。

2つ目はガバガバサニタイザ処理だ。
if(username.includes("flag") || username.includes("alert"))がtrueとなるとXSS対象の要素が埋め込まれないようにされている。

"alert"と"flag"の文字列が含まれないように工夫してコードを書き、alert(flag)を実行させれば良い。

解法は大雑把に2つ思いついた。どちらもevalを用い、ブラウザ側でalert(flag)というコードを組み立てて実行させる方式だ。

①文字列の分割結合
"alert","flag"という連続した文字列があるといけないのなら、分割して後でくっつければいいじゃんという考え方。
<script>eval('al'+'ert(fl'+'ag)')</script>をそのままURLに入れるとWebの仕様1+ (スペース)に置換され、<script>eval('al' 'ert(fl' 'ag)')</script>として解釈されてしまうので、URLエンコードした文字列を使わなければならない(1敗)

ペイロード:%3Cscript%3Eeval('al'%2B'ert(fl'%2B'ag)')%3C%2Fscript%3E

②Base64エンコード
実行するコード自体をbase64エンコードし、実行時にデコードする。
base64("alert(flag)") = "YWxlcnQoZmxhZyk="より、
<script>eval(atob('YWxlcnQoZmxhZyk='))</script>をURLエンコードして

ペイロード:%3Cscript%3Eeval%28atob%28%27YWxlcnQoZmxhZyk%3D%27%29%29%3C%2Fscript%3E

最終的な解法リンクをコピーしました

対象URLの末尾に
?username=%3Cscript%3Eeval('al'%2B'ert(fl'%2B'ag)')%3C%2Fscript%3E
もしくは
?username=%3Cscript%3Eeval%28atob%28%27YWxlcnQoZmxhZyk%3D%27%29%29%3C%2Fscript%3E
を付与してSubmit this page!をクリック。

脚注

  1. application/x-www-form-urlencoded
    https://url.spec.whatwg.org/#:~:text=spaceAsPlus,-is