Skip to content

Pin engine-teardown signalling with a pidfd, re-verify before kill (refs #298) - #303

Merged
vyskocilm merged 1 commit into
mainfrom
issue-298-pidfd
Aug 22, 2026
Merged

Pin engine-teardown signalling with a pidfd, re-verify before kill (refs #298)#303
vyskocilm merged 1 commit into
mainfrom
issue-298-pidfd

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

Gap

internal/engine/reap.go signalOwned scanned /proc for processes whose command line names this run's engine socket, then syscall.Kill(pid, sig) each BY NUMBER. Its own comment (waitQuiet) says these are "not our children, so there is no wait(2) to call on them" — exactly the foreign-pid case. Between the scan and the kill the number can be reaped and recycled to an unrelated process, which snug then SIGKILLs. Same reuse TOCTOU #294 removed from the orphan sweep; this was the live instance the #298 audit named.

Fix

signalOwned now, per discovered pid: unix.PidfdOpen(pid) → re-read the cmdline through the number to confirm the pinned process still names the engine → unix.PidfdSendSignal. The pidfd pins the task the number named AT OPEN TIME, so the signal can never land on a later reuse; the re-read drops a number the scan matched but that no longer names us (recycled, or an engine that exited between scan and signal). Returns the pids actually signalled (was: all discovered; the single caller in engine.go discards it).

cmdlineNamesPath factored out of ownedPIDs and shared.

Namespace correctness (#167)

The pids are HOST-namespace: ownedPIDs reads the host /proc and matches the socket path in a HOST command line, so pidfd_open — which takes a pid in the caller's namespace — refers to the same task. This path never sees the engine's OWN pids, which are numbered in the engine's namespace and are not what it matches.

Verified

  • make gate green (internal/engine unit tests included; the new test needs no sandbox).
  • TestSignalOwnedReverifiesThroughAPidfdBeforeKilling: a live process NOT naming the socket, handed to signalPinned by pid, must survive — the reuse case. Mutation-checked: dropping the re-verify guard kills it (signalPinned killed pid … which does not name …). Positive control: a process that names the socket is signalled and dies from SIGKILL, so the negative cannot pass on a signal path that kills nothing.

Scope / not in scope

Own-child cmd.Process.Kill() sites (reaper.go, stage/serve.go, stage/stage.go, sandbox/netns.go pasta, cli/attach.go) are reuse-SAFE — the unreaped child holds its number — and left as-is. Reading /proc/<pid>/… for DATA by number stays; the rule is about signalling and liveness-identity.

.claude/agents/go-implementer.md gets the standing rule (pin with a pidfd to signal/track; bare numeric pid only safe for your own not-yet-waited child; namespace caveat #167).

Not a hole-adding change — it removes a TOCTOU on a teardown kill path — so no new mount/seccomp/@net surface; the regression test is the deliverable.

refs #298

…efs #298)

engine/reap.go signalOwned scanned /proc for processes whose command line
names this run's socket, then syscall.Kill(pid) each by number. Foreign-pid
case: between the scan and the kill the number can be reaped and handed to an
unrelated process, which snug then SIGKILLs -- the reuse TOCTOU #294 removed
from the orphan sweep, still live here.

signalOwned now pins each discovered pid with unix.PidfdOpen, re-reads the
cmdline through the pinned number to confirm it still names the engine, and
only then unix.PidfdSendSignal. The pidfd pins the task the number named at
open time, so the signal can never land on a later reuse; the re-read drops a
number the scan matched but that no longer names us (recycled, or an engine
that exited between scan and signal). Returns the pids actually signalled.

The pids are HOST-namespace: ownedPIDs reads the host /proc and matches the
socket path in a host cmdline, so pidfd_open (caller's namespace) refers to the
same task. It never sees the engine's own pids, numbered in its own namespace
(#167). cmdlineNamesPath factored out and shared with ownedPIDs; reading
/proc/<pid>/cmdline for DATA by number stays -- the rule is about signalling.

TestSignalOwnedReverifiesThroughAPidfdBeforeKilling: a live process not naming
the socket, handed to signalPinned by pid, must survive (the reuse case);
mutation-checked (dropping the re-verify kills it). Positive control: a process
that names the socket is signalled and dies from SIGKILL.

go-implementer.md gets the standing rule: pin with a pidfd to signal or track a
process; a bare numeric pid is only safe for your own not-yet-waited child, and
only ever safe to READ /proc for data. Namespace caveat (#167) noted. Own-child
cmd.Process.Kill() sites left as-is.
@vyskocilm
vyskocilm merged commit 766850f into main Aug 22, 2026
5 checks passed
@vyskocilm
vyskocilm deleted the issue-298-pidfd branch August 22, 2026 07:07
vyskocilm added a commit that referenced this pull request Aug 22, 2026
…reen (refs #345, refs #348, refs #31, refs #344) (#362)

* Re-derive three teardown justifications, and sanitize a cmdline on screen (refs #345, refs #348, refs #31, refs #344)

Four comments that describe something other than what the code does, plus the
half of #344 that needs no decision. #344's MATCHER is NOT fixed here — see
below.

#345 — orphansweep.go claimed "while the fd is held the kernel cannot recycle
that pid number, so there is no residual pid-reuse window at all". MEASURED
false on this kernel, in a fresh user+pid namespace writing ns_last_pid:

  pinned pid           = 7 (reaped, pidfd still open)
  fdinfo says          = Pid: -1  NSpid: -1
  next child got pid   = 7  (collision=true)
  signal through pin   = no such process
  new occupant alive   = true

The NUMBER recycles. What the pin guarantees is the other half and it is the
half the sweep needs: the pidfd refers to the task it opened, dies with it, and
returns ESRCH rather than reaching the new occupant (#303, confirmed by the same
run). That is what makes the kill fail CLOSED. The comment now says so, and says
what follows: anything reading /proc/<pid> BY NUMBER after the open may be
describing a different task, which is what the starttime and namespace-inode
checks are for. The false version was the licence a later edit needs to act on
the number inside that window.

#348 — reap.go justified identity-by-path with the WRAPPER case: a podman
forwarding to the host, engine parented to the host's systemd. Closed since Tier
C, two independent ways. The argv names guest paths only — Spec resolves --root,
--runroot and the socket through guestPath, which refuses a path no graft
exposes — and those resolve only inside the derived mount namespace, so an
engine exec'd on the host cannot open its store or bind its socket. Preflight P1
refuses the shim before that, by name, measured on this host.

Path identity stays, for the reason that holds NOW: it verifies the cascade, and
a recorded pid is not an option because libpod's are numbered in the engine's own
namespace (#167). A wrapper re-execing INSIDE the derived view is still possible
and is not that case — it is a descendant in the engine's pid namespace, so the
cascade covers it.

TestEngineArgvNamesOnlyGuestPaths makes the first half checkable: every
path-shaped argv element is under /snug, and spec.Sock is still the HOST path
because P1's waitForSocket and the proxy dial it. Positive control asserts the
argv carries at least three paths, so it cannot pass on a Spec that stopped
resolving.

#31 — inengine.go described step 4 as "a plain private COPY of the host tree,
deliberately NOT derived from the resolved Policy — that is Tier C's later
pieces", contradicted by its own code 210 lines below: setns into the sandbox's
mount namespace, unshare, MS_REC|MS_PRIVATE, then move_mount every graft. Step 4
is now the real four calls in order (4a-4d), states that p.Grafts is their author
(invariant 6), and keeps the distinction the stale text got right: the view is
not the container bind filter, and neither may be read as the other.

Same statement at enginefork.go's #146 inventory — "no derived mount VIEW, no
grafts — those are issue #125's later pieces" — now says C0 is the PRECONDITION
for the view rather than a piece that shipped without it.

#344, PARTIAL. describe() rendered a matched process's command line with only
NUL replaced. A command line is not snug's text, so an ESC there erases the line
snug printed above it; it goes through policy.VisibleText now.
TestNoSnugScreenEmitsARawControlCharacter drives the --dry-run screen and does
not reach internal/engine, so TestDescribeSanitisesACommandLine does, with a
live decoy. Mutation observed: without the sanitiser, "describe emitted '\x1b'
raw".

That test's own two false starts are worth carrying, both being harness traps
#344 names: `/bin/sleep "30<ESC>..."` rejects the duration and the decoy becomes
a ZOMBIE, whose cmdline reads back empty; and a single-command `sh -c` is
exec-optimised and loses the crafted argv. The working decoy is
`sh -c 'sleep 30; :' <argv>`, and the empty-cmdline window (#318) is polled past
rather than raced.

NOT FIXED: paths() still returns the host socket while the engine's cmdline names
the guest one, so stopLocked's step 3 remains a check that cannot fail. Handed
back — correcting the matcher changes what step 3 verifies and when, because
stopLocked runs while the engine is deliberately ALIVE and exclude holds no
engine pid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix reapidentity_test's Spec call for #360's fifth parameter (refs #345, refs #307)

PR #360 (7d4a351) gave Engine.Spec a fifth parameter *SignaturePolicy after
this branch merged main. New test reapidentity_test.go called the old
signature:

    vet: internal/engine/reapidentity_test.go:34:54: not enough arguments in call to e.Spec
    	have (*policy.Policy, string, []string, bool)
    	want (*policy.Policy, string, []string, bool, *SignaturePolicy)

Passes noSignaturePolicy(t) — "this host configured none" — same as every
other Spec call in the package. NOT nil: Spec refuses nil outright as a
caller that skipped ProjectHostSignaturePolicy, which is #307's no-fallback
clause.

The value does not change what TestEngineArgvNamesOnlyGuestPaths measures.
The signature policy reaches the engine as a generated
$HOME/.config/containers/policy.json (writeEngineHome), and podman 5.8.4 has
no --signature-policy flag at all (engine.go:1068), so no value of it puts a
path in the argv this test sweeps. Comment states that at the call site.

make gate green.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant