Skip to content

GAUNT-FD-POSTCONDITION: prove the exec-time descriptor set and fail closed on CLOEXEC/close faults #218

Description

@heyoub

Why now

Recent downstream dogfooding has repeatedly exposed the same assurance failure shape: the system proved the mechanism it intended to run rather than the postcondition it actually established.

  • batpak-saas repaired a witness rig whose frontier probe mutated the frontier it claimed merely to observe, and whose benchmark verdict was hardcoded green: batpak-saas PR #11.
  • Its wasm verifier then closed a self-blessing provenance path so stale generated bytes could not notarize themselves as current: batpak-saas PR #12.
  • Its repository-integrity judge also had to replace lexical/path assumptions with canonical containment and exact fixture-root checks: batpak-saas PR #17.

A fresh upstream audit found the same class in bvisor's Linux launcher.

The launcher says the Landlock ruleset descriptors are CLOEXEC-marked and therefore cannot enter the workload, but the operation that establishes that claim is explicitly best-effort:

  • build_confinement discovers the newly opened ruleset fds, calls sys::set_cloexec(fd) for each, ignores any result, and returns BuiltConfinement whose documentation says those fds are CLOEXEC-set.
  • set_cloexec ignores both F_GETFD and F_SETFD failure. Its rustdoc says that at worst the fd leaks.
  • allowlist deliberately exempts those ruleset fds from the child's scrub so restrict_self can use them.
  • After a successful fexecve, finish_child emits ConfinementPhaseResolved, ReadyToExec, and ExecSucceeded. It has no evidence that every transitional descriptor actually left the exec image.
  • The unsafe_ledger.yaml entry likewise blesses this security-boundary syscall as best-effort.

fcntl() is fallible and returns -1 with errno on error. The Linux kernel's own Landlock example does not rely on a best-effort FD_CLOEXEC promise after policy installation: it calls landlock_restrict_self() and then immediately closes ruleset_fd. Once restriction succeeds, the policy remains enforced on the thread and its descendants. Sources:

This is not a claim that the leaked ruleset fd is an immediate sandbox escape. It is a concrete violation of the launcher's no-ambient-fd and evidence-honesty contract: a descriptor not declared for the workload can survive while the transcript reports the hygiene/confinement phases as applied.

Exact failure sequence

Landlock ruleset created
  -> ruleset fd discovered
  -> F_GETFD or F_SETFD fails
  -> failure ignored
  -> ruleset fd exempted from scrub
  -> child successfully restricts itself
  -> child successfully fexecves
  -> ruleset fd survives into target image
  -> transcript reports Applied / ExecSucceeded

The implementation currently proves scheduled + built + exec succeeded. The public claim is stronger: the target entered with exactly the allowed descriptor authority.

Core law

A setup phase may report Applied only after every authority-bearing transition it promises has reached a checked postcondition.

Every descriptor present before fexecve must have one explicit lifecycle: close before exec, close-on-exec verified, or intentionally inherited under a declared descriptor role. There is no fourth bucket called best effort.

Proposal

1. Model descriptor lifecycle explicitly

Introduce a small BatPak-owned launcher model, exact spelling optional:

enum ExecFdDisposition {
    CloseBeforePolicy,
    CloseAfterPolicy,
    VerifiedCloseOnExec,
    DeclaredInherited(DescriptorRole),
}

struct ExecFdPlan {
    fd: RawFd,
    disposition: ExecFdDisposition,
    origin: FdOrigin,
}

The coordinator should derive one complete descriptor plan from the verified launch body plus launcher-created descriptors. The scrub list and the post-policy close list become generated projections of that one plan rather than independently maintained allowlists.

At minimum classify stdio, target executable, control/error channels, userns rendezvous pipes, Landlock ruleset fds, cgroup fd, declared roots, and future setup descriptors. Unknown descriptors remain scrubbed. A descriptor required only during setup must not silently become exec-inherited.

2. Close Landlock ruleset fds immediately after successful restriction

Follow the kernel's reference lifecycle:

build ruleset in parent
  -> child scrub preserving ruleset fd
  -> landlock_restrict_self
  -> checked raw close of every ruleset fd
  -> install later mechanisms
  -> fexecve

The close runs before seccomp. On a nonzero close result, capture errno, report through the existing error pipe, and _exit; never continue into the target with an uncertain descriptor set. Do not retry close blindly.

This fits the child branch's no-destructor discipline: after restriction the RulesetCreated value is never used again and the child always diverges through exec or _exit.

3. Make every required CLOEXEC transition typed and checked

For descriptors that legitimately must remain open until exec:

  • set_cloexec returns io::Result<()>;
  • F_GETFD failure is surfaced;
  • F_SETFD failure is surfaced;
  • a second F_GETFD verifies FD_CLOEXEC is present;
  • failure before clone refuses/faults the launch;
  • no type or documentation may claim VerifiedCloseOnExec before the transition succeeds.

Prefer atomic creation flags such as O_CLOEXEC, MFD_CLOEXEC, pipe2(O_CLOEXEC), and F_DUPFD_CLOEXEC where available. The postcondition still belongs in the lifecycle model so future descriptor sources cannot bypass it.

4. Bind phase resolution to completed child transitions

ConfinementPhaseResolved and AmbientAuthorityPhaseResolved must mean more than "the coordinator built the objects."

