0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Transfer Family (SFTP) の断続的な接続断を追う ── TCP "piggy-back" ACK 問題・ポート2223・SSH.NET クライアントの裏付け

0
Last updated at Posted at 2026-07-04

TL;DR

  • AWS Transfer Family の SFTP で、TCP 接続は張れるのに直後に切られ、クライアント側に kex_exchange_identification: read: Connection reset by peer が出ることがある。
  • 原因は、3 ウェイハンドシェイクの最終 ACK にデータが同乗する TCP "piggy-back" ACK。素の ACK が消失/遅延したり、経路上の機器が ACK と初送データを合体させたりすると発生する。
  • 回避策は ポート 2223。ただし 2223 は「サーバが先にバナーを送る」挙動をやめて「クライアントが先に話す」挙動に変わるため、古い/独自実装のクライアントとは非互換になりうる。
  • クライアントが SSH.NET(.NET) の場合、ソースコード上 send-first + NoDelay(Nagle 無効) であることを確認した。これは piggy-back の必要条件を満たし、かつ 2223 と互換である(§5)。
  • 本記事では、nc による「どちらが先に話すか」の判定手順、tcpdump による piggy-back シグネチャの捕捉方法、両者を一括判定する bash スクリプト、そして SSH.NET のソースコード調査までをまとめる。

この記事の構成

  • §1–2 … 事象と AWS 公式ドキュメントの見解
  • §3–5 … 原因の分析(§3–4 が TCP/サーバ側、§5 が SSH.NET クライアント側)
  • §6–8 … 検証手順(nc / tcpdump)と一括判定スクリプト
  • §9–10 … 対応方針と、まとめ

1. 事象 ── 断続的な "Connection reset by peer"

Transfer Family の SFTP エンドポイントに対して、次のような切断が起きることがある。

  • TCP 接続自体は成立した(ように見える)直後に切られる
  • クライアントのデバッグログには次が記録される
kex_exchange_identification: read: Connection reset by peer
Connection reset by <endpoint-ip> port 22
  • 常時発生するケースと、断続的に発生するケースの両方がある

これは「TCP は繋がったが、SSH の識別文字列(バージョンバナー)を交換する段階でサーバ側から RST が返る」という切断パターンである。認証よりはるか手前で切れているのがポイントになる。


2. 公式ドキュメントは何と言っているか

起点は AWS の 2 ページである。

2-1. トラブルシューティングページ

Troubleshoot SFTP connectivity and transfer issues には、次の趣旨の記述がある。

  • データを含まないゼロバイト TCP ACK(=3 ウェイハンドシェイクの最終 ACK)がドロップまたは遅延するエッジケースが存在する
  • 回避策として、Transfer Family は異なる構成を用いたソリューションを提供している
  • ただし古いクライアントとの互換性問題を起こしうるため、このソリューションはポート 2223 限定で提供される
  • VPC でサーバを作成する手順でセキュリティグループを指定する際、SSH トラフィックにポート 2223 を許可するよう設定する

2-2. VPC サーバ作成ページ

Create a server in a virtual private cloud には、この「異なる構成」の正体を示す注記がある。

  • ポート 2223 は、TCP "piggy-back" ACK を必要とするクライアント、すなわち TCP 3 ウェイハンドシェイクの最終 ACK にデータを同乗させる能力を必要とするクライアントのためのものである
  • 一方で、サーバが先に SFTP 識別文字列を送信することを要求するクライアントのように、ポート 2223 と非互換なクライアントソフトウェアも存在しうる

SFTP サーバは標準でポート 22 で動作する。VPC ホスト型エンドポイントの場合のみ、追加で 2222 / 2223 / 22000 でも動作可能(Create an SFTP-enabled server)。PUBLIC タイプのエンドポイントではセキュリティグループが使えないため、これらのカスタムポートは利用できない。


3. なぜ「最終 ACK にデータ」が起きるのか ── TCP の背景

3-1. そもそも規格違反ではない

前提として、3 ウェイハンドシェイクの 3 パケット目(ACK)にデータを載せること自体は、RFC 793 / RFC 9293 上まったく合法である。SYN-RECEIVED 状態のサーバは、受理可能な ACK を受け取れば ESTABLISHED へ遷移し、同一セグメント内のデータもそのまま処理するのが規格上の動作だ。通常の Linux sshd であれば、このケースで問題は起きない。

