From 6a535177520edbeb03f6567ed247962e2211cac2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 3 May 2026 23:21:28 +0300 Subject: [PATCH] fix: record exec path symmetric with rule-side resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The application-profile recorder in ReportFileExec derives the path it stores into the AP from `args[0]`, while the rule-side resolver (`parse.get_exec_path` in pkg/rulemanager/cel/libraries/parse/parse.go) falls back to `comm` when `args[0]` is empty. The asymmetry causes "Unexpected process launched" (R0001) and other ap.was_executed-based rules to fire on processes that are present in the application profile. Trigger: fexecve / execveat with AT_EMPTY_PATH. modern libpam (>= 1.5) invokes its helpers (unix_chkpwd, unix_update, ...) via fexecve to avoid TOCTOU on the helper path. The kernel implements fexecve as execveat(fd, "", argv, envp, AT_EMPTY_PATH) — pathname is empty by design. Inspektor Gadget's trace_exec puts the syscall pathname into args[0] and reads argv from index 1 (gadgets/trace_exec/program.bpf.c:146-153). For fexecve/execveat empty-pathname, this produces args = ["", argv[1]] in the agent's exec event. The recorder then sets path = args[0] = "" and the AP entry is unreachable to ap.was_executed("unix_chkpwd") (which the rule-side resolver computes via the empty-args[0] -> comm fallback). Fix: derive the recorder's path the same way the rule-side does — prefer exepath (the kernel-authoritative exe_file path, immune to argv[0] spoofing too), then argv[0] when non-empty, then comm. Concrete impact in production: 408 of 1976 Bonial I013 incidents on production scoring-api APs are exactly this case — cron user-context setup invokes pam_unix -> unix_chkpwd via fexecve, AP records path: "" with args ["", "root"], rule looks up "unix_chkpwd" via comm fallback, no match. The new resolveExecPath helper is also more defensive against argv[0] spoofing in general — exepath comes from task->mm->exe_file in the BPF side and cannot be controlled by user code. Verified locally on a kind cluster with kubescape v0.3.94: a pod that loops execve (control) and execveat-AT_EMPTY_PATH (bug) reproduces the production-shape AP entry on the unfixed code path. Signed-off-by: Ben --- .../v1/event_reporting.go | 24 ++++++-- .../v1/event_reporting_test.go | 57 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 pkg/containerprofilemanager/v1/event_reporting_test.go diff --git a/pkg/containerprofilemanager/v1/event_reporting.go b/pkg/containerprofilemanager/v1/event_reporting.go index 997065da37..077875fe1a 100644 --- a/pkg/containerprofilemanager/v1/event_reporting.go +++ b/pkg/containerprofilemanager/v1/event_reporting.go @@ -32,17 +32,33 @@ func (cpm *ContainerProfileManager) ReportCapability(containerID, capability str cpm.logEventError(err, "capability", containerID) } +// resolveExecPath derives the path to record for an exec event. It is kept +// symmetric with the rule-side resolver in +// pkg/rulemanager/cel/libraries/parse/parse.go (parse.get_exec_path): prefer +// the kernel-authoritative exepath, then argv[0] when non-empty, then comm. +// Using args[0] unconditionally produces an empty Path when the syscall has +// an empty pathname (fexecve / execveat AT_EMPTY_PATH — the libpam helper +// invocation pattern), while the rule-side resolver falls back to comm — +// leaving the AP entry unreachable to ap.was_executed and producing spurious +// "Unexpected process launched" alerts. +func resolveExecPath(exepath, comm string, args []string) string { + if exepath != "" { + return exepath + } + if len(args) > 0 && args[0] != "" { + return args[0] + } + return comm +} + // ReportFileExec reports a file execution event for a container func (cpm *ContainerProfileManager) ReportFileExec(containerID string, event utils.ExecEvent) { err := cpm.withContainer(containerID, func(data *containerData) (int, error) { if data.execs == nil { data.execs = &maps.SafeMap[string, []string]{} } - path := event.GetComm() args := event.GetArgs() - if len(args) > 0 { - path = args[0] - } + path := resolveExecPath(event.GetExePath(), event.GetComm(), args) // Use SHA256 hash of the exec to identify it uniquely execIdentifier := utils.CalculateSHA256FileExecHash(path, args) diff --git a/pkg/containerprofilemanager/v1/event_reporting_test.go b/pkg/containerprofilemanager/v1/event_reporting_test.go new file mode 100644 index 0000000000..ee38683d53 --- /dev/null +++ b/pkg/containerprofilemanager/v1/event_reporting_test.go @@ -0,0 +1,57 @@ +package containerprofilemanager + +import "testing" + +func TestResolveExecPath(t *testing.T) { + tests := []struct { + name string + exepath string + comm string + args []string + want string + }{ + { + name: "exepath present (canonical exec)", + exepath: "/usr/sbin/unix_chkpwd", + comm: "unix_chkpwd", + args: []string{"/usr/sbin/unix_chkpwd", "root"}, + want: "/usr/sbin/unix_chkpwd", + }, + { + name: "fexecve / execveat AT_EMPTY_PATH — pathname empty, argv[0] non-empty", + exepath: "", + comm: "unix_chkpwd", + args: []string{"unix_chkpwd", "root"}, + want: "unix_chkpwd", + }, + { + name: "fexecve with empty argv[0] (older PAM convention)", + exepath: "", + comm: "unix_chkpwd", + args: []string{"", "root"}, + want: "unix_chkpwd", + }, + { + name: "no exepath, no args — fall back to comm", + exepath: "", + comm: "some_proc", + args: nil, + want: "some_proc", + }, + { + name: "exepath wins even when argv[0] disagrees (argv[0] spoofing)", + exepath: "/usr/bin/curl", + comm: "curl", + args: []string{"sshd", "-i"}, + want: "/usr/bin/curl", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveExecPath(tt.exepath, tt.comm, tt.args) + if got != tt.want { + t.Errorf("resolveExecPath(%q, %q, %v) = %q, want %q", tt.exepath, tt.comm, tt.args, got, tt.want) + } + }) + } +}