以下の内容はhttps://tech-blog.abeja.asia/entry/ssh-1char-100packets-claude-code-investigationより取得しました。


なぜSSHでは一文字送信するのに100パケットも必要なのか? Claude Code を使って調査してみた

こんにちは、ABEJA Platform に搭載しているアプリケーション、「ABEJA Insight for Retail」の開発と運用を担当している森永です。

みなさんは普段 SSH を使っていますか?

私は元々 Vimmer だったこともあり (最近は機能面で楽なので VSCode を使用しておりますが...笑) 、SSH には非常にお世話になっており、現在でも直接叩くことは減りましたが、 VSCode やその他の便利なツールの裏で動く縁の下の力持ちとしてお世話になっています。

とあるテックブログで公開されていた『 Why does SSH send 100 packets per keystroke? · eieio.games 』という記事が Claude Code を有効に使ってかなり興味深いデバッグを行なっていたので、要点の翻訳もかねつつ、コメントを行なっていければと思います。


SSHセッションでたった1回のキーストロークを送信した際の、tcpdump の出力の最初の数行を要約したものが以下です。

36 bytes と表記されたパケットがやたら多いですね。

tcpdump は UNIX 系 OS で利用できるパケットキャプチャ用ツールです。出力データは .pcap の拡張子が付き、 「pcap ファイル」とも呼ばれます

$ ./first_lines_of_pcap.sh single-key.pcap
  1  0.000s CLIENT->SERVER  36 bytes
  2  0.007s SERVER->CLIENT 564 bytes
  3  0.015s CLIENT->SERVER   0 bytes
  4  0.015s CLIENT->SERVER  36 bytes
  5  0.015s SERVER->CLIENT  36 bytes
  6  0.026s CLIENT->SERVER   0 bytes
  7  0.036s CLIENT->SERVER  36 bytes
  8  0.036s SERVER->CLIENT  36 bytes
  9  0.046s CLIENT->SERVER   0 bytes
 10  0.059s CLIENT->SERVER  36 bytes
実際のスクリプトの中身はこちら
# first_lines_of_pcap.sh
tshark -r "$1" \
  -T fields -e frame.number -e frame.time_relative -e ip.src -e ip.dst -e tcp.len | \
  awk 'NR<=10 {dir = ($3 ~ /71\.190/ ? "CLIENT->SERVER" : "SERVER->CLIENT");
       printf "%3d  %6.3fs  %-4s  %3s bytes\n", $1, $2, dir, $5}'

『数行』という表現を使っているのは、実際にはこのような行が大量にあるからです。

実際に統計値を取ってみると、以下のようになります。

$ ./summarize_pcap.sh single-key.pcap
合計パケット数: 270

 36 バイトのメッセージ  : 179 packets ( 66.3%)  6444 bytes
その他のデータ:     1 packet  (  0.4%)   564 bytes
 TCP ACKs:      90 packets ( 33.3%)

送信データ量: 6444 bytes in 36-byte messages, 564 bytes in other data
データ比率: 11.4x more data in 36-byte messages than other data

データパケット レート: ~90 パケット/秒 (データパケットの間隔の平均 11.1 ms)
実際のスクリプトの中身はこちら
# summarize_pcap.sh
tshark -r "$1" -Y "frame.time_relative <= 2.0" -T fields -e frame.time_relative -e tcp.len | awk '
  {
      count++
      payload = $2

      if (payload == 0) {
          acks++
      } else if (payload == 36) {
          mystery++
          if (NR > 1 && prev_data_time > 0) {
              delta = $1 - prev_data_time
              sum_data_deltas += delta
              data_intervals++
          }
          prev_data_time = $1
      } else {
          game_data++
          game_bytes = payload
          if (NR > 1 && prev_data_time > 0) {
              delta = $1 - prev_data_time
              sum_data_deltas += delta
              data_intervals++
          }
          prev_data_time = $1
      }
  }
  END {
      print "Total packets:", count
      print ""
      printf "  36-byte msgs:   %3d packets (%5.1f%%)  %5d bytes\n", mystery, 100*mystery/count, mystery*36
      printf "  Other data:     %3d packet  (%5.1f%%)  %5d bytes\n", game_data, 100*game_data/count, game_bytes
      printf "  TCP ACKs:       %3d packets (%5.1f%%)\n", acks, 100*acks/count
      print ""
      printf "  Data sent:      %d bytes in 36-byte messages,  %d bytes in other data\n", mystery*36, game_bytes
      printf "  Ratio:          %.1fx more data in 36-byte messages than other data\n", (mystery*36)/game_bytes
      print ""
      avg_ms = (sum_data_deltas / data_intervals) * 1000
      printf "  Data packet rate: ~%d packets/second (avg %.1f ms between data packets)\n", int(1000/avg_ms + 0.5), avg_ms
  }'

たった1回のキー入力にしては、あまりにもパケットが多すぎます (特に 36 バイトのパケット)。一体何が起きているのでしょうか?また、元ブログの筆者はどうして事象が気になったのでしょうか?

発見 (Discovery)

