Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- README License section: "Why Apache 2.0" paragraph. Documents the kernel-uABI posture (no kernel source, no cgo, no GPL deps; `/proc` and `/sys` reads fall under the kernel `COPYING` "normal syscalls" carve-out) and the Apache-2.0-over-MIT rationale (patent grant for security-adjacent probing; same-license adopter base of Cilium, Tetragon, Falco, etc.).
- `ReadIMARuntimeMeasurementsCount()`: exported helper that reads `/sys/kernel/security/ima/runtime_measurements_count` and returns the current count. No side effects. Useful for diagnostics and for callers building their own before/after measurement probes.
- `ProbeIMAExecMeasurementActive()`: checks whether an IMA measurement rule covering exec (e.g., `func=BPRM_CHECK`) is active by creating a fresh temporary executable (new inode), running it, and checking for a measurement count increase. No count > 1 shortcut; returns `Supported=true` only when the controlled exec stimulus increments the count. A fresh inode avoids false negatives from IMA's per-inode measurement cache.
- `ProbeIMAFileCheckMeasurementActive()`: checks whether an IMA measurement rule covering file open (e.g., `func=FILE_CHECK`) is active by creating a fresh temporary file, rewriting it to invalidate any IMA measurement cache, then opening it `O_RDONLY` and checking for a count increase. The measurement window contains only the read-open — the canonical `FILE_CHECK` stimulus.

### Breaking

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Neither tells you whether your tool can **actually run**. For example, BPF LSM r
| **IMA any measurement active** (runtime measurement activity) | ✗ | ✗ | ✓ |
| **IMA runtime measurement count** (raw count for before/after probes) | ✗ | ✗ | ✓ |
| **IMA exec measurement active** (fresh-inode exec probe) | ✗ | ✗ | ✓ |
| **IMA file-check measurement active** (fresh-inode file-open probe) | ✗ | ✗ | ✓ |
| **Process capabilities** (CAP_BPF, CAP_SYS_ADMIN, CAP_PERFMON) | ✗ | ✗ | ✓ |
| **Unprivileged BPF status** | ✗ | ✓ | ✓ |
| **Mount-state gates** (bpffs/tracefs/custom paths via superblock magic) | ✗ | ✗ | ✓ |
Expand Down Expand Up @@ -334,7 +335,7 @@ Tools exposed: `probe`, `check`, `config`. The server stays alive across busines
|---|---|
| Program types | LSM, kprobe, kprobe.multi, tracepoint, fentry |
| Core | BTF availability (CO-RE) |
| Security | BPF LSM enabled, IMA enabled, IMA any measurement active, IMA exec measurement active, active LSM list |
| Security | BPF LSM enabled, IMA enabled, IMA any measurement active, IMA exec measurement active, IMA file-check measurement active, active LSM list |
| Capabilities and runtime gates | CAP_BPF, CAP_SYS_ADMIN, CAP_PERFMON, unprivileged BPF disabled, BPF stats enabled |
| Syscalls | `bpf()`, `perf_event_open()` |
| JIT | enabled, hardened, kallsyms, memory limit, `CONFIG_BPF_JIT_ALWAYS_ON` |
Expand Down
141 changes: 140 additions & 1 deletion probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
package kfeatures

