CTFをやっていると、問題サーバがどこかで動いていて、そのサーバにつなぎに行ってなにかする(平文を暗号化してくれる機能を呼び出すとか、ノートにページを作ってもらうとか)ということはよくあると思います。そしてそういうサーバの実装をみると singal.alarm(500) とかかいてあってタイムアウトが指定されているということもよくあると思います。そしてそういう問題で通信を何往復もする必要があると、ローカルでは余裕で解けるのに本番サーバは地球の裏側にあってタイムアウトして解けない、ということも往々往々にしてあることだと思います。そんなときの解決策を2種類ご紹介します。
1. batch send
まず、遅いケースがなぜ遅いのかを考えてみます。典型的には次のようなコードですね。
sock = Socket(...) def encrypt(plaintext : bytes) -> bytes: """ サーバにplaintextを暗号化してもらいその結果を返す""" sock.sendlineafter("choice: ", "encrypt") sock.sendlineafter("plaintext: ", plaintext) return bytes.fromhex(sock.recvlineafter("ciphertext: ").decode().strip()) pairs = [] for _ in range(1000): plaintext = os.urandom(8) ciphertext = encrypt(plaintext) pairs.append((plaintext, ciphertext))
このように送信と受信を順番に行う場合はラウンドトリップタイムの大きさの影響を受けやすいです。仮に、クライアントとサーバ間のRTTがおよそ199ミリ秒程度、暗号化処理にかかる時間が1ミリ秒程度だとすると、一回の往復におよそ200ミリ秒程度かかり、それを1000回くりかえすためには200秒かかることになります。

上記のコードのように、ある通信がそれ以前の通信に依存しない場合は送信と受信をそれぞれまとめて行うbatch send*1を行うことで全体の処理を高速化できることがあります。まずは書き換え後の例を見てみます。
sock = Socket(...) def encrypt_send(plaintext: bytes): """ サーバにplaintextを暗号化してもらう部分だけをやる""" sock.sendline("encrypt") sock.sendline(plaintext.hex()) def encrypt_recv() -> bytes: return bytes.fromhex(sock.recvlineafter("ciphertext: ").decode().strip()) plaintexts = [] for _ in range(1000): plaintext = os.urandom(8) encrypt_send(plaintext) plaintexts.append(plaintext) pairs = [] for plaintext in plaintexts: ciphertext = encrypt_recv() pairs.append((plaintext, ciphertext))
もともとのencrypt 関数をencrypt_sendとencrypt_recvに分けて、まずencrypt_sendを1000回呼び出したあと、encrypt_recv で暗号化結果を1000個受け取っています。また、encrypt関数ではsock.sendlineafterという関数をつかって"choice: "が来たら"encrypt"を送り、"plaintext: "を受け取ったらplaintextを送るとしていたところを、encrypt_send 関数では"choice: "や"plaintext: "がサーバ側から送られてくるのを待たずにいきなり"encrypt"やplaintextを送りつけています*2。
これらの書き換えによって、送信処理と受信処理を完全に分けることができました。こうするとサーバとクライアントの間の通信は次のようになります。

クライアントとサーバ間の通信が1つ完了するまでには変わらず200ミリ秒かかりますが、今回はクライアントはサーバの応答を待たずにplaintextを送りつけまくればよくなりました。仮に1000個のメッセージを送りつけるのに300ミリ秒かかったとしておきます。
さて、サーバはクライアントが待ち受けているかどうかなどは気にせず、暗号化要求と平文を受け取ったら暗号文を送り返してきます。当然このTCPパケットはOSによって処理されてバッファに溜まっていきますが、クライアントはその頃1000個のplaintextをいそいそと送りつけています*3。そして、クライアントは1000個のメッセージを送りつけ終わったあと、このバッファに溜まったパケットを受け取りはじめます。最初の平文に対応する暗号文はすでに受け取り済みなので、このときのencrypt_recvの呼び出しは一瞬です。そして、200ミリ秒後には最後の平文に対応する暗号文も受け取ることができ、結果的に最初の平文の送信から最後の暗号文の受け取りまでは500ミリ秒程度になりました。
めでたしめでたし
2. 物理的に近いマシンから通信させる
上記のような工夫は、「ある通信で送りたい内容がそれ以前の通信に依存しない」「サーバ側の実装がいい感じ」という条件のもとでのみ有効です。例えば「個目の平文の暗号化の結果によって、
個目に送信する平文を変化させたい」といった場合にはこの方法は使えません。
そんなときはサーバに物理的に近いマシンを使いましょう。今はAWSやGCPでいろんなリージョンにサーバを立て放題です。どうせ大した時間は建てないので懐もそんなに傷まないと思って勢いよく一時的にインスタンスを借りて、さくっと問題を解いてしまい、さっさとインスタンスを落としましょう
感想
今回紹介したbatch sendのような方法で通信を高速化できるケースがあることは知っていたけど、気休め程度のものだろうと思っていたので、改めて原理をちゃんと考えてみてこりゃすごいやとなりました。CTF本番中にちゃんとbatch sendを実装してくれて問題を解くピースを埋めてくれて、ちゃんと考える切っ掛けをくれた
id:keymoon に感謝です