Skip to content

How it works

An application's entrypoint is wrapped with kernelseal-exec, a small static shim. The shim asks the agent for the secrets bound to the binary it is about to run, applies them to the environment, and then execves the real program.

sequenceDiagram
    participant Shim as "kernelseal-exec (PID N)"
    participant Agent as "KernelSeal agent"
    participant Kernel as "BPF-LSM maps"
    participant App as "application (PID N)"

    Shim->>Agent: "connect, request secrets for /usr/bin/myapp"
    Agent->>Agent: "SO_PEERCRED yields verified PID N"
    Agent->>Agent: "resolve N's cgroup to a pod, authorize"
    Agent->>Kernel: "mark PID N protected"
    Agent-->>Shim: "environment variables"
    Shim->>App: "execve, PID preserved"
    Note over App,Kernel: "reads of /proc/N/environ now refused"

The ordering is the point

The naive version of this design has a hole in it. Deliver secrets to a process and then protect it, and there is a window between those two events where the secrets are readable.

So the order is inverted. The agent marks the calling PID protected before it returns any values. The load-bearing detail is that execve preserves the PID: the shim and the application it becomes are the same process, and that PID was already in the protected set before any secret crossed the socket.

There is no window. The process is protected before it has anything worth stealing, and it inherits both the environment and the protection through the exec.

What the kernel refuses

Two BPF-LSM hooks do the work:

Hook Refuses
file_open Reads of /proc/<pid>/environ, /proc/<pid>/mem and /proc/<pid>/maps for protected PIDs
ptrace_access_check Debugger attach to a protected process

allowSelfRead lets a process inspect its own /proc files, because plenty of runtimes do that at startup and breaking them buys nothing.

Enforcement is a policy decision with three modes: disabled, audit (log would-be denials but allow them) and enforce (log and deny).

Identity comes from the kernel

The agent identifies its caller with SO_PEERCRED, which the kernel fills in with the peer's PID, UID and GID. A client cannot forge it. That is also what makes the ordering possible: the agent knows which PID to protect because the kernel told it, not because the caller said so.

SO_PEERCRED proves which process is calling. It does not prove which binary that process is about to become, since at handshake time /proc/<pid>/exe still points at the shim. Authorization therefore rests on the caller's cgroup rather than on the binary name. See Authorization.

Cleanup, and why the reconciler exists

Protection has to be released when a process exits, or a recycled PID inherits it. The sched_process_exit tracepoint drives that, backed by a periodic reconciler that drops protection for PIDs that no longer exist. Without the reconciler, an evicted LRU entry could leave protection attached to a PID later reused by an unrelated process.

sched_process_exit fires per thread, not per process

This caused a real protection leak. kernelseal-exec is a Go program, so it has a runtime with several threads, and when it execs its target those siblings exit first. Each was reported as the process exiting, protection was dropped moments after delivery, and /proc/<pid>/environ stayed readable for the life of the application.

The fix is a kernel-side check on signal->live, emitting an exit event only when the last thread of the group leaves.

A follow-up "second opinion" in user space, checking the process was really gone before unprotecting, then made things worse: sched_process_exit fires from inside do_exit(), so the task is still mid-exit when the event arrives and /proc/<pid> still exists. Every genuine exit looked alive, and cleanup fell entirely to the 30 second sweep. It shipped in one release and was reverted in the next.

The ABI check

make abi-check covers two failure modes that are invisible at build time, and it runs in CI.

First, it verifies that the Go structs in internal/types/events.go still match the C definitions in bpf/kernelseal_common.h field by field, including padding the compiler inserts implicitly. This matters more than it looks:

struct ks_policy_config {
    __u8 enforce_mode;
    __u8 block_environ;
    __u8 block_mem;
    __u8 block_maps;
    __u8 block_ptrace;
    __u8 allow_self_read;
    __u8 audit_all;
    __u8 reserved;
};

Eight single-byte fields, eight bytes. Reorder two on the Go side and the struct is still eight bytes, the map update still succeeds, and no error appears at any layer. What changes is that each setting is written to the wrong byte, so the enforced policy silently differs from the configured one.

Second, it checks that every program and map the loader resolves by name exists in the compiled objects. Those names come from ebpf:"..." struct tags, so a rename in the BPF source fails in CI rather than at someone's agent startup.