SECCON Beginners CTF 2023
https://score.beginners.seccon.jp/
IPFactoryの皆さんと協力プレイで参加しました。
順位は84/778位でした。
全部はわからないので、解説できるものだけ載せてあります。
目次
- Welcome(50pt)
- Half(50pt)
- Three(65pt)
- Forbidden(56pt)
- aiwaf(68pt)
- Poem(65pt)
- Poker(94pt)
- 最後に
Welcome(50pt)
CTF開始直後、Discordのannouncements
チャンネルにてflagが投稿されました。
ctf4b{Welcome_to_SECCON_Beginners_CTF_2023!!!}
コピペの速さだけは自信があります。
Half(50pt)
Linuxのstrings
コマンドを使うとflagが出てきます。
実はLinux使わなくても解けます。
Stirling
ctf4b{ge4_t0_kn0w_the_bin4ry_fi1e_with_s4ring3}
Three(65pt)
青い空を見上げればいつもそこに白い猫
というツールを使ってflagの欠片がないか探します。
c
、t
、f
、4
、b
という文字と{
、}
という記号が赤枠に集まっているようです。赤枠を書くとc4c_ub__dt_r_1_4}tb4y_1tu04tesifgf{n0ae0n_e4ept13
となります。
これではflagではないので以下の法則に従ってflagにします。
1.flagの最初はctf4b{
2.flagの最後は}
これを基にすると、まず最初に、以下の3つに分割します。
c4c_ub__dt_r_1_4}
tb4y_1tu04tesifg
f{n0ae0n_e4ept13
1つ目の1文字目、2つ目の1文字目、3つ目の1文字目、1つ目の2文字目、2つ目の2文字目、3つ目の2文字目・・・のように順番に読むとflagになります。
面倒くさかったらプログラムでやるのも手です。
こちらはC++で書いたプログラムです。
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "c4c_ub__dt_r_1_4}";
string t = "tb4y_1tu04tesifg";
string u = "f{n0ae0n_e4ept13";
for(int i = 0; i<3*s.size()-2; i++){
if(i % 3 == 0){
cout << s.at(i/3);
} else if(i % 3 == 1){
cout << t.at(i/3);
}else{
cout << u.at(i/3);
}
}
cout << endl;
}
Python版
s = "c4c_ub__dt_r_1_4}"
t = "tb4y_1tu04tesifg"
u = "f{n0ae0n_e4ept13"
for i in range (49):
if(i % 3 == 0):
print(s[i//3], end='')
elif(i % 3 == 1):
print(t[i//3], end='')
else:
print(u[i//3], end='')
ctf4b{c4n_y0u_ab1e_t0_und0_t4e_t4ree_sp1it_f14g3}
Forbidden(56pt)
いかにもflagに行けそうなので行ってみると・・・
はじかれました。
どうしてはじかれたかコードを見てみましょう。
const block = (req, res, next) => {
if (req.path.includes('/flag')) {
return res.send(403, 'Forbidden :(');
}
next();
}
app.get("/flag", block, (req, res, next) => {
return res.send(FLAG);
})
つまり、/flag
というキーワードが1つでも含まれていたらはじかれるように設定してあったからです。
しかし、app.get("/flag", block, (req, res, next)
のflag
は大文字、小文字どちらでも対応しています。
https://forbidden.beginners.seccon.games/flag
のflag
を大文字にしてみましょう。
flagゲットです。
ctf4b{403_forbidden_403_forbidden_403}
aiwaf(68pt)
コードを見ていくと怪しいものが・・・
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。
AI-WAFはパストラバーサル攻撃(ディレクトリトラバーサル攻撃)を防いでいるようですが・・・
puuid = uuid.uuid4()
prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。
{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
try:
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "user",
"content": prompt,
},
],
)
result = response.choices[0]["message"]["content"].strip()
except:
return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
if "No" in result:
with open(f"./books/{file}", encoding="utf-8") as f:
return f.read().replace(KEY, "")
return abort(403, "AI-WAFに検知されました👻")
{urllib.parse.unquote(request.query_string)[:50]}
より
AI-WAFはクエリパラメータ先頭50文字しか判定にしてないようです。
クエリパラメータとはhttps://aiwaf.beginners.seccon.games/?
以降の部分です。
クエリパラメータの先頭50文字を全く意味ないものにして、後ろのほうに&file=../flag
を付け加えましょう。
https://aiwaf.beginners.seccon.games/?aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&file=../flag
こうすることにより、
AI-WAFはクエリパラメータ先頭50文字のaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
の部分のみで
ディレクトリトラバーサルか判定するが、Noと判定される。
しかし、request.args.get("file")の結果として出てくるのは../flag
のみ。
flagゲットです。
ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}
poem(65pt)
まず最初に、src.c
を見てみると
char *flag = "ctf4b{***CENSORED***}";
char *poem[] = {
"In the depths of silence, the universe speaks.",
"Raindrops dance on windows, nature's lullaby.",
"Time weaves stories with the threads of existence.",
"Hearts entwined, two souls become one symphony.",
"A single candle's glow can conquer the darkest room.",
};
つまり、poemの前にflagが宣言されているのがわかります。
また、以下のコードより5よりも小さければマイナスでも出力を行うことがわかります。
int main() {
int n;
printf("Number[0-4]: ");
scanf("%d", &n);
if (n < 5) {
printf("%s\n", poem[n]);
}
return 0;
}
Ghidraでmain関数をDisplay Function Graphで見てみます。
flagはStack[-0x10]
、poemは[-0x14]
からスタックを始めています。
したがって、以下の図が推測できます。
したがって、14-10=4よりpoem[-4]
であればflagにアクセスすることができます。
flagげっとです。
ctf4b{y0u_sh0uld_v3rify_the_int3g3r_v4lu3}
Poker(94pt)
Ghidraを使って解析を行うと
FUN_00101fb7
がスコアを制御している場所だと推測できます。
if (param_2 == 1) {
puts("[+] Player 1 wins! You got score!");
local_1dc = param_1 + 1;
}
else {
puts("[-] Player 1 wins! Your score is reseted...");
local_1dc = 0;
}
}
そして、FUN_00102262
が目的のスコアまでに達しているかを判定します。
{
int iVar1;
int local_10;
int local_c;
local_c = 0;
FUN_001021c3();
local_10 = 0;
while( true ) {
if (0x62 < local_10) {
return 0;
}
FUN_00102222();
iVar1 = FUN_00102179();
local_c = FUN_00101fb7(local_c,iVar1);
if (99 < local_c) break;
local_10 = local_10 + 1;
}
FUN_001011a0();
return 0;
}
つまり、flagを取るには97回以内にスコア(local_c)を100以上にすればよいということがわかります。
したがって以下の2つの方法のどちらかで突破すればよいことがわかります。
1.スコアをゲットした際、100ポイントゲットして目標値を超える
2.目標のスコア(100以上)を書き換える
それぞれ見ていきます。
1.スコアをゲットした際、100ポイント以上ゲットして目標値を超える
今回はPlayer1を選択してPlayer1が勝利した際、100ポイントゲットするように書き換えます。
左のアドレスの緑の部分が右のやっていることと対応しています。
ここで、+1
している部分は左のアドレスの一番最後、01
に対応していることがわかります。
なぜなら、左のアドレスは16進数であらわされていて、+1
は16進数で01
とあらわされているからです。
ここでこの処理が行われているアドレスを覚えます。001020f4
または83 85 2c fe ff ff 01
を覚えていれば大丈夫です。
今回Ghidraの001020f4
はStirの000020f4
に対応しています。
覚えたら、バイナリの書き換えを行います。
01
の部分を64
に変えます。
FF
でやってしまうと-1
になってしまいます。
2進数で表記した際に
1.......
のように一番左の桁が1
になってしまうとマイナスになってしまうようです。
したがって、00
~79
以内でやる必要があります。
2.目標のスコア(100以上)を書き換える
右のif関数で行っている場所で99
を16進数にした63
は左のほうでは見つかりません。
アセンブラで99より大きいかどうかの比較を行っているのは
001022b5
の前の部分、001022b1
で行っています。
したがって、001022b1
もしくは83 7d fc 63
を覚えます。
Ghidraの001022b1
はStirの000022b1
に対応しています。
Stir
というツールを使って検索します。
63
を00
もしくは80
~FF
に変えます。
無事にFlagがゲットできました。
ctf4b{4ll_w3_h4v3_70_d3cide_1s_wh4t_t0_d0_w1th_7he_71m3_7h47_i5_g1v3n_u5}
ちなみになぜかGhidraのアドレスとStirのアドレスの左4桁が異なることがあるのはなぜかはわからないです。
最後に
去年は1問しか解けなかったですが、今年は3問解けて良かったです。