feat(go): port memory-map detector tranche

This commit is contained in:
Luna 2026-07-11 01:19:56 -07:00
parent c6f7219de8
commit 6a27e6e770
No known key found for this signature in database
13 changed files with 377 additions and 41 deletions

View file

@ -0,0 +1,153 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const (
SIDMemoryObfuscation = 100039
SIDProcessHidingLibrary = 100048
SIDProcessInjectionLibrary = 100049
)
var memoryWritablePrefixes = []string{"/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/"}
var memoryLibraryExtensions = []string{".so", ".so.", ".dylib"}
var memoryHideHints = []string{
"libhide", "libprocesshide", "process_hide", "proc_hide", "rootkit_hide",
}
// MemoryObfuscation ports the /proc/<pid>/maps detector. It reports one alert
// per signature and process, preserving Python's useful signal compression
// when a process has many suspicious mappings of the same kind.
func MemoryObfuscation(state model.State, cfg config.Config) []model.Alert {
alerts := make([]model.Alert, 0)
for _, process := range state.Processes {
comm := process.Comm
if comm == "" {
comm = "?"
}
seen := map[string]bool{}
for _, memory := range process.MemoryMaps {
signature, severity, detail := memoryIndicator(memory, cfg)
if signature == "" || seen[signature] {
continue
}
// JIT allowlisting applies only to generic executable memory. A
// library whose name suggests hiding or injection stays reviewable.
if signature == "memory_obfuscation" && cfg.MemoryObfuscationAllowComms[comm] {
continue
}
seen[signature] = true
sid, classtype := SIDMemoryObfuscation, "memory-obfuscation"
if signature == "process_hiding_library" {
sid, classtype = SIDProcessHidingLibrary, "process-hiding"
} else if signature == "process_injection_library" {
sid, classtype = SIDProcessInjectionLibrary, "process-injection"
}
alerts = append(alerts, model.Alert{
SID: sid, Severity: severity, Signature: signature,
Classtype: classtype,
Key: fmt.Sprintf("mem:%s:%d:%x-%x", signature, process.PID, memory.Start, memory.End),
Detail: fmt.Sprintf("pid=%d comm=%s %s range=%x-%x",
process.PID, comm, detail, memory.Start, memory.End),
PIDs: []int{process.PID},
})
}
}
return alerts
}
func memoryIndicator(memory model.MemoryMap, cfg config.Config) (string, string, string) {
path := memory.Path
if pathAllowed(path, cfg.MemoryObfuscationAllowPaths) {
return "", "MEDIUM", ""
}
if path != "" && hasMemoryHint(path) {
return "process_hiding_library", "CRITICAL",
fmt.Sprintf("mapped library name suggests process hiding: %s", path)
}
executable := strings.Contains(memory.Perms, "x")
writable := strings.Contains(memory.Perms, "w")
if executable && path != "" && writableLibrary(path) {
return "process_injection_library", "HIGH",
fmt.Sprintf("executable library mapped from writable/runtime path: %s", path)
}
if executable && (strings.Contains(path, "memfd:") || strings.Contains(path, "(deleted)")) {
return "memory_obfuscation", "CRITICAL",
fmt.Sprintf("executable transient mapping perms=%s path=%s", memory.Perms, displayPath(path))
}
if executable && writable {
return "memory_obfuscation", "HIGH",
fmt.Sprintf("writable+executable memory mapping perms=%s path=%s", memory.Perms, displayPath(path))
}
if executable && anonymousPath(path) {
return "memory_obfuscation", "HIGH",
fmt.Sprintf("executable anonymous memory mapping perms=%s path=%s", memory.Perms, displayPath(path))
}
return "", "MEDIUM", ""
}
func pathAllowed(path string, prefixes []string) bool {
for _, prefix := range prefixes {
if prefix != "" && strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func hasMemoryHint(path string) bool {
name := path
if index := strings.LastIndex(name, "/"); index >= 0 {
name = name[index+1:]
}
name = strings.ToLower(name)
for _, hint := range memoryHideHints {
if strings.Contains(name, hint) {
return true
}
}
return false
}
func writableLibrary(path string) bool {
lower := strings.ToLower(path)
allowedPrefix := false
for _, prefix := range memoryWritablePrefixes {
if strings.HasPrefix(lower, prefix) {
allowedPrefix = true
break
}
}
if !allowedPrefix {
return false
}
name := lower
if index := strings.LastIndex(name, "/"); index >= 0 {
name = name[index+1:]
}
for _, extension := range memoryLibraryExtensions {
if strings.Contains(name, extension) {
return true
}
}
return false
}
func anonymousPath(path string) bool {
return path == "" || path == "[heap]" || path == "[stack]" ||
strings.HasPrefix(path, "[anon") || strings.HasPrefix(path, "[stack:")
}
func displayPath(path string) string {
if path != "" {
return path
}
return "<anonymous>"
}

View file

@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestMemoryObfuscationMatchesPythonShapes(t *testing.T) {
cases := []struct {
name string
process model.Process
signature string
severity string
sid int
}{
{"memfd", model.Process{PID: 350, Comm: "hoxha", MemoryMaps: []model.MemoryMap{{
Start: 0x1000, End: 0x2000, Perms: "r-xp", Path: "/memfd:stage (deleted)",
}}}, "memory_obfuscation", "CRITICAL", SIDMemoryObfuscation},
{"anonymous-rwx", model.Process{PID: 351, Comm: "packer", MemoryMaps: []model.MemoryMap{{
Start: 0x3000, End: 0x4000, Perms: "rwxp",
}}}, "memory_obfuscation", "HIGH", SIDMemoryObfuscation},
{"hide-library", model.Process{PID: 352, Comm: "bash", MemoryMaps: []model.MemoryMap{{
Start: 0x5000, End: 0x6000, Perms: "r-xp", Path: "/usr/lib/libhide.so",
}}}, "process_hiding_library", "CRITICAL", SIDProcessHidingLibrary},
{"injection-library", model.Process{PID: 355, Comm: "sshd", MemoryMaps: []model.MemoryMap{{
Start: 0xb000, End: 0xc000, Perms: "r-xp", Path: "/tmp/libinject.so",
}}}, "process_injection_library", "HIGH", SIDProcessInjectionLibrary},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
alerts := MemoryObfuscation(model.State{Processes: []model.Process{tc.process}}, config.Default())
if len(alerts) != 1 {
t.Fatalf("expected one alert, got %#v", alerts)
}
if alerts[0].Signature != tc.signature || alerts[0].Severity != tc.severity || alerts[0].SID != tc.sid {
t.Fatalf("unexpected alert: %#v", alerts[0])
}
})
}
}
func TestMemoryObfuscationAllowlistsMatchPython(t *testing.T) {
cfg := config.Default()
if len(MemoryObfuscation(model.State{Processes: []model.Process{{
PID: 353, Comm: "node", MemoryMaps: []model.MemoryMap{{Perms: "rwxp"}},
}}}, cfg)) != 0 {
t.Fatal("generic executable mapping should honor the runtime allowlist")
}
cfg.MemoryObfuscationAllowPaths = []string{"/tmp/known-profiler/"}
if len(MemoryObfuscation(model.State{Processes: []model.Process{{
PID: 356, Comm: "python3", MemoryMaps: []model.MemoryMap{{
Perms: "r-xp", Path: "/tmp/known-profiler/libhook.so",
}},
}}}, cfg)) != 0 {
t.Fatal("configured mapping path should be silent")
}
alerts := MemoryObfuscation(model.State{Processes: []model.Process{{
PID: 354, Comm: "node", MemoryMaps: []model.MemoryMap{{
Perms: "r-xp", Path: "/tmp/libhide.so",
}},
}}}, config.Default())
if len(alerts) != 1 || alerts[0].Signature != "process_hiding_library" {
t.Fatalf("hide-library evidence must survive comm allowlist: %#v", alerts)
}
}