元ブログの筆者 (以降、「筆者」と記述) は、SSH上で動作する高パフォーマンスなゲームを開発中でした。ゲームのTUI(テキストユーザインターフェース)は bubbletea *1 で作成し、wish *2 を介してSSHで送信しているそうです。

このゲームは 80x60 のウィンドウでプレイでき、秒間 10 回更新されます。少なくとも 2,000 人の同時接続プレイヤーを目標としており、これは毎秒約 1 億セルということになり、パフォーマンスが非常に重要になります。

そこで筆者は、数百のボットをSSH経由で接続させ、1秒に1回動作させるスクリプトを用意し、Go言語のプロファイリングツールを使って何が起きているかを観察したそうです。

色々と変更を加えている内にバグが入ってしまい、筆者はうっかりテストハーネス(テスト環境)を壊してしまったそうです。サーバーはボットに定期的にゲームデータを送信する代わりに、「画面が小さすぎます」という単一のメッセージを送るようにしてしまっていたのだそうです。

このバグにより、ゲームの CPU 使用率と帯域幅の使用量は半分になりました。

筆者は最初はがっかりしたそうです。手軽に大幅な高速化ができたかと思ったものが、実際にはテストのミスだったからです。

しかし、そこで疑問が湧いたそうです。

もしボットに有用なゲームデータを送り返していないのなら、なぜ CPU 使用率は 0% 近くに落ちず、 半分ほどにしか 下がらなかったのでしょうか?

調査 (Investigation)

テストハーネスの問題をデバッグする一環として、破壊的な変更を加えた場合と加えていない場合のゲームトラフィックを記録するために tcpdump を使用したそうです。以下のようなコマンドです。

# ゲームはポート22で動作中
timeout 30s tcpdump -i eth0 'port 22' -w with-breaking-change.pcap

# 変更を元に戻す
timeout 30s tcpdump -i eth0 'port 22' -w without-breaking-change.pcap

混入したバグにより、SSH 上でのゲームのレンダリングが停止しました。つまり、with-breaking-change.pcap には、実際にゲームを描画することなく、各接続のオーバーヘッドのみを表すパケットが含まれていることになります。

Claude Code に pcap ファイルで見た内容の要約を依頼すると...

Claude Code: うわっ!これを見てください:


パケットサイズ分布 (合計 413,703 パケット):

  • 274,907 パケット (66%): 36バイトのデータ

  • 138,778 パケット (34%): 0バイト (TCP ACK)

  • 18 パケット (<0.1%): 72バイトのデータ


66 % のもパケットを謎の 36 バイトのデータを運んでいるパケットで占めていることが分かります。

より小さな pcap ファイルを作って分析を進めたところ、この謎パケットが約20ミリ秒間隔で到着していることがわかりました。

これは筆者にとっても(そして一緒にデバッグしていた Claude Code にとっても)不可解だったそうです。ここで原因として考えられるものをいくつか出し合ったそうです:

  • SSHのフロー制御メッセージ?
  • PTYサイズのポーリングやその他のステータスチェック?
  • bubbleteawish の何らかの癖?

ただ、一つ際立っていたのはこれらのやり取りが筆者のサーバーからではなく、筆者が利用していた SSH クライアント( MacOS にインストールされている標準の ssh )によって送信されていた、という点でした。

そこで勘を頼りに、通常の SSH セッションの tcpdump を取ってみたそうです。

# Mac上の1つのタブで実行
sudo tcpdump -ien0 'port 22'

# Mac上の別のタブで実行
ssh $some_vm_of_mine

初期接続の通信が落ち着くのを待ち、リモート VM に 1 回だけキーストロークを送信して、tcpdump の出力を確認すると...

全く同じパターンが見られたそうでした!あとはこの現象の原因を探っていくだけですね。

根本原因 (Root cause)

これが標準の SSH の挙動起因であると気づいて調査範囲を絞れたので、デバッグはだいぶ容易になったようです。

SSH コマンドをデバッグ出力するために ssh -vvv *3 を実行すると、次の出力が得られました:

debug3: obfuscate_keystroke_timing: starting: interval ~20ms
debug3: obfuscate_keystroke_timing: stopping: chaff time expired (49 chaff packets sent)
debug3: obfuscate_keystroke_timing: starting: interval ~20ms
debug3: obfuscate_keystroke_timing: stopping: chaff time expired (101 chaff packets sent)

前述した『20ms』と一致する記述が含まれる行が決定的証拠ですね。これは先ほど見た謎のパターンと完全に一致するのが分かるかと思います!

そしてメッセージの残りの部分も非常に役立ちます。最初のキーストロークに対して49個の「チャフ(おとり)」パケットを送信し、2回目あたりでは101個の「チャフ」を送信しています。

実は別途調査すると、SSH には 2023 年に「キーストロークタイミングの難読化(keystroke timing obfuscation) *4 」機能が追加されたみたいです。これは、異なる文字をタイプする速度で、どの文字をタイプしているかという情報を推測できてしまうという考えに基づいています。そのため、SSH はキーストロークと一緒に大量の「チャフ」パケットを送信し、攻撃者がいつ実際にキーを入力しているか特定するのを困難にしています。

