WSL2 Tailscale SSHがKEXで停止
Raspberry Pi 5 を Tailscale 越しにセットアップしていたら、WSL2 から ssh が一切通らなくなりました。TCP の 22 番は accept されるのに、鍵交換の途中で止まったまま ConnectTimeout に達します。
ところが、同じ Windows マシンの PowerShell から ssh.exe で同じホストに繋ぐと、パスワード認証まで含めて普通に通ります。同じ物理マシン、同じ Tailscale のネットワーク、同じ宛先です。
この非対称を私は最初、Tailscale 側か Pi 側の設定だと思って追いかけました。犯人はどちらでもありません。クライアントが送るパケットのサイズでした。
症状
ssh -vvv の最終行がここで止まります。
debug1: expecting SSH2_MSG_KEX_ECDH_REPLY
事後に観察した状態は次のとおりです。
ss -tanで該当のコネクションがFIN-WAIT-1のまま残り、Send-Q に 1337 バイトが滞留していたip -s link show tailscale0の差分が TX +92 パケットに対して RX +1 パケット- TCP の 3-way handshake は完了していて、
ncでバナーの取得まではできる
送ったデータが相手の TCP に ACK されず、ひたすら再送されている形です。92 対 1 という比は、再送以外では出ません。同じ内容を何度も投げ直して、一度も返事が来ていない形です。
切り分けを難しくしたもの
同じ時刻に、次のものは全部健全に見えました。
tailscale pingが TSMP 5ms / ICMP 6mstailscale netcheckが UDP=true、DERP Tokyo 47ms- 同じ Tailscale の P2P 経路で、HTTP の 8088 番が 300ms で 200 OK
- Windows ネイティブの
ssh.exeは正常疎通
この4つが揃うと、経路は生きているのだから SSH の設定かサーバー側だろう、と考えたくなります。実際には逆で、この4つはどれも経路の健全性を証明していません。理由は後ろで書きます。
先に回避策
対象ホストについて、~/.ssh/config で KEX を非 post-quantum に固定し、Cipher か MAC のどちらかも明示します。
Host my-pi
HostName 100.x.y.z
User pi
KexAlgorithms curve25519-sha256
Ciphers aes128-ctr
MACs hmac-sha2-256
これで ssh / scp / rsync -e ssh / git over ssh が、すべてこの alias 経由で通ります。
手がかりは「2つ指定しないと効かない」こと
オプションを1つずつ変えて試した結果がこれです。
| 指定 | 結果 |
|---|---|
| なし (既定) | — 停止 |
KexAlgorithms=curve25519-sha256 のみ | — 停止 |
Ciphers=aes128-ctr のみ | — 停止 |
MACs=hmac-sha2-256 のみ | — 停止 |
| KEX + Cipher | ✓ 疎通 |
| KEX + MAC | ✓ 疎通 |
この表が奇妙です。暗号アルゴリズムの相性が原因なら、原因になっている側を1つ外せば直るはずです。ところが KEX だけ、Cipher だけ、MAC だけでは、どれも直りません。2つ組み合わせると急に通ります。
アルゴリズムの種類の話だと、この6行は説明できません。サイズの話だと、全部説明がつきます。
鍵交換で何バイト送っているか測る
クライアントが送る最初の2つのパケットを、実際に数えます。バナーだけ返して黙り込む偽サーバーを立てて、クライアントが書き込んだバイト列から SSH のバイナリパケット長を読むだけです。
import socket, subprocess, threading, struct
def serve(port, out, ready):
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", port)); s.listen(1); ready.set()
c, _ = s.accept()
c.sendall(b"SSH-2.0-measure\r\n")
buf = b""; c.settimeout(3)
try:
while len(buf) < 9000:
d = c.recv(4096)
if not d: break
buf += d
except Exception:
pass
c.close(); s.close(); out.append(buf)
def measure(opts, port=2300):
ready = threading.Event(); out = []
t = threading.Thread(target=serve, args=(port, out, ready)); t.start(); ready.wait()
subprocess.run(["ssh", "-p", str(port), "-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=4"]
+ opts + ["nobody@127.0.0.1", "true"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
t.join()
rest = out[0][out[0].find(b"\r\n") + 2:]
return struct.unpack(">I", rest[:4])[0] + 4 # KEXINIT のワイヤ上のサイズ
print(measure([]))
対応アルゴリズム一覧のパケット
SSH2_MSG_KEXINIT には、自分が対応している KEX / ホスト鍵 / 暗号 / MAC / 圧縮の名前が、文字列のリストとして全部入ります。つまり KexAlgorithms や Ciphers を絞ると、このパケットが直接縮みます。
OpenSSH 10.0p2 のクライアント (Debian trixie) で測った値です。
| 指定 | KEXINIT のサイズ |
|---|---|
| なし (既定) | 1568 バイト |
Ciphers のみ | 1376 バイト |
KexAlgorithms のみ | 1280 バイト |
MACs のみ | 1168 バイト |
| KEX + Cipher | 1080 バイト |
| KEX + MAC | 880 バイト |
| KEX + Cipher + MAC | 680 バイト |
名前のリストを削ると素直に減ります。ここまでは当たり前の話です。
公開鍵を載せるパケット
もうひとつ、SSH2_MSG_KEX_ECDH_INIT があります。こちらはクライアントの公開鍵そのものが入るので、交渉で決まった KEX が何かだけでサイズが決まります。偽サーバーに正しい KEXINIT を1つ返させて、クライアントが次に送るパケットを測りました。
| 交渉された KEX | KEX_ECDH_INIT のサイズ |
|---|---|
mlkem768x25519-sha256 | 1232 バイト |
sntrup761x25519-sha512@openssh.com | 1208 バイト |
curve25519-sha256 | 48 バイト |
post-quantum KEX にすると 25 倍です。ML-KEM-768 の公開鍵が 1184 バイト、sntrup761 が 1158 バイトあり、それがそのまま1つのパケットに載るので当然ではあります。
MTU 1280 が引く境界
Tailscale の tailscale0 は MTU が 1280 です。これは IPv6 の最小 MTU (RFC 8200) で、どんな経路でも通る保守的な値として選ばれています。私の環境でも確認できます。
$ ip -d link show tailscale0
3: tailscale0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1280 ...
ここから TCP の実効 MSS が出ます。
1280 (MTU) − 20 (IPv4 ヘッダ) − 20 (TCP ヘッダ) = 1240 バイト
1280 (MTU) − 20 − 20 − 12 (TCP timestamps、Linux は既定で有効) = 1228 バイト
1228 バイトが境界です。これを超えるデータを書くと、TCP はフルサイズのセグメントを作ります。そして、このフルサイズのセグメントだけが落ちていました。
6行を全部あててみる
測った2つのサイズを、さきほどの切り分け表に重ねます。KEX_ECDH_INIT は ECDH_INIT と略しています。
| 指定 | KEXINIT | 交渉される KEX | ECDH_INIT | 1228 超え | 結果 |
|---|---|---|---|---|---|
| なし | 1568 | mlkem768 | 1232 | 両方 | 停止 |
| KEX のみ | 1280 | curve25519 | 48 | KEXINIT | 停止 |
| Cipher のみ | 1376 | mlkem768 | 1232 | 両方 | 停止 |
| MAC のみ | 1168 | mlkem768 | 1232 | ECDH_INIT | 停止 |
| KEX + Cipher | 1080 | curve25519 | 48 | なし | 疎通 |
| KEX + MAC | 880 | curve25519 | 48 | なし | 疎通 |
6行が全部合いました。「1228 バイトを超えるパケットが1つでも残っていれば停止、なくなれば疎通」で、例外がありません。
そして「2つ指定しないと効かない」理由もこれで出ます。境界を超えるパケットが2種類あって、効く指定が別々だからです。
KexAlgorithmsを絞ると KEX_ECDH_INIT が 1232 から 48 バイトに落ちるが、KEXINIT は 1280 バイトのまま残るCiphersかMACsを絞ると KEXINIT が落ちるが、KEX が post-quantum のままなので KEX_ECDH_INIT が 1232 バイトで残る- 両方やって、はじめて2つとも境界の内側に入る
Send-Q に残っていた 1337 バイトも、この帯域に収まります。SSH のハンドシェイクで 1KB を超える送信はこの2つしかなく、あとは数十バイトのやりとりが続くだけなので、1.3KB が滞留しているという事実だけでも、詰まったのが鍵交換のどちらかだと絞り込めます。
post-quantum KEX は 10.0 からではありません
私は最初、これを OpenSSH 10.0 で入った新しい既定のせいだと書きました。調べ直したら間違いでした。
| バージョン | 変更 |
|---|---|
| OpenSSH 9.0 (2022-04) | sntrup761x25519-sha512@openssh.com が既定の鍵合意方式になる |
| OpenSSH 9.9 (2024-09) | mlkem768x25519-sha256 が追加される |
| OpenSSH 10.0 (2025-04) | mlkem768x25519-sha256 が既定になる |
OpenSSH 公式は「9.0 以降、post-quantum の鍵合意を既定で提供している」と書いています。つまり 2022 年以降のクライアントは、ほぼ全部このサイズ帯のパケットを送っています。
ここに、実測値と噛み合う細かい差があります。sntrup761 の KEX_ECDH_INIT は 1208 バイトで境界 1228 の内側、mlkem768 は 1232 バイトで外側です。差は 24 バイトです。
つまりクライアントが 9.x 系なら、MACs だけ絞れば KEXINIT が 1168 バイトに落ち、KEX_ECDH_INIT も 1208 バイトで収まるので、それだけで通ってしまう可能性があります。私の切り分け表で MACs のみが停止したことは、クライアントが 10.x 系 (mlkem768 が既定) だったことを示しています。同じ症状を追う人は、まず ssh -V を見たほうが早いです。
まだ分かっていないこと
なぜフルサイズのセグメントが落ちるのか、その機構は特定できていません。有力なのはセグメンテーションオフロードとの干渉です。WSL2 の tailscale0 は generic-segmentation-offload: on になっており、Tailscale は UDP でカプセル化したパケットをユーザー空間で処理します。この組み合わせで、フルサイズのセグメントだけが消える形はよくある壊れ方です。
根本的に直す候補は次のとおりですが、この記事の執筆時点では未検証です。
sudo ethtool -K tailscale0 gso off
sudo ethtool -K tailscale0 tso off
sudo ethtool -K tailscale0 gro off
ethtool -k tailscale0 で generic-segmentation-offload: off を確認したうえで、~/.ssh/config の回避策を消して既定の SSH が通れば、オフロードが原因だと確定できます。起動時に反映するなら systemd-networkd の .link ファイルに置くことになります。
同じ症状は 2022 年に microsoft/WSL の issue #9110 として報告されていて、こちらは原因も回避策も書かれないままクローズされています。当時は post-quantum KEX が既定になった直後 (OpenSSH 9.0 は 2022 年 4 月) で、時期は重なります。
同じ症状に遭ったときの手順
別の Tailscale ホストで同じことが起きたときに、私が次にやる順番です。
ssh -vvvの最終行を見る。expecting SSH2_MSG_KEX_ECDH_REPLYで止まっていたら、以下に進む価値があるss -tanで Send-Q に 1KB 前後が滞留しているか見る。滞留していれば、送信側のパケットが相手に届いていないip -s link show tailscale0を前後で2回取り、TX と RX の増え方が極端に非対称か見るssh -o KexAlgorithms=curve25519-sha256 -o Ciphers=aes128-ctrで繋ぎ直す。通ればサイズの問題で確定- 通ったら
~/.ssh/configに書いて alias にする。ssh -Vも控えておく
2 と 3 は、「相手が悪い」と「自分の送信が出ていない」を分ける工程です。ここを飛ばすと、相手側の sshd の設定や authorized_keys を延々と疑うことになります。私がそれで半日使いました。
まとめ
Windows で通って WSL で通らないときは、暗号の設定より先に WSL2 の仮想ネットワークを疑ったほうが早いです。そして SSH が鍵交換の途中で固まるときは、アルゴリズムの相性ではなくパケットのサイズを数えると、原因の切り分けが一気に決定的になります。
post-quantum KEX は、鍵交換の最初のパケットを 48 バイトから 1232 バイトへ、25 倍に膨らませました。MTU 1280 の経路では、この 1232 バイトが境界のすぐ外側に落ちます。24 バイトの差で挙動が変わるので、ssh -V の1行が切り分けの材料になります。
References
- OpenSSH Release Notes — 10.0 で
mlkem768x25519-sha256が既定になった記述 - OpenSSH: Post-Quantum Cryptography — 9.0 以降 post-quantum 鍵合意を既定で提供している旨
- OpenSSH 9.0 release notes —
sntrup761x25519-sha512@openssh.comを既定にした 2022-04-08 のリリース - RFC 8200 — IPv6 Specification — 最小 MTU 1280 の根拠
- microsoft/WSL issue #9110 — 同じ症状の報告 (2022年、未解決のままクローズ)
- Tailscale — Troubleshoot TCP connection issues between two devices
この記事は役に立ちましたか?