Skip to content

Mask the procfs sysctl surface read-only (host escape via core_pattern) - #8

Merged
folded merged 2 commits into
mainfrom
harden-proc-sysctl-escape
Jul 24, 2026
Merged

Mask the procfs sysctl surface read-only (host escape via core_pattern)#8
folded merged 2 commits into
mainfrom
harden-proc-sysctl-escape

Conversation

@folded

@folded folded commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Severity: critical — full host escape from a stock hardened guest. Found by the postern-jailbreak effort (FINDINGS.md); this is the fix.

The bug

build_base_argv mounts a fresh --proc /proc (_sandbox.py). That procfs is owned by the guest's new user namespace and discards the read-only /proc mask the container runtime applied. Two facts combine:

  1. --uid 65534 only 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_pattern is seen owner uid 65534 (kernel 0 mapped through the userns), mode 0644.
  2. Sensitive global sysctls (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. Writing core_pattern to |/proc/%P/root/workspace/handler %P and 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 need CAP_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 shape tests/docker/run.sh documents as the Cloud-Run-gen2-equivalent):

before after
stock guest writes core_pattern CORE_PATTERN_WRITABLE CORE_PATTERN_DENIED EROFS
full escape_selfcontained.py root handler runs, PWNED written fails at arming: OSError [Errno 30] Read-only file system, no PWNED
bwrap e2e suite (69 tests) passes (added binds don't break launches)

Regression guard: test_proc_sysctl_surface_masked_read_only asserts the read-only self-bind of /proc/sys (+ sysrq-trigger, kcore) is emitted after --proc.

Recommended follow-ups (not here)

  • Root-cause / defense-in-depth: drop bwrap's real uid to a non-root value before exec so the guest doesn't own root's files at all (unprivileged userns doesn't need root). Then any re-exposed root-owned surface is EACCES, not just the ones we remember to mask. More deploy-sensitive (bind-source access), so proposing separately.
  • seccomp: add io_uring_setup/io_uring_enter/io_uring_register to 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.

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
folded requested a review from lgruen-cpg July 24, 2026 04:13
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
folded merged commit 9e48985 into main Jul 24, 2026
6 checks passed
@folded
folded deleted the harden-proc-sysctl-escape branch July 24, 2026 04:44
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).
@folded folded mentioned this pull request Jul 24, 2026
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.

2 participants