The child's success path should prove through checked syscall completion before fexecve that scrub completed, setup descriptors were closed, Landlock installed, seccomp installed when scheduled, and every remaining descriptor was declared and verified for exec inheritance.

The existing error-pipe EOF can remain the compact success signal if every forbidden transition calls child_fail. Do not add allocation or a protocol encoder inside the post-clone window.

5. Make the unsafe ledger grade failure policy

Extend unsafe-ledger entries with a machine-readable failure policy, for example:

failure_policy: refuse_before_child | child_exit | best_effort_non_authority

For entries backing InheritedFds, Filesystem, ChildSpawn, confinement, identity, or evidence claims, best_effort_non_authority must be structurally forbidden unless a narrow waiver proves the operation cannot affect authority or reported success.

Plant a RED fixture that changes a checked fcntl or close into let _ = ... and prove structural-check bites.

6. Keep the proof physics-bounded

Do not make a noisy cloud stopwatch the acceptance gate. Measure:

  • fds classified, scrubbed, and closed after policy;
  • fcntl, clone, and exec syscall counts;
  • allocations before clone;
  • zero allocations and zero locks after clone;
  • startup latency distributions as supporting evidence only.

Sweep descriptor count and confinement-root count independently so a faster result cannot hide a smaller workload.

Required negative fixtures

  1. Force F_GETFD failure on a ruleset fd before clone: launch faults/refuses, no target execution.
  2. Force F_SETFD failure: launch faults/refuses, no target execution.
  3. Remove the post-restrict_self close: an exec-target fd census catches the leaked descriptor.
  4. Force the post-restriction close syscall to fail: child reports failure and _exits before target execution.
  5. Add an unknown coordinator fd after the initial snapshot: it is scrubbed or launch refuses.
  6. Duplicate a declared fd under another number: only the explicitly classified occurrence survives.
  7. Run with zero, one, and many Landlock roots and prove the exec-time fd set is identical except for declared descriptors.
  8. Combine Landlock, seccomp, userns, netns, and cgroup and prove the staged lifecycle remains exact.
  9. Execute a tiny target that enumerates /proc/self/fd; compare the actual inherited set to the reference plan.
  10. Mutate one descriptor disposition from CloseAfterPolicy to VerifiedCloseOnExec without establishing CLOEXEC and require refusal/test failure.

Validation gates

Reference fd-state machine

CoordinatorOpen
  -> ChildInherited
  -> Scrubbed
  -> PolicyInstalled
  -> TransitionalClosed
  -> ExecInherited

Generate descriptor origins, roles, setup mechanisms, duplicates, syscall failures, and ordering changes. Compare the real launch disposition and transcript to the model.

Deterministic syscall-fault matrix

Add narrow test-only fault points around F_GETFD, F_SETFD, post-policy close, scrub close, restrict_self, seccomp install, and fexecve. Prepare hook state before clone and read it allocation-free in the child. Every planted fault must leave observable evidence that it fired.

Mutation seam

Kill mutations that ignore an fcntl return, skip post-policy close, omit a descriptor from classification, mark a phase applied early, collapse declared inheritance with temporary setup inheritance, or continue after an uncertain close.

Structural and unsafe qualification

  • one source of truth for fd disposition;
  • no independent hand-maintained scrub, allow, and post-close lists;
  • every child-window syscall appears in the unsafe ledger;
  • every authority-affecting syscall has a fail-closed policy;
  • the post-clone path remains allocation-free, lock-free, and divergent.

Cross-kernel Linux lane

Run the fd-census and fault fixtures across the supported Landlock ABI floor and at least one newer ABI. Record kernel release, Landlock ABI, seccomp mode, namespace profile, and compiled launcher fingerprint.

Acceptance criteria

  • A successful workload inherits exactly the descriptors declared for the exec image, with no launcher-temporary descriptors.
  • Landlock ruleset fds are closed after successful restrict_self and before seccomp/exec.
  • F_GETFD or F_SETFD failure cannot be reported as a successful hygiene/confinement phase.
  • Every required CLOEXEC bit is established and verified, not assumed from intent.
  • Every descriptor has one canonical lifecycle disposition.
  • Unknown descriptors are scrubbed or cause refusal.
  • Child cleanup failures call child_fail and never execute the target.
  • ConfinementPhaseResolved, AmbientAuthorityPhaseResolved, and ExecSucceeded cannot over-claim the fd postcondition.
  • The exec-target census matches the reference model under all supported mechanism combinations.
  • Mutation tests kill removal of the checked transitions and phase binding.
  • The unsafe ledger rejects best-effort policy for authority-bearing syscalls.
  • No async runtime, heap allocation, lock, unwinding, or new general IPC protocol enters the child window.
  • Normal-path overhead is reported by work counts plus latency distributions; no benchmark win may remove a descriptor check.

Relationship to existing issues

Non-goals

  • no claim that a leaked Landlock ruleset fd is by itself a sandbox escape;
  • no async executor or Tokio dependency;
  • no heap allocation, lock, formatting, or channel use in the child window;
  • no replacement of Landlock/seccomp with containers or a new runtime;
  • no broad bvisor rewrite;
  • no weakening the exact-fd contract because the leaked descriptor appears harmless today.

Bottom line

The launcher currently has a tiny honesty crack with a large semantic shadow: it calls descriptor cleanup best-effort, then reports the descriptor boundary as proven.

Close transitional authority explicitly, check every transition, and make the target's exec-time fd set a postcondition rather than a comment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions