見出し画像

【lwIP】Bug #4|lwip_close()後に38分間PCBが解放されない|FIN_WAIT_1タイムアウト未実装の落とし穴

(English Version Here.)

このnoteでは、lwIPで実際に起こり得る不具合・脆弱性・再現方法・最小修正の考え方やデバッグのノウハウをシリーズで解説しています。
▶ 日本語記事一覧はこちら:lwIPトラブル対策室|日本語記事一覧


■ lwIPでこんな現象に遭遇していませんか?

  • `lwip_close()` を呼んだのに、Wiresharkで数十分間FINパケットが飛び続けている

  • ネットワーク障害の後、新規TCP接続が突然できなくなった

  • `memp_malloc`失敗やPCBプール枯渇のログが出ている

  • 障害が復旧しても、しばらくの間接続を受け付けない状態が続く

lwIPのFIN_WAIT_1ステートに専用タイムアウトが存在しないことが原因の可能性があります。


■ 発生条件

  • lwIPバージョン: 1.0.0〜2.2.1(執筆時の全バージョン)

  • 影響を受ける状況: `lwip_close()` 呼び出し後、対向機器がFINパケットに応答しない場合

    • ネットワークの物理的な切断

    • 対向機器の異常停止

    • ファイアウォールによるパケット遮断(iptables DROP等)


■ この記事で分かること

  1. FIN_WAIT_1状態でPCBが最大38分間占有される再現手順

    • Linux + Python スクリプトによる再現環境の構築

    • Wiresharkで観測できるFIN再送パターン

  2. なぜ38分かかるのか(ソースコード解析)

    • FIN_WAIT_2にはタイムアウトがあってFIN_WAIT_1にはない非対称な設計

    • `tcp_backoff[]` による指数バックオフで再送間隔が延びる仕組み

  3. 対策を考えるときの観点

    • 再送回数・強制切断・状態タイムアウトの違いと使い分け

    • 製品に適用する際に検討すべき副作用

  4. 本記事で検証した修正案の概要

    • FIN_WAIT_1に対するタイムアウト追加の考え方

    • 修正後にpcapで確認できること


■ 問題の概要

`lwip_close()` を呼ぶと、lwIPはFINパケットを送信してPCB(Protocol Control Block)をFIN_WAIT_1ステートへ遷移させます。

TCPの接続終了は通常4ステップ(4-wayハンドシェイク)で完了します。

  • FIN_WAIT_1:こちらからFINを送信し、相手のACK(確認応答)を待っている状態

  • FIN_WAIT_2:FINのACKを受け取り、今度は相手からのFINを待っている状態

通常この遷移は数秒以内に完了します。しかし対向機器がFINに一切応答しない場合、FIN_WAIT_1から先に進めず、そのまま再送を繰り返し続けます。

対向機器がFINに応答しない場合、lwIPはFINを最大 `TCP_MAXRTX`(デフォルト12)回まで再送し続けます。このとき、再送のたびに待機時間が2倍に延びる指数バックオフが働くため、12回目の再送が終わるまでに約38分かかります。

FIN_WAIT_2ステートには `TCP_FIN_WAIT_TIMEOUT`(デフォルト20秒)という専用のタイムアウトが実装されています。しかしFIN_WAIT_1にはそれがなく、再送回数の上限(`TCP_MAXRTX`)に達するまでPCBが解放されません。原因は、TCPの定期タイマー処理関数 `tcp_slowtmr()` にFIN_WAIT_1専用のタイムアウト判定ブロックが存在しないためです。

lwIPでは `MEMP_NUM_TCP_PCB`(デフォルト5)個しかPCBを確保できないため、38分間PCBが1つ占有されるとその分だけ新規接続に使えるリソースが減ります。複数の接続でこの状態が重なればPCBプールが枯渇し、新規TCP接続の確立に失敗したり、TCP通信全体に影響が及ぶ可能性があります。


■ 再現手順

前提環境

  • lwIP 2.2.0

  • 対向環境:Linux(Ubuntu)+ Python 3

再現の仕組み

Linuxの `iptables` を使い、lwIPデバイスとのすべての送受信パケットをDROPします。これにより「ネットワーク障害が発生してFINパケットが届かない」状況を擬似的に作れます。