import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"slices"
Expand Down Expand Up @@ -486,6 +489,10 @@ func probeIMAExecMeasurementActive() ProbeResult {
// must invoke cleanup when done. Materializing the binary before the
// measurement window avoids false positives from FILE_CHECK rules measuring
// the source read or temp-file write.
//
// A unique random trailer is appended to the ELF so that IMA's global
// hash-table deduplication does not suppress the measurement. The trailer
// sits past the ELF's segment table and does not affect execution.
func createFreshTempBinary() (string, func(), error) {
noop := func() {}

Expand All @@ -494,7 +501,14 @@ func createFreshTempBinary() (string, func(), error) {
return "", noop, err
}

dir, err := os.MkdirTemp("", "kfeatures-ima-probe-*")
// Append random bytes so the digest is unique per invocation.
trailer, err := uniqueTrailer()
if err != nil {
return "", noop, err
}
src = append(src, trailer...)

dir, err := imaProbeTempDir()
if err != nil {
return "", noop, err
}
Expand All @@ -514,6 +528,131 @@ func execTempBinary(path string) error {
return (&exec.Cmd{Path: path}).Run()
}

// ProbeIMAFileCheckMeasurementActive checks whether an IMA measurement rule
// covering file open (e.g., func=FILE_CHECK) is active by opening a fresh
// temporary file and checking for a measurement count increase.
//
// Unlike [ProbeIMAAnyMeasurementActive], this probe does not use a count > 1
// shortcut. It returns Supported=true only when the controlled file-open
// stimulus increments the IMA measurement count.
func ProbeIMAFileCheckMeasurementActive() ProbeResult {
return probeIMAFileCheckMeasurementActive()
}

// probeIMAFileCheckMeasurementActive creates a fresh temporary file (new
// inode), rewrites it with unique content to invalidate any IMA measurement
// cache and avoid global hash-table deduplication. The baseline count is
// taken after all setup I/O, and the measurement window contains only an
// O_RDONLY open — the canonical FILE_CHECK stimulus.
func probeIMAFileCheckMeasurementActive() ProbeResult {
path, cleanup, err := createFreshTempFile()
if err != nil {
return ProbeResult{Supported: false, Error: err}
}
defer cleanup()

// Rewrite the file with unique content to invalidate any IMA measurement
// cached from the initial create and ensure the digest has not been seen
// before in IMA's global hash table.
trailer, err := uniqueTrailer()
if err != nil {
return ProbeResult{Supported: false, Error: err}
}
if err := os.WriteFile(path, trailer, 0644); err != nil {
return ProbeResult{Supported: false, Error: err}
}

before, err := readMeasurementCount()
if err != nil {
return ProbeResult{Supported: false, Error: err}
}

// Open O_RDONLY — this is the FILE_CHECK stimulus.
f, err := os.Open(path)
if err != nil {
return ProbeResult{Supported: false, Error: err}
}
f.Close()

after, err := readMeasurementCount()
if err != nil {
return ProbeResult{Supported: false, Error: err}
}

return ProbeResult{Supported: after > before}
}

// createFreshTempFile creates a regular file with initial content in a new
// temp directory (fresh inode) and returns the path, a cleanup function, and
// any error. The caller must invoke cleanup when done.
func createFreshTempFile() (string, func(), error) {
noop := func() {}

dir, err := imaProbeTempDir()
if err != nil {
return "", noop, err
}

path := dir + "/probe-file"
if err := os.WriteFile(path, []byte("ima-probe-init\n"), 0644); err != nil {
os.RemoveAll(dir)
return "", noop, err
}

return path, func() { os.RemoveAll(dir) }, nil
}

// imaProbeTempDir creates a temp directory on a non-tmpfs filesystem.
// Common IMA policies exclude tmpfs before FILE_CHECK/BPRM_CHECK rules,
// which would cause false negatives. Prefers /var/tmp (typically on the
// root filesystem) over the default temp dir. Returns an error if no
// writable non-tmpfs candidate is available rather than silently falling
// back to tmpfs.
func imaProbeTempDir() (string, error) {
var errs []error
seen := map[string]bool{}

for _, base := range []string{"/var/tmp", os.TempDir()} {
if base == "" || seen[base] {
continue
}
seen[base] = true

if !isNonTmpfs(base) {
errs = append(errs, fmt.Errorf("%s is unavailable or tmpfs", base))
continue
}

dir, err := os.MkdirTemp(base, "kfeatures-ima-probe-*")
if err == nil {
return dir, nil
}
errs = append(errs, fmt.Errorf("create temp dir under %s: %w", base, err))
}

return "", fmt.Errorf("no writable non-tmpfs temp directory for IMA probe: %w", errors.Join(errs...))
}

// isNonTmpfs returns true if path exists and is not on a tmpfs filesystem.
func isNonTmpfs(path string) bool {
var st unix.Statfs_t
if err := unix.Statfs(path, &st); err != nil {
return false
}
return uint32(st.Type) != unix.TMPFS_MAGIC
}

// uniqueTrailer returns 16 random bytes hex-encoded (32 bytes) to produce
// a unique file digest per invocation. This prevents IMA's global hash-table
// deduplication from suppressing repeated measurements.
func uniqueTrailer() ([]byte, error) {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return nil, err
}
return []byte(hex.EncodeToString(buf[:]) + "\n"), nil
}

// ReadIMARuntimeMeasurementsCount returns the current IMA runtime measurement
// count from /sys/kernel/security/ima/runtime_measurements_count. No side
// effects. Useful for diagnostics and for callers building before/after probes.
Expand Down
98 changes: 98 additions & 0 deletions probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,101 @@ func TestExecTempBinary(t *testing.T) {
}
})
}

func TestCreateFreshTempFile(t *testing.T) {
t.Run("creates regular file with content", func(t *testing.T) {
path, cleanup, err := createFreshTempFile()
if err != nil {
t.Fatalf("createFreshTempFile() error = %v", err)
}
defer cleanup()

info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat(%s) error = %v", path, err)
}
if !info.Mode().IsRegular() {
t.Errorf("expected regular file, got mode = %v", info.Mode())
}
if info.Size() == 0 {
t.Error("expected non-empty file")
}
})

t.Run("cleanup removes temp directory", func(t *testing.T) {
path, cleanup, err := createFreshTempFile()
if err != nil {
t.Fatalf("createFreshTempFile() error = %v", err)
}

dir := filepath.Dir(path)
cleanup()

if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("temp directory still exists after cleanup: %s", dir)
}
})
}

func TestUniqueTrailer(t *testing.T) {
t.Run("returns non-empty content", func(t *testing.T) {
data, err := uniqueTrailer()
if err != nil {
t.Fatalf("uniqueTrailer() error = %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty trailer")
}
})

t.Run("successive calls produce different content", func(t *testing.T) {
a, err := uniqueTrailer()
if err != nil {
t.Fatalf("uniqueTrailer() error = %v", err)
}
b, err := uniqueTrailer()
if err != nil {
t.Fatalf("uniqueTrailer() error = %v", err)
}
if string(a) == string(b) {
t.Error("expected different trailers on successive calls")
}
})
}

func TestIsNonTmpfs(t *testing.T) {
t.Run("root filesystem is non-tmpfs", func(t *testing.T) {
if !isNonTmpfs("/") {
t.Skip("root is tmpfs on this system")
}
})

t.Run("nonexistent path returns false", func(t *testing.T) {
if isNonTmpfs("/nonexistent/path/that/should/not/exist") {
t.Error("expected false for nonexistent path")
}
})
}

func TestImaProbeTempDir(t *testing.T) {
t.Run("creates directory on non-tmpfs when available", func(t *testing.T) {
dir, err := imaProbeTempDir()
if err != nil {
t.Fatalf("imaProbeTempDir() error = %v", err)
}
defer os.RemoveAll(dir)

info, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat(%s) error = %v", dir, err)
}
if !info.IsDir() {
t.Errorf("expected directory, got mode = %v", info.Mode())
}

// Verify it's not on tmpfs if /var/tmp is available and non-tmpfs.
if isNonTmpfs("/var/tmp") && !isNonTmpfs(dir) {
t.Error("expected non-tmpfs directory when /var/tmp is available")
}
})
}
Loading