Skip to content

fix: attribute /proc scan instability to the entry's owner - #44

Merged
xxvcc merged 6 commits into
mainfrom
fix/v2.10.6-scan-attribution
Sep 7, 2026
Merged

xxvcc merged 6 commits into
mainfrom
fix/v2.10.6-scan-attribution

Conversation

@xxvcc

@xxvcc xxvcc commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Problem

processSnapshotForUID let any vanishing numeric /proc entry clear the whole snapshot's stable flag, and processesForUID needs two consecutive stable empty snapshots before it will report a UID absent. Ordinary background activity on a busy host denied the scan; any local account could deny it deliberately with a plain fork loop.

Both call sites are load-bearing:

  • user.go:947 — the UID-reuse check after account creation, so invite fails
  • user.go:2083 — the post-SIGKILL sweep in TerminateProcesses, so account deletion fails

Fix

A cheap first pass records each entry's directory owner while the listing is still fresh. /proc/<pid> carries the process's real UID, and moving a thread to another UID needs privilege this threat model does not grant an unprivileged local user — so an entry owned by another unprivileged account can neither host nor fork a target-UID thread, and its disappearance says nothing about whether the target UID is absent.

Root, the target UID, and entries that vanished before they could be attributed all stay conservative. Accepted residual, recorded in the comment: a multi-threaded process whose leader dropped to another UID while a worker still holds root is attributed to the leader.

Measured (four-core host, 30 scans per run)

Load Before After
Idle 0/30 0/30
~50 processes/second 8–19/30 0/30
Continuous tight fork loop 27–29/30 2–3/30

An earlier attempt that instead spread the retries over a longer delay was measured and discarded: it made a moderate load worse (27/30), because the two confirming snapshots must stay close together to fall between disturbances. The tight retry is deliberate.

Diagnostics

The refusal now reports how many attempts were disturbed, so the operator can distinguish host churn from a fault in the account being revoked.

The at-queue bound gets the same treatment: its refusal names that the count spans every account's jobs and points at atq, and its comment records why raising the bound would only trade a fast refusal for a slow timeout (each job costs one probe under a 30-second deadline, so 4096 already sits at what that deadline can complete), and that this cleanup runs only after revoke has stripped the grants and disabled the login — so a filled queue delays deletion and leaves the account retained and disabled, never privileged.

Tests

New TestProcessSnapshotAttributesInstabilityByOwner covers third-party / root / target owners. It was mutation-checked: reverting the condition to the old logic makes it fail (stable = false, want true for owner 65534), so it is not a test that passes for the wrong reason.

go vet -printf.funcs=printf,errorf,warnf ./... and go test -race ./... pass.

🤖 Generated with Claude Code

xxvcc and others added 6 commits September 6, 2026 04:25
processSnapshotForUID let ANY vanishing numeric /proc entry clear the whole
snapshot's stable flag, and processesForUID needs two consecutive stable empty
snapshots before it will report a UID absent. Unrelated background activity on
a busy host therefore denied the scan, and any local account could deny it on
purpose with a plain fork loop. Both call sites are load-bearing: the UID-reuse
check after account creation, so invite fails, and the post-SIGKILL sweep in
TerminateProcesses, so account deletion fails.

A cheap first pass now records each entry's directory owner while the listing
is still fresh. /proc/<pid> carries the process's real UID, and moving a thread
to another UID needs privilege this threat model does not grant an unprivileged
local user, so an entry owned by another unprivileged account can neither host
nor fork a target-UID thread and its disappearance says nothing about whether
the target UID is absent. Root, the target UID, and entries that vanished
before they could be attributed all stay conservative. Accepted residual: a
multi-threaded process whose leader dropped to another UID while a worker still
holds root is attributed to the leader.

Measured on this four-core host, 30 scans per run: continuous unrelated forking
went from 27-29 failures to 2-3, a steady 50 processes per second from 8-19 to
none, and an idle host stayed at none. An earlier attempt that instead spread
the retries over a longer delay was measured and discarded: it made a moderate
load worse (27/30), because the two confirming snapshots must be close together
to fall between disturbances.

The refusal now reports how many attempts were disturbed, so the operator can
distinguish host churn from a fault in the account being revoked. The at-queue
bound gains the same treatment: its refusal names that the count spans every
account's jobs and points at atq, and its comment records why raising the bound
would only trade a fast refusal for a slow timeout, and that this cleanup runs
after the grants are already stripped and the login already disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None of the three is reachable on a released build; each is a guard that does
not hold on its own terms, found while triaging the audit's low-severity list.

planLogin's deferred Match-Group branch returned fixSSHD from `fix == "yes"`
alone, while the blocker path eleven lines below refuses when a.SSHD is nil.
The authorization survives confirmLogin and is applied in
installCredentialsAndGrants, which calls a.SSHD.Grant after useradd, after the
registry row is completed, and after the key is written — so an unwired manager
would abort a root process mid-transaction rather than fail closed. Production
always wires SSHD, so this is defence in depth, restored by gating the deferred
authorization on the same condition.

MatchBlock validated nothing while Grant and Remove both re-validate "even if a
future caller forgets to validate", and dropIn re-validates the group name. Its
output is not decorative: printSSHDFixHint renders it as a heredoc the operator
is told to write into a privileged sshd config and reload. A name carrying a
newline therefore paints extra directives into that paste; the added test
demonstrates exactly that against the unguarded renderer.