大まかな流れは次の通りです。

  1. Linux上でTCPサーバースクリプトを起動し、lwIPデバイスからの接続を待つ

  2. lwIPデバイスから接続し、`HELLO`/`OK` メッセージを交換する

  3. `OK` 応答後、iptablesでlwIPデバイスとのパケットをDROPする

  4. 8秒後にlwIPデバイスで `lwip_close()` を呼び出す

  5. WiresharkでFIN再送パターンを観察する

Linux側サーバースクリプト

以下のPythonスクリプト(`finwait_server.py`)をLinux上で実行します。ポート5001でlwIPデバイスからの接続を待ち受け、`OK` 応答後に自動でiptables DROPを設定します。

iptablesはLinuxのパケットフィルタリング機能です。`-j DROP` を指定すると、該当するパケットを応答なしで破棄します。ここではlwIPデバイスとのすべての送受信パケットをDROPすることで、「ネットワーク障害が発生してFINパケットが届かない」状況を擬似的に作り出します。接続は確立済みのまま、以降のパケットだけが双方向で遮断されます。

import socket, subprocess, time, threading

TARGET_IP = "192.168.1.200"  # lwIPデバイスのIPアドレス

def run(cmd): subprocess.run(cmd, shell=True)

def drop_packets():
    time.sleep(1)  # OK送信後1秒待つ
    run(f"sudo iptables -A INPUT  -s {TARGET_IP} -j DROP")
    run(f"sudo iptables -A OUTPUT -d {TARGET_IP} -j DROP")
    print("[Server] iptables DROP active.")

with socket.socket() as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(('', 5001))
    srv.listen(1)
    print("[Server] Waiting for lwIP device connection...")
    conn, addr = srv.accept()
    print(f"[Server] Connected from {addr}")
    data = conn.recv(16)
    print(f"[Server] Received: {data.decode()}")
    conn.send(b"OK\r\n")
    threading.Thread(target=drop_packets, daemon=True).start()
    try:
        elapsed = 0
        while True:
            time.sleep(30)
            elapsed += 30
            print(f"[Server] +{elapsed} sec")
    except KeyboardInterrupt:
        pass
    finally:
        run(f"sudo iptables -D INPUT  -s {TARGET_IP} -j DROP")
        run(f"sudo iptables -D OUTPUT -d {TARGET_IP} -j DROP")
        print("[Server] iptables rules removed.")
sudo python3 finwait_server.py

Ctrl+C で停止すると `finally` ブロックが自動実行され、iptablesルールが削除されます。

lwIPデバイス側の再現コード

以下の関数をタスクとして実行します。Linux:5001へ接続し、`HELLO`/`OK` 交換の8秒後に `lwip_close()` を呼び出します(`osDelay()` はRTOSのディレイAPIに合わせて変更してください)。

#define FINWAIT_SERVER_IP   "192.168.1.22"
#define FINWAIT_SERVER_PORT 5001
#define FINWAIT_PRE_CLOSE_MS 8000

void tcp_finwait_task(void *arg)
{
    int sock = lwip_socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port   = PP_HTONS(FINWAIT_SERVER_PORT),
    };
    ip4addr_aton(FINWAIT_SERVER_IP, (ip4_addr_t *)&addr.sin_addr);

    lwip_connect(sock, (struct sockaddr *)&addr, sizeof(addr));
    lwip_send(sock, "HELLO\r\n", 7, 0);

    char buf[16] = {0};
    lwip_recv(sock, buf, sizeof(buf)-1, 0);  /* "OK" を受信 */

    osDelay(FINWAIT_PRE_CLOSE_MS);
    lwip_close(sock);  /* ← FIN_WAIT_1へ遷移 */

    /* PCB解放を待ちながら経過時間を表示 */
    int elapsed = 0;
    while (1) {
        osDelay(30000);
        elapsed += 30;
        printf("[FINWait] +%d sec elapsed since lwip_close()\n", elapsed);
    }
}

WiresharkでFIN再送を観察する

キャプチャフィルタを設定してlwIPデバイスのIPアドレスのTCPパケットだけを表示します。

ip.addr == 192.168.1.200 && tcp

動作を確認する

lwIPデバイスを起動すると以下の順序で動作します。

  • Linux:5001へ接続

  • `HELLO` 送信 → Linux側で `OK` 応答 → iptables DROP設定

  • 8秒後に `lwip_close()` 呼び出し → FIN送信 → FIN_WAIT_1遷移

  • WiresharkにFINパケットが現れ、指数バックオフで間隔を延ばしながら再送を繰り返す

