You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The single tracker for redteam rounds that are owed on merged code. Maintainer's ruling, 2026-08-23: one issue, not one per round. A new owed round becomes a row here, never a new ticket.
A round is deferred, never waived — the standing ruling is that a round runs against the code that ships, not against a lane in flight, because attacking a moving diff records that a surface was attacked when what was attacked was a draft.
OWED-REDTEAM.md keeps the long-form rows and the reasoning. It is gitignored, so this ticket is the half that survives the machine. A row leaves this list when the round runs and its result is recorded — findings or nothing, because a round that finds nothing is a result and an unrecorded round is not.
No escape, no finding — and one editorial fix owed that is deliberately NOT
folded into this clean discharge, because row 1 going green is exactly what
would bury it. See "Fix owed" at the end of this row; it stays OPEN while the
round above it is discharged.
Merged in d21d03f (PR #363). #344 was closed at merge; the round was never run.
The matcher. Decoys naming the GUEST spelling /snug/engine/sock/podman-<pid>.sock versus the HOST spelling /tmp/snug-<uid>-<pid>/sock/podman-<pid>.sock. The guest string is one a payload can author on its own argv, which is why the ordering below is load-bearing.
Whether the ORDERING that makes the guest sock non-forgeable actually holds. The sweep runs only after the stage is reaped and the sandbox's pid namespace has collapsed, so no payload process is alive to match. That is the claim the design rests on, and moving the sweep earlier re-opens it. Never attacked.
Engines, per-claim and never per-branch: everything behind 80ca6b2 was measured against test/integration/testdata/fakepodman via $SNUG_PODMAN; the integration work drives the pinned static podman 5.8.4 bundle at ~/.local/opt/podman-static. podmanBundle/requireRealEngine skip when it is unavailable, so PASS-not-SKIP is the proof a real engine served a request. /usr/bin/podman here is distrobox's distrobox-host-exec shim, refused by preflight P1.
Row 1's verdict criteria — written before the result, on purpose
Running 2026-08-23 against 3491d0b. These are on the ticket rather than only
in a session brief because a brief dies with a context — the defect this tracker
already committed once today (see row 3's correction block). Written before
the round returns, so the result cannot be graded against criteria shaped by it.
Target 3 — the sweep/collapse ordering — is the one that matters, and it is an
ORDERING claim. The design rests on: the sweep runs only after the stage is
reaped and the sandbox's pid namespace has collapsed, so no payload process is
alive to match a forged guest sock. The guest spelling /snug/engine/sock/podman-<pid>.sock is one a payload can author on its own
argv, which is what makes the ordering load-bearing rather than incidental.
Clear it by observing the order, never by reading the code that intends it.
Reading stopLocked and concluding the sweep follows the collapse grades the
author, not the run. What is needed is evidence from a live run that the collapse
had already happened when the sweep looked, measured from outside the code
that claims it.
Then the question that can actually fail: what ENFORCES the order? "Does the
sweep run later" can only confirm. "Can anything make the sweep run early, or the
collapse run late" can refute — and a payload that delays the collapse past the
sweep breaks the claim without breaking a line of code.
Three outcomes, and the round names whichever is true:
Structurally enforced — a wait, a synchronisation point, a kernel
guarantee. Name the mechanism. Clearing, and a strong one.
Nothing enforces it; it holds because one side is fast and the other is
slow.Finding. Stop, measure, report. A timing-dependent ordering that
holds today is not a property, and it goes on this tracker as a row rather
than into a clearing with a caveat attached.
A payload can invert it. Escape-shaped finding. Stop, measure, report.
If no payload can be constructed that is still alive when the sweep looks,
then what made that impossible IS the property to name — a far stronger
sentence than "the sweep runs later", and the one a later change re-opens.
The other two targets in this row (the guest/host matcher spellings, and the SIGKILL escalation in stopLocked step 3 that no test reached until #363
because the matcher bug made it unreachable) are graded the same way: one unit
test and zero adversarial exposure is the starting position, not a baseline.
Row 1 outcome — 2026-08-23
Ran against 3491d0b. PASS-not-SKIP, version pulled by hand per #384: /v1.41/version inside a live -p @podman-socket sandbox returned "Podman Engine", "Version":"5.8.4", ApiVersion 1.44, go1.25.11, opensuse-tumbleweed; libpod/info gave remoteSocket.path = unix:///snug/engine/sock/podman-<pid>.sock, buildah 1.43.2,
crun 1.28, netavark 2.0.0. A real alpine:3.20 pull landed in the store and a
real build with a RUN step executed. The decoys in targets 2–3 were real
crun processes in the engine's own pid namespace, not fakepodman.
Target 1 — matcher, cleared. Live scan of host /proc during a run: the
engine's cmdline carries the guest spelling
(unix:///snug/engine/sock/podman-1018693.sock) and nothing on the machine
carries the host spelling. Property: Spec records the argv it built and paths() reads that exact recorded string — one author for the host→guest
mapping. That paths() returns e.guestSock was verified in the tree, not
relayed.
Target 2 — SIGKILL escalation, kill leg reached LIVE. A host decoy naming the
guest sock was matched by signalOwned and SIGKILLed ~3.7s after payload exit.
The persistent kill -9 WARNING and describe() were not reached and cannot be by a payload — they need a matched process surviving quietBudget+killBudget (2s+3s) including a direct SIGKILL. Stand-in
dimensions stated as required: the host decoy shares exactly one dimension with a
leaked engine — outside the cascade's reach, carrying the guest-sock string —
and shares neither pid-1-of-its-own-namespace status nor the container cascade.
Right stand-in for "a leaked engine", wrong one for "a clean run". No 15.3s-style
number was inherited.
Target 5 — describe() sanitizes today, measured. It renders through policy.VisibleText before the length clamp; the call site was confirmed. The
committed test drives a live decoy with $'30\x1b[1A\r FORGED-BY-A-CMDLINE'
and asserts no raw ESC survives, with a positive control that the argv reached
the renderer. #344/#362's gap is closed.
Target 3 — the ordering — outcome 1 (structural), by a DIFFERENT mechanism than
the code's comment names. Observed from outside the code, 2ms poll on host /proc, 1500 forged engine-ns processes from a build RUN loop under CPU load:
t=679.81 count=1502 1500 forged + build sh + host decoy
t=705.78 count=1501 payload exits
t=706.68 count=1 ALL 1500 engine-ns forgeries gone
t=709.44 count=0 snug's sweep SIGKILLs the surviving host decoy
The collapse preceded the sweep's first poll by ~0.76s. The inverting input
could not be constructed, and the impossibility is the property: the payload has
its own pid namespace (pid:[4026534104] vs host pid:[4026531836]), CapEff: 0, unshare -p/-U → EPERM, HostConfig.PidMode="host" → 403 at the
proxy. So every process it can create lives in a pid namespace whose init is Pdeathsig-chained and reaped by st.Close()'s Wait() before stopLocked
runs; pid-1's SIGKILL and zap_pid_ns_processes are unblockable; the 5s budget
outlasts any collapse the machine can hold.
Clearing sentences — quote verbatim
Matcher — one author for the host→guest mapping: Spec records the string
the engine's argv carries, and paths() reads that string.
Escalation — the kill leg fires against a process outside the cascade, and
the payload cannot create one.
Ordering — the payload has no route to a host-namespace process, so every
forgery lives under an unblockable pid-1 SIGKILL that the sweep's fixed budget
outlasts. The guarantee is confinement plus budget, not the wall-clock
ordering the comment names.
Fix owed — OPEN, and kept out of the green above on purpose
reap.go/stopLocked phrase the guarantee as a strict wall-clock ordering — "Stop sweeps only after the collapse … there is nothing of the payload's left to
match." That is slightly stronger than the timing strictly guarantees: a
poll could land while the cascade is completing and momentarily match a dying,
payload-argv'd engine-ns process. The security property survives anyway, carried
by confinement + unblockable pid-1 SIGKILL + fixed budget — which is stronger than the ordering the comment names. So the code is right and its
comment names the wrong load-bearing property; a future reader quoting "the
sweep runs later" leans on the one part that is only usually true.
This is its own shape, distinct from row 3's and from today's after-the-fact
accounts: not stale, not wrong-when-written, but true-while-attributing-
correctness-to-the-wrong-reason. A round wrote no fix (discovery stays separate
from the graded code), so the reword is owed to a later edit lane, referencing
this row. It is filed here rather than as a new ticket per the ruling that
redteam findings track under #369, and it stays OPEN while row 1's round is
discharged — the whole point being that a green row must not swallow it.
Row 2 — #339/#340 — DISCHARGED 2026-08-23. ONE CONFIRMED EXPLOIT (sev:medium, OPEN), two reasoned findings, two clearings
Finding A is a reproduced, live exploit against 902cf53 — a payload
destroyed ANOTHER run's container record, host state that outlives the sandbox.
It stays OPEN and needs a fix; the round wrote none (discovery stays separate from
graded code). Original row brief kept below the outcome.
Row 2 outcome — 2026-08-23
PASS-not-SKIP, version by hand:GET /v1.41/version through /snug/podman.sock returned Podman Engine 5.8.4, opensuse-tumbleweed-20260724,
APIVersion 5.8.4, go1.25.11, kernel 7.1.8. Real crun/conmon containers, real alpine:3.20 registry pull landed in the store. No fakepodman, no SKIP.
FINDING A — CONFIRMED EXPLOIT, sev:medium — filed standalone as #386 (with full reproduction) — DELETE ownership TOCTOU via un-gated rename
Ownership is checked on a mutable NAME, and the engine re-resolves that name at
delete. Verified in tree:
handleContainerDelete (proxy.go:340) checks ownership via containerRunLabel(ctx, id) where id = segs[1] — the client's request
string, which may be a container NAME, not the immutable 64-hex id.
It then forwards the ORIGINAL request: p.forward(w, r, nil), and forward()
(proxy.go:600) rebuilds the URI from r.URL.RequestURI() — so the engine re-resolves the same name a second time.
POST /containers/{id}/rename is ALLOWED and NOT ownership-gated: allowed()'s containers clause refuses only {archive,export,commit,update};
rename falls through to return true.
Composition: proxy inspects name victim → resolves to OWN (this run's
label) → ownership passes. A concurrent, un-gated rename swaps victim onto
another run's container BEFORE the forwarded DELETE reaches the engine → the
engine deletes the other run's container. #284's shape at a new surface
(row 2 angle 3 / row 4): ownership checked on one string, another object
forwarded — divergence by name remapping rather than by inode.
Reproduced live, podman 5.8.4, two runs on one shared target/store:
Run A (snug.run=1028668) created container foreign, id 9603bf3f911f;
persisted across teardown.
Run B (snug.run=1029200): baseline DELETE /containers/foreign → 403 "container foreign was not created by this sandbox run … stamps
snug.run=1029200" (ownership works). Primitive: POST /containers/foreign/rename?name=foreign_renamed → 204 (a payload
renamed ANOTHER run's container).
Racer (3 deleter + 3 swapper threads over a raw unix socket): swapper flips victim between own and foreign via rename; deleter issues DELETE /containers/victim. foreign went alive → GONE in 6 attempts /
0.5s. The only DELETE issued was against victim; foreign is removable
only if forward-time victim==foreign while inspect-time victim==own — the
TOCTOU. Structural, not a timing artifact of the harness — the window opens
every time at podman's rename/delete latency.
Impact: destruction of host state that outlives the sandbox, defeating the
exact property the ownership check exists to provide. Same bracket as #339. Fix direction (NOT written, handed back): canonicalize to the immutable id
before the check AND forward a delete addressed by that id; or gate rename by
ownership. Full reproduction (payloadA/B/C, verbatim, both TOCTOU confirmations) is on #386.
FINDING B — reasoned, sev:low — cross-run rename is un-gated
Measured: 204 renaming another run's container. Standalone it lets a payload
rename any other run's containers, breaking that run's ability to address them by
name. It is the enabling primitive for A; gating rename by ownership closes
both.
FINDING C — reasoned, sev:low — no timeout on the engine round-trip (angle 2)
p.client (proxy.go:79) sets no Timeout; containerRunLabel uses r.Context(), which the payload controls. A never-answering inspect hangs the
DELETE handler indefinitely. Bounded by the payload's own connections
(self-DoS), and teardown uses srv.Close() not graceful Shutdown, so collapse
is not blocked — hence low, not a cross-run DoS. Exactly angle 2: a functional
test passes straight through. Fix: bound the round-trip with its own context
deadline.
CLEARINGS — naming the property
isPrune, "any prune-shaped spelling evades segs[len-1]=="prune"": CLEARS.
Every spelling refused 403 live — containers/prune, trailing-slash prune/,
encoded prun%65, libpod/system/prune?all=true&volumes=true, libpod/containers/prune, build/prune, images/volumes/networks prune. Property:isPrune tests segs[len-1]=="prune" AFTER normaliseFull
collapses empty segments (kills the trailing-slash bypass), strips version and libpod prefixes, and refuses ./..; the ServeHTTP switch consults isPrune regardless of the libpodExamined gate, so system/prune is caught
even though system is not in libpodExamined.
Angle 1, hostile engine ANSWER: CLEARS structurally. The engine is snug's
own, inside the trust boundary. stampRunLabel (create.go:976) REPLACES any
client-set snug.run at create, so a payload cannot forge the label into the
answer. The check defends against the payload's REQUEST, not the engine's
answer; a compromised engine already owns everything the check protects.
Cleanup verified: no process references the round's target or store; the
bwrap/podman forest on the box is other sessions', attributed by /proc cmdline
and nothing killed.
Original row 2 brief — the headline is the MACHINERY rather than either finding
Nothing in internal/dockerproxy had ever round-tripped to the engine to decide a request.containerRunLabel (proxy.go:383) is the first: a synchronous dependency on the engine inside the decision path of a destructive request (DELETE /containers/{id}), issuing GET /v1.41/containers/<id>/json with the id url.PathEscape'd. Six fail-closed answer shapes are handled — transport error, non-200, undecodable body, absent Config, null Config, absent label — plus an empty run label, which refuses rather than skipping.
Three angles the lane could not test and did not claim:
What a hostile engine ANSWER buys. The proxy now believes a JSON document the engine produced.
What a NEVER-ANSWERING one buys. The request holds a proxy handler open — there is no timeout on that round-trip beyond the client's context. Reasoned, not measured.
Under those, not above them: the enumeration route (GET /containers/json?all=1, then DELETE each id); whether any prune-shaped spelling evades isPrune's segs[len(segs)-1] == "prune" (trailing slash, encoded segment, a version prefix surviving normalisation); and whether any request reaches the engine with a body libpodExamined did not read.
Residuals the PR does not claim closed:DELETE /networks/{id} stays allowed deliberately; no integration test drives a real docker client through docker run --rm end to end.
Result: no escape, no finding. Ran against 9643e4f; #383's branch was never
read. #125 closed on this round, and #146's verdict is confirmed — the
outcome block sits at the end of this row. The brief is kept below unchanged: a
round is graded on what it was asked to do as well as on what it found, and a
brief rewritten after the result cannot be read that way.
The row this ticket was missing while the round on it was already running. It
was carried in a session brief and in PLAN.md, both of which die with a
context or a machine; this ticket is the half that survives, and a row that is
not on it is not tracked. Recorded late, on purpose visibly.
#125 is the only part of the Tier C ladder still open, and it is open on one
thing: this round. C0/C1/C2-path/C2-view/C3 are built and merged.
The claim under attack. A container's mount visibility is structural — the
engine's mount namespace is derived from the sandbox's view rather than from a
private copy of the host tree — so a path the sandbox cannot see is not a path
the engine can be talked into mounting. Every -v, --mount and --build-context shape naming a path the sandbox cannot see, run against the
derived view.
Two independent arms, and grading only one clears both by accident. The
engine's create-time auto-create is real and MkdirAll-shaped, and it is
harmless for two reasons that do not depend on each other: the derived view,
and the bind filter. A clean sweep that cannot say which arm refused a given
shape has graded one arm and cleared two. Getting a shape past one and watching
the other catch it is worth more to this ticket than a quiet pass.
Where this round is most likely to lie to itself.NetworkMode: "host" is
required to reach the mount step at all — without it the start fails at
networking and the bind question is never asked. A run that died at networking
and a run that reached the mount step and was refused look identical from
outside. The positive control is therefore a shape expected to reach the mount
step and be refused there, with output showing it got that far.
This round also decides #146's verdict.The proxy shrinks by exactly one
arm rests on the derived view holding, and has never been attacked. Whatever
sentence clears (or falsifies) it here is the sentence #372 copies into INDEX.md as the permanent table, so it names the property that carried the
clearing — not "the derived view held", but which property refused which shape.
Scope, stated to the round as a hard stop: it grades origin/main = 9643e4f. PR #383 is in flight and unmerged, so it is out of scope — an attack reaching
into what #383 changed stops and reports rather than continuing. Grading an open
branch records that a surface was attacked when what was attacked was a draft;
this is the same separation as "a round that produces a fix hands back", one step
earlier.
Outcome — 2026-08-23
PASS not SKIP, with no substitute anywhere./v1.41/version through the
proxy returned "Podman Engine", "Version":"5.8.4", opensuse-tumbleweed, go1.25.11. No fakepodman, no decoy, no SKIP in this round, so nothing here
rests on a substitute and there is no dimension-sharing caveat to write. That
is worth stating positively: the usual caveat is absent because it was measured
away, not because nobody looked.
Refused, every spelling tried.-v / --mount / --build-context /
volume-create for a path the sandbox cannot see: /etc, /root, /, /snug/engine/store, type=tmpfs, rshared as a legacy option, rw over a ro-granted /usr, volume create type=none,o=bind,device=/etc, build volume=, additionalbuildcontexts (both Value and DownloadedCache), IsURL, nsoptions=net=/proc/1/ns/net, networkmode=host, .. traversal out
of the target, a symlink to /etc, a symlink to /snug/engine/store. Every 403
carried "cause":"snug policy".
The positive control worked, which is the part that makes the negatives
readable. The failure mode this row warned about — a run that died at
networking being indistinguishable from a run refused at the mount step — did
not occur: -v /usr:/usr:ro and the target root both returned 404 image not known, so they were forwarded and served. Strongest control: a build with volume=<target>:/x:ro returned 200 and real podman 5.8.4 entered the build,
failing only at stat …/build/Dockerfile because the tar was empty.
Both arms graded three ways, one of which is stronger than the live separation
the row asked for.
The divergence, live. A symlink to /snug/engine/storedangles in the
sandbox's namespace and is real in the engine's; resolveForwardable
refuses it — a check that exists only because the two namespaces diverge.
Arm B measured directly, independent of the proxy, from the host through the
live engine's /proc/<pid>/mountinfo and /proc/<pid>/root: engine /etc
carried 14 entries against the host's 111; no /etc/shadow; /proc/<engine>/root/homeempty; the engine's root is the sandbox's, with
no /oldroot and no host /etc, /home or /root bind.
The honest limit, recorded rather than smoothed over. The round could not
get an ungranted host path past arm A in order to watch arm B refuse it live —
because by construction arm A passes exactly the sandbox-visible set, which is
exactly what the derived view contains. That is invariant 6 doing its job, not
a gap in the round: two mechanisms over one policy.Policy cannot disagree, so
the experiment that would show them disagreeing has no input. The direct
structural measurement was substituted for it and is stronger than a single live
refusal would have been.
The clearing sentence — owed to INDEX.md via #372, quote it verbatim
The container engine cannot name an ungranted host path because it runs in the
sandbox's own mount namespace plus this run's grafts and nothing else
(internal/stage/inengine.go), and the proxy's HostPathVisible filter
(internal/policy/graft.go) refuses such a path at CREATE anyway — two
mechanisms over one policy.Policy, verified by execution against podman
5.8.4: engine /etc carried 14 entries not the host's 111, /home was empty,
and no -v/--mount/--build-context shape for an ungranted path was
forwarded.
The code half was verified in the tree rather than relayed: inengine.go does setns(mntFD, CLONE_NEWNS) into the sandbox's namespace and then unshare(CLONE_NEWNS) for the engine's own copy, and the only OpenTree is the
graft authority running before the setns. There is no open_tree of the host
root anywhere on that path.
Per the standing rule, this names the PROPERTY that carried the clearing —
the derived namespace plus grafts and nothing else, and one policy.Policy
behind both mechanisms. A change to either property re-opens the question instead
of inheriting this pass.
#146 confirmed, and the caveat is load-bearing for anyone quoting it
The proxy shrinks by exactly one arm. The visibility arm becomes
structural. The proxy does not shrink to nothing, because the derived view
CONTAINS the grafts — /snug/engine/store, the sock, the toolchain: host-backed
and sensitive. A start-time symlink swap aimed at a graft resolves to a real
object inside the derived view, so arm B alone does not contain #284. CheckEngineBindSource and the graft-shadow divergence checks stay live and
necessary, and this round confirmed the anchored-source arm still fires. Visibility
arm structural, TOCTOU/graft arm retained. "Exactly one arm" is exact — and the
sentence is only safe to quote with this paragraph attached.
Two branches unreachable on shipped profiles — belt and braces, not a gap
EngineGuestPath's guest != source branch needs a divergent host:guest
grant, and the graft-shadow branch needs a graft-guest/target coincidence. No shipped selection produces either.#383 is about exactly that first
branch, which is a further reason it was correctly out of scope here.
No leaked processes; every sandbox exited on its own and the post-run scan was
clean.
The two reproductions that carry the most weight, host side and inside
Host preamble, both. -p @podman-build is added to the defaults
(@sys @home @cwd-rw @parent-ro), not --no-defaults:
export HOME=$(mktemp -d)/u && mkdir -p "$HOME"
PROJ=$(mktemp -d)/proj && mkdir -p "$PROJ"
cp probe3.sh "$PROJ/probe3.sh"# or probe2.sh for the secondexport SNUG_PODMAN=$HOME/../../.local/opt/podman-static/usr/local/bin/podman
export SNUG_PODMAN_ROOT=$HOME/../../.local/opt/podman-static
export XDG_CONFIG_HOME=$(mktemp -d)/cfg && mkdir -p "$XDG_CONFIG_HOME"
timeout 90 <snug built from 9643e4f> -p @podman-build "$PROJ" -- \
/bin/sh -c 'sh "$SNUG_TARGET/probe3.sh"'
(The two SNUG_PODMAN* values were absolute paths to ~/.local/opt/podman-static on the developer's machine — see #384 on why the
version they point at is asserted nowhere.)
1 — the positive control, HTTP 200.$SNUG_TARGET was /tmp/tmp.12XNEyM0eR/proj. Inside the sandbox:
The python3 substitution is deliberately not replaced with a
percent-encoded literal: the literal was never observed, and urllib.parse.quote leaves / unescaped by default, so writing one out would be
a guess wearing the clothes of a measurement. Response, HTTP 200:
{"errorDetail":{"message":"stat /snug/engine/runroot/tmp/libpod_builder2614703372/build/Dockerfile: no such file or directory\n"},...}
2 — symlink to /snug/engine/store, refused by resolveForwardable (#251). $SNUG_TARGET was /tmp/tmp.KPmJxHAQkf/proj. The shape is a create and does not
need @podman-build, but that is the run it was executed in and it is recorded
as run rather than restated as @podman-socket. Inside:
HTTP 403 (capture truncated at 500 bytes by the probe):
{"cause":"snug policy","message":"snug refused this request: mount source \"…/glink\" cannot be resolved: its component …/glink is a symlink whose target does not exist in this sandbox's own namespace. …"}
Corroboration — the same property at a different entry point.probe3.sh
case E4: ln -sf /snug/engine/store "$SNUG_TARGET/glink2", then /build?additionalbuildcontexts= carrying {"extra":{"Value":"<target>/glink2"}},
target /tmp/tmp.12XNEyM0eR/proj. HTTP 403 with the same #251 text, wrapped in build parameter additionalbuildcontexts: context "extra": . Two entry points,
one property — which is what makes it a property rather than a special case in
one handler.
The full probe set, verbatim — and a correction about where it lived
Correction, made in place: the first account of where these lived was wrong,
and the wrong version is here rather than deleted. It said the probes were
written to redteam's pinned scratch $HOME/proj/ and therefore landed
somewhere the safety rule had put out of reach. They were not: the run's TARGET
directory was itself a mktemp -d, and the probes were copied into the target
because that is the one tree the payload can read from inside. The scratch HOME
rule is unrelated to where they ended up. Two independent mktemp -d directories
in the same run is exactly the sort of thing that gets collapsed into one when
the account is written afterwards.
What was actually wrong was only the path in the report — it cited the
repository's .claude/scratchpad/, which never held them. Recovered by timestamp
(find /tmp -name 'probe*' -mmin -300), all intact and byte-identical to what
ran. Pasted here in full rather than cited by path: a gitignored path is a
citation no reader can follow, and /tmp is not a place evidence lives.
The rule this produces, for every round from here: a reproduction is
pasteable command lines in the report itself, written as the round goes rather
than recovered afterwards. A reproduction that cannot be recovered exactly is
reported as not recovered — never reconstructed from a description of itself,
which would land on a ticket carrying the authority of something that was run.
That rule held twice here under pressure, and both times it changed the text:
The build control was an EMPTY TAR, not a build "with a Dockerfile". dockerfile=Dockerfile was a query parameter; the request body was --data-binary ''. That is precisely why podman entered the build and then
failed at stat. The control is stronger stated correctly: HTTP 200 plus
a /snug/engine/runroot/... path inside the error proves the request cleared
the filter and a real podman 5.8.4 began a build with the granted target bind
forwarded — an empty tar cannot produce that error unless everything before it
worked.
The 403 bodies are truncated at 500 bytes by the probes' own head -c 500, so the captures end mid-sentence. They are quoted as captured.
For the full refusal text read resolveForwardable in internal/dockerproxy/create.go — do not complete a captured error from
memory.
All five run inside the sandbox, against /snug/podman.sock, under snug -p @podman-socket <dir> or -p @podman-build <dir>. $SNUG_TARGET is set
by snug inside the sandbox. Every refusal below returned 403 with "cause":"snug policy"; the controls returned 404 image not known (forwarded
and served) and 200 for the build.
probe.sh — Ungranted host paths, the graft path, a volume driver, a tmpfs, an option-list rshared, and the first positive control.
#!/bin/sh
S=/snug/podman.sock
post() {
# $1 = path, $2 = json body ; prints HTTP code + short bodyprintf'%s => '"$3"
curl -s -o /tmp/resp -w '%{http_code}' --unix-socket "$S" \
-X POST -H 'Content-Type: application/json' \
"http://d/v1.41$1" -d "$2"echo''
head -c 400 /tmp/resp;echo''echo'----'
}
echo"### 1. classic -v /etc:/etc (ungranted)"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["/etc:/etc"],"NetworkMode":"host"}}''bind /etc'echo"### 2. -v /root:/x (ungranted)"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["/root:/x"],"NetworkMode":"host"}}''bind /root'echo"### 3. -v / :/host"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["/:/host"],"NetworkMode":"host"}}''bind /'echo"### 4. structured Mounts bind /etc"
post /containers/create '{"Image":"scratch","HostConfig":{"Mounts":[{"Type":"bind","Source":"/etc","Target":"/etc"}],"NetworkMode":"host"}}''mount /etc'echo"### 5. graft path /snug/engine/store direct"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["/snug/engine/store:/x"],"NetworkMode":"host"}}''bind /snug/engine/store'echo"### 6. volume type=none device=/etc"
post /volumes/create '{"Name":"v1","Driver":"local","DriverOpts":{"type":"none","device":"/etc","o":"bind"}}''volume device=/etc'echo"### 7. tmpfs mount type"
post /containers/create '{"Image":"scratch","HostConfig":{"Mounts":[{"Type":"tmpfs","Target":"/x"}],"NetworkMode":"host"}}''mount tmpfs'echo"### 8. bind option rshared smuggle"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["'"$SNUG_TARGET"':/x:rshared"],"NetworkMode":"host"}}''bind target rshared'echo"### POSITIVE CONTROL: -v /usr:/usr:ro (granted, anchored)"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["/usr:/usr:ro"],"NetworkMode":"host"}}''bind /usr ro'echo ALL_DONE
probe2.sh — Symlinks (one dangling in the sandbox and real in the engine), .. traversal, and the build query-string forms.
#!/bin/sh
S=/snug/podman.sock
T="$SNUG_TARGET"post() {
printf'%s => '"$3"
curl -s -o /tmp/resp -w '%{http_code}' --unix-socket "$S" \
-X POST -H 'Content-Type: application/json' \
"http://d/v1.41$1" -d "$2"echo''
head -c 500 /tmp/resp;echo''echo'----'
}
postq() {
# query-string endpoint (build): $1 url with query, $2 empty body, $3 labelprintf'%s => '"$3"
curl -s -o /tmp/resp -w '%{http_code}' --unix-socket "$S" \
-X POST -H 'Content-Type: application/x-tar' \
"http://d/v1.41$1" --data-binary ''echo''
head -c 500 /tmp/resp;echo''echo'----'
}
echo"### A. symlink in target -> /snug/engine/store (dangles in sandbox, real in engine view #251)"
ln -sf /snug/engine/store "$T/glink"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["'"$T"'/glink:/x"],"NetworkMode":"host"}}''bind target/glink->store'echo"### B. symlink in target -> /etc (resolves on host, ungranted)"
ln -sf /etc "$T/etclink"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["'"$T"'/etclink:/x"],"NetworkMode":"host"}}''bind target/etclink->/etc'echo"### C. target subpath with .. traversal out"
post /containers/create '{"Image":"scratch","HostConfig":{"Binds":["'"$T"'/../../../../etc:/x"],"NetworkMode":"host"}}''bind target/../../etc'echo"### D. build -v /etc:/x (host bind via build param)"
postq '/build?v='$(python3 -c "import urllib.parse;print(urllib.parse.quote('/etc:/x'))"2>/dev/null ||echo'%2Fetc%3A%2Fx')'&networkmode=host''''build -v /etc'echo"### E. build --build-context Value=/etc"
BC='{"extra":{"Value":"/etc"}}'
EBC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))""$BC"2>/dev/null ||echo'%7B%22extra%22%3A%7B%22Value%22%3A%22%2Fetc%22%7D%7D')
postq "/build?buildcontext=$EBC&networkmode=host"'''build-context Value=/etc'echo"### F. build additionalcontext DownloadedCache=/etc"
BC2='{"extra":{"Value":"'"$T"'","DownloadedCache":"/etc"}}'
EBC2=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))""$BC2"2>/dev/null)
postq "/build?buildcontext=$EBC2&networkmode=host"'''build-context DownloadedCache=/etc'echo"### G. POSITIVE CONTROL build -v target:/x:ro (granted)"
postq '/build?v='$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))""$T:/x:ro"2>/dev/null)'&networkmode=host''''build -v target ro'echo ALL_DONE
probe3.sh — build?volume=, additionalbuildcontexts in both fields, IsURL, nsoptions, and the build positive control.
probe4.sh — Arm A split in two: a sandbox-visible path refused by the anchored-source check, next to two that pass both.
#!/bin/sh
S=/snug/podman.sock
T="$SNUG_TARGET"post() {
printf'%s => '"$3"
curl -s -o /tmp/resp -w '%{http_code}' --unix-socket "$S" \
-X POST -H 'Content-Type: application/json' \
"http://d/v1.41$1" -d "$2"echo''
head -c 600 /tmp/resp;echo''echo'----'
}
echo"SNUG_TARGET=$T"
mkdir -p "$T/realdir"echo"### H. bind target/realdir:/x (passes hostPathVisible; anchored-source check?)"
post /containers/create '{"Image":"x","HostConfig":{"Binds":["'"$T"'/realdir:/x:rw"],"NetworkMode":"host"}}''bind target/realdir'echo"### I. bind target itself:/x (target is a mount root)"
post /containers/create '{"Image":"x","HostConfig":{"Binds":["'"$T"':/x:rw"],"NetworkMode":"host"}}''bind target'echo"### J. bind /usr:/x:ro (anchored mount root under readonly root)"
post /containers/create '{"Image":"x","HostConfig":{"Binds":["/usr:/x:ro"],"NetworkMode":"host"}}''bind /usr ro'echo ALL_DONE
probe5.sh — A rw request over a ro grant, the STRUCTURED propagation form, and a create with no NetworkMode: "host".
#!/bin/sh
S=/snug/podman.sock
post() {
printf'%s => '"$3"
curl -s -o /tmp/resp -w '%{http_code}' --unix-socket "$S" \
-X POST -H 'Content-Type: application/json' \
"http://d/v1.41$1" -d "$2"echo''; head -c 400 /tmp/resp;echo'';echo'----'
}
echo"### K. bind /usr:/x:rw (visible ro, request writable -> should refuse)"
post /containers/create '{"Image":"x","HostConfig":{"Binds":["/usr:/x:rw"],"NetworkMode":"host"}}''bind /usr rw'echo"### L. Mounts bind /usr with BindOptions rshared (propagation smuggle via structured form)"
post /containers/create '{"Image":"x","HostConfig":{"Mounts":[{"Type":"bind","Source":"/usr","Target":"/x","ReadOnly":true,"BindOptions":{"Propagation":"rshared"}}],"NetworkMode":"host"}}''mount /usr rshared'echo"### M. no NetworkMode host (Mounts /usr) -> reaches mount step?"
post /containers/create '{"Image":"x","HostConfig":{"Binds":["/usr:/x:ro"]}}''bind /usr ro no-nethost'echo ALL_DONE
Reading probe5.sh case L against #33.Mounts[].BindOptions.Propagation = "rshared" is accepted by the filter and neutralised downstream by
re-serialisation into the 4-field mount struct — while the legacy option-list
spelling in probe.sh case 8 is refused explicitly. Not a finding: nothing
gets through. Filed as a test-coverage row on #33, because what stands where a
test could is a comment in create.go.
Against 902cf53, live podman 5.8.4 (PASS-not-SKIP: real alpine:3.20, real start=204 with logs). The #284 inode/name swap is closed AT CREATE — the refusal lands at create, not at start, so the attacker-controlled gap before start-time resolution never opens on a re-pointable source.
Deeper swappable $TARGET/a/b → 403 naming the shallowest writable component a — CheckEngineBindSource walks top-down and refuses at the first re-pointable name.
Positive control (filter is not blanket-denying): -v $TARGET:/x:rw (whole target root, no payload-writable component) → create 201, start 204, logs listed the target's own contents — the engine mounted the target, not a store.
Clearing — property named, with the name-vs-inode caveat
The create/start bind-source inode/name swap (#284) is pinned by the anchored-source rule (policy.CheckEngineBindSource, called by create.gocheckOne and build.go on the SAME resolved string it forwards): a bind source is forwarded only if EVERY component from it up to / is anchored — no payload-writable parent, or a mount root no rw-bind reaches by another route. A swappable source is refused AT CREATE, so the gap before the engine's start-time resolution never opens on a re-pointable name. Layered with danglingSymlinkOn (#251) for pre-existing symlinks and the graft-component clause for /snug/engine/*. Verified against podman 5.8.4: refusal at CREATE, not at start.
Caveat (required): this holds FOR THE BIND-SOURCE surface, where snug resolves and re-checks the same string it forwards. It does NOT close the TOCTOU class. The sibling is Finding A / #386 — a NAME repointing at DELETE, a string snug checks once and the engine re-resolves, which snug does not pin. #284's inode member is closed; the name member is OPEN. A clean inode result must not be read as "the TOCTOU class is closed."
Completeness on shipped profiles: the payload can only write inside the target (rw bind) and snug's tmpfs dirs; any component there is refused (case 2), and every accepted host-backed source is a mount root whose parent is not payload-writable (case 1) — so an accepted source has no swappable component by construction. The mount-root-under-rw-bind clause is a safety net; no shipped selection nests a bind under an rw bind.
Targets added 2026-08-23, not yet scheduled
Neither is a ticket. Both are rows, per this tracker's rule.
argv[0] resolved out of the host user's $PATH. Measured: snug calls exec.LookPath(<bare name>) at run time and hands LookPath's answer to exec.Command. The search is the host user's, so what gets executed is
decided by an environment snug does not own. Note this is the host side of the
process, not the payload's — which is what the round is for: to find out
whether anything the payload can touch reaches it.
$SNUG_PODMAN_ROOT pointing inside a granted rw tree. If the payload
can write there, it can overwrite the binaries the engine executes on the
next run — a persistence primitive across sandbox lifetimes rather than an
escape from this one, which is the class that a per-run escape test does not
look for.
How a round is discharged
It runs against merged code, and its result goes into OWED-REDTEAM.md's head table and onto this ticket as a struck row with its outcome. Findings come back to the planner for filing; a finding needing a fix stops and hands back, because discovery stays separate from the code being graded.
The single tracker for
redteamrounds that are owed on merged code. Maintainer's ruling, 2026-08-23: one issue, not one per round. A new owed round becomes a row here, never a new ticket.A round is deferred, never waived — the standing ruling is that a round runs against the code that ships, not against a lane in flight, because attacking a moving diff records that a surface was attacked when what was attacked was a draft.
OWED-REDTEAM.mdkeeps the long-form rows and the reasoning. It is gitignored, so this ticket is the half that survives the machine. A row leaves this list when the round runs and its result is recorded — findings or nothing, because a round that finds nothing is a result and an unrecorded round is not.Row 1 — #344's teardown matcher, escalation path and sweep ordering — DISCHARGED 2026-08-23, CLEAN (one fix owed, OPEN)
No escape, no finding — and one editorial fix owed that is deliberately NOT
folded into this clean discharge, because row 1 going green is exactly what
would bury it. See "Fix owed" at the end of this row; it stays OPEN while the
round above it is discharged.
Merged in
d21d03f(PR #363). #344 was closed at merge; the round was never run./snug/engine/sock/podman-<pid>.sockversus the HOST spelling/tmp/snug-<uid>-<pid>/sock/podman-<pid>.sock. The guest string is one a payload can author on its own argv, which is why the ordering below is load-bearing.stopLockedstep 3. No test exercised it until Teardown matches the socket spelling the engine carries, and verifies after the cascade (refs #344, refs #345) #363, because the matcher bug made it unreachable — one unit test, zero adversarial exposure.Engines, per-claim and never per-branch: everything behind
80ca6b2was measured againsttest/integration/testdata/fakepodmanvia$SNUG_PODMAN; the integration work drives the pinned static podman 5.8.4 bundle at~/.local/opt/podman-static.podmanBundle/requireRealEngineskip when it is unavailable, so PASS-not-SKIP is the proof a real engine served a request./usr/bin/podmanhere is distrobox'sdistrobox-host-execshim, refused by preflight P1.Row 1's verdict criteria — written before the result, on purpose
Running 2026-08-23 against
3491d0b. These are on the ticket rather than onlyin a session brief because a brief dies with a context — the defect this tracker
already committed once today (see row 3's correction block). Written before
the round returns, so the result cannot be graded against criteria shaped by it.
Target 3 — the sweep/collapse ordering — is the one that matters, and it is an
ORDERING claim. The design rests on: the sweep runs only after the stage is
reaped and the sandbox's pid namespace has collapsed, so no payload process is
alive to match a forged guest sock. The guest spelling
/snug/engine/sock/podman-<pid>.sockis one a payload can author on its ownargv, which is what makes the ordering load-bearing rather than incidental.
Clear it by observing the order, never by reading the code that intends it.
Reading
stopLockedand concluding the sweep follows the collapse grades theauthor, not the run. What is needed is evidence from a live run that the collapse
had already happened when the sweep looked, measured from outside the code
that claims it.
Then the question that can actually fail: what ENFORCES the order? "Does the
sweep run later" can only confirm. "Can anything make the sweep run early, or the
collapse run late" can refute — and a payload that delays the collapse past the
sweep breaks the claim without breaking a line of code.
Three outcomes, and the round names whichever is true:
guarantee. Name the mechanism. Clearing, and a strong one.
slow. Finding. Stop, measure, report. A timing-dependent ordering that
holds today is not a property, and it goes on this tracker as a row rather
than into a clearing with a caveat attached.
If no payload can be constructed that is still alive when the sweep looks,
then what made that impossible IS the property to name — a far stronger
sentence than "the sweep runs later", and the one a later change re-opens.
The other two targets in this row (the guest/host matcher spellings, and the
SIGKILLescalation instopLockedstep 3 that no test reached until #363because the matcher bug made it unreachable) are graded the same way: one unit
test and zero adversarial exposure is the starting position, not a baseline.
Row 1 outcome — 2026-08-23
Ran against
3491d0b. PASS-not-SKIP, version pulled by hand per #384:/v1.41/versioninside a live-p @podman-socketsandbox returned"Podman Engine","Version":"5.8.4",ApiVersion 1.44,go1.25.11,opensuse-tumbleweed;libpod/infogaveremoteSocket.path = unix:///snug/engine/sock/podman-<pid>.sock, buildah 1.43.2,crun 1.28, netavark 2.0.0. A real
alpine:3.20pull landed in the store and areal build with a
RUNstep executed. The decoys in targets 2–3 were realcrun processes in the engine's own pid namespace, not
fakepodman.Target 1 — matcher, cleared. Live scan of host
/procduring a run: theengine's cmdline carries the guest spelling
(
unix:///snug/engine/sock/podman-1018693.sock) and nothing on the machinecarries the host spelling. Property:
Specrecords the argv it built andpaths()reads that exact recorded string — one author for the host→guestmapping. That
paths()returnse.guestSockwas verified in the tree, notrelayed.
Target 2 — SIGKILL escalation, kill leg reached LIVE. A host decoy naming the
guest sock was matched by
signalOwnedand SIGKILLed ~3.7s after payload exit.The persistent
kill -9WARNING anddescribe()were not reached andcannot be by a payload — they need a matched process surviving
quietBudget+killBudget(2s+3s) including a direct SIGKILL. Stand-indimensions stated as required: the host decoy shares exactly one dimension with a
leaked engine — outside the cascade's reach, carrying the guest-sock string —
and shares neither pid-1-of-its-own-namespace status nor the container cascade.
Right stand-in for "a leaked engine", wrong one for "a clean run". No 15.3s-style
number was inherited.
Target 5 —
describe()sanitizes today, measured. It renders throughpolicy.VisibleTextbefore the length clamp; the call site was confirmed. Thecommitted test drives a live decoy with
$'30\x1b[1A\r FORGED-BY-A-CMDLINE'and asserts no raw ESC survives, with a positive control that the argv reached
the renderer. #344/#362's gap is closed.
Target 3 — the ordering — outcome 1 (structural), by a DIFFERENT mechanism than
the code's comment names. Observed from outside the code, 2ms poll on host
/proc, 1500 forged engine-ns processes from a buildRUNloop under CPU load:The collapse preceded the sweep's first poll by ~0.76s. The inverting input
could not be constructed, and the impossibility is the property: the payload has
its own pid namespace (
pid:[4026534104]vs hostpid:[4026531836]),CapEff: 0,unshare -p/-U→ EPERM,HostConfig.PidMode="host"→ 403 at theproxy. So every process it can create lives in a pid namespace whose init is
Pdeathsig-chained and reaped byst.Close()'sWait()beforestopLockedruns; pid-1's SIGKILL and
zap_pid_ns_processesare unblockable; the 5s budgetoutlasts any collapse the machine can hold.
Clearing sentences — quote verbatim
Specrecords the stringthe engine's argv carries, and
paths()reads that string.the payload cannot create one.
forgery lives under an unblockable pid-1 SIGKILL that the sweep's fixed budget
outlasts. The guarantee is confinement plus budget, not the wall-clock
ordering the comment names.
Fix owed — OPEN, and kept out of the green above on purpose
reap.go/stopLockedphrase the guarantee as a strict wall-clock ordering —"Stop sweeps only after the collapse … there is nothing of the payload's left to
match." That is slightly stronger than the timing strictly guarantees: a
poll could land while the cascade is completing and momentarily match a dying,
payload-argv'd engine-ns process. The security property survives anyway, carried
by confinement + unblockable pid-1 SIGKILL + fixed budget — which is
stronger than the ordering the comment names. So the code is right and its
comment names the wrong load-bearing property; a future reader quoting "the
sweep runs later" leans on the one part that is only usually true.
This is its own shape, distinct from row 3's and from today's after-the-fact
accounts: not stale, not wrong-when-written, but true-while-attributing-
correctness-to-the-wrong-reason. A round wrote no fix (discovery stays separate
from the graded code), so the reword is owed to a later edit lane, referencing
this row. It is filed here rather than as a new ticket per the ruling that
redteam findings track under #369, and it stays OPEN while row 1's round is
discharged — the whole point being that a green row must not swallow it.
Row 2 — #339/#340 — DISCHARGED 2026-08-23. ONE CONFIRMED EXPLOIT (sev:medium, OPEN), two reasoned findings, two clearings
Finding A is a reproduced, live exploit against
902cf53— a payloaddestroyed ANOTHER run's container record, host state that outlives the sandbox.
It stays OPEN and needs a fix; the round wrote none (discovery stays separate from
graded code). Original row brief kept below the outcome.
Row 2 outcome — 2026-08-23
PASS-not-SKIP, version by hand:
GET /v1.41/versionthrough/snug/podman.sockreturned Podman Engine 5.8.4, opensuse-tumbleweed-20260724,APIVersion 5.8.4, go1.25.11, kernel 7.1.8. Real crun/conmon containers, real
alpine:3.20registry pull landed in the store. Nofakepodman, no SKIP.FINDING A — CONFIRMED EXPLOIT, sev:medium — filed standalone as #386 (with full reproduction) — DELETE ownership TOCTOU via un-gated rename
Ownership is checked on a mutable NAME, and the engine re-resolves that name at
delete. Verified in tree:
handleContainerDelete(proxy.go:340) checks ownership viacontainerRunLabel(ctx, id)whereid = segs[1]— the client's requeststring, which may be a container NAME, not the immutable 64-hex id.
p.forward(w, r, nil), andforward()(
proxy.go:600) rebuilds the URI fromr.URL.RequestURI()— so the enginere-resolves the same name a second time.
POST /containers/{id}/renameis ALLOWED and NOT ownership-gated:allowed()'s containers clause refuses only{archive,export,commit,update};rename falls through to
return true.Composition: proxy inspects name
victim→ resolves to OWN (this run'slabel) → ownership passes. A concurrent, un-gated rename swaps
victimontoanother run's container BEFORE the forwarded DELETE reaches the engine → the
engine deletes the other run's container. #284's shape at a new surface
(row 2 angle 3 / row 4): ownership checked on one string, another object
forwarded — divergence by name remapping rather than by inode.
Reproduced live, podman 5.8.4, two runs on one shared target/store:
snug.run=1028668) created containerforeign, id9603bf3f911f;persisted across teardown.
snug.run=1029200): baselineDELETE /containers/foreign→ 403"container foreign was not created by this sandbox run … stamps
snug.run=1029200" (ownership works). Primitive:
POST /containers/foreign/rename?name=foreign_renamed→ 204 (a payloadrenamed ANOTHER run's container).
victimbetween own and foreign via rename; deleter issuesDELETE /containers/victim.foreignwent alive → GONE in 6 attempts /0.5s. The only DELETE issued was against
victim;foreignis removableonly if forward-time
victim==foreign while inspect-timevictim==own — theTOCTOU. Structural, not a timing artifact of the harness — the window opens
every time at podman's rename/delete latency.
Impact: destruction of host state that outlives the sandbox, defeating the
exact property the ownership check exists to provide. Same bracket as #339.
Fix direction (NOT written, handed back): canonicalize to the immutable id
before the check AND forward a delete addressed by that id; or gate rename by
ownership. Full reproduction (payloadA/B/C, verbatim, both TOCTOU confirmations) is on #386.
FINDING B — reasoned, sev:low — cross-run rename is un-gated
Measured: 204 renaming another run's container. Standalone it lets a payload
rename any other run's containers, breaking that run's ability to address them by
name. It is the enabling primitive for A; gating rename by ownership closes
both.
FINDING C — reasoned, sev:low — no timeout on the engine round-trip (angle 2)
p.client(proxy.go:79) sets noTimeout;containerRunLabelusesr.Context(), which the payload controls. A never-answering inspect hangs theDELETE handler indefinitely. Bounded by the payload's own connections
(self-DoS), and teardown uses
srv.Close()not gracefulShutdown, so collapseis not blocked — hence low, not a cross-run DoS. Exactly angle 2: a functional
test passes straight through. Fix: bound the round-trip with its own context
deadline.
CLEARINGS — naming the property
isPrune, "any prune-shaped spelling evadessegs[len-1]=="prune"": CLEARS.Every spelling refused 403 live —
containers/prune, trailing-slashprune/,encoded
prun%65,libpod/system/prune?all=true&volumes=true,libpod/containers/prune,build/prune, images/volumes/networks prune.Property:
isPrunetestssegs[len-1]=="prune"AFTERnormaliseFullcollapses empty segments (kills the trailing-slash bypass), strips version and
libpodprefixes, and refuses./..; theServeHTTPswitch consultsisPruneregardless of thelibpodExaminedgate, sosystem/pruneis caughteven though
systemis not inlibpodExamined.own, inside the trust boundary.
stampRunLabel(create.go:976) REPLACES anyclient-set
snug.runat create, so a payload cannot forge the label into theanswer. The check defends against the payload's REQUEST, not the engine's
answer; a compromised engine already owns everything the check protects.
Cleanup verified: no process references the round's target or store; the
bwrap/podman forest on the box is other sessions', attributed by
/proccmdlineand nothing killed.
Original row 2 brief — the headline is the MACHINERY rather than either finding
Merged in
5e627fa(PR #366).Nothing in
internal/dockerproxyhad ever round-tripped to the engine to decide a request.containerRunLabel(proxy.go:383) is the first: a synchronous dependency on the engine inside the decision path of a destructive request (DELETE /containers/{id}), issuingGET /v1.41/containers/<id>/jsonwith the idurl.PathEscape'd. Six fail-closed answer shapes are handled — transport error, non-200, undecodable body, absentConfig, nullConfig, absent label — plus an empty run label, which refuses rather than skipping.Three angles the lane could not test and did not claim:
Under those, not above them: the enumeration route (
GET /containers/json?all=1, thenDELETEeach id); whether any prune-shaped spelling evadesisPrune'ssegs[len(segs)-1] == "prune"(trailing slash, encoded segment, a version prefix surviving normalisation); and whether any request reaches the engine with a bodylibpodExamineddid not read.Residuals the PR does not claim closed:
DELETE /networks/{id}stays allowed deliberately; no integration test drives a real docker client throughdocker run --rmend to end.Row 3 — #125's derived container mount view — DISCHARGED 2026-08-23, CLEAN
Result: no escape, no finding. Ran against
9643e4f; #383's branch was neverread. #125 closed on this round, and #146's verdict is confirmed — the
outcome block sits at the end of this row. The brief is kept below unchanged: a
round is graded on what it was asked to do as well as on what it found, and a
brief rewritten after the result cannot be read that way.
The row this ticket was missing while the round on it was already running. It
was carried in a session brief and in
PLAN.md, both of which die with acontext or a machine; this ticket is the half that survives, and a row that is
not on it is not tracked. Recorded late, on purpose visibly.
#125 is the only part of the Tier C ladder still open, and it is open on one
thing: this round. C0/C1/C2-path/C2-view/C3 are built and merged.
The claim under attack. A container's mount visibility is structural — the
engine's mount namespace is derived from the sandbox's view rather than from a
private copy of the host tree — so a path the sandbox cannot see is not a path
the engine can be talked into mounting. Every
-v,--mountand--build-contextshape naming a path the sandbox cannot see, run against thederived view.
Two independent arms, and grading only one clears both by accident. The
engine's create-time auto-create is real and
MkdirAll-shaped, and it isharmless for two reasons that do not depend on each other: the derived view,
and the bind filter. A clean sweep that cannot say which arm refused a given
shape has graded one arm and cleared two. Getting a shape past one and watching
the other catch it is worth more to this ticket than a quiet pass.
Where this round is most likely to lie to itself.
NetworkMode: "host"isrequired to reach the mount step at all — without it the start fails at
networking and the bind question is never asked. A run that died at networking
and a run that reached the mount step and was refused look identical from
outside. The positive control is therefore a shape expected to reach the mount
step and be refused there, with output showing it got that far.
This round also decides #146's verdict. The proxy shrinks by exactly one
arm rests on the derived view holding, and has never been attacked. Whatever
sentence clears (or falsifies) it here is the sentence #372 copies into
INDEX.mdas the permanent table, so it names the property that carried theclearing — not "the derived view held", but which property refused which shape.
Scope, stated to the round as a hard stop: it grades
origin/main=9643e4f.PR #383 is in flight and unmerged, so it is out of scope — an attack reaching
into what #383 changed stops and reports rather than continuing. Grading an open
branch records that a surface was attacked when what was attacked was a draft;
this is the same separation as "a round that produces a fix hands back", one step
earlier.
Outcome — 2026-08-23
PASS not SKIP, with no substitute anywhere.
/v1.41/versionthrough theproxy returned
"Podman Engine","Version":"5.8.4",opensuse-tumbleweed,go1.25.11. Nofakepodman, no decoy, no SKIP in this round, so nothing hererests on a substitute and there is no dimension-sharing caveat to write. That
is worth stating positively: the usual caveat is absent because it was measured
away, not because nobody looked.
Refused, every spelling tried.
-v/--mount/--build-context/volume-create for a path the sandbox cannot see:
/etc,/root,/,/snug/engine/store,type=tmpfs,rsharedas a legacy option,rwover aro-granted/usr,volume create type=none,o=bind,device=/etc, buildvolume=,additionalbuildcontexts(bothValueandDownloadedCache),IsURL,nsoptions=net=/proc/1/ns/net,networkmode=host,..traversal outof the target, a symlink to
/etc, a symlink to/snug/engine/store. Every 403carried
"cause":"snug policy".The positive control worked, which is the part that makes the negatives
readable. The failure mode this row warned about — a run that died at
networking being indistinguishable from a run refused at the mount step — did
not occur:
-v /usr:/usr:roand the target root both returned 404image not known, so they were forwarded and served. Strongest control: a build withvolume=<target>:/x:roreturned 200 and real podman 5.8.4 entered the build,failing only at
stat …/build/Dockerfilebecause the tar was empty.Both arms graded three ways, one of which is stronger than the live separation
the row asked for.
path.
target/realdirpasseshostPathVisibleand is then refused byCheckEngineBindSource(Container proxy bind filter defeated by create/start TOCTOU: symlink swap mounts the engine image store into a container #284), while the target root and/usrpass bothand are forwarded. A path was watched getting past one check and stopped by
the next, with siblings passing both.
/snug/engine/storedangles in thesandbox's namespace and is real in the engine's;
resolveForwardablerefuses it — a check that exists only because the two namespaces diverge.
live engine's
/proc/<pid>/mountinfoand/proc/<pid>/root: engine/etccarried 14 entries against the host's 111; no
/etc/shadow;/proc/<engine>/root/homeempty; the engine's root is the sandbox's, withno
/oldrootand no host/etc,/homeor/rootbind.The honest limit, recorded rather than smoothed over. The round could not
get an ungranted host path past arm A in order to watch arm B refuse it live —
because by construction arm A passes exactly the sandbox-visible set, which is
exactly what the derived view contains. That is invariant 6 doing its job, not
a gap in the round: two mechanisms over one
policy.Policycannot disagree, sothe experiment that would show them disagreeing has no input. The direct
structural measurement was substituted for it and is stronger than a single live
refusal would have been.
The clearing sentence — owed to
INDEX.mdvia #372, quote it verbatimThe code half was verified in the tree rather than relayed:
inengine.godoessetns(mntFD, CLONE_NEWNS)into the sandbox's namespace and thenunshare(CLONE_NEWNS)for the engine's own copy, and the onlyOpenTreeis thegraft authority running before the setns. There is no
open_treeof the hostroot anywhere on that path.
Per the standing rule, this names the PROPERTY that carried the clearing —
the derived namespace plus grafts and nothing else, and one
policy.Policybehind both mechanisms. A change to either property re-opens the question instead
of inheriting this pass.
#146 confirmed, and the caveat is load-bearing for anyone quoting it
The proxy shrinks by exactly one arm. The visibility arm becomes
structural. The proxy does not shrink to nothing, because the derived view
CONTAINS the grafts —
/snug/engine/store, the sock, the toolchain: host-backedand sensitive. A start-time symlink swap aimed at a graft resolves to a real
object inside the derived view, so arm B alone does not contain #284.
CheckEngineBindSourceand the graft-shadow divergence checks stay live andnecessary, and this round confirmed the anchored-source arm still fires. Visibility
arm structural, TOCTOU/graft arm retained. "Exactly one arm" is exact — and the
sentence is only safe to quote with this paragraph attached.
Two branches unreachable on shipped profiles — belt and braces, not a gap
EngineGuestPath'sguest != sourcebranch needs a divergenthost:guestgrant, and the graft-shadow branch needs a graft-guest/target coincidence.
No shipped selection produces either. #383 is about exactly that first
branch, which is a further reason it was correctly out of scope here.
No leaked processes; every sandbox exited on its own and the post-run scan was
clean.
The two reproductions that carry the most weight, host side and inside
Host preamble, both.
-p @podman-buildis added to the defaults(
@sys @home @cwd-rw @parent-ro), not--no-defaults:(The two
SNUG_PODMAN*values were absolute paths to~/.local/opt/podman-staticon the developer's machine — see #384 on why theversion they point at is asserted nowhere.)
1 — the positive control, HTTP 200.
$SNUG_TARGETwas/tmp/tmp.12XNEyM0eR/proj. Inside the sandbox:The
python3substitution is deliberately not replaced with apercent-encoded literal: the literal was never observed, and
urllib.parse.quoteleaves/unescaped by default, so writing one out would bea guess wearing the clothes of a measurement. Response, HTTP 200:
2 — symlink to
/snug/engine/store, refused byresolveForwardable(#251).$SNUG_TARGETwas/tmp/tmp.KPmJxHAQkf/proj. The shape is a create and does notneed
@podman-build, but that is the run it was executed in and it is recordedas run rather than restated as
@podman-socket. Inside:HTTP 403 (capture truncated at 500 bytes by the probe):
Corroboration — the same property at a different entry point.
probe3.shcase E4:
ln -sf /snug/engine/store "$SNUG_TARGET/glink2", then/build?additionalbuildcontexts=carrying{"extra":{"Value":"<target>/glink2"}},target
/tmp/tmp.12XNEyM0eR/proj. HTTP 403 with the same #251 text, wrapped inbuild parameter additionalbuildcontexts: context "extra":. Two entry points,one property — which is what makes it a property rather than a special case in
one handler.
The full probe set, verbatim — and a correction about where it lived
Correction, made in place: the first account of where these lived was wrong,
and the wrong version is here rather than deleted. It said the probes were
written to
redteam's pinned scratch$HOME/proj/and therefore landedsomewhere the safety rule had put out of reach. They were not: the run's TARGET
directory was itself a
mktemp -d, and the probes were copied into the targetbecause that is the one tree the payload can read from inside. The scratch
HOMErule is unrelated to where they ended up. Two independent
mktemp -ddirectoriesin the same run is exactly the sort of thing that gets collapsed into one when
the account is written afterwards.
What was actually wrong was only the path in the report — it cited the
repository's
.claude/scratchpad/, which never held them. Recovered by timestamp(
find /tmp -name 'probe*' -mmin -300), all intact and byte-identical to whatran. Pasted here in full rather than cited by path: a gitignored path is a
citation no reader can follow, and
/tmpis not a place evidence lives.The rule this produces, for every round from here: a reproduction is
pasteable command lines in the report itself, written as the round goes rather
than recovered afterwards. A reproduction that cannot be recovered exactly is
reported as not recovered — never reconstructed from a description of itself,
which would land on a ticket carrying the authority of something that was run.
That rule held twice here under pressure, and both times it changed the text:
dockerfile=Dockerfilewas a query parameter; the request body was--data-binary ''. That is precisely why podman entered the build and thenfailed at
stat. The control is stronger stated correctly: HTTP 200 plusa
/snug/engine/runroot/...path inside the error proves the request clearedthe filter and a real podman 5.8.4 began a build with the granted target bind
forwarded — an empty tar cannot produce that error unless everything before it
worked.
head -c 500, so the captures end mid-sentence. They are quoted as captured.For the full refusal text read
resolveForwardableininternal/dockerproxy/create.go— do not complete a captured error frommemory.
All five run inside the sandbox, against
/snug/podman.sock, undersnug -p @podman-socket <dir>or-p @podman-build <dir>.$SNUG_TARGETis setby snug inside the sandbox. Every refusal below returned 403 with
"cause":"snug policy"; the controls returned 404image not known(forwardedand served) and 200 for the build.
probe.sh— Ungranted host paths, the graft path, a volume driver, a tmpfs, an option-listrshared, and the first positive control.probe2.sh— Symlinks (one dangling in the sandbox and real in the engine),..traversal, and the build query-string forms.probe3.sh—build?volume=,additionalbuildcontextsin both fields,IsURL,nsoptions, and the build positive control.probe4.sh— Arm A split in two: a sandbox-visible path refused by the anchored-source check, next to two that pass both.probe5.sh— Arwrequest over arogrant, the STRUCTURED propagation form, and a create with noNetworkMode: "host".Reading probe5.sh case L against #33.
Mounts[].BindOptions.Propagation = "rshared"is accepted by the filter and neutralised downstream byre-serialisation into the 4-field mount struct — while the legacy option-list
spelling in
probe.shcase 8 is refused explicitly. Not a finding: nothinggets through. Filed as a test-coverage row on #33, because what stands where a
test could is a comment in
create.go.Row 4 — #284 re-attack (create/start bind-source inode swap) — DISCHARGED 2026-08-23, CLEAN. Pin HOLDS
Against
902cf53, live podman 5.8.4 (PASS-not-SKIP: realalpine:3.20, real start=204 with logs). The #284 inode/name swap is closed AT CREATE — the refusal lands at create, not at start, so the attacker-controlled gap before start-time resolution never opens on a re-pointable source.Measured:
-v $TARGET/realdir:/x:rw(real empty dir under the writable target, the original PoC) → 403 at create: "the name 'realdir' sits in …/proj, which this sandbox can write, so it can be replaced with a symlink after this check and before the container starts." Pre-fix this was 201-then-swap-then-escape.target/realdir → /snug/engine/store→ 403danglingSymlinkOn(A container -v/--mount reaches the engine store via a dangling symlink into a /snug/engine graft (Tier C two-namespace divergence) #251).→ /snug/engine/sock→ 403, same A container -v/--mount reaches the engine store via a dangling symlink into a /snug/engine graft (Tier C two-namespace divergence) #251. Sock route closed identically to store.$TARGET/a/b→ 403 naming the shallowest writable componenta—CheckEngineBindSourcewalks top-down and refuses at the first re-pointable name.volume=$TARGET/bcx:/x→ 403 — build now forwards RESOLVED paths through the same anchored check (The bind filter answers in host-path space while the engine resolves in guest-path space: EngineGuestPath exists to translate and the proxy never calls it #371/Ask the forwarding question in the space the engine answers it in (refs #371) #383 closed the build gap row 3 saw in its old shape).Positive control (filter is not blanket-denying):
-v $TARGET:/x:rw(whole target root, no payload-writable component) → create 201, start 204, logs listed the target's own contents — the engine mounted the target, not a store.Clearing — property named, with the name-vs-inode caveat
Caveat (required): this holds FOR THE BIND-SOURCE surface, where snug resolves and re-checks the same string it forwards. It does NOT close the TOCTOU class. The sibling is Finding A / #386 — a NAME repointing at DELETE, a string snug checks once and the engine re-resolves, which snug does not pin. #284's inode member is closed; the name member is OPEN. A clean inode result must not be read as "the TOCTOU class is closed."
Completeness on shipped profiles: the payload can only write inside the target (rw bind) and snug's tmpfs dirs; any component there is refused (case 2), and every accepted host-backed source is a mount root whose parent is not payload-writable (case 1) — so an accepted source has no swappable component by construction. The mount-root-under-rw-bind clause is a safety net; no shipped selection nests a bind under an rw bind.
Targets added 2026-08-23, not yet scheduled
Neither is a ticket. Both are rows, per this tracker's rule.
argv[0]resolved out of the host user's$PATH. Measured: snug callsexec.LookPath(<bare name>)at run time and hands LookPath's answer toexec.Command. The search is the host user's, so what gets executed isdecided by an environment snug does not own. Note this is the host side of the
process, not the payload's — which is what the round is for: to find out
whether anything the payload can touch reaches it.
$SNUG_PODMAN_ROOTpointing inside a grantedrwtree. If the payloadcan write there, it can overwrite the binaries the engine executes on the
next run — a persistence primitive across sandbox lifetimes rather than an
escape from this one, which is the class that a per-run escape test does not
look for.
How a round is discharged
It runs against merged code, and its result goes into
OWED-REDTEAM.md's head table and onto this ticket as a struck row with its outcome. Findings come back to the planner for filing; a finding needing a fix stops and hands back, because discovery stays separate from the code being graded.