確かに、通信の秘匿性を保つことが重要な通常のSSHセッションにおいては非常に理にかなっていますね。しかし、今回のようにレイテンシ(遅延)が重要で、インターネット全体に公開されているゲームにとっては、多大なオーバーヘッドとなります。

修正 (Remediation)

実はキーストロークの難読化はクライアント側で無効にできます。筆者は元の破壊的な変更を元に戻した後、SSH セッションを開始する際に ObscureKeystrokeTiming=no を渡すようにテストハーネスを更新してみたようです。

これはうまくいきました。CPU 使用率は劇的に低下し、ボットは依然として有効なデータを受け取りました。

ただし、これは現実的な解決策になりません。なぜなら筆者は ssh mygame の実行だけでゲームを動作するようにしたく、追加の依存性や操作を不要したかったからでした。

試しに Claude Code に相談してみても、この機能をサーバー側で無効にできないと言われたそうです。

でも粘り強く調査してみると幸運なことに、筆者がとあるサイト上 SSH のキーストローク難読化の説明 をで見つけたおかげで、依存していた Go の SSH ライブラリ内の関連コードを簡単に調べることができたそうです。

(下記は当該サイト上からの引用)

ログメッセージ: トランスポートレベルのping機能を導入 これは、ping機能を実装するために、SSHトランスポートプロトコルメッセージ SSH2_MSG_PING / PONG のペアを追加します。これらのメッセージは「ローカル拡張」番号空間の番号を使用し、バージョン番号文字列 "0" を持つ "ping@openssh.com" という ext-info メッセージを使って通知 (アドバタイズ) されます。

(原文)

Introduce a transport-level ping facility

This adds a pair of SSH transport protocol messages SSH2_MSG_PING/PONG to implement a ping capability. These messages use numbers in the "local extensions" number space and are advertised using a "ping@openssh.com" ext-info message with a string version number of "0".

上記のログから、 SSH がキーストロークを隠蔽するために使用する「チャフ」メッセージは、SSH2_MSG_PING メッセージと読み取れます。

そしてそのメッセージは、ping@openssh.com 拡張機能の利用可能性を通知 (アドバタイズ) しているサーバーに対して送信されます。

ここで、「もし ping@openssh.com を通知しないようにしたらどうなるのだろうか...?」と勘の良い方は気づくかもしれません。

筆者は、Go の SSH ライブラリで ping@openssh.com を検索し、キーストローク隠蔽機能追加のコミットを見つけたそうです。

また幸いにも、そのコミットは非常に小さかったので、修正するのはとても簡単そうにみえたそうです。

そこで、試しに Go の crypto リポジトリをクローンし、この変更を revert するよう Claude Code に指示、依存関係を更新してローカルの crypto リポジトリのクローンを使用して自作ゲームを再構成してみたそうです (Goの replace ディレクティブのおかげでライブラリのフォークは非常に簡単だったとのこと)。

その後、テストハーネスを再実行したところ、 CPU 負荷や利用する帯域幅の低減等、適切に対処ができていそうな結果が得られたそうです:

  • 合計 CPU 使用率: 29.90% -> 11.64%
  • システムコール: 3.10s -> 0.66s
  • 暗号化処理: 1.6s -> 0.11s
  • 帯域幅: ~6.5 Mbit/sec -> ~3 Mbit/sec

(余談ですが) Claude Code もかなり興奮していまたようです:

Claude Code: なんてこった(HOLY COW)!この CPU 使用率を見てくださいよ


計測期間: 30.15 秒

合計のサンプル数: 3.51 秒 (11.64 %)

ただ明らかに、Go の crypto ライブラリをフォークして修正したものを利用し続けるのは少々セキュリティの観点で怖いので、筆者は自分が適用した小さなパッチを安全に維持して適用する方法についてはまだ考慮する点があると言うことについて留意して記事を締め括っています。

まとめ

パケットキャプチャを取られ、そのバイトサイズから送信文字列を推測される可能性があること考慮してパッチが当てられているのには、「ここまでするのか...」と個人的に驚きました。

SSH が現在でもアップデートされ続け、現役でも使われ続ける理由に納得が行きました。

もし SSH を経由した通信が遅いと感じたら、セキュリティ的に安全な範囲 (ローカルネット内での作業等) で該当のオプションを無効化を検討して見るのもありですね。

また、SSH ライブラリのデバッグでも Claude Code を活用してかなり有益なデバッグが行えることを実際に確認できた例かと思います。

*1:https://github.com/charmbracelet/bubbletea

*2:https://github.com/charmbracelet/wish

*3: 「-vvv」は最も詳細な verbose 出力を指定できるオプション。 パケットレベルのやり取りまで含めた、SSHクライアントの内部動作のほぼ全てが表示されます。

*4:https://marc.info/?l=openbsd-cvs&m=169319335902218&w=2




以上の内容はhttps://tech-blog.abeja.asia/entry/ssh-1char-100packets-claude-code-investigationより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14