← ブログに戻る

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 6ms
  • tailscale 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 + Cipher1080 バイト
KEX + MAC880 バイト
KEX + Cipher + MAC680 バイト

名前のリストを削ると素直に減ります。ここまでは当たり前の話です。

公開鍵を載せるパケット

もうひとつ、SSH2_MSG_KEX_ECDH_INIT があります。こちらはクライアントの公開鍵そのものが入るので、交渉で決まった KEX が何かだけでサイズが決まります。偽サーバーに正しい KEXINIT を1つ返させて、クライアントが次に送るパケットを測りました。

交渉された KEXKEX_ECDH_INIT のサイズ
mlkem768x25519-sha2561232 バイト
sntrup761x25519-sha512@openssh.com1208 バイト
curve25519-sha25648 バイト

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交渉される KEXECDH_INIT1228 超え結果
なし1568mlkem7681232両方停止
KEX のみ1280curve2551948KEXINIT停止
Cipher のみ1376mlkem7681232両方停止
MAC のみ1168mlkem7681232ECDH_INIT停止
KEX + Cipher1080curve2551948なし疎通
KEX + MAC880curve2551948なし疎通

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 ホストで同じことが起きたときに、私が次にやる順番です。

  1. ssh -vvv の最終行を見る。expecting SSH2_MSG_KEX_ECDH_REPLY で止まっていたら、以下に進む価値がある
  2. ss -tan で Send-Q に 1KB 前後が滞留しているか見る。滞留していれば、送信側のパケットが相手に届いていない
  3. ip -s link show tailscale0 を前後で2回取り、TX と RX の増え方が極端に非対称か見る
  4. ssh -o KexAlgorithms=curve25519-sha256 -o Ciphers=aes128-ctr で繋ぎ直す。通ればサイズの問題で確定
  5. 通ったら ~/.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