3-2. では、なぜ普段はデータ入り ACK にならないのか

BSD ソケット API では connect() が SYN-ACK 受信時点でカーネルにより即座に「素の ACK(len=0)」を返す。そのため、アプリケーションの最初の write()(SSH クライアントなら SSH-2.0-... の識別文字列)は別セグメントになるのが普通である。

データ入りの最終 ACK が発生する主な経路は次の 2 つ。

(a) 素の ACK の消失・遅延 → 断続的な失敗
クライアントは自分が ACK を送った時点で「確立済み」と認識し、直ちに識別文字列を送出する。ここで素の ACK が経路上で失われると、サーバから見た「ハンドシェイクの 3 パケット目」は、事実上この PSH+ACK+データ のセグメントになる。ドキュメントが原因を「ドロップまたは遅延」と表現しているのはこのケース。

(b) スタック/ミドルボックスによる意図的な合体 → 常時失敗
一部の TCP 実装や、WAN 最適化装置・一部ファイアウォール等の中継機器は、往復パケット数削減のために ACK と初送データを 1 セグメントに合体させる。この場合、当該クライアント/経路からの接続は毎回失敗する。ドキュメントの言う「piggy-back ACK を必要とするクライアント」はこちらの類型を指すと読める(具体的な製品名は AWS ドキュメントには挙げられていない)。

3-3. 失敗時のパケットシグネチャ(ポート 22)

3 番目のパケットに PSH+ACK フラグと SSH-2.0- のペイロードが同居し、その直後にサーバから RST が返る ── これがドキュメントの言うエッジケースの正体である。


4. なぜ Transfer Family でだけ問題になるのか / ポート 2223 の副作用

ここからはドキュメントの記述からの推論を含む。断定ではない点に留意されたい。

Transfer Family はマルチテナントの接続受付層(フロントエンド)を挟むマネージドサービスである。ポート 22 / 2222 / 22000 のリスナーは「素の ACK 受領によるハンドシェイク完了」をトリガーとして、後段への接続確立とサーババナー(SSH-2.0-AWS_SFTP_... 系)の即時送出を行う設計とみられる。この状態機械にとって、データ入りの 3 パケット目は想定外であり、RST に至る ── これがエッジケースの正体だろう。

ポート 2223 の注記「サーバが先に識別文字列を送ることを要求するクライアントは非互換」は、この推論を裏付ける。データ入り最終 ACK を受理するために、2223 のリスナーはクライアントが先に話す(client-speaks-first)まで自分のバナーを送らない構成になっていると考えるのが最も整合的だ。

RFC 4253 §4.2 は接続確立後に「双方が」識別文字列を送らねばならないと規定するのみで、送信順序は定めていない。したがってこの挙動自体は規格違反ではない。現行の OpenSSH クライアントは接続直後に自分のバナーを即時送信する実装なので 2223 で問題なく動くが、サーババナーの受信を待ってから自分のバナーを送る古い/独自実装のクライアントは、双方が待ち合うデッドロックとなりタイムアウトする。

4-1. 成功パターン(ポート 2223)

4-2. ポート 22 と 2223 はトレードオフ

ポート 22 / 2222 / 22000 ポート 2223
バナー送信順序 サーバが先(通常の sshd 互換) クライアントが先
piggy-back ACK 最終 ACK にデータが載ると RST されうる データ入り最終 ACK を受理
非互換になるクライアント (通常は問題なし) サーババナーを待ってから話す実装
位置づけ 標準 回避策(workaround)

両方を同時に満たす単一リスナーは提供されていない。この点が、サービスのフロントエンド実装上の制約を示唆している。


5. クライアント側の裏付け ── SSH.NET のソースコード調査

§3–4 で「port 22 側で最終 ACK にデータが同乗すると RST される」というサーバ側の機序を確認した。本章では、今回のクライアント(Windows Server 2025 上の .NET アプリ、ライブラリは SSH.NET)が、その "データ入り最終 ACK" を実際に作り出す挙動なのかを、ソースコードで検証する。

