Mask the procfs sysctl surface read-only (host escape via core_pattern) - #8
Merged
Conversation
bwrap's fresh `--proc /proc` is owned by the guest's user namespace and discards the read-only /proc mask the container runtime applied. Under the Cloud Run gen2 posture bwrap runs at the host's real uid 0, so the guest's kernel uid maps to 0 and it *owns* root's global sysctls (mode 0644). Writing one needs no capability and no blocked syscall — just open()+write() on the re-exposed proc. Writing `/proc/sys/kernel/core_pattern` (or modprobe/poweroff_cmd) then yields arbitrary code execution as root in the initial namespace: a full host escape from a stock hardened guest, bypassing --cap-drop ALL, seccomp, the empty netns and --uid. Re-mask the sysctl and other sensitive procfs surfaces after --proc, each bound read-only over itself (`--ro-bind-try`, so a path absent on this kernel is skipped) — the same masking every container runtime applies. Read-only is sufficient: this is a *write* escape, and the info-leak reads (/proc/kcore, keyrings) already need a capability the cap-dropped guest lacks. Validated in Docker with the exact permissive posture the escape needs (`--security-opt seccomp=unconfined --security-opt systempaths=unconfined`): before, a stock guest writes core_pattern and runs a handler as init-ns root; after, the write is EROFS and the full exploit fails at the arming step. The existing bwrap e2e suite still passes (69 tests), so the added binds don't break guest launches. Follow-ups (not in this change): drop bwrap's real uid to non-root so the guest doesn't own root's files at all (the root-cause, defense-in-depth complement); add io_uring_* to the seccomp denylist.
folded
added a commit
that referenced
this pull request
Jul 24, 2026
…-ups Defense in depth beyond the /proc/sys read-only mask, from the jailbreak review: - host_uid/host_gid (opt-in): bwrap maps the guest's --uid to its own real uid, so a root bwrap gives the guest kernel uid 0 — owning root's files by DAC. Setting host_uid runs bwrap itself at a non-root uid, so the guest's kernel uid owns none of root's files and a re-exposed root surface is unwritable by ownership as well as by the mask. As root any uid works with no newuidmap/subuid (bwrap maps --uid to whatever real uid we exec it as); point host_uid at a dedicated owns-nothing uid for the tightest mapping. Off by default: dropping requires the deploy to make every bind source (the workspace and its parents, the hatch socket dir, the rootfs) reachable by that uid, which is not true for a caller-owned workspace under a private home or the hatch's private socket dir — so the always-on /proc/sys mask remains the default fix. - seccomp: block io_uring_setup/enter/register. io_uring is a recurring kernel LPE surface and lets a guest perform operations (openat, read, …) as ring entries that never re-enter the syscall filter. Regenerated _seccomp.bpf. Validated in Docker (the permissive escape posture): with host_uid set the guest writes core_pattern -> EACCES (kernel uid non-root) while the workspace still works and the host reads the guest's file back; io_uring_setup -> EPERM in-guest. Default (host_uid unset) keeps the full suite green (70 passed in-container). Stacked on the /proc/sys mask (#8).
lgruen-cpg
approved these changes
Jul 24, 2026
folded
added a commit
that referenced
this pull request
Jul 24, 2026
…-ups Defense in depth beyond the /proc/sys read-only mask, from the jailbreak review: - host_uid/host_gid (opt-in): bwrap maps the guest's --uid to its own real uid, so a root bwrap gives the guest kernel uid 0 — owning root's files by DAC. Setting host_uid runs bwrap itself at a non-root uid, so the guest's kernel uid owns none of root's files and a re-exposed root surface is unwritable by ownership as well as by the mask. As root any uid works with no newuidmap/subuid (bwrap maps --uid to whatever real uid we exec it as); point host_uid at a dedicated owns-nothing uid for the tightest mapping. Off by default: dropping requires the deploy to make every bind source (the workspace and its parents, the hatch socket dir, the rootfs) reachable by that uid, which is not true for a caller-owned workspace under a private home or the hatch's private socket dir — so the always-on /proc/sys mask remains the default fix. - seccomp: block io_uring_setup/enter/register. io_uring is a recurring kernel LPE surface and lets a guest perform operations (openat, read, …) as ring entries that never re-enter the syscall filter. Regenerated _seccomp.bpf. Validated in Docker (the permissive escape posture): with host_uid set the guest writes core_pattern -> EACCES (kernel uid non-root) while the workspace still works and the host reads the guest's file back; io_uring_setup -> EPERM in-guest. Default (host_uid unset) keeps the full suite green (70 passed in-container). Stacked on the /proc/sys mask (#8).
folded
added a commit
that referenced
this pull request
Jul 24, 2026
…-ups (#9) Defense in depth beyond the /proc/sys read-only mask, from the jailbreak review: - host_uid/host_gid (opt-in): bwrap maps the guest's --uid to its own real uid, so a root bwrap gives the guest kernel uid 0 — owning root's files by DAC. Setting host_uid runs bwrap itself at a non-root uid, so the guest's kernel uid owns none of root's files and a re-exposed root surface is unwritable by ownership as well as by the mask. As root any uid works with no newuidmap/subuid (bwrap maps --uid to whatever real uid we exec it as); point host_uid at a dedicated owns-nothing uid for the tightest mapping. Off by default: dropping requires the deploy to make every bind source (the workspace and its parents, the hatch socket dir, the rootfs) reachable by that uid, which is not true for a caller-owned workspace under a private home or the hatch's private socket dir — so the always-on /proc/sys mask remains the default fix. - seccomp: block io_uring_setup/enter/register. io_uring is a recurring kernel LPE surface and lets a guest perform operations (openat, read, …) as ring entries that never re-enter the syscall filter. Regenerated _seccomp.bpf. Validated in Docker (the permissive escape posture): with host_uid set the guest writes core_pattern -> EACCES (kernel uid non-root) while the workspace still works and the host reads the guest's file back; io_uring_setup -> EPERM in-guest. Default (host_uid unset) keeps the full suite green (70 passed in-container). Stacked on the /proc/sys mask (#8).
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Severity: critical — full host escape from a stock hardened guest. Found by the
postern-jailbreakeffort (FINDINGS.md); this is the fix.The bug
build_base_argvmounts a fresh--proc /proc(_sandbox.py). That procfs is owned by the guest's new user namespace and discards the read-only/procmask the container runtime applied. Two facts combine:--uid 65534only changes the guest's namespaced uid. bwrap runs at the host's real uid — root, under the Cloud Run gen2 posture — so the guest's kernel uid maps to 0. Inside the guest,/proc/sys/kernel/core_patternis seenowner uid 65534(kernel 0 mapped through the userns), mode0644.core_pattern,modprobe,poweroff_cmd, …) are gated by a DAC owner-write check only — no capability gate. Owner +0644⇒ the guest can write them.So
--cap-drop ALL+ seccomp + empty netns are all bypassed: the write needs no capability and no blocked syscall. Writingcore_patternto|/proc/%P/root/workspace/handler %Pand crashing a child makes the kernel run the guest's staged handler as root in the initial namespace — arbitrary code execution on the host, plus persistent VM-wide sysctl tamper.The fix
Re-mask the sysctl and other sensitive procfs surfaces after
--proc, each bound read-only over itself (--ro-bind-try, so a path absent on this kernel is skipped) — the masking every container runtime applies. Read-only suffices: this is a write escape, and the info-leak reads (/proc/kcore, keyrings) already needCAP_SYS_RAWIO/owner the cap-dropped guest lacks.Validation (Docker, the exact posture the escape needs)
--security-opt seccomp=unconfined --security-opt systempaths=unconfined(the shapetests/docker/run.shdocuments as the Cloud-Run-gen2-equivalent):core_patternCORE_PATTERN_WRITABLECORE_PATTERN_DENIED EROFSescape_selfcontained.pyPWNEDwrittenOSError [Errno 30] Read-only file system, noPWNEDRegression guard:
test_proc_sysctl_surface_masked_read_onlyasserts the read-only self-bind of/proc/sys(+ sysrq-trigger, kcore) is emitted after--proc.Recommended follow-ups (not here)
EACCES, not just the ones we remember to mask. More deploy-sensitive (bind-source access), so proposing separately.io_uring_setup/io_uring_enter/io_uring_registerto the denylist (a known kernel-LPE surface, currently reachable in-guest).Given this ships in 0.3.0, I'd hold the release PR (#7) until this lands and rebase it on top.