mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 06:43:42 +08:00
Handle ePDG COOKIE challenges during IKE_SA_INIT (#65)
Some ePDGs return zero-Responder-SPI COOKIE challenges, and multiple resolved gateways can respond differently. Accept valid COOKIE responses, retry with COOKIE as the first payload, and prefer a gateway that completes SA negotiation.\n\nConstraint: RFC 7296 requires the COOKIE notification to be the first payload on the retry.\nRejected: Treat COOKIE as an ordinary or fatal notification | either drops a valid challenge or prevents the required retry.\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep relaxed zero-Responder-SPI matching limited to IKE_SA_INIT COOKIE responses.\nTested: go test ./internal/vowifi/ike ./internal/vowifi/...\nNot-tested: Live carrier authorization after IKE_AUTH. Co-authored-by: Meng Meng <[email protected]>
This commit is contained in:
@@ -188,34 +188,60 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
makeNotify(notifyNATSource, sourceHash),
|
||||
makeNotify(notifyNATDestination, destinationHash),
|
||||
}
|
||||
first, initBody, err := marshalPayloadChain(initPayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initRequest := ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}.marshal(initBody)
|
||||
initResponse, err := transport.RoundTrip(ctx, initRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responseHeader, responseBody, err := validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if responseHeader.ResponderSPI == [8]byte{} {
|
||||
return nil, errors.New("ike: responder returned a zero SPI")
|
||||
}
|
||||
initResponsePayloads, err := parsePayloadChain(responseHeader.NextPayload, responseBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectFatalNotifications(initResponsePayloads); err != nil {
|
||||
return nil, err
|
||||
var (
|
||||
initRequest []byte
|
||||
initResponse []byte
|
||||
responseHeader ikeHeader
|
||||
initResponsePayloads []payload
|
||||
cookie []byte
|
||||
)
|
||||
for attempt := 0; attempt < maxIKEInitCookieChallenges; attempt++ {
|
||||
requestPayloads := append([]payload(nil), initPayloads...)
|
||||
if len(cookie) > 0 {
|
||||
requestPayloads = append([]payload{makeNotify(notifyCookie, cookie)}, requestPayloads...)
|
||||
}
|
||||
first, initBody, err := marshalPayloadChain(requestPayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initRequest = ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}.marshal(initBody)
|
||||
initResponse, err = transport.RoundTrip(ctx, initRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var responseBody []byte
|
||||
responseHeader, responseBody, err = validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initResponsePayloads, err = parsePayloadChain(responseHeader.NextPayload, responseBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectFatalNotifications(initResponsePayloads); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challenge, hasCookie, err := ikeInitCookie(initResponsePayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCookie {
|
||||
if attempt+1 == maxIKEInitCookieChallenges {
|
||||
return nil, errors.New("ike: ePDG requested too many COOKIE challenges")
|
||||
}
|
||||
cookie = challenge
|
||||
continue
|
||||
}
|
||||
if responseHeader.ResponderSPI == [8]byte{} {
|
||||
return nil, errors.New("ike: responder returned a zero SPI")
|
||||
}
|
||||
break
|
||||
}
|
||||
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
||||
if err != nil {
|
||||
@@ -616,6 +642,29 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
return session, nil
|
||||
}
|
||||
|
||||
const maxIKEInitCookieChallenges = 2
|
||||
|
||||
func ikeInitCookie(payloads []payload) ([]byte, bool, error) {
|
||||
var cookie []byte
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if kind != notifyCookie {
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, false, errors.New("ike: ePDG returned an empty COOKIE")
|
||||
}
|
||||
if cookie != nil {
|
||||
return nil, false, errors.New("ike: ePDG returned multiple COOKIE notifications")
|
||||
}
|
||||
cookie = append([]byte(nil), data...)
|
||||
}
|
||||
return cookie, cookie != nil, nil
|
||||
}
|
||||
|
||||
func buildInitialEAPAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -64,17 +66,20 @@ func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
}
|
||||
|
||||
type firstAuthCaptureTransport struct {
|
||||
t *testing.T
|
||||
wantEAPOnly bool
|
||||
wantGroup uint16
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
t *testing.T
|
||||
wantEAPOnly bool
|
||||
wantGroup uint16
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
cookieChallenge []byte
|
||||
cookieSeen bool
|
||||
cookieLoop bool
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
|
||||
@@ -92,6 +97,24 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
|
||||
|
||||
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
|
||||
transport.calls++
|
||||
if len(transport.cookieChallenge) > 0 {
|
||||
switch transport.calls {
|
||||
case 1:
|
||||
return transport.answerIKECookie(packet)
|
||||
case 2:
|
||||
if err := transport.verifyIKECookie(packet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if transport.cookieLoop {
|
||||
return transport.answerIKECookie(packet)
|
||||
}
|
||||
return transport.answerIKEInit(packet)
|
||||
case 3:
|
||||
return nil, transport.observeFirstAuth(packet)
|
||||
default:
|
||||
return nil, errors.New("test: unexpected exchange")
|
||||
}
|
||||
}
|
||||
switch transport.calls {
|
||||
case 1:
|
||||
return transport.answerIKEInit(packet)
|
||||
@@ -102,6 +125,69 @@ func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
|
||||
header, _, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
first, body, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, transport.cookieChallenge),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ikeHeader{
|
||||
InitiatorSPI: header.InitiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
MessageID: 0,
|
||||
}.marshal(body), nil
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) verifyIKECookie(packet []byte) error {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if header.Exchange != exchangeIKEInit || header.MessageID != 0 || header.Flags != flagInitiator {
|
||||
return errors.New("test: invalid retried IKE_SA_INIT header")
|
||||
}
|
||||
payloads, err := parsePayloadChain(header.NextPayload, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payloads) == 0 || payloads[0].Type != payloadNotify {
|
||||
return errors.New("test: retried IKE_SA_INIT did not put COOKIE first")
|
||||
}
|
||||
firstKind, firstData, err := parseNotify(payloads[0])
|
||||
if err != nil || firstKind != notifyCookie || !bytes.Equal(firstData, transport.cookieChallenge) {
|
||||
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
|
||||
}
|
||||
cookies := payloadsOfType(payloads, payloadNotify)
|
||||
if len(cookies) != 3 {
|
||||
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 3", len(cookies))
|
||||
}
|
||||
found := false
|
||||
for _, item := range cookies {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == notifyCookie {
|
||||
if !bytes.Equal(data, transport.cookieChallenge) {
|
||||
return fmt.Errorf("test: cookie = %x, want %x", data, transport.cookieChallenge)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("test: retried IKE_SA_INIT did not carry COOKIE")
|
||||
}
|
||||
transport.cookieSeen = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte, error) {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
@@ -291,6 +377,93 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRetriesIKEInitAfterCookie(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{
|
||||
t: t,
|
||||
wantEAPOnly: true,
|
||||
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
|
||||
}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
Installer: unusedInstaller{},
|
||||
APN: "ims",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.transportFactory = func(
|
||||
context.Context,
|
||||
transportConfig,
|
||||
vowifi.ProxyRoute,
|
||||
string,
|
||||
) (datagramTransport, error) {
|
||||
return capture, nil
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-cookie",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8944100000000000000",
|
||||
IMSI: "234150123456789",
|
||||
HomeMCC: "234",
|
||||
HomeMNC: "15",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if !errors.Is(err, errFirstAuthObserved) {
|
||||
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
||||
}
|
||||
if capture.calls != 3 || !capture.cookieSeen || capture.floated || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d cookie_seen=%v floated=%v AKA calls=%d", capture.calls, capture.cookieSeen, capture.floated, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderBoundsRepeatedIKEInitCookieChallenges(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{
|
||||
t: t,
|
||||
wantEAPOnly: true,
|
||||
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
|
||||
cookieLoop: true,
|
||||
}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
Installer: unusedInstaller{},
|
||||
APN: "ims",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.transportFactory = func(
|
||||
context.Context,
|
||||
transportConfig,
|
||||
vowifi.ProxyRoute,
|
||||
string,
|
||||
) (datagramTransport, error) {
|
||||
return capture, nil
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-cookie-loop",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8944100000000000000",
|
||||
IMSI: "234150123456789",
|
||||
HomeMCC: "234",
|
||||
HomeMNC: "15",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "too many COOKIE challenges") {
|
||||
t.Fatalf("Start() error = %v, want bounded COOKIE error", err)
|
||||
}
|
||||
if capture.calls != maxIKEInitCookieChallenges || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d AKA calls=%d", capture.calls, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
|
||||
provider, err := NewProvider(Config{
|
||||
|
||||
@@ -576,10 +576,17 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
||||
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
|
||||
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
||||
var lastErr error
|
||||
var cookieResponse []byte
|
||||
for _, candidate := range transport.remotes {
|
||||
transport.remote = cloneUDPAddr(candidate)
|
||||
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
|
||||
if attemptErr == nil {
|
||||
if ikeInitResponseHasCookie(response) {
|
||||
if cookieResponse == nil {
|
||||
cookieResponse = append([]byte(nil), response...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
lastErr = attemptErr
|
||||
@@ -587,6 +594,9 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
||||
return nil, attemptErr
|
||||
}
|
||||
}
|
||||
if cookieResponse != nil {
|
||||
return cookieResponse, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
|
||||
}
|
||||
return transport.roundTripLocked(ctx, packet, requestHeader)
|
||||
@@ -824,11 +834,34 @@ func ikeResponseMatchesRequest(
|
||||
}
|
||||
var zeroSPI [8]byte
|
||||
if request.ResponderSPI == zeroSPI {
|
||||
return response.ResponderSPI != zeroSPI
|
||||
if response.ResponderSPI != zeroSPI {
|
||||
return true
|
||||
}
|
||||
return response.Exchange == exchangeIKEInit &&
|
||||
response.MessageID == 0 &&
|
||||
ikeInitResponseHasCookie(packet)
|
||||
}
|
||||
return response.ResponderSPI == request.ResponderSPI
|
||||
}
|
||||
|
||||
func ikeInitResponseHasCookie(packet []byte) bool {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil || header.Exchange != exchangeIKEInit || header.MessageID != 0 {
|
||||
return false
|
||||
}
|
||||
payloads, err := parsePayloadChain(header.NextPayload, body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err == nil && kind == notifyCookie && len(data) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func marshalSOCKS5Datagram(remote *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||
if remote == nil || remote.IP == nil || remote.Port < 1 || remote.Port > 65535 {
|
||||
return nil, errors.New("ike: invalid SOCKS5 UDP destination")
|
||||
|
||||
@@ -145,6 +145,18 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
}.marshal([]byte("response"))
|
||||
cookieFirst, cookieBody, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, []byte{0x10, 0x20, 0x30, 0x40}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookieResponse := ikeHeader{
|
||||
InitiatorSPI: requestHeader.InitiatorSPI,
|
||||
NextPayload: cookieFirst,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
}.marshal(cookieBody)
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, 2048)
|
||||
@@ -160,6 +172,14 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
return
|
||||
}
|
||||
if !destination.IP.Equal(second.IP) {
|
||||
cookieWire, marshalErr := marshalSOCKS5Datagram(first, cookieResponse)
|
||||
if marshalErr == nil {
|
||||
_, marshalErr = relay.WriteToUDP(cookieWire, peer)
|
||||
}
|
||||
if marshalErr != nil {
|
||||
serverDone <- marshalErr
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
wire, marshalErr := marshalSOCKS5Datagram(second, response)
|
||||
@@ -186,6 +206,31 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIKEResponseMatchesCookieChallengeWithZeroResponderSPI(t *testing.T) {
|
||||
request := ikeHeader{
|
||||
InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}
|
||||
first, body, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, []byte{0x10, 0x20, 0x30, 0x40}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := ikeHeader{
|
||||
InitiatorSPI: request.InitiatorSPI,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
MessageID: 0,
|
||||
NextPayload: first,
|
||||
}.marshal(body)
|
||||
if !ikeResponseMatchesRequest(response, request) {
|
||||
t.Fatal("IKE COOKIE response with zero Responder SPI was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKS5RoundTripSkipsStaleAndESPDatagrams(t *testing.T) {
|
||||
relay, err := net.ListenUDP(
|
||||
"udp",
|
||||
|
||||
@@ -59,6 +59,7 @@ const (
|
||||
notifyMOBIKESupported = 16396
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyCookie = 16390
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyDeviceIdentity = 41101
|
||||
notifyInvalidKE = 17
|
||||
|
||||
Reference in New Issue
Block a user