diff --git a/pkg/security/probe/probe_ebpf.go b/pkg/security/probe/probe_ebpf.go index f41db3c377e0..263b46a063c8 100644 --- a/pkg/security/probe/probe_ebpf.go +++ b/pkg/security/probe/probe_ebpf.go @@ -193,6 +193,14 @@ type EBPFProbe struct { // PrCtl and name truncation MetricNameTruncated *atomic.Uint64 + + // per-event scratch state — only safe because handleEvent is single-goroutine. + // onNewPCE / onCgroupUpdate are stored as function values rather than declared as + // methods because a `p.method` expression allocates a fresh method value on every + // call; binding them once at probe init keeps the per-event hot path allocation-free. + relatedEvents []*model.Event + onNewPCE func(*model.ProcessCacheEntry, error) + onCgroupUpdate func(*model.ProcessCacheEntry) } // GetUseRingBuffers returns p.useRingBuffers @@ -1112,9 +1120,51 @@ func (p *EBPFProbe) triggerNopEvent() error { return p.Erpc.Request(req) } +func (p *EBPFProbe) newRelatedProcessEvent(pce *model.ProcessCacheEntry, err error) *model.Event { + // all Execs are forwarded since used by AD. Forks are forwarded only if there are consumers + if !pce.IsExec && p.probe.eventConsumers[model.ForkEventType] == nil { + return nil + } + + var errResolution *path.ErrPathResolution + if err != nil && !errors.As(err, &errResolution) { + return nil + } + + relatedEvent := p.newEBPFPooledEventFromPCE(pce) + relatedEvent.Source = model.EventSourceRelated + + if errResolution != nil { + relatedEvent.SetPathResolutionError(&relatedEvent.ProcessCacheEntry.FileEvent, err) + } + + p.Resolvers.ProcessResolver.TryReparentFromProcfsLocked(pce, metrics.ReparentCallpathRelatedEvent, nil) + + return relatedEvent +} + +// newRelatedCGroupWriteEvent synthesizes a cgroup_write event when a process is +// observed in a cgroup that differs from the cached one. It backstops cgroup +// migrations that bypass cgroup.procs and therefore never trigger the real +// cgroup_write tracepoint (notably CLONE_INTO_CGROUP). +func (p *EBPFProbe) newRelatedCGroupWriteEvent(pce *model.ProcessCacheEntry) *model.Event { + relatedEvent := p.getPoolEvent() + + relatedEvent.Type = uint32(model.CgroupWriteEventType) + relatedEvent.Source = model.EventSourceRelated + relatedEvent.Timestamp = time.Now() + relatedEvent.TimestampRaw = uint64(p.Resolvers.TimeResolver.ComputeMonotonicTimestamp(relatedEvent.Timestamp)) + relatedEvent.ProcessCacheEntry = pce + relatedEvent.ProcessContext = &pce.ProcessContext + relatedEvent.CgroupWrite.File.PathKey = pce.CGroup.CGroupPathKey + relatedEvent.CgroupWrite.Pid = pce.Pid + + return relatedEvent +} + // setProcessContext set the process context, should return false if the event shouldn't be dispatched -func (p *EBPFProbe) setProcessContext(eventType model.EventType, event *model.Event, cgroupContext model.CGroupContext, newEntryCb func(entry *model.ProcessCacheEntry, err error)) bool { - entry, isResolved := p.fieldHandlers.ResolveProcessCacheEntry(event, newEntryCb) +func (p *EBPFProbe) setProcessContext(eventType model.EventType, event *model.Event, cgroupContext model.CGroupContext) bool { + entry, isResolved := p.fieldHandlers.ResolveProcessCacheEntry(event, p.onNewPCE) event.ProcessCacheEntry = entry if event.ProcessCacheEntry == nil { panic("should always return a process cache entry") @@ -1135,7 +1185,7 @@ func (p *EBPFProbe) setProcessContext(eventType model.EventType, event *model.Ev // cache, the process was reparented (e.g. subreaper). Update // the cache tree immediately using the authoritative kernel value. if event.PIDContext.PPid != 0 { - p.Resolvers.ProcessResolver.TryReparentFromKernelPPid(entry, event.PIDContext.PPid, newEntryCb) + p.Resolvers.ProcessResolver.TryReparentFromKernelPPid(entry, event.PIDContext.PPid, p.onNewPCE) } // If the kernel reports a different SID than the one in our @@ -1147,7 +1197,7 @@ func (p *EBPFProbe) setProcessContext(eventType model.EventType, event *model.Ev // Attempt to repair the lineage of processes that were orphaned // during subreaper reparenting (the exit tracepoint may fire // before the kernel has completed forget_original_parent). - p.Resolvers.ProcessResolver.TryReparentFromProcfs(entry, metrics.ReparentCallpathSetProcessContext, newEntryCb) + p.Resolvers.ProcessResolver.TryReparentFromProcfs(entry, metrics.ReparentCallpathSetProcessContext, p.onNewPCE) if _, err := entry.HasValidLineage(); err != nil { event.Error = &model.ErrProcessBrokenLineage{Err: err} @@ -1163,6 +1213,7 @@ func (p *EBPFProbe) setProcessContext(eventType model.EventType, event *model.Ev seclog.Debugf("Failed to resolve cgroup for pid %d: %+v", entry.Pid, cgroupContext.CGroupPathKey) } else { p.Resolvers.ProcessResolver.UpdateProcessContexts(entry, cacheEntry.GetCGroupContext(), cacheEntry.GetContainerContext()) + p.onCgroupUpdate(entry) } } } @@ -1218,34 +1269,22 @@ func (p *EBPFProbe) handleEvent(CPU int, data []byte) { p.replayEvents(false) } + // Return any related events to the pool on every exit path (success, early + // return, or panic). Nil-out the backing slots so completed entries don't + // pin pool memory between calls. + defer func() { + for i, re := range p.relatedEvents { + p.putBackPoolEvent(re) + p.relatedEvents[i] = nil + } + p.relatedEvents = p.relatedEvents[:0] + }() + var ( offset = 0 event = p.zeroEvent() dataLen = uint64(len(data)) - relatedEvents []*model.Event cgroupContext model.CGroupContext - newEntryCb = func(entry *model.ProcessCacheEntry, err error) { - // all Execs will be forwarded since used by AD. Forks will be forwarded all if there are consumers - if !entry.IsExec && p.probe.eventConsumers[model.ForkEventType] == nil { - return - } - - relatedEvent := p.newEBPFPooledEventFromPCE(entry) - relatedEvent.Source = model.EventSourceRelated - - if err != nil { - var errResolution *path.ErrPathResolution - if errors.As(err, &errResolution) { - relatedEvent.SetPathResolutionError(&relatedEvent.ProcessCacheEntry.FileEvent, err) - } else { - return - } - } - - p.Resolvers.ProcessResolver.TryReparentFromProcfsLocked(entry, metrics.ReparentCallpathRelatedEvent, nil) - - relatedEvents = append(relatedEvents, relatedEvent) - } ) read, err := event.UnmarshalBinary(data) @@ -1286,25 +1325,23 @@ func (p *EBPFProbe) handleEvent(CPU int, data []byte) { }() // handle exec and fork before process context resolution as they modify the process context resolution - if !p.handleBeforeProcessContext(event, data, offset, dataLen, cgroupContext, newEntryCb) { + if !p.handleBeforeProcessContext(event, data, offset, dataLen, cgroupContext) { return } // resolve process context - if !p.setProcessContext(eventType, event, cgroupContext, newEntryCb) { + if !p.setProcessContext(eventType, event, cgroupContext) { return } // handle regular events - if !p.handleRegularEvent(event, offset, dataLen, data, newEntryCb) { + if !p.handleRegularEvent(event, offset, dataLen, data) { return } - // send related events - for _, relatedEvent := range relatedEvents { + // send related events; pool return is handled by the deferred drain. + for _, relatedEvent := range p.relatedEvents { p.DispatchEvent(relatedEvent, true) - p.putBackPoolEvent(relatedEvent) } - relatedEvents = relatedEvents[0:0] p.DispatchEvent(event, true) @@ -1319,7 +1356,7 @@ func (p *EBPFProbe) handleEvent(CPU int, data []byte) { // handleRegularEvent performs the standard unmarshaling process common to all events. // It returns false if an error occurs during processing, indicating the event should be dropped. -func (p *EBPFProbe) handleRegularEvent(event *model.Event, offset int, dataLen uint64, data []byte, newEntryCb func(entry *model.ProcessCacheEntry, err error)) bool { +func (p *EBPFProbe) handleRegularEvent(event *model.Event, offset int, dataLen uint64, data []byte) bool { var err error var read int eventType := event.GetEventType() @@ -1463,7 +1500,7 @@ func (p *EBPFProbe) handleRegularEvent(event *model.Event, offset int, dataLen u if !p.regularUnmarshalEvent(&event.Exit, eventType, offset, dataLen, data) { return false } - exists := p.Resolvers.ProcessResolver.ApplyExitEntry(event, newEntryCb) + exists := p.Resolvers.ProcessResolver.ApplyExitEntry(event, p.onNewPCE) if exists { // update action reports p.processKiller.HandleProcessExited(event) @@ -1520,7 +1557,7 @@ func (p *EBPFProbe) handleRegularEvent(event *model.Event, offset int, dataLen u if !p.regularUnmarshalEvent(&event.PTrace, eventType, offset, dataLen, data) { return false } - ok := resolveTraceProcessContext(event, p, newEntryCb) + ok := resolveTraceProcessContext(event, p) if !ok { return false } @@ -1556,7 +1593,7 @@ func (p *EBPFProbe) handleRegularEvent(event *model.Event, offset int, dataLen u if !p.regularUnmarshalEvent(&event.Signal, eventType, offset, dataLen, data) { return false } - event.Signal.Target = resolveTargetProcessContext(event.Signal.PID, p, newEntryCb) + event.Signal.Target = resolveTargetProcessContext(event.Signal.PID, p) case model.SpliceEventType: if !p.regularUnmarshalEvent(&event.Splice, eventType, offset, dataLen, data) { return false @@ -1732,7 +1769,7 @@ func (p *EBPFProbe) handleRegularEvent(event *model.Event, offset int, dataLen u if !p.regularUnmarshalEvent(&event.Setrlimit, eventType, offset, dataLen, data) { return false } - event.Setrlimit.Target = resolveTargetProcessContext(event.Setrlimit.TargetPid, p, newEntryCb) + event.Setrlimit.Target = resolveTargetProcessContext(event.Setrlimit.TargetPid, p) case model.CapabilitiesEventType: if !p.regularUnmarshalEvent(&event.CapabilitiesUsage, eventType, offset, dataLen, data) { return false @@ -1766,7 +1803,7 @@ func (p *EBPFProbe) handleRegularEvent(event *model.Event, offset int, dataLen u } pid := event.CgroupWrite.Pid - pce := p.Resolvers.ProcessResolver.Resolve(pid, pid, 0, true, newEntryCb) + pce := p.Resolvers.ProcessResolver.Resolve(pid, pid, 0, true, p.onNewPCE) if pce == nil { seclog.Debugf("failed to resolve process: %d", pid) return false @@ -1794,7 +1831,7 @@ func (p *EBPFProbe) handleRegularEvent(event *model.Event, offset int, dataLen u // handleBeforeProcessContext unmarshals and populates the process cache entry for fork and exec events before setting the process context. // It returns false if the event should be dropped due to processing errors. -func (p *EBPFProbe) handleBeforeProcessContext(event *model.Event, data []byte, offset int, dataLen uint64, cgroupContext model.CGroupContext, newEntryCb func(entry *model.ProcessCacheEntry, err error)) bool { +func (p *EBPFProbe) handleBeforeProcessContext(event *model.Event, data []byte, offset int, dataLen uint64, cgroupContext model.CGroupContext) bool { var err error eventType := event.GetEventType() switch eventType { @@ -1804,7 +1841,7 @@ func (p *EBPFProbe) handleBeforeProcessContext(event *model.Event, data []byte, return false } - if err := p.Resolvers.ProcessResolver.AddForkEntry(event, cgroupContext, newEntryCb); err != nil { + if err := p.Resolvers.ProcessResolver.AddForkEntry(event, cgroupContext, p.onNewPCE); err != nil { seclog.Errorf("failed to insert fork event: %s (pid %d, offset %d, len %d)", err, event.PIDContext.Pid, offset, len(data)) return false } @@ -1901,7 +1938,7 @@ func (p *EBPFProbe) handleEarlyReturnEvents(event *model.Event, offset int, data // resolveTraceProcessContext resolves the process context of a ptrace event. // It returns false if an error occurs, true otherwise. -func resolveTraceProcessContext(event *model.Event, p *EBPFProbe, newEntryCb func(entry *model.ProcessCacheEntry, err error)) bool { +func resolveTraceProcessContext(event *model.Event, p *EBPFProbe) bool { var pce *model.ProcessCacheEntry if event.PTrace.Request == unix.PTRACE_TRACEME { // pid can be 0 for a PTRACE_TRACEME request pce = newPlaceholderProcessCacheEntryPTraceMe() @@ -1933,7 +1970,7 @@ func resolveTraceProcessContext(event *model.Event, p *EBPFProbe, newEntryCb fun } } - pce = p.Resolvers.ProcessResolver.Resolve(pidToResolve, pidToResolve, 0, false, newEntryCb) + pce = p.Resolvers.ProcessResolver.Resolve(pidToResolve, pidToResolve, 0, false, p.onNewPCE) if pce == nil { pce = model.NewPlaceholderProcessCacheEntry(pidToResolve, pidToResolve, false) } @@ -1942,10 +1979,10 @@ func resolveTraceProcessContext(event *model.Event, p *EBPFProbe, newEntryCb fun return true } -func resolveTargetProcessContext(pid uint32, p *EBPFProbe, newEntryCb func(entry *model.ProcessCacheEntry, err error)) *model.ProcessContext { +func resolveTargetProcessContext(pid uint32, p *EBPFProbe) *model.ProcessContext { var pce *model.ProcessCacheEntry if pid > 0 { // Linux accepts a kill syscall with both negative and zero pid - pce = p.Resolvers.ProcessResolver.Resolve(pid, pid, 0, false, newEntryCb) + pce = p.Resolvers.ProcessResolver.Resolve(pid, pid, 0, false, p.onNewPCE) } if pce == nil { pce = model.NewPlaceholderProcessCacheEntry(pid, pid, false) @@ -3090,6 +3127,15 @@ func NewEBPFProbe(probe *Probe, config *config.Config, hostname string, opts Opt pid: utils.Getpid(), } + p.onNewPCE = func(pce *model.ProcessCacheEntry, err error) { + if e := p.newRelatedProcessEvent(pce, err); e != nil { + p.relatedEvents = append(p.relatedEvents, e) + } + } + p.onCgroupUpdate = func(pce *model.ProcessCacheEntry) { + p.relatedEvents = append(p.relatedEvents, p.newRelatedCGroupWriteEvent(pce)) + } + if err := p.detectKernelVersion(); err != nil { // we need the kernel version to start, fail if we can't get it return nil, err diff --git a/pkg/security/tests/cgroup_test.go b/pkg/security/tests/cgroup_test.go index 973112a5f89e..5810b19af204 100644 --- a/pkg/security/tests/cgroup_test.go +++ b/pkg/security/tests/cgroup_test.go @@ -197,6 +197,12 @@ ExecStart=/usr/bin/touch ` + testFile2 }) } +// TestCGroupPropagation validates cgroup propagation when a process is born into +// a different cgroup via CLONE_INTO_CGROUP. The "open-event" subtest checks that +// the child's open is correctly attributed to the new cgroup; the +// "cgroup-write-fallback" subtest checks that the synthesized cgroup_write +// related event fires (the real cgroup_write tracepoint can't, since +// CLONE_INTO_CGROUP bypasses cgroup.procs). func TestCGroupPropagation(t *testing.T) { if testEnvironment == DockerEnvironment { t.Skip("skipping cgroup propagation test in docker") @@ -212,46 +218,76 @@ func TestCGroupPropagation(t *testing.T) { t.Skip("cgroup v2 unified hierarchy not available") } - ruleDefs := []*rules.RuleDefinition{ + cases := []struct { + name string + ruleID string + expression string + cgroupName string + fileName string + validate func(t *testing.T, test *testModule, event *model.Event, rule *rules.Rule, testFile string) + }{ { - ID: "test_cgroup_propagation", - Expression: `open.file.path == "{{.Root}}/test-cgroup-propagation" && process.cgroup.id =~ "*/cg-propagation"`, + name: "open-event", + ruleID: "test_cgroup_propagation", + expression: `open.file.path == "{{.Root}}/test-cgroup-propagation" && process.cgroup.id =~ "*/cg-propagation"`, + cgroupName: "cg-propagation", + fileName: "test-cgroup-propagation", + validate: func(t *testing.T, test *testModule, event *model.Event, rule *rules.Rule, testFile string) { + assertTriggeredRule(t, rule, "test_cgroup_propagation") + assertFieldEqual(t, event, "open.file.path", testFile) + assertFieldIsOneOf(t, event, "process.cgroup.id", []string{"/cg-propagation", "/systemd/cg-propagation"}) + test.validateOpenSchema(t, event) + }, + }, + { + name: "cgroup-write-fallback", + ruleID: "test_cgroup_write_on_clone", + expression: `cgroup_write.pid != 0 && process.cgroup.id =~ "*/cg-propagation-write"`, + cgroupName: "cg-propagation-write", + fileName: "test-cgroup-write-on-clone", + validate: func(t *testing.T, _ *testModule, event *model.Event, rule *rules.Rule, _ string) { + assertTriggeredRule(t, rule, "test_cgroup_write_on_clone") + assertFieldIsOneOf(t, event, "process.cgroup.id", []string{"/cg-propagation-write", "/systemd/cg-propagation-write"}) + validateProcessContext(t, event) + }, }, } - test, err := newTestModule(t, nil, ruleDefs) - if err != nil { - t.Fatal(err) - } - defer test.Close() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ruleDefs := []*rules.RuleDefinition{{ID: tc.ruleID, Expression: tc.expression}} - targetCGroupPath := "/sys/fs/cgroup/cg-propagation" - if err := os.MkdirAll(targetCGroupPath, 0700); err != nil { - t.Fatal(err) - } - defer os.Remove(targetCGroupPath) + test, err := newTestModule(t, nil, ruleDefs) + if err != nil { + t.Fatal(err) + } + defer test.Close() - testFile, _, err := test.Path("test-cgroup-propagation") - if err != nil { - t.Fatal(err) - } + targetCGroupPath := "/sys/fs/cgroup/" + tc.cgroupName + if err := os.MkdirAll(targetCGroupPath, 0700); err != nil { + t.Fatal(err) + } + defer os.Remove(targetCGroupPath) - syscallTester, err := loadSyscallTester(t, test, "syscall_tester") - if err != nil { - t.Fatal(err) - } + testFile, _, err := test.Path(tc.fileName) + if err != nil { + t.Fatal(err) + } - test.WaitSignalFromRule(t, func() error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return runSyscallTesterFunc(ctx, t, syscallTester, "process-clone-into-cgroup", targetCGroupPath, testFile) - }, func(event *model.Event, rule *rules.Rule) { - assertTriggeredRule(t, rule, "test_cgroup_propagation") - assertFieldEqual(t, event, "open.file.path", testFile) - assertFieldIsOneOf(t, event, "process.cgroup.id", []string{"/cg-propagation", "/systemd/cg-propagation"}) + syscallTester, err := loadSyscallTester(t, test, "syscall_tester") + if err != nil { + t.Fatal(err) + } - test.validateOpenSchema(t, event) - }, "test_cgroup_propagation") + test.WaitSignalFromRule(t, func() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return runSyscallTesterFunc(ctx, t, syscallTester, "process-clone-into-cgroup", targetCGroupPath, testFile) + }, func(event *model.Event, rule *rules.Rule) { + tc.validate(t, test, event, rule, testFile) + }, tc.ruleID) + }) + } } func TestCGroupSnapshot(t *testing.T) {