基本情報リンクをコピーしました
- CTF名:Alpacahack Daily
- 開催日時:2026/03/05
- カテゴリ:Web
- 問題URL:https://alpacahack.com/daily/challenges/alert-my-flag
問題文リンクをコピーしました
alert(flag)が実行できたら勝ち!
作業ログリンクをコピーしました
コードをダウンロードして展開。
$tree alert-my-flag/
alert-my-flag/
├── compose.yaml
└── web
├── Dockerfile
├── index.js
├── package-lock.json
└── package.json
2 directories, 5 filesDockerとJavaScriptを使ったシンプルなプロジェクト。
まずDocker関連のファイルを見る。
services:
web:
build:
context: ./web
restart: unless-stopped
init: true
environment:
- PORT=3000
- FLAG=Alpaca{REDACTED}
ports:
- ${PORT:-3000}:3000
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は特に怪しい記述がなかったのでカット。
ソースコードを見てみる。
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つに分けて重要なところだけ抜粋すると以下のようになる
/エンドポイントでの処理の受け付け
リクエストヘッダに含まれるCookieのflagを<script>const flag="${flag}";</script>で定数に埋め込む。
GETの?username=パラメーターに"flag","alert"が含まれていない場合、<h1>Hello ${username}!</h1>を含むレスポンスを返す。/reportエンドポイントでの処理の受け付け
POSTのusernameパラメータから文字列を受け取り、encodeURIComponent()を用いて組み立てたURLをBotにアクセスさせる。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!をクリック。
脚注
application/x-www-form-urlencoded
https://url.spec.whatwg.org/#:~:text=spaceAsPlus,-is ←