実機で確認したWiresharkの結果です。FINから最終再送12回目(2308.743s)まで38.3分かかっています。

Pkt  8: t=   8.036s  FIN(初回送信)
Pkt  9: t=  10.695s  再送1回目   Δ=  2.659s
Pkt 10: t=  17.151s  再送2回目   Δ=  6.456s
Pkt 11: t=  28.695s  再送3回目   Δ= 11.545s
Pkt 12: t=  52.695s  再送4回目   Δ= 24.000s
Pkt 13: t= 100.696s  再送5回目   Δ= 48.000s
Pkt 14: t= 196.696s  再送6回目   Δ= 96.000s
Pkt 15: t= 389.525s  再送7回目   Δ=192.829s
Pkt 16: t= 772.698s  再送8回目   Δ=383.173s  ← 384秒プラトー開始
Pkt 17: t=1156.719s  再送9回目   Δ=384.021s
Pkt 18: t=1540.701s  再送10回目  Δ=383.982s
Pkt 19: t=1924.735s  再送11回目  Δ=384.034s
Pkt 20: t=2308.743s  再送12回目  Δ=384.008s  ← 最終再送
(以降パケットなし。RSTなし。最終再送まで約38.3分)

FIN送信から最終再送まで:2300.7秒(約38.3分)。その後 tcp_slowtmr() が再送上限到達を検出してPCBを解放します(ソースコード上の経路)。

別の再現方法(物理切断)

iptablesが使えない環境では、`HELLO`/`OK` 交換直後にLANケーブルを物理的に抜くことでも再現できます。iptables DROPとの違いは、切断後に対向から送信されるパケットも届かなくなる点です。FINに対する応答が届かないという観点では同様の状況を作れます。


■ 原因・対策

ここまでで、発生条件・問題の概要・再現手順を公開しました。
次に、ソースコード解析の入口として、問題の全体像が分かる最小限の部分だけ紹介します。
詳細なコード追跡・修正方法・修正後の確認方法・公式対応状況は以降の有料パートで解説します。

同様の現象で困っている方、出荷済み製品の修正対応が必要な方に向けて、すぐに使用できる具体的な内容まで解説しています。

またlwIPの全体像や、各ベンダーSDKごとの差異、なぜlwIPの不具合情報が重要なのか、といった背景については、導入記事で整理しています。

【lwIP】よくある不具合と対策まとめ|組み込みTCP/IPトラブル事例集


■ ソースコード解析

lwIP 2.2.0の `src/core/tcp.c` を中心に原因を追います。


【解析1】FIN_WAIT_2にはタイムアウトがあってFIN_WAIT_1にはない

`tcp_slowtmr()` は500msごとに呼ばれるスロータイマ関数です。各PCBのタイムアウト判定を順番に処理します。

FIN_WAIT_2のタイムアウト判定は `tcp.c` の `tcp_slowtmr()` 内に実装されています。

/* Check if this PCB has stayed too long in FIN-WAIT-2 */
if (pcb->state == FIN_WAIT_2) {
  if (pcb->flags & TF_RXCLOSED) {
    if ((u32_t)(tcp_ticks - pcb->tmr) >
        TCP_FIN_WAIT_TIMEOUT / TCP_SLOW_INTERVAL) {
      ++pcb_remove;
      LWIP_DEBUGF(TCP_DEBUG,
        ("tcp_slowtmr: removing pcb stuck in FIN-WAIT-2\n"));
    }
  }
}

`TCP_FIN_WAIT_TIMEOUT` は `tcp_priv.h` で2.2.0のデフォルト値として20000ms(20秒)が定義されています。`TCP_SLOW_INTERVAL`(500ms)で割ると40 × 500ms = 20秒になります。

FIN_WAIT_1に対してはこれに相当するブロックが存在しません。`tcp_slowtmr()` はFIN_WAIT_1のPCBを通過してもタイムアウト判定を行わず、再送ロジックだけが動き続けます。


【解析2】tcp_backoff[]とRTO計算:なぜ38分かかるのか

再送間隔を決めるのは `tcp.c` に定義された次の配列です。

ここから先は

7,268字 / 2画像

¥ 800

Amazon Payで支払うと最大2%還元のチャンス! 9/30まで

この記事が気に入ったらチップで応援してみませんか?