本章の結論(先に):
SSH.NET は 接続確立の直後に、サーバの応答を一切待たず、自分の識別文字列 SSH-2.0-Renci.SshNet... を最初のソケット操作として送信する(client-speaks-first)。加えて全接続ソケットで NoDelay = true(Nagle 無効) を設定している。これは AWS が言う「TCP 3-way ハンドシェイクの最終 ACK にデータを同乗させる能力を必要とするクライアント」= ポート 2223 が対象とするまさにその類型である。したがって SSH.NET は piggy-back ACK 問題を踏みうるクライアントであり、かつ 2223 と互換(2223 が壊すのは「サーバに先に話させたい」クライアントで、SSH.NET はその逆)である。

5-1. 調査対象(再現性のため)

  • リポジトリ: https://github.com/sshnet/SSH.NET
  • 検証コミット: develop tip(89f378a)およびリリースタグ 2024.2.0 で同一挙動を確認
  • 該当挙動はタグ 2020.0.22025.1.0 を通じて構造的に不変(後述の該当行が全リリースに存在)

5-2. 発見①:接続直後に、読み取りより先に、自分の識別文字列を送る

src/Renci.SshNet/Connection/ProtocolVersionExchange.cs(Start() 同期版、StartAsync() も同一ロジック):

// L48-50
// Immediately send the identification string since the spec states both sides MUST send an identification string
// when the connection has been established
SocketAbstraction.Send(socket, Encoding.UTF8.GetBytes(clientVersion + "\x0D\x0A"));
// ...この後で初めて、サーバの識別文字列を1バイトずつ読むループに入る(L56-)

Send()この関数で最初に実行されるソケット操作であり、サーバからの読み取り(SocketReadLine)はその後。つまり send-first。「サーバが先に識別文字列を送ることを要求する」タイプではない

5-3. 発見②:全接続ソケットで Nagle を無効化(NoDelay = true)

src/Renci.SshNet/Connection/SocketFactory.cs:

// L13
return new Socket(socketType, protocolType) { NoDelay = true };

同様の NoDelay = trueAbstractions/SocketAbstraction.cs の接続経路にも存在する。Nagle が無効なので、発見①で送る識別文字列はアプリ層で一切遅延されず、即座に TCP スタックへ渡される。

5-4. 発見③:connect と識別文字列送信の間に、何も挟まれていない

src/Renci.SshNet/Session.csConnect():

// L602-606
_socket = _serviceFactory.CreateConnector(ConnectionInfo, _socketFactory)
                            .Connect(ConnectionInfo);                       // ← TCP接続(blocking connect)

var serverIdentification = _serviceFactory.CreateProtocolVersionExchange()
                            .Start(ClientVersion, _socket, ConnectionInfo.Timeout);  // ← 直後に発見①の送信

Connect(...)(内部で Socket.ConnectAsync による接続完了待ち)の直後Start(...)(=識別文字列の即時送信)が呼ばれる。両者の間に読み取りも待機もない。ちなみに送信される識別文字列は次で、これがそのまま最初のデータになる:

// Session.cs L72
"SSH-2.0-Renci.SshNet.SshClient." + <NuGetパッケジバジョン>

5-5. これが piggy-back ACK とどう結びつくか

TCP の観点では、connect() 完了時にカーネルは「素の ACK(len=0)」を返し、その後のアプリの write() は別セグメント(PSH+ACK+データ)になるのが通常である。しかし SSH.NET は、ハンドシェイク完了のまさにその瞬間に、送るべきデータ(識別文字列)を既に握っている(send-first + NoDelay)。これは AWS ドキュメントの言う「最終 ACK にデータを同乗させる」ための必要条件を満たしている。

重要な但し書き ── ソースだけでは "確実に発生する" とまでは言えない。
識別文字列が実際に最終 ACK に乗るか、別セグメントになるかを最終的に決めるのは、Windows Server 2025 の TCP スタックと経路であり、ライブラリ単独ではない。

  • 通常の blocking connect では、Windows は SYN-ACK 受信時に素の ACK を送り、続く send() を別の PSH+ACK セグメントで送る → piggy-back せず、成功
  • 一方、(a) 素の ACK が経路上で消失/遅延し、サーバから見た実効的な 3 パケット目が PSH+ACK+データ になる、または (b) スタック/ミドルボックスが素の ACK と初送データを1 セグメントに合体させる、といった条件が揃うと piggy-back が現実化する。

