476 lines
14 KiB
Go
476 lines
14 KiB
Go
// 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)<<capability.bit) != 0 {
|
|
names = append(names, capability.name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func resolveProcessPath(procRoot string, pid int, dirFD int64, path string) string {
|
|
if filepath.IsAbs(path) {
|
|
return filepath.Clean(path)
|
|
}
|
|
processRoot := filepath.Join(procRoot, fmt.Sprintf("%d", pid))
|
|
baseLink := filepath.Join(processRoot, "cwd")
|
|
if dirFD >= 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)
|