// SPDX-License-Identifier: GPL-3.0-or-later package ebpfsource import ( "bufio" "bytes" "encoding/binary" "encoding/hex" "errors" "fmt" "io" "net" "os" "path/filepath" "slices" "strconv" "strings" "github.com/cilium/ebpf" "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/perf" "github.com/cilium/ebpf/rlimit" "golang.org/x/sys/unix" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events" ) const ( syscallMprotect uint32 = iota + 1 syscallMmap syscallMemfdCreate syscallPtrace syscallPrctl syscallSeccomp syscallProcessVMReadv syscallProcessVMWritev syscallMlock syscallMlock2 syscallMlockall hostSetuid hostSetgid hostChmod hostFchmodat hostChown hostLchown hostFchownat hostCapset hostFinitModule hostFileWrite hostTCPConnect hostBind hostListen hostAccept hostAccept4 ) var syscallNames = map[uint32]string{ syscallMprotect: "mprotect", syscallMmap: "mmap", syscallMemfdCreate: "memfd_create", syscallPtrace: "ptrace", syscallPrctl: "prctl", syscallSeccomp: "seccomp", syscallProcessVMReadv: "process_vm_readv", syscallProcessVMWritev: "process_vm_writev", syscallMlock: "mlock", syscallMlock2: "mlock2", syscallMlockall: "mlockall", } type syscallEventRaw struct { PID uint32 PPID uint32 UID uint32 SyscallID uint32 Comm [16]int8 Args [6]uint64 Text [80]int8 } type SyscallLostSamplesError struct { Count uint64 } // SecurityEvent is the typed result of the shared filtered syscall transport. // Exactly one field is populated. type SecurityEvent struct { Syscall *events.SyscallEvent Host *events.HostEvent pathDirFD int64 } func (e SyscallLostSamplesError) Error() string { return fmt.Sprintf("syscall perf buffer lost %d samples", e.Count) } // SyscallSource filters raw syscall entry events in-kernel using a map of // architecture-specific syscall numbers, then exposes transport-neutral // SyscallEvent values to the existing rule engine. type SyscallSource struct { objects syscallObjects links []link.Link reader *perf.Reader procRoot string } func OpenSyscallSource(procRoot string) (*SyscallSource, error) { if procRoot == "" { procRoot = "/proc" } numbers := monitoredSyscalls() memlockErr := rlimit.RemoveMemlock() var objects syscallObjects if err := loadSyscallObjects(&objects, nil); err != nil { if memlockErr != nil { return nil, fmt.Errorf("load syscall BPF objects: %w (memlock adjustment: %v)", err, memlockErr) } return nil, fmt.Errorf("load syscall BPF objects: %w", err) } for number, identifier := range numbers { if err := objects.MonitoredSyscalls.Update(number, identifier, ebpf.UpdateAny); err != nil { objects.Close() return nil, fmt.Errorf("configure syscall %d: %w", number, err) } } for _, comm := range events.HostInterpreterComms() { var key [16]byte copy(key[:], comm) if err := objects.MonitoredHostComms.Update(key, uint32(1), ebpf.UpdateAny); err != nil { objects.Close() return nil, fmt.Errorf("configure write comm %q: %w", comm, err) } } enterLink, err := link.Tracepoint("raw_syscalls", "sys_enter", objects.TraceSysEnter, nil) if err != nil { objects.Close() return nil, fmt.Errorf("attach raw syscall tracepoint: %w", err) } exitLink, err := link.Tracepoint("raw_syscalls", "sys_exit", objects.TraceSysExit, nil) if err != nil { enterLink.Close() objects.Close() return nil, fmt.Errorf("attach raw syscall exit tracepoint: %w", err) } reader, err := perf.NewReader(objects.SyscallEvents, 64*4096) if err != nil { exitLink.Close() enterLink.Close() objects.Close() return nil, fmt.Errorf("open syscall perf reader: %w", err) } return &SyscallSource{objects: objects, links: []link.Link{enterLink, exitLink}, reader: reader, procRoot: procRoot}, nil } func (s *SyscallSource) Read() (SecurityEvent, error) { record, err := s.reader.Read() if err != nil { return SecurityEvent{}, err } if record.LostSamples != 0 { return SecurityEvent{}, SyscallLostSamplesError{Count: record.LostSamples} } securityEvent, err := decodeSecuritySample(record.RawSample) if err == nil && securityEvent.Host != nil { if securityEvent.Host.Path != "" { securityEvent.Host.Path = resolveProcessPath( s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD, securityEvent.Host.Path, ) } else if securityEvent.Host.Event == "module_load" || securityEvent.Host.Event == "file_write" { securityEvent.Host.Path = resolveProcessFD(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD) if securityEvent.Host.Event == "module_load" && securityEvent.Host.Path != "" { securityEvent.Host.ModuleName = filepath.Base(strings.TrimSuffix(securityEvent.Host.Path, " (deleted)")) } } if securityEvent.Host.Event == "tcp_connect" && !isTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD) { securityEvent.Host = nil } if securityEvent.Host != nil && (securityEvent.Host.Event == "listen" || securityEvent.Host.Event == "accept") { local, peer, ok := lookupTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD) if !ok { securityEvent.Host = nil } else { securityEvent.Host.LocalIP, securityEvent.Host.LocalPort = local.IP, local.Port securityEvent.Host.PeerIP, securityEvent.Host.PeerPort = peer.IP, peer.Port } } } return securityEvent, err } func decodeSecuritySample(sample []byte) (SecurityEvent, error) { var raw syscallEventRaw if len(sample) != binary.Size(raw) { return SecurityEvent{}, fmt.Errorf("syscall event size %d, want %d", len(sample), binary.Size(raw)) } if err := binary.Read(bytes.NewReader(sample), binary.NativeEndian, &raw); err != nil { return SecurityEvent{}, fmt.Errorf("decode syscall event: %w", err) } if raw.SyscallID == hostSetuid || raw.SyscallID == hostSetgid { event := events.HostEvent{ PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID), Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1, } if raw.SyscallID == hostSetuid { event.Event = "setuid" event.TargetUID = int(raw.Args[0]) } else { event.Event = "setgid" event.TargetGID = int(raw.Args[0]) } return SecurityEvent{Host: &event}, nil } if raw.SyscallID >= hostChmod && raw.SyscallID <= hostFchownat { event := events.HostEvent{ PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID), Comm: cString(raw.Comm[:]), Path: cString(raw.Text[:]), TargetUID: -1, TargetGID: -1, } switch raw.SyscallID { case hostChmod, hostFchmodat: event.Event = "chmod" event.Mode = int(raw.Args[0]) case hostChown, hostLchown, hostFchownat: event.Event = "chown" event.TargetUID = int(raw.Args[0]) event.TargetGID = int(raw.Args[1]) } return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[2])}, nil } if raw.SyscallID == hostCapset { event := events.HostEvent{ Event: "capset", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID), Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1, Capabilities: capabilityNames(raw.Args[0]), } return SecurityEvent{Host: &event}, nil } if raw.SyscallID == hostFinitModule { event := events.HostEvent{ Event: "module_load", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID), Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1, } return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil } if raw.SyscallID == hostFileWrite { event := events.HostEvent{ Event: "file_write", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID), Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1, } return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil } if raw.SyscallID == hostTCPConnect || raw.SyscallID == hostBind { event := events.HostEvent{ PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID), Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1, } address := socketAddress(raw) if raw.SyscallID == hostTCPConnect { event.Event, event.PeerIP, event.PeerPort = "tcp_connect", address, int(raw.Args[1]) } else { event.Event, event.LocalIP, event.LocalPort = "bind", address, int(raw.Args[1]) } return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil } if raw.SyscallID >= hostListen && raw.SyscallID <= hostAccept4 { eventName := "accept" if raw.SyscallID == hostListen { eventName = "listen" } event := events.HostEvent{ Event: eventName, PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID), Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1, } return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil } name, ok := syscallNames[raw.SyscallID] if !ok { return SecurityEvent{}, fmt.Errorf("unknown canonical syscall id %d", raw.SyscallID) } event := events.SyscallEvent{ PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID), Comm: cString(raw.Comm[:]), Syscall: name, Args: raw.Args, Text: cString(raw.Text[:]), } return SecurityEvent{Syscall: &event}, nil } func decodeSyscallSample(sample []byte) (events.SyscallEvent, error) { securityEvent, err := decodeSecuritySample(sample) if err != nil { return events.SyscallEvent{}, err } if securityEvent.Syscall == nil { return events.SyscallEvent{}, fmt.Errorf("sample is a typed host event") } return *securityEvent.Syscall, nil } func (s *SyscallSource) Close() error { var failures []error if s.reader != nil { failures = append(failures, s.reader.Close()) } for _, attached := range s.links { if attached != nil { failures = append(failures, attached.Close()) } } failures = append(failures, s.objects.Close()) return errors.Join(failures...) } func monitoredSyscalls() map[uint64]uint32 { numbers := map[uint64]uint32{ unix.SYS_MPROTECT: syscallMprotect, unix.SYS_MMAP: syscallMmap, unix.SYS_MEMFD_CREATE: syscallMemfdCreate, unix.SYS_PTRACE: syscallPtrace, unix.SYS_PRCTL: syscallPrctl, unix.SYS_SECCOMP: syscallSeccomp, unix.SYS_PROCESS_VM_READV: syscallProcessVMReadv, unix.SYS_PROCESS_VM_WRITEV: syscallProcessVMWritev, unix.SYS_MLOCK: syscallMlock, unix.SYS_MLOCK2: syscallMlock2, unix.SYS_MLOCKALL: syscallMlockall, unix.SYS_SETUID: hostSetuid, unix.SYS_SETGID: hostSetgid, unix.SYS_FCHMODAT: hostFchmodat, unix.SYS_FCHOWNAT: hostFchownat, unix.SYS_CAPSET: hostCapset, unix.SYS_FINIT_MODULE: hostFinitModule, unix.SYS_WRITE: hostFileWrite, unix.SYS_WRITEV: hostFileWrite, unix.SYS_PWRITE64: hostFileWrite, unix.SYS_PWRITEV: hostFileWrite, unix.SYS_PWRITEV2: hostFileWrite, unix.SYS_CONNECT: hostTCPConnect, unix.SYS_BIND: hostBind, unix.SYS_LISTEN: hostListen, unix.SYS_ACCEPT4: hostAccept4, } for number, identifier := range legacyPermissionSyscalls() { numbers[number] = identifier } for number, identifier := range legacyNetworkSyscalls() { numbers[number] = identifier } return numbers } func socketAddress(raw syscallEventRaw) string { switch raw.Args[0] { case unix.AF_INET: bytes := make([]byte, 4) binary.NativeEndian.PutUint32(bytes, uint32(raw.Args[2])) return net.IP(bytes).String() case unix.AF_INET6: bytes := make([]byte, 16) for index := range bytes { bytes[index] = byte(raw.Text[index]) } return net.IP(bytes).String() default: return "" } } func capabilityNames(effective uint64) []string { known := []struct { bit uint name string }{ {0, "CAP_CHOWN"}, {1, "CAP_DAC_OVERRIDE"}, {2, "CAP_DAC_READ_SEARCH"}, {12, "CAP_NET_ADMIN"}, {13, "CAP_NET_RAW"}, {16, "CAP_SYS_MODULE"}, {19, "CAP_SYS_PTRACE"}, {21, "CAP_SYS_ADMIN"}, } names := make([]string, 0) for _, capability := range known { if effective&(uint64(1)<= 0 { baseLink = filepath.Join(processRoot, "fd", fmt.Sprintf("%d", dirFD)) } base, err := os.Readlink(baseLink) if err != nil || !filepath.IsAbs(base) { return path } return filepath.Clean(filepath.Join(base, path)) } func resolveProcessFD(procRoot string, pid int, fd int64) string { if fd < 0 { return "" } path, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd))) if err != nil || !filepath.IsAbs(path) { return "" } return filepath.Clean(path) } func isTCPSocket(procRoot string, pid int, fd int64) bool { _, _, ok := lookupTCPSocket(procRoot, pid, fd) return ok } type socketEndpoint struct { IP string Port int } func lookupTCPSocket(procRoot string, pid int, fd int64) (socketEndpoint, socketEndpoint, bool) { target, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd))) if err != nil || !strings.HasPrefix(target, "socket:[") || !strings.HasSuffix(target, "]") { return socketEndpoint{}, socketEndpoint{}, false } inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]") for _, name := range []string{"tcp", "tcp6"} { file, err := os.Open(filepath.Join(procRoot, "net", name)) if err != nil { continue } scanner := bufio.NewScanner(file) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) > 9 && fields[9] == inode { local, localErr := decodeProcEndpoint(fields[1], name == "tcp6") peer, peerErr := decodeProcEndpoint(fields[2], name == "tcp6") file.Close() return local, peer, localErr == nil && peerErr == nil } } file.Close() } return socketEndpoint{}, socketEndpoint{}, false } func decodeProcEndpoint(value string, ipv6 bool) (socketEndpoint, error) { addressHex, portHex, ok := strings.Cut(value, ":") if !ok { return socketEndpoint{}, fmt.Errorf("invalid proc endpoint %q", value) } address, err := hex.DecodeString(addressHex) if err != nil || len(address) != 4 && len(address) != 16 { return socketEndpoint{}, fmt.Errorf("invalid proc address %q", addressHex) } if ipv6 { for offset := 0; offset < len(address); offset += 4 { slices.Reverse(address[offset : offset+4]) } } else { slices.Reverse(address) } port, err := strconv.ParseUint(portHex, 16, 16) if err != nil { return socketEndpoint{}, err } return socketEndpoint{IP: net.IP(address).String(), Port: int(port)}, nil } var _ io.Closer = (*SyscallSource)(nil)