SSH.NET の send-first 挙動は、この (a) と (b) の両方が「データを最終 ACK に乗せうる」状態を作る前提になっている。もし SSH.NET が「サーバ応答待ち」型だったら、ハンドシェイク完了時点で送るデータが無いため、(a) の ACK ロスが起きても最終 ACK にデータは乗り得ない。つまり send-first であること自体が、この障害を成立させている。 観測された「断続的」という性質は、この "スタック/経路のタイミング依存" と完全に整合する。

5-6. SSH.NET 版・失敗シーケンス(port 22)

5-7. なぜ 2223 が SSH.NET に適合するのか

ポート 2223 が非互換になるのは「サーバが先に識別文字列を送ることを要求する」クライアントだけである。SSH.NET は発見①の通り自分から先に送る実装なので、この非互換条件に該当しない。2223 側がクライアントの発話を待ってから応答する構成であっても、SSH.NET は待たせず即座に送るため、デッドロックは起きない。よって SSH.NET に対しては 2223 が安全かつ有効な回避策となる(具体的な設定は §9-2、確認手順は §6〜§8)。

補足: この send-first の挙動は SSH.NET 固有の癖ではなく、OpenSSH をはじめとする一般的な SSH クライアントと同じく RFC 4253 §4.2(接続確立後、双方が識別文字列を送る/順序規定なし)に沿った実装である。AWS が piggy-back 回避策(2223)を特定製品向けでなく汎用的に用意しているのは、この "接続直後に送るクライアント" が広く存在するためと考えられる。


6. 検証その1 ── 「どちらが先に話すか」を nc で判定

原理はシンプル。TCP 接続後に自分から何も送らず、サーバが先にバナーを送ってくるかを見るnc は TCP 確立後に自発送信しないため、この用途にちょうど合う。

6-1. 受動プローブ(何も送らず待つ)

# 何も送らずに3秒待ち、サーバが先に話すかを見る
timeout 3 nc <endpoint> 22       # 予想: バナーが即座に表示される(サーバファースト)
timeout 3 nc <endpoint> 2223     # 予想: 沈黙のまま(サーバがこちらの発話を待っている)

判定は出力の有無で行う。バナーが即出れば server-first、無音のまま経過すれば client-first の疑いが濃厚。

6-2. 能動プローブ(こちらが先に話す)

client-first であることを確定させるには、こちらから識別文字列を送り、その後にバナーが返るかを見る。パイプを sleep で開いたまま保つのがコツで、これをしないと nc が EOF で早期に接続を閉じ、サーバの応答を取りこぼす。

# こちらが先に話し、接続を3秒保持して、応答バナーが返るか見る
{ printf 'SSH-2.0-probe\r\n'; sleep 3; } | nc <endpoint> 2223
# 予想: ここで初めて SSH-2.0-AWS_SFTP... 系のバナーが返ってくる

6-3. 期待される挙動

server-first / client-first を模した検証用リスナーに対して上記 idiom を実行し、次の想定一致を確認した。

プローブ server-first ポート client-first ポート
受動(無送信で待機) バナー即時表示 無音
能動(先に発話+保持) (該当せず) 発話後にバナー返却

読み取り上の注意: timeout 3 nc ... | cat の形だと timeoutnc だけをラップし、client-first の無音ケースでも終了コードが 124 にならず 0 になることがある。判定は終了コードでなく「バイトが返ってきたか否か」で行うこと。機械判定するなら次のようにする。

out=$(timeout 3 nc <endpoint> 2223); \
[ -z "$out" ] && echo "SILENT (client-firstの疑い)" || echo "GOT: $out"

前提条件: VPC ホスト型エンドポイントは私設 IP なので、検査元はそのVPCに到達可能な位置(同一VPC内の EC2、または Direct Connect / VPN 経由のオンプレ等)にあり、かつ対象ポートを許可するセキュリティグループのインバウンド範囲に検査元 IP が含まれている必要がある。


7. 検証その2 ── piggy-back シグネチャを tcpdump で捕捉

nc が「アプリ層で誰が先に話すか」を見るのに対し、tcpdump は「3 パケット目にデータが同乗し、サーバが RST を返す」という TCP 層の決定的証拠を捉える。

7-1. 基本キャプチャ(ハンドシェイク+初送データをそのまま読む)

payload 長の BPF フィルタは壊れやすいので、まずは接続まるごとを取得して目視で読むのが確実。

# クライアント側(または経路上)で。-A でペイロードのASCIIを表示、-S で絶対シーケンス番号
sudo tcpdump -i <egress-if> -nn -S -vv -A \
  'host <endpoint-ip> and (tcp port 22 or tcp port 2223)'

