// SPDX-License-Identifier: GPL-3.0-or-later // Package rootcheck ports Sentinel's user-space anti-rootkit cross-view // checks. It cannot prove a kernel free of a rootkit: a sufficiently capable // implant can lie consistently to every local view. It does catch common // hiding faults by comparing independently collected kernel, procfs, sysfs, // socket-tool, and interface views. package rootcheck import ( "sort" "strconv" "strings" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) const ( SIDHiddenProcess = 100022 SIDHiddenModule = 100023 SIDHiddenPort = 100024 SIDPromiscuous = 100025 SIDKnownRootkitModule = 100028 SIDTaintedModule = 100029 SIDKernelTainted = 100030 SIDHiddenUDPPort = 100031 SIDHiddenRawSocket = 100034 SIDRawICMPSocket = 100035 SIDHiddenProtocolSocket = 100037 SIDPSHiddenProcess = 100038 ) var knownModules = map[string]bool{ "adore": true, "adore_ng": true, "azazel": true, "diamorphine": true, "ipsecs_kbeast": true, "kbeast": true, "knark": true, "reptile": true, "suterusu": true, "syslogk": true, } var taintFlags = map[int]string{ 0: "proprietary-module", 1: "forced-module-load", 2: "unsafe-smp", 3: "forced-module-unload", 4: "machine-check", 5: "bad-page", 6: "user-tainted", 7: "kernel-died-recently", 8: "acpi-override", 9: "kernel-warning", 10: "staging-driver", 11: "firmware-workaround", 12: "out-of-tree-module", 13: "unsigned-module", 14: "soft-lockup", 15: "live-patched", 16: "auxiliary-taint", 17: "randstruct-plugin", } var highRiskTaintBits = map[int]bool{1: true, 3: true, 7: true, 12: true, 13: true} var rawProtocolNames = map[string]int{"icmp": 1, "ipv6-icmp": 58, "icmp6": 58, "sctp": 132} // Views contains independently collected host observations. The two PID // callbacks preserve Python's race checks: a hidden-process candidate must // still be alive through the kernel while remaining absent from procfs; a // ps-only candidate must still be present in procfs. type Views struct { VisiblePIDs map[int]bool AlivePIDs map[int]bool PSPIDs map[int]bool PSAvailable bool PIDAlive func(int) bool ProcPIDExists func(int) bool ProcModules map[string]bool SysLiveModules map[string]bool ModuleTaints map[string]string KernelTaint int ProcTCPPorts map[int]bool SSTCPPorts map[int]bool ProcUDPPorts map[int]bool SSUDPPorts map[int]bool ProcRawProtocols map[int]bool SSRawProtocols map[int]bool ProcProtocolKinds map[string]bool SSProtocolKinds map[string]bool PromiscuousIfaces []string } // Options supplies explicit operator policy. The PID cap is enforced by the // collector; Check is deliberately pure so parity tests do not touch a host. type Options struct { AllowedModules map[string]bool } // Check turns a captured set of cross-views into Python-compatible alerts. func Check(views Views, options Options) []model.Alert { alerts := make([]model.Alert, 0) confirmed := make([]int, 0) for _, pid := range sortedIntDifference(views.AlivePIDs, views.VisiblePIDs) { alive := views.PIDAlive == nil || views.PIDAlive(pid) hidden := views.ProcPIDExists == nil || !views.ProcPIDExists(pid) if alive && hidden { confirmed = append(confirmed, pid) } } if len(confirmed) > 0 { shown := confirmed if len(shown) > 20 { shown = shown[:20] } alerts = append(alerts, model.Alert{SID: SIDHiddenProcess, Severity: "CRITICAL", Signature: "rootkit_hidden_process", Classtype: "rootkit-hidden-process", Key: "rk:hidproc:" + itoa(shown[0]), Detail: "process(es) alive but hidden from /proc: " + joinInts(shown), PIDs: shown}) } if views.PSAvailable { missing := make([]int, 0) for _, pid := range sortedIntDifference(views.VisiblePIDs, views.PSPIDs) { if views.ProcPIDExists == nil || views.ProcPIDExists(pid) { missing = append(missing, pid) } } if len(missing) > 0 { shown := missing if len(shown) > 20 { shown = shown[:20] } alerts = append(alerts, model.Alert{SID: SIDPSHiddenProcess, Severity: "HIGH", Signature: "rootkit_ps_hidden_process", Classtype: "rootkit-hidden-process", Key: "rk:pshidproc:" + itoa(shown[0]), Detail: "process(es) visible in /proc but missing from ps output (process tool may be hooked): " + joinInts(shown), PIDs: shown}) } } for _, module := range sortedStringDifference(views.SysLiveModules, views.ProcModules) { alerts = append(alerts, alert(SIDHiddenModule, "CRITICAL", "rootkit_hidden_module", "rootkit-hidden-module", "rk:hidmod:"+module, "kernel module live in /sys/module but hidden from /proc/modules: "+module)) } allModules := unionStrings(views.ProcModules, views.SysLiveModules) known := map[string]bool{} for module := range allModules { if knownModules[normalizeModule(module)] { known[module] = true alerts = append(alerts, alert(SIDKnownRootkitModule, "CRITICAL", "rootkit_known_module", "rootkit-known-module", "rk:knownmod:"+module, "known LKM rootkit module name loaded or visible: "+module)) } } for _, module := range sortedKeysString(views.ModuleTaints) { if moduleAllowed(options.AllowedModules, module) || known[module] { continue } taint := views.ModuleTaints[module] alerts = append(alerts, alert(SIDTaintedModule, "HIGH", "rootkit_tainted_module", "rootkit-tainted-module", "rk:taintmod:"+module, "kernel module has taint marker '"+taint+"' (out-of-tree/proprietary/unsigned module): "+module)) } if views.KernelTaint != 0 { severity := "MEDIUM" for bit := range highRiskTaintBits { if views.KernelTaint&(1< 0 { alerts = append(alerts, alert(SIDRawICMPSocket, "HIGH", "raw_icmp_socket", "raw-socket-backdoor", "rk:rawicmp:"+itoa(icmp[0]), "persistent raw ICMP socket protocol(s) visible in /proc/net/raw: "+joinInts(icmp)+" — validate authorized ping/monitoring tools or ICMP knockers")) } protocols := sortedStringDifference(views.ProcProtocolKinds, views.SSProtocolKinds) if len(protocols) > 0 { alerts = append(alerts, alert(SIDHiddenProtocolSocket, "HIGH", "rootkit_hidden_protocol_socket", "rootkit-hidden-socket", "rk:hidproto:"+protocols[0], "special protocol socket family present in /proc/net but absent from ss output (tool may be hooked): "+strings.Join(protocols, ", "))) } for _, iface := range sortedStrings(views.PromiscuousIfaces) { alerts = append(alerts, alert(SIDPromiscuous, "MEDIUM", "promiscuous_interface", "network-sniffer", "rk:promisc:"+iface, "interface in promiscuous mode (possible sniffer): "+iface)) } return alerts } func appendPortAlert(alerts []model.Alert, values []int, sid int, signature, keyPrefix, detailPrefix, class string) []model.Alert { if len(values) == 0 { return alerts } return append(alerts, alert(sid, "HIGH", signature, class, keyPrefix+itoa(values[0]), detailPrefix+joinInts(values))) } func alert(sid int, severity, signature, class, key, detail string) model.Alert { return model.Alert{SID: sid, Severity: severity, Signature: signature, Classtype: class, Key: key, Detail: detail, PIDs: []int{}} } func normalizeModule(value string) string { return strings.ReplaceAll(strings.ToLower(value), "-", "_") } func moduleAllowed(allowed map[string]bool, module string) bool { for name := range allowed { if normalizeModule(name) == normalizeModule(module) { return true } } return false } func itoa(value int) string { return strconv.Itoa(value) } func sortedIntDifference(left, right map[int]bool) []int { values := make([]int, 0) for value := range left { if !right[value] { values = append(values, value) } } sort.Ints(values) return values } func sortedStringDifference(left, right map[string]bool) []string { values := make([]string, 0) for value := range left { if !right[value] { values = append(values, value) } } sort.Strings(values) return values } func sortedKeys[V any](source map[int]V) []int { values := make([]int, 0, len(source)) for value := range source { values = append(values, value) } sort.Ints(values) return values } func sortedStrings(values []string) []string { result := append([]string(nil), values...) sort.Strings(result) return result } func sortedKeysString[V any](source map[string]V) []string { values := make([]string, 0, len(source)) for value := range source { values = append(values, value) } sort.Strings(values) return values } func unionStrings(left, right map[string]bool) map[string]bool { result := map[string]bool{} for value := range left { result[value] = true } for value := range right { result[value] = true } return result } func decodeTaint(value int) []string { keys := make([]int, 0, len(taintFlags)) for bit := range taintFlags { keys = append(keys, bit) } sort.Ints(keys) values := make([]string, 0) for _, bit := range keys { if value&(1<