Skip to content

fix: record exec path symmetric with rule-side resolver (fexecve/AT_EMPTY_PATH)#800

Merged
matthyx merged 1 commit intomainfrom
fix-exec-empty-pathname-fexecve
May 4, 2026
Merged

fix: record exec path symmetric with rule-side resolver (fexecve/AT_EMPTY_PATH)#800
matthyx merged 1 commit intomainfrom
fix-exec-empty-pathname-fexecve

Conversation

@slashben
Copy link
Copy Markdown
Contributor

@slashben slashben commented May 3, 2026

Problem

The application-profile recorder (ReportFileExec in pkg/containerprofilemanager/v1/event_reporting.go) 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 any rule built on ap.was_executed (R0001 Unexpected process launched and friends) 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's pathname into args[0] and reads argv from index 1 (gadgets/trace_exec/program.bpf.c:146-153). For fexecve/execveat-with-empty-pathname this produces args = ["", argv[1]] in the agent's exec event.

The current recorder then does:

path := event.GetComm()
args := event.GetArgs()        // ["", "root"]
if len(args) > 0 {
    path = args[0]             // overwrites with ""
}

→ AP entry: Path: "", Args: ["", "root"].

At runtime, the rule's parse.get_exec_path falls back to comm = "unix_chkpwd" (because argsList[0] == ""). ap.was_executed("unix_chkpwd") then walks the AP execs comparing exec.Path == "unix_chkpwd" — never finds the empty-path entry — and the rule fires.

Fix

Make the recorder symmetric with the rule-side resolver:

  1. Prefer event.GetExePath() — the kernel-authoritative path (task->mm->exe_file in BPF, immune to argv[0] spoofing too).
  2. Fall back to args[0] when non-empty.
  3. Fall back to comm otherwise.

Extracted into a small resolveExecPath helper so the logic is unit-testable.

Concrete impact

In a Bonial customer snapshot, 408 of 1976 R0001 incidents (20.6%) on scoring-api production workloads are exactly this case — cron user-context setup invokes pam_unixunix_chkpwd via fexecve, the AP records Path: "" with Args: ["", "root"], and the rule fires every time. With this fix the recorded entry becomes Path: /usr/sbin/unix_chkpwd, Args: ["", "root"] and matches what the rule looks up.

Same pattern applies to any rule using ap.was_executed.

Reproducer

Confirmed locally on a kind cluster with kubescape + node-agent. A pod that loops between execve (control: pathname non-empty) and execveat-with-AT_EMPTY_PATH (bug: pathname="") reproduces both AP entry shapes on the unfixed code; with the fix applied, both produce a usable Path field.

Note: Python's os.execv rejects empty argv[0] — bypass with ctypes direct libc, or call execveat via syscall(__NR_execveat, fd, "", ...) from C.

Test

Added resolveExecPath unit tests covering canonical exec, fexecve with non-empty argv[0], fexecve with empty argv[0] (older PAM convention), bare-comm fallback, and an argv[0]-spoofing case where exepath correctly wins.

go test -run TestResolveExecPath ./pkg/containerprofilemanager/v1/
ok  github.com/kubescape/node-agent/pkg/containerprofilemanager/v1

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved executable path identification logic in event reporting for more accurate exec path tracking and enrichment across different execution scenarios.
  • Tests

    • Added comprehensive test coverage for executable path resolution functionality.

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 <ben@armosec.io>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a78a78f-648b-4bcf-8925-d344171e4bef

📥 Commits

Reviewing files that changed from the base of the PR and between dbe9a16 and 6a53517.

📒 Files selected for processing (2)
  • pkg/containerprofilemanager/v1/event_reporting.go
  • pkg/containerprofilemanager/v1/event_reporting_test.go

📝 Walkthrough

Walkthrough

A new helper function resolveExecPath determines exec path with updated precedence: syscall-exposed exepath first, then args[0], then comm. ReportFileExec now uses this helper instead of inline logic. Comprehensive tests verify all fallback scenarios.

Changes

Exec Path Resolution Refactoring

Layer / File(s) Summary
Core Implementation
pkg/containerprofilemanager/v1/event_reporting.go
New unexported resolveExecPath(exepath, comm string, args []string) string helper applies precedence: non-empty exepath → non-empty args[0]comm. ReportFileExec calls this helper instead of inline default-to-comm with args[0] override logic.
Test Coverage
pkg/containerprofilemanager/v1/event_reporting_test.go
Table-driven TestResolveExecPath verifies the helper across scenarios: canonical exepath, empty exepath with varying args, fallback to comm, and exepath precedence over potentially spoofed argv[0].

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

Hopping through the syscall trace 🐰
We find the exec path in its place
Exepath first, then args array
Comm's the fallback when all else stray
With tests to verify the way!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: fixing exec path recording to match the rule-side resolver's behavior, specifically addressing fexecve/AT_EMPTY_PATH scenarios.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-exec-empty-pathname-fexecve

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 3, 2026

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.182 0.185 +1.4%
Peak CPU (cores) 0.192 0.191 -0.7%
Avg Memory (MiB) 335.735 259.972 -22.6%
Peak Memory (MiB) 339.773 272.887 -19.7%
Dedup Effectiveness (AFTER only)
Event Type Passed Deduped Ratio
capabilities 2 0 0.0%
hardlink 6000 0 0.0%
http 1700 119460 98.6%
network 900 78000 98.9%
open 36198 619974 94.5%
symlink 6000 0 0.0%
syscall 991 1904 65.8%
Event Counters
Metric BEFORE AFTER
capability_counter 8 9
dns_counter 1447 1399
exec_counter 7272 6997
network_counter 95581 92024
open_counter 796412 766308
syscall_counter 3659 3497

@slashben slashben requested a review from matthyx May 4, 2026 04:03
Copy link
Copy Markdown
Contributor

@matthyx matthyx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule-side resolvers still derive the lookup key from args[0] / comm only in pkg/utils/events.go and pkg/rulemanager/cel/libraries/parse/parse.go. In the fexecve / execveat(..., AT_EMPTY_PATH) case that means the application profile can store /usr/sbin/unix_chkpwd, while parse.get_exec_path(...) still asks ap.was_executed("unix_chkpwd"), so the lookup still misses and the false positive remains.
I would add an integration test to be sure...

@slashben
Copy link
Copy Markdown
Contributor Author

slashben commented May 4, 2026

The rule-side resolvers still derive the lookup key from args[0] / comm only in pkg/utils/events.go and pkg/rulemanager/cel/libraries/parse/parse.go. In the fexecve / execveat(..., AT_EMPTY_PATH) case that means the application profile can store /usr/sbin/unix_chkpwd, while parse.get_exec_path(...) still asks ap.was_executed("unix_chkpwd"), so the lookup still misses and the false positive remains. I would add an integration test to be sure...

Yes, I am working on fixing the CELs now

@matthyx matthyx merged commit bfd6059 into main May 4, 2026
48 of 49 checks passed
@matthyx matthyx deleted the fix-exec-empty-pathname-fexecve branch May 4, 2026 13:05
@matthyx matthyx moved this to To Archive in KS PRs tracking May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To Archive

Development

Successfully merging this pull request may close these issues.

2 participants