clearReusedUsername read Registry.UnitFor to recover "the only direct handle to
an at job from an older run", but the Lookup above it already fails the whole
invite for any username that still holds a registry row, and UnitFor scans that
same row set — so it could only ever return "". The dead read and its dead
error path are gone and the comment now says what actually clears an older run:
Cancel sweeping by name across every namespace.

Both new tests were mutation-checked: reverting each guard makes its test fail.

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

inviteTransaction.rollback walks its cleanups in reverse, and the cancellation
was registered at scheduling time — last in, so first out. It therefore ran
before rollbackInviteAccount, which is the step that decides whether the account
can be deleted at all: mayDelete is sudoRemovalConfirmed && sshdRemovalConfirmed,
so a failed grant removal deliberately retains a disabled account. The account
was then left holding a live sudo drop-in with nothing scheduled to come back
for it, which is the same shape as the compact defect fixed in v2.10.5: the
state that keeps the grant also destroys the mechanism that removes it.

The cancellation moves into the first-registered cleanup, which reverse order
runs last and which already gates on all three confirmations before releasing
the registry row. That matches the rule revoke states outright when it leaves a
task armed on a failed teardown: only once the account is provably gone is the
fallback safe to remove.

Nothing covered this ordering, which is why it survived. The new integration
test drives a real invite whose expiry step fails after the task exists, with
sudo removal failing during rollback, and asserts the task survives. It was
mutation-checked: restoring the old registration makes it fail with "rollback
cancelled the auto-revoke task while retaining an account that still holds a
sudo drop-in".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
printInvite renders into a bytes.Buffer and relies on clear(out.Bytes()) to
destroy the one-time key. clear reaches only the buffer's current backing array,
and the render keeps writing after the private-key heredoc: the security note
always, the sshd and permanent-account notes when they apply. Each write past
capacity makes bytes.Buffer allocate a new array, copy, and orphan the old one —
still holding the complete PEM, unreachable and unclearable, for the rest of the
process's life. In menu mode that is until the operator quits, across later
privileged actions, and into any swap or hibernation image.

Growing once to a reservation far above any real invite keeps the key in a
single array that the deferred clear actually reaches.

The accompanying test is deliberately narrow and says so: Go offers no way to
reach an orphaned backing array, so it cannot fail if the Grow call is removed.
It bounds the thing that can rot instead — the render outgrowing its reservation
as fields and notes are added — and was checked against a lowered reservation to
confirm it fires. The widest real render is 1395 bytes against a 16 KiB reserve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
honorExistingQuarantine re-asserts the access gates when an old expiry task
races a live quarantine. Removing the sudo grant and the sshd exception by name
is safe against any target, because those files are this tool's own, but
DisableLogin mutates the account itself and was issued against the username with
no reference to the passwd snapshot loadAccount captured.

Every other destructive-adjacent step in this file binds to that snapshot:
teardownLocalAccountWith checks before and after its DisableLogin,
beginIdentityQuarantine checks immediately after its own, and
revalidateAndPrepareDeletion re-reads passwd and requires SameAccountIdentity
before disabling anything, with the comment that spells out why — do not disable
one generation and signal another. An account replaced out of band between
loadAccount and this phase would have been disabled here and then reported as a
successfully re-gated quarantine.

The phase had no test at all, which is how the gap survived. It now has three
covering the matching identity, a replaced one, and a vanished one, checked
against the unguarded version to confirm the two refusals actually depend on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two boundary defects, both about state this tool creates but could then fail to
account for.

InspectIdentityAllocation took its floor from login.defs UID_MIN/GID_MIN and
accepted anything validate.AccountID allows, i.e. 1 and up, while the deletion
protection treats a UID below 1000 as system range: protected unless the
registry row is present, identity-bound and marker-matched. On a host carrying
the legacy 500 floor the tool would therefore mint accounts it can only delete
while that row survives, and a lost row or a legacy-degraded one leaves the
account permanently undeletable — the identical situation above the boundary
still has explicit recovery. The floor is now one named constant shared by the
allocator and both protection checks, so they cannot drift.

Store.Init branched only on whether the registry data file existed. When absent
it seeded the sequence with 0, which is a no-op against an existing sequence,
wrote a bare v5 header, and satisfied requireIdentitySequenceCovering trivially
against no rows. A host that lost only registry.tsv was therefore accepted as a
fresh install, silently, while its identity sequence still recorded every
allocation ever made. This package fails closed on the opposite asymmetry and
ships recover-identity-sequence to repair it; this direction had nothing.

Init deliberately does not fail closed. Doing so would take doctor and uninstall
down with it, turning a recoverable state into an unrecoverable one, so it
records the surviving high-water mark and doctor reports it. Only Init can
observe the condition: once it recreates the file, an empty registry satisfies
every sequence invariant.

Both tests were mutation-checked against the unguarded code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xxvcc
xxvcc merged commit 7fa5876 into main Sep 7, 2026
8 checks passed
@xxvcc
xxvcc deleted the fix/v2.10.6-scan-attribution branch September 7, 2026 06:24
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