読むべきシグネチャ(ポート 22・失敗時):

1) SYN                    client -> server
2) SYN-ACK                server -> client
3) [P.], seq=1:N          client -> server   ← このパケットに "SSH-2.0-..." が載る
                                                (SSH.NETなら SSH-2.0-Renci.SshNet...、
                                                 OpenSSHなら SSH-2.0-OpenSSH_...)
                                                = 素のACKを経ずデータ同乗 = piggy-back
4) R  (RST)               server -> client   ← 直後にサーバがリセット

3 番目のパケットに Flags [P.](PSH+ACK)と SSH-2.0- のペイロードが同居し、その直後に Flags [R.] が続けば、それが本事象である。

-i any は Linux cooked ヘッダのオフセット差異で厄介なことがあるため、実際の送出インタフェース(eth0 等)を明示すると余計な問題を避けられる。

7-2. 補助フィルタ

# RST だけを抜き出す
sudo tcpdump -i <if> -nn 'host <endpoint-ip> and (tcp[tcpflags] & tcp-rst != 0)'

# データ同乗(PSH)パケットだけを抜き出す
sudo tcpdump -i <if> -nn -A 'host <endpoint-ip> and (tcp[tcpflags] & tcp-push != 0)'

# TCPペイロード長 > 0 のパケットのみ(IPv4限定・上級者向け。IPv6エンドポイントには非適用)
sudo tcpdump -i <if> -nn -A \
 'host <endpoint-ip> and tcp and (ip[2:2] - ((ip[0]&0x0f)<<2) - ((tcp[12]&0xf0)>>2)) > 0'

最後のフィルタは「IP 全長 − IP ヘッダ長 − TCP ヘッダ長 > 0」でデータ有りセグメントを選別する。ip[...] を使うため IPv4 専用。

この切断は認証前に起きるため、Transfer Family の CloudWatch ログにはほぼ痕跡が残らない。 VPC フローログも TCP フラグを集約期間で OR 合成してしまうため、パケット単位の PSH/RST 判別には使えない。クライアント側または経路上でのキャプチャが実質的に唯一の確定手段になる。


8. 一括判定スクリプト(bash)

22 / 2222 / 22000 / 2223 を順にプローブし、各ポートが server-first / client-first / 接続不可のどれかを表形式で出力する。バナー文字列も併記する。

8-1. 使い方

chmod +x sftp-banner-probe.sh

# デフォルトで 22 / 2222 / 22000 / 2223 を判定
./sftp-banner-probe.sh s-xxxx.server.transfer.ap-northeast-1.amazonaws.com

# ポートを絞る
./sftp-banner-probe.sh 10.0.12.34 22 2223

出力例(検証用リスナーに対する実出力):

PORT    REACHABLE   BEHAVIOR        BANNER / NOTE
----    ---------   --------        -------------
2201    yes         server-first    SSH-2.0-DEMO_SERVERFIRST
2202    yes         client-first    SSH-2.0-DEMO_CLIENTFIRST
2203    NO          -               TCP connect failed (check security group / route / server state)

本番の Transfer Family に向ければ、22 / 2222 / 22000 が server-first(即バナー)、2223 が client-first(こちらが話すまで沈黙)と並ぶはずで、これが仮説の最終確認になる。

8-2. スクリプト本体

#!/usr/bin/env bash
#
# sftp-banner-probe.sh
#
# AWS Transfer Family(や任意のSSH/SFTP)エンドポイントの各ポートについて、
# サーバが server-first(接続直後に自分のバナーを送る)か client-first
# (クライアントが先に話すまで沈黙)かを判定し、バナー文字列を出力する。
#
# 【スコープ】本スクリプトが判定するのは「バナーの発話順序」だけで、
# piggy-back ACK障害そのものは再現しない(ncは通常のハンドシェイク=素のACK
# →別途writeを行うため、データ同乗の最終ACK→RSTは発生しない)。
# 「相手クライアントが2223と互換か」はこのスクリプト、「port22で実際に
# piggy-back RSTが起きているか」の確定はtcpdump、という役割分担になる。
#
# Usage:  ./sftp-banner-probe.sh <endpoint-host> [port ...]
#         (default ports: 22 2222 22000 2223)

set -u

WAIT=3          # サーバの発話を待つ秒数(受動プローブ)
HOLD=3          # 発話後にパイプを開いたまま保つ秒数(能動プローブ)
OUTER=$((HOLD + 2))

host="${1:-}"
if [[ -z "$host" ]]; then
    echo "usage: $0 <endpoint-host> [port ...]" >&2
    exit 2
fi
shift || true
if [[ $# -gt 0 ]]; then
    ports=("$@")
else
    ports=(22 2222 22000 2223)
fi

command -v nc >/dev/null 2>&1 || { echo "error: nc not found in PATH" >&2; exit 3; }

# --- helpers ---------------------------------------------------------------
#
# 別途「疎通確認用の接続」は張らない。server-firstのポートに対してはバナーを
# 消費してしまい、かつ別接続のタイミングが不安定になるため。1本の受動接続で
# 疎通確認とバナー捕捉を兼ねる。状態はグローバル変数でなく「関数の終了コード」
# で返す ── resp="$(passive_probe ...)" は関数をコマンド置換のサブシェルで
# 実行するため、関数内で設定した変数は失われるが、置換されたコマンドの
# 終了ステータスは呼び出し元に伝わるからである。
#
# passive_probe 終了コード: 0 = バナー受信(server-first)
#                          10 = 接続できたが無音(client-first候補)
#                          11 = TCP接続失敗(到達不可)
passive_probe() {
    local h="$1" p="$2" out rc
    out="$(timeout "$WAIT" nc "$h" "$p" </dev/null 2>/dev/null)"; rc=$?
    printf '%s' "$out"                       # バナー(あれば)をstdoutへ
    if [[ -n "$out" ]]; then
        return 0                             # バイトあり => server-first
    elif [[ $rc -eq 124 ]]; then
        return 10                            # 読み取りタイムアウト => 接続済み・無音
    elif timeout "$WAIT" bash -c "exec 3<>/dev/tcp/${h}/${p}" 2>/dev/null; then
        return 10                            # 接続はできるが無音
    else
        return 11                            # 接続失敗
    fi
}

# 能動プローブ: こちらが先に識別文字列を送り、接続を保持して応答を待つ。
# 内側のsleepでstdinを開いたまま保つことで、ncが応答前に接続を閉じるのを防ぐ。
active_probe() {
    local h="$1" p="$2"
    timeout "$OUTER" bash -c \
        "{ printf 'SSH-2.0-probe\r\n'; sleep ${HOLD}; } | nc '$h' '$p' 2>/dev/null"
}

# 先頭1行・制御文字を除去して表示用に整形
clean() { printf '%s' "$1" | tr -d '\r' | head -n1; }

# --- run -------------------------------------------------------------------

printf '\n=== SFTP banner-ordering probe ===\n'
printf 'endpoint : %s\n' "$host"
printf 'ports    : %s\n' "${ports[*]}"
printf 'waited   : %ss passive / %ss active per port\n' "$WAIT" "$HOLD"
printf 'time     : %s\n\n' "$(date '+%Y-%m-%d %H:%M:%S %Z')"

printf '%-6s  %-10s  %-14s  %s\n' "PORT" "REACHABLE" "BEHAVIOR" "BANNER / NOTE"
printf '%-6s  %-10s  %-14s  %s\n' "----" "---------" "--------" "-------------"

for p in "${ports[@]}"; do
    resp="$(passive_probe "$host" "$p")"; state=$?
    if [[ $state -eq 11 ]]; then
        printf '%-6s  %-10s  %-14s  %s\n' "$p" "NO" "-" \
            "TCP connect failed (check security group / route / server state)"
        continue
    fi
    if [[ $state -eq 0 ]]; then
        printf '%-6s  %-10s  %-14s  %s\n' "$p" "yes" "server-first" "$(clean "$resp")"
        continue
    fi

    # state 10: 無送信では無音 -> こちらから話してみる
    resp="$(active_probe "$host" "$p")"
    if [[ -n "$resp" ]]; then
        printf '%-6s  %-10s  %-14s  %s\n' "$p" "yes" "client-first" "$(clean "$resp")"
    else
        printf '%-6s  %-10s  %-14s  %s\n' "$p" "yes" "inconclusive" \
            "silent both ways (possible on-path filtering after connect)"
    fi
done

printf '\ninterpretation:\n'
printf '  server-first  = 通常のサーバ先行ポート。どのクライアントも動くが、\n'
printf '                  データ同乗の最終ACKがRSTされうる(piggy-back)。\n'
printf '                  確定は port 22 の tcpdump で。\n'
printf '  client-first  = port 2223 の挙動。サーババナーを待ってから話す\n'
printf '                  クライアントはここでデッドロック/タイムアウトする。\n'
printf '  inconclusive  = 接続はできるがどちらでも無音。ハンドシェイク後に\n'
printf '                  ペイロードを落とすFW/ミドルボックスを疑う。\n\n'

8-3. 移植性

  • 疎通判定は nc のフラグ差(OpenBSD nc / Nmap ncat / traditional nc)に依存しないよう、bash の /dev/tcp と外側の timeout で実装している。必須なのは nc host port で TCP を張って stdin/stdout を中継する挙動だけなので、どの nc 系でも動く。
  • 判定は**終了コードでなく「バイトが返ったか」**を基準にしている。
方式確認用のローカルリスナー(banner_listeners.py)

本番エンドポイントに向ける前に、判定ロジックの妥当性を手元で確認したい場合に使う。片方の端末でこれを起動し、もう片方で ./sftp-banner-probe.sh 127.0.0.1 2201 2202 を実行すれば、AWS に向けるのと同一操作でロジックを検証できる。

#!/usr/bin/env python3
"""
server-first(:2201)と client-first(:2202)を模した2つのローカルTCPリスナー。
"""
import socket, threading, sys, time

SERVER_FIRST_PORT = 2201
CLIENT_FIRST_PORT = 2202
HOST = "127.0.0.1"

def log(tag, msg):
    print(f"[{time.strftime('%H:%M:%S')}] ({tag}) {msg}", flush=True)

def serve_server_first(port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind((HOST, port)); s.listen(5)
    log("server-first", f"listening on {HOST}:{port}")
    while True:
        conn, addr = s.accept()
        log("server-first", f"accepted {addr} -> sending banner immediately")
        try:
            conn.sendall(b"SSH-2.0-DEMO_SERVERFIRST\r\n")  # 先に話す
            conn.settimeout(3)
            try:
                data = conn.recv(256)
                log("server-first", f"client then sent: {data!r}")
            except socket.timeout:
                log("server-first", "client sent nothing within 3s")
        finally:
            conn.close()

def serve_client_first(port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind((HOST, port)); s.listen(5)
    log("client-first", f"listening on {HOST}:{port}")
    while True:
        conn, addr = s.accept()
        log("client-first", f"accepted {addr} -> waiting for client to speak first")
        conn.settimeout(10)
        try:
            try:
                data = conn.recv(256)              # クライアントを待つ
                log("client-first", f"client spoke first: {data!r} -> now replying")
                conn.sendall(b"SSH-2.0-DEMO_CLIENTFIRST\r\n")
            except socket.timeout:
                log("client-first", "client never spoke; closing (deadlock case)")
        finally:
            conn.close()

if __name__ == "__main__":
    threading.Thread(target=serve_server_first, args=(SERVER_FIRST_PORT,), daemon=True).start()
    threading.Thread(target=serve_client_first, args=(CLIENT_FIRST_PORT,), daemon=True).start()
    time.sleep(0.3)
    print("\n=== probe from another terminal ===")
    print(f"timeout 3 nc {HOST} {SERVER_FIRST_PORT}    # expect: banner instantly")
    print(f"timeout 3 nc {HOST} {CLIENT_FIRST_PORT}    # expect: silence")
    print(f"{{ printf 'SSH-2.0-probe\\r\\n'; sleep 3; }} | nc {HOST} {CLIENT_FIRST_PORT}")
    try:
        while True: time.sleep(1)
    except KeyboardInterrupt:
        sys.exit(0)

9. 対応方針

対処は (a) サーバ側(ポート開放)(b) クライアント側(接続先ポート)(c) 経路側(ACK 挙動の是正) の3方向。SSH.NET のようにライブラリ実装を変えられない場合は (a)+(b) が現実解になる。

9-1. サーバ側 ── セキュリティグループで 2223 を許可

有効化の実体はセキュリティグループのインバウンドルールである。VPC でのサーバ作成手順でセキュリティグループを指定する際、SSH トラフィックに ポート 2223/tcp を許可するだけでよく、サーバ側の API 設定変更は不要。認証方式・ユーザ・ホストキーはポートによらず共通で、変わるのは TCP/バナー交換の挙動のみ。これらのカスタムポートは NLB を構成せずに利用できる。

9-2. クライアント側 ── 接続先ポートを 2223 に

クライアントは接続先ポートを 2223 に変更する。

汎用 CLI(疎通確認にも使える):

sftp -P 2223 -i transfer-key sftp_user@<service_endpoint>

SSH.NET(.NET)の場合: ホスト/ポートを指定するだけでよい。

// 例: ConnectionInfo でポート 2223 を指定
var connInfo = new ConnectionInfo(host, 2223, username, authMethod);
using var client = new SftpClient(connInfo);
client.Connect();

SSH.NET 側に "piggy-back を止める" 設定は存在しない。 send-first + NoDelay はライブラリにハードコードされており(§5)、公開 API で「サーバ応答を待ってから送る」モードへ切り替える手段はない。したがってクライアント設定で piggy-back そのものを回避することはできず、対処は接続ポート(9-2)か経路の是正(9-3)に限られる。

9-3. 経路側 ── ACK 合体の無効化 / 素の ACK ロスの根絶

2223 への切替は AWS 自身が**回避策(workaround)**と位置づけるもの。可能であれば根本原因側も並行して検討する価値がある。

  • 経路上の WAN 最適化装置・ファイアウォールの ACK 合体機能を無効化する
  • 素の ACK がドロップ/遅延される原因(輻輳、経路上のセキュリティ機器のドロップ)を排除する

9-4. 恒久対応の位置づけ

  • クライアント側の実装や経路機器を変更できない B2B ファイル連携(相手方が管理する環境)では、2223 の開放が現実的な唯一解になるケースが多い。
  • その際は、相手方クライアントが「サーババナー待ち」実装でないことの事前確認(§6 の疎通テスト)が必須。SSH.NET を含む OpenSSH 系の send-first クライアントは問題ないが、独自実装クライアントは 2223 でデッドロックしうる。

9-5. 切り分けチェックリスト

  1. 失敗は断続的常時か → 断続なら素の ACK ロス、常時なら合体系を第一容疑に
  2. クライアント側 or 経路上で tcpdump → 「3 パケット目が PSH+ACK+SSH-2.0- → 直後に RST」を確認(§7)
  3. sftp-banner-probe.sh で 22 と 2223 の発話順序を確認 → 相手クライアントが 2223 と互換かを判断(§8)
  4. クライアントが SSH.NET なら、send-first + NoDelay により piggy-back の必要条件を満たすと判断してよい(§5)
  5. CloudWatch/フローログだけを見て原因に到達しようとしない(認証前フェーズは追えない)

10. まとめ

  • 事象は「TCP は繋がるが SSH バナー交換直前に RST」で、正体は 3 ウェイハンドシェイクの最終 ACK にデータが同乗する piggy-back ACK
  • 原因経路は、(a) 素の ACK の消失/遅延(断続)(b) スタック/ミドルボックスによる ACK+データの合体(常時) の 2 つ。
  • クライアントが SSH.NET の場合、ソースコード上 send-first + NoDelay であり、piggy-back の必要条件を満たす。実際に最終 ACK に乗るかは Windows の TCP スタック/経路依存で、これが「断続的」という観測と整合する(§5)。
  • 回避策は ポート 2223。ただし 2223 は client-first に挙動が変わるため、サーババナーを待つ古いクライアントとは非互換というトレードオフがある。SSH.NET は send-first なので 2223 と互換
  • SSH.NET 側に piggy-back を止める設定は無いため、対処は 接続ポート(2223)経路の是正に限られる(§9)。
  • nc で発話順序を、tcpdump で piggy-back シグネチャを確認できる。両者は役割が異なる(順序判定 vs 障害の実捕捉)。認証前フェーズは CloudWatch/フローログでは追えないため、パケットキャプチャが確定手段

参考リンク


本記事のシーケンス図は、公式ドキュメントの記述と TCP/SSH 仕様から導いた想定挙動である。事象の再現・断定にはパケットキャプチャによる確認が必要であり、nc 判定ロジックは server-first / client-first を模した検証用リスナーで、SSH.NET の挙動はソースコード(タグ 2024.2.0 / develop)で確認している。実環境の挙動は各自の構成で検証されたい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?