free5GC's SMF UPI POST /upi/v1/upNodesLinks exits the SMF process on overlapping UE pools (unauthenticated, reachable Fatalf)
Description
### Summary free5GC's SMF mounts the UPI management route group without inbound OAuth2 middleware (same root cause as free5gc/free5gc#887). The POST /upi/v1/upNodesLinks create-or-update handler accepts attacker-controlled JSON and passes it directly into UpNodesFromConfiguration(), which calls logger.InitLog.Fatalf(...) on several validation failures. One confirmed path is the UE-IP-pool overlap check: a single unauthenticated POST that adds a new UPF whose pool overlaps an existing UPF terminates the entire SMF process (docker ps shows Exited (1)), not just the goroutine. This is a stronger sink than free5gc/free5gc#905: that one panics inside the request goroutine and Gin recovers; this one calls Fatalf which is os.Exit(1)-equivalent and kills the whole SMF process, dropping all of SMF's SBI surface (PDU-session establishment, UE policy lookups, etc.) until the process is restarted.
Details
Validated against the SMF container in the official Docker compose lab. - Source repo tag: v4.2.1 - Running Docker image: free5gc/smf:v4.2.1 - Runtime SMF commit: 8385c00a - Docker validation date: 2026-03-22 local (container log timestamp 2026-03-21T23:47:07Z) - SMF endpoint: http://10.100.200.6:8000
The broader UPI auth gap (#887) lets the unauthenticated POST reach the create/update handler. From there:
Vulnerable handler dispatches into topology parsing: `` POST /upi/v1/upNodesLinks -> UpNodesFromConfiguration() -> isOverlap(allUEIPPools) -> logger.InitLog.Fatalf("overlap cidr value between UPFs") ``
Code evidence (paths in free5gc/smf): - UPI group mounted WITHOUT auth middleware (preconditions for unauthenticated reachability): - NFs/smf/internal/sbi/server.go:76 - NFs/smf/internal/sbi/server.go:78 - Create-or-update handler accepts attacker JSON and forwards it to UpNodesFromConfiguration(): - NFs/smf/internal/sbi/api_upi.go:60 - NFs/smf/internal/sbi/api_upi.go:72 - Pool parsing (input from attacker JSON): - NFs/smf/internal/context/user_plane_information.go:413 - Overlap check that calls Fatalf: - NFs/smf/internal/context/user_plane_information.go:479
The same unauthenticated POST path also reaches sibling Fatalf calls for invalid-pool and static-pool-exclusion failures, so this is not a one-off code smell -- it is a class of attacker-reachable Fatalf call sites on a single unauthenticated handler: - NFs/smf/internal/context/user_plane_information.go:416 - NFs/smf/internal/context/user_plane_information.go:424 - NFs/smf/internal/context/user_plane_information.go:430
PoC
Reproduced end-to-end against the running SMF at http://10.100.200.6:8000.
1. Trigger: unauthenticated POST that adds a UPF with a UE pool overlapping the default UPF (10.60.0.0/16): `` curl -i -X POST http://10.100.200.6:8000/upi/v1/upNodesLinks \ -H 'Content-Type: application/json' \ --data '{"links":[{"A":"gNB1","B":"UPF-OVERLAP-20260322"}],"upNodes":{"UPF-OVERLAP-20260322":{"type":"UPF","nodeID":"198.51.100.20","addr":"198.51.100.20","sNssaiUpfInfos":[{"sNssai":{"sst":1,"sd":"010203"},"dnnUpfInfoList":[{"dnn":"internet","pools":[{"cidr":"10.60.0.0/16"}]}]}]}}}' ``
Client-side observation (server died mid-request, no HTTP response written): `` curl: (52) Empty reply from server ``
2. Confirm the SMF container exited: `` docker ps -a --filter name=smf --format '{{.Names}}\t{{.Status}}' ``
smf Exited (1) 9 seconds ago
3. SMF container logs (docker logs --tail 80 smf) show the FATA line that terminated the process: `` [FATA][SMF][Init] overlap cidr value between UPFs ``
Impact
Unauthenticated process-kill DoS on the SMF management plane.
- Missing inbound authentication (CWE-306) and authorization (CWE-862) on the
UPIroute group makes the trigger reachable to any off-path network attacker who can reach SMF on the SBI -- no token, no UE state needed. The same-instancensmf-oamreturning401(see free5gc/free5gc#887) proves OAuth middleware is wired in for other SMF route groups and only missing on UPI. - Reachable assertion / fail-fast (CWE-617): topology parsing calls
logger.InitLog.Fatalf(...)on attacker-influenced validation failures.Fatalfisos.Exit(1)-equivalent -- it skips Gin's recovery, the deferred handlers, and kills the whole SMF process. This is materially worse than the related panic-DoS in free5gc/free5gc#905, which Gin recovers from at the goroutine level.
Any party that can reach SMF on the SBI can: - Send one unauthenticated POST with an overlapping UE pool and immediately terminate the SMF process, dropping all of SMF's SBI surface (PDU-session establishment, UE policy interactions) until SMF is restarted. - Repeat the trigger after every restart to sustain the outage. - Use sibling Fatalf paths (invalid-pool, static-pool exclusion) to sustain the same DoS even if the overlap check is hardened in isolation, because the underlying defect is using Fatalf for request-time validation on an unauthenticated handler.
No Confidentiality impact (the crash returns no data to the attacker). No persistent Integrity impact (the topology updates are in-memory and are lost when SMF dies). The whole impact concentrates in Availability: complete loss of SMF service via a single unauthenticated request.
Affected: free5gc v4.2.1.
Upstream issue: https://github.com/free5gc/free5gc/issues/906 Upstream fix: https://github.com/free5gc/smf/pull/203
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/free5gc/smfGo | <= 1.4.3 | — |
Affected products
1Patches
1e0974e07ddabMerge pull request #203 from DBGR18/fix/906
10 files changed · +117 −40
internal/context/context.go+10 −6 modified@@ -123,10 +123,9 @@ func AllocateLocalSEID() uint64 { return atomic.AddUint64(&smfContext.LocalSEIDCount, 1) } -func InitSmfContext(config *factory.Config) { +func InitSmfContext(config *factory.Config) error { if config == nil { - logger.CtxLog.Error("Config is nil") - return + return fmt.Errorf("config is nil") } logger.CtxLog.Infof("smfconfig Info: Version[%s] Description[%s]", config.Info.Version, config.Info.Description) @@ -137,8 +136,7 @@ func InitSmfContext(config *factory.Config) { sbi := configuration.Sbi if sbi == nil { - logger.CtxLog.Errorln("Configuration needs \"sbi\" value") - return + return fmt.Errorf("configuration needs \"sbi\" value") } else { smfContext.URIScheme = models.UriScheme(sbi.Scheme) smfContext.RegisterIPv4 = factory.SmfSbiDefaultIPv4 // default localhost @@ -242,7 +240,11 @@ func InitSmfContext(config *factory.Config) { smfContext.SupportedPDUSessionType = "IPv4" - smfContext.UserPlaneInformation = NewUserPlaneInformation(&configuration.UserPlaneInformation) + userPlaneInformation, err := NewUserPlaneInformation(&configuration.UserPlaneInformation) + if err != nil { + return fmt.Errorf("initialize user plane information failed: %w", err) + } + smfContext.UserPlaneInformation = userPlaneInformation smfContext.ChargingIDGenerator = idgenerator.NewGenerator(1, math.MaxUint32) @@ -253,6 +255,8 @@ func InitSmfContext(config *factory.Config) { TeidGenerator = idgenerator.NewGenerator(1, math.MaxUint32) smfContext.Ues = InitSmfUeData() + + return nil } func InitSMFUERouting(routingConfig *factory.RoutingConfig) {
internal/context/sm_context_policy_test.go+7 −3 modified@@ -111,7 +111,9 @@ var testConfig = factory.Config{ } func initConfig() { - smf_context.InitSmfContext(&testConfig) + if err := smf_context.InitSmfContext(&testConfig); err != nil { + panic(err) + } factory.SmfConfig = &testConfig } @@ -655,7 +657,9 @@ func TestApplyPccRules(t *testing.T) { } smfContext := smf_context.GetSelf() - smfContext.UserPlaneInformation = smf_context.NewUserPlaneInformation(&userPlaneConfig) + userPlaneInformation, err := smf_context.NewUserPlaneInformation(&userPlaneConfig) + require.NoError(t, err) + smfContext.UserPlaneInformation = userPlaneInformation for _, n := range smfContext.UserPlaneInformation.UPFs { n.UPF.AssociationContext = context.Background() } @@ -713,7 +717,7 @@ func TestApplyPccRules(t *testing.T) { Downlink: "1 Gbps", }, } - err := smctx.AllocUeIP() + err = smctx.AllocUeIP() require.NoError(t, err) err = smctx.SelectDefaultDataPath() require.NoError(t, err)
internal/context/ue_datapath_test.go+5 −3 modified@@ -16,7 +16,9 @@ var config = configuration func TestNewUEPreConfigPaths(t *testing.T) { smfContext := context.GetSelf() - smfContext.UserPlaneInformation = context.NewUserPlaneInformation(config) + userPlaneInformation, err := context.NewUserPlaneInformation(config) + require.NoError(t, err) + smfContext.UserPlaneInformation = userPlaneInformation fmt.Println("Start") testcases := []struct { name string @@ -150,8 +152,8 @@ func TestNewUEPreConfigPaths(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - retUePreConfigPaths, err := context.NewUEPreConfigPaths(tc.inPaths) - require.Nil(t, err) + retUePreConfigPaths, preConfigErr := context.NewUEPreConfigPaths(tc.inPaths) + require.Nil(t, preConfigErr) require.NotNil(t, retUePreConfigPaths.PathIDGenerator) for pathIndex, path := range tc.inPaths { retDataPath := retUePreConfigPaths.DataPathPool[int64(pathIndex+1)]
internal/context/user_plane_information.go+72 −19 modified@@ -2,6 +2,7 @@ package context import ( "errors" + "fmt" "math/rand" "net" "reflect" @@ -80,7 +81,7 @@ func AllocateUPFID() { } // NewUserPlaneInformation process the configuration then returns a new instance of UserPlaneInformation -func NewUserPlaneInformation(upTopology *factory.UserPlaneInformation) *UserPlaneInformation { +func NewUserPlaneInformation(upTopology *factory.UserPlaneInformation) (*UserPlaneInformation, error) { nodePool := make(map[string]*UPNode) upfPool := make(map[string]*UPNode) anPool := make(map[string]*UPNode) @@ -142,7 +143,7 @@ func NewUserPlaneInformation(upTopology *factory.UserPlaneInformation) *UserPlan for _, pool := range dnnInfoConfig.Pools { ueIPPool := NewUEIPPool(pool) if ueIPPool == nil { - logger.InitLog.Fatalf("invalid pools value: %+v", pool) + return nil, fmt.Errorf("invalid pools value: %+v", pool) } else { ueIPPools = append(ueIPPools, ueIPPool) allUEIPPools = append(allUEIPPools, ueIPPool) @@ -151,13 +152,13 @@ func NewUserPlaneInformation(upTopology *factory.UserPlaneInformation) *UserPlan for _, staticPool := range dnnInfoConfig.StaticPools { staticUeIPPool := NewUEIPPool(staticPool) if staticUeIPPool == nil { - logger.InitLog.Fatalf("invalid pools value: %+v", staticPool) + return nil, fmt.Errorf("invalid pools value: %+v", staticPool) } else { staticUeIPPools = append(staticUeIPPools, staticUeIPPool) for _, dynamicUePool := range ueIPPools { if dynamicUePool.ueSubNet.Contains(staticUeIPPool.ueSubNet.IP) { if err := dynamicUePool.Exclude(staticUeIPPool); err != nil { - logger.InitLog.Fatalf("exclude static Pool[%s] failed: %v", + return nil, fmt.Errorf("exclude static Pool[%s] failed: %v", staticUeIPPool.ueSubNet, err) } } @@ -200,7 +201,7 @@ func NewUserPlaneInformation(upTopology *factory.UserPlaneInformation) *UserPlan } if isOverlap(allUEIPPools) { - logger.InitLog.Fatalf("overlap cidr value between UPFs") + return nil, fmt.Errorf("overlap cidr value between UPFs") } for _, link := range upTopology.Links { @@ -229,7 +230,7 @@ func NewUserPlaneInformation(upTopology *factory.UserPlaneInformation) *UserPlan DefaultUserPlanePathToUPF: make(map[string]map[string][]*UPNode), } - return userplaneInformation + return userplaneInformation, nil } func (upi *UserPlaneInformation) UpNodesToConfiguration() map[string]*factory.UPNode { @@ -358,13 +359,51 @@ func (upi *UserPlaneInformation) LinksToConfiguration() []*factory.UPLink { return links } -func (upi *UserPlaneInformation) UpNodesFromConfiguration(upTopology *factory.UserPlaneInformation) { +func (upi *UserPlaneInformation) UpNodesFromConfiguration(upTopology *factory.UserPlaneInformation) error { + candidateUPNodes := make(map[string]*UPNode, len(upi.UPNodes)) + for name, node := range upi.UPNodes { + candidateUPNodes[name] = node + } + + candidateUPFs := make(map[string]*UPNode, len(upi.UPFs)) + for name, node := range upi.UPFs { + candidateUPFs[name] = node + } + + candidateAccessNetwork := make(map[string]*UPNode, len(upi.AccessNetwork)) + for name, node := range upi.AccessNetwork { + candidateAccessNetwork[name] = node + } + + candidateUPFsID := make(map[string]string, len(upi.UPFsID)) + for name, id := range upi.UPFsID { + candidateUPFsID[name] = id + } + + candidateUPFsIPtoID := make(map[string]string, len(upi.UPFsIPtoID)) + for ip, id := range upi.UPFsIPtoID { + candidateUPFsIPtoID[ip] = id + } + + candidateUPFIPToName := make(map[string]string, len(upi.UPFIPToName)) + for ip, name := range upi.UPFIPToName { + candidateUPFIPToName[ip] = name + } + + createdUPFs := make([]*UPF, 0) + cleanupCreatedUPFs := func() { + for _, upf := range createdUPFs { + upfPool.Delete(upf.UUID()) + } + } + for name, node := range upTopology.UPNodes { - if _, ok := upi.UPNodes[name]; ok { + if _, ok := candidateUPNodes[name]; ok { logger.InitLog.Warningf("Node [%s] already exists in SMF.\n", name) continue } upNode := new(UPNode) + upNode.Name = name upNode.Type = UPNodeType(node.Type) switch upNode.Type { case UPNODE_UPF: @@ -397,6 +436,7 @@ func (upi *UserPlaneInformation) UpNodesFromConfiguration(upTopology *factory.Us } upNode.UPF = NewUPF(&upNode.NodeID, node.InterfaceUpfInfoList) + createdUPFs = append(createdUPFs, upNode.UPF) snssaiInfos := make([]*SnssaiUPFInfo, 0) for _, snssaiInfoConfig := range node.SNssaiInfos { snssaiInfo := &SnssaiUPFInfo{ @@ -413,21 +453,24 @@ func (upi *UserPlaneInformation) UpNodesFromConfiguration(upTopology *factory.Us for _, pool := range dnnInfoConfig.Pools { ueIPPool := NewUEIPPool(pool) if ueIPPool == nil { - logger.InitLog.Fatalf("invalid pools value: %+v", pool) + cleanupCreatedUPFs() + return fmt.Errorf("invalid pools value: %+v", pool) } else { ueIPPools = append(ueIPPools, ueIPPool) } } for _, pool := range dnnInfoConfig.StaticPools { ueIPPool := NewUEIPPool(pool) if ueIPPool == nil { - logger.InitLog.Fatalf("invalid pools value: %+v", pool) + cleanupCreatedUPFs() + return fmt.Errorf("invalid pools value: %+v", pool) } else { staticUeIPPools = append(staticUeIPPools, ueIPPool) for _, dynamicUePool := range ueIPPools { if dynamicUePool.ueSubNet.Contains(ueIPPool.ueSubNet.IP) { if err := dynamicUePool.Exclude(ueIPPool); err != nil { - logger.InitLog.Fatalf("exclude static Pool[%s] failed: %v", + cleanupCreatedUPFs() + return fmt.Errorf("exclude static Pool[%s] failed: %v", ueIPPool.ueSubNet, err) } } @@ -445,39 +488,49 @@ func (upi *UserPlaneInformation) UpNodesFromConfiguration(upTopology *factory.Us snssaiInfos = append(snssaiInfos, snssaiInfo) } upNode.UPF.SNssaiInfos = snssaiInfos - upi.UPFs[name] = upNode + candidateUPFs[name] = upNode // AllocateUPFID upfid := upNode.UPF.UUID() upfip := upNode.NodeID.ResolveNodeIdToIp().String() - upi.UPFsID[name] = upfid - upi.UPFsIPtoID[upfip] = upfid + candidateUPFsID[name] = upfid + candidateUPFsIPtoID[upfip] = upfid case UPNODE_AN: upNode.ANIP = net.ParseIP(node.ANIP) - upi.AccessNetwork[name] = upNode + candidateAccessNetwork[name] = upNode default: logger.InitLog.Warningf("invalid UPNodeType: %s\n", upNode.Type) } - upi.UPNodes[name] = upNode + candidateUPNodes[name] = upNode ipStr := upNode.NodeID.ResolveNodeIdToIp().String() - upi.UPFIPToName[ipStr] = name + candidateUPFIPToName[ipStr] = name } // overlap UE IP pool validation allUEIPPools := []*UeIPPool{} - for _, upf := range upi.UPFs { + for _, upf := range candidateUPFs { for _, snssaiInfo := range upf.UPF.SNssaiInfos { for _, dnn := range snssaiInfo.DnnList { allUEIPPools = append(allUEIPPools, dnn.UeIPPools...) } } } if isOverlap(allUEIPPools) { - logger.InitLog.Fatalf("overlap cidr value between UPFs") + cleanupCreatedUPFs() + return fmt.Errorf("overlap cidr value between UPFs") } + + upi.UPNodes = candidateUPNodes + upi.UPFs = candidateUPFs + upi.AccessNetwork = candidateAccessNetwork + upi.UPFsID = candidateUPFsID + upi.UPFsIPtoID = candidateUPFsIPtoID + upi.UPFIPToName = candidateUPFIPToName + + return nil } func (upi *UserPlaneInformation) LinksFromConfiguration(upTopology *factory.UserPlaneInformation) {
internal/context/user_plane_information_test.go+8 −4 modified@@ -150,7 +150,8 @@ var configuration = &factory.UserPlaneInformation{ } func TestNewUserPlaneInformation(t *testing.T) { - userplaneInformation := smf_context.NewUserPlaneInformation(configuration) + userplaneInformation, err := smf_context.NewUserPlaneInformation(configuration) + require.NoError(t, err) require.NotNil(t, userplaneInformation.AccessNetwork["GNodeB"]) @@ -249,7 +250,8 @@ func TestGenerateDefaultPath(t *testing.T) { }, } - userplaneInformation := smf_context.NewUserPlaneInformation(&config1) + userplaneInformation, err := smf_context.NewUserPlaneInformation(&config1) + require.NoError(t, err) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { pathExist := userplaneInformation.GenerateDefaultPath(tc.param) @@ -268,7 +270,8 @@ func TestSelectUPFAndAllocUEIP(t *testing.T) { expectedIPPool = append(expectedIPPool, net.ParseIP(fmt.Sprintf("10.60.0.%d", i)).To4()) } - userplaneInformation := smf_context.NewUserPlaneInformation(configuration) + userplaneInformation, err := smf_context.NewUserPlaneInformation(configuration) + require.NoError(t, err) for _, upf := range userplaneInformation.UPFs { upf.UPF.AssociationContext = context.Background() } @@ -485,7 +488,8 @@ var testCasesOfGetUEIPPool = []struct { } func TestGetUEIPPool(t *testing.T) { - userplaneInformation := smf_context.NewUserPlaneInformation(configForIPPoolAllocate) + userplaneInformation, err := smf_context.NewUserPlaneInformation(configForIPPoolAllocate) + require.NoError(t, err) for _, upf := range userplaneInformation.UPFs { upf.UPF.AssociationContext = context.Background() }
internal/pfcp/message/build_test.go+3 −1 modified@@ -37,7 +37,9 @@ var testNodeID = &pfcpType.NodeID{ } func initSmfContext() { - context.InitSmfContext(&testConfig) + if err := context.InitSmfContext(&testConfig); err != nil { + panic(err) + } } func initRuleList() ([]*context.PDR, []*context.FAR, []*context.BAR,
internal/sbi/api_upi.go+5 −1 modified@@ -69,7 +69,11 @@ func (s *Server) PostUpNodesLinks(c *gin.Context) { return } - upi.UpNodesFromConfiguration(&json) + if err := upi.UpNodesFromConfiguration(&json); err != nil { + c.Set(sbi.IN_PB_DETAILS_CTX_STR, http.StatusText(int(http.StatusBadRequest))) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } upi.LinksFromConfiguration(&json) for _, upf := range upi.UPFs {
internal/sbi/consumer/pcf_service_test.go+1 −1 modified@@ -31,7 +31,7 @@ var testConfig = factory.Config{ } func TestSendSMPolicyAssociationUpdateByUERequestModification(t *testing.T) { - smf_context.InitSmfContext(&testConfig) + require.NoError(t, smf_context.InitSmfContext(&testConfig)) testCases := []struct { name string
internal/sbi/processor/pdu_session_test.go+3 −1 modified@@ -119,7 +119,9 @@ var testConfig = factory.Config{ } func initConfig() { - smf_context.InitSmfContext(&testConfig) + if err := smf_context.InitSmfContext(&testConfig); err != nil { + panic(err) + } factory.SmfConfig = &testConfig }
internal/sbi/server.go+3 −1 modified@@ -48,7 +48,9 @@ func NewServer(smf ServerSmf, tlsKeyLogPath string) (*Server, error) { ServerSmf: smf, } - smf_context.InitSmfContext(factory.SmfConfig) + if err := smf_context.InitSmfContext(factory.SmfConfig); err != nil { + return nil, err + } // allocate id for each upf smf_context.AllocateUPFID() smf_context.InitSMFUERouting(factory.UERoutingConfig)
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5News mentions
0No linked articles in our index yet.