IPsec ESPとSAの更新
SPIと方向を照合し、認証・復号・SA更新で扱う値を確認します。
説明
IPsecのESPは、SAに対応した鍵と方式でパケットを保護します。外から見えるSPIやsequenceと、暗号化された内側の情報を分けて理解すると、観測できる範囲が分かります。
AES-GCMの固定試料を解析し、SAの方向とSPI、認証後の内側パケット、1ビット改変時の失敗を確認します。受信済み番号の扱い、新SAへの更新、追加長も調べます。
1外から確認できるESPの欄
outer-esp.pcapはIPv4の内側パケットをAES-GCMで保護したESPトンネルの試料です。
tcpdump -n -tt -vvv外側IP・SPI・sequence -r outer-esp.pcapESPトンネルの試料外側IPのプロトコル50と、ESPのSPI0x1001、sequence1を確認できます。外側の送受信IPは配送に使うため見えますが、内側のIPとUDPは暗号化範囲に入っています。
2SPIとsequenceの8バイト
esp.binの先頭24バイトを表示します。
xxd -g 1 -l 24ESP先頭とIV esp.bin外側IPを除いたESP最初の4バイト00001001がSPI、次の00000001がsequenceです。その後8バイトがこのAES-GCM方式の明示的IVで、16バイト目から暗号文が始まります。SPIは鍵そのものではなく、受信側がSAを選ぶための識別子です。
3方向ごとのSAを確認する
sa.jsonはこの試料のSAの対応です。更新準備中の新しいSAも含みます。
cat sa.jsonSPI・方向・宛先・状態外向きSPI0x1001と内向きSPI0x1002は別のSAです。SAは方向を持つため、往復で一つのSPIを共有するとは限りません。相手、SPI、暗号方式、寿命やsequenceの状態を対応付けます。
4認証してから内側を取り出す
esp.mjsは公開の固定試験鍵で作った試料の復号処理です。GCMの認証を確認した後、paddingとtrailerを除きます。
node esp.mjsこの試料のAES-GCM処理 decrypt認証後に内側を復号1000バイト、padding2、Next Header4、内側10.1.0.10→10.2.0.20で元データと一致します。トンネルモードでは内側IP全体を保護します。トランスポートモードとは暗号化範囲が違うため、モードも合わせて確認します。
5暗号文を1ビット変えた場合
同じ試料の暗号文を1ビット変え、元の認証タグで復号を試します。
node esp.mjs試料の認証処理 tamper1ビット改変を検査認証が失敗し、平文は受け入れられません。この方式はSPIとsequenceを追加認証データとして扱い、暗号文とともに改変を検出します。外側IPヘッダー全部がこのESP認証の対象になるわけではありません。
6同じsequenceの再受信
暗号学的な認証が通る古いパケットでも、再送攻撃対策の状態によって受け入れない場合があります。この例は受信済み1と未受信2を比較します。
python3 -c受信済み番号を確認 'highest=1; seen={1}; print([(seq,seq not in seen and seq>highest-64) for seq in (1,2)])'認証とは別の再利用検出受信済みの1はFalse、未受信の2はTrueです。ここでは64幅の確認窓の一部を示しています。実装では認証成功後に受信済み状態を更新し、未認証の番号では窓を進めません。
7更新後は新しいSAで番号を管理する
更新前後のESP先頭8バイトを比較します。
python3 -cSA更新前後のSPIと番号 'from pathlib import Path; import struct; print([(name,tuple(hex(v) for v in struct.unpack("!II",Path(name).read_bytes()[:8]))) for name in ("esp.bin","esp-new.bin")])'識別子が変わった新しいSASPI0x1001と0x2001で、どちらもsequence1です。新しい鍵とSAへ切り替えるため、新SAの番号を1から始められます。同じSAの番号やGCMのnonceを使い回してよいという意味ではありません。実際の更新には鍵管理と旧SAの切替手順が必要です。
8ESPで増えた長さ
内側、ESP全体、外側IP込みの長さを比較します。
python3 -c各層の長さを比較 'from pathlib import Path; sizes=[len(Path(n).read_bytes()) for n in ("inner.bin","esp.bin","outer-esp.bin")]; print(sizes,sizes[2]-sizes[0])'この試料の追加長1000→1036→1056バイトで、追加は56バイトです。この内訳はESPヘッダー8、IV8、padding2、trailer2、タグ16、外側IP20です。アルゴリズムやpadding、NAT-T等の有無で追加長が変わるので、常に56バイトとは限りません。
まとめ
SPIは受信側がSAを選ぶ識別子で、方向や相手と合わせて確認します。
認証、再利用の検出、SA更新は異なる処理で、暗号化範囲と追加長も方式によって変わります。
操作履歴と解説を振り返れます。
シェル / PCAPと通信の記録
tcpdump -n -tt -vvv -r outer-esp.pcapreading from file outer-esp.pcap, link-type EN10MB (Ethernet)
0.000000 IP (tos 0x0, ttl 64, id 1, offset 0, flags [DF], proto ESP (50), length 1056)
192.0.2.1 > 198.51.100.20: ESP(spi=0x00001001,seq=0x1), length 1036xxd -g 1 -l 24 esp.bin00000000: 00 00 10 01 00 00 00 01 00 00 00 00 00 00 00 01 ................ 00000010: 1f ae 03 b6 20 19 51 0c .... .Q.
cat sa.json[
{
"direction": "out",
"destination": "198.51.100.20",
"spi": "0x1001",
"algorithm": "AES-128-GCM",
"state": "current"
},
{
"direction": "in",
"destination": "192.0.2.1",
"spi": "0x1002",
"algorithm": "AES-128-GCM",
"state": "current"
},
{
"direction": "out",
"destination": "198.51.100.20",
"spi": "0x2001",
"algorithm": "AES-128-GCM",
"state": "new"
}
]node esp.mjs decrypt{
"length": 1000,
"padding": 2,
"nextHeader": 4,
"source": "10.1.0.10",
"destination": "10.2.0.20",
"matchesOriginal": true
}node esp.mjs tamperauthentication failed; plaintext not accepted
python3 -c 'highest=1; seen={1}; print([(seq,seq not in seen and seq>highest-64) for seq in (1,2)])'[(1, False), (2, True)]
python3 -c 'from pathlib import Path; import struct; print([(name,tuple(hex(v) for v in struct.unpack("!II",Path(name).read_bytes()[:8]))) for name in ("esp.bin","esp-new.bin")])'[('esp.bin', ('0x1001', '0x1')), ('esp-new.bin', ('0x2001', '0x1'))]python3 -c 'from pathlib import Path; sizes=[len(Path(n).read_bytes()) for n in ("inner.bin","esp.bin","outer-esp.bin")]; print(sizes,sizes[2]-sizes[0])'[1000, 1036, 1056] 56
演習完了