{"repro_id":"REPRO-2026-00339","version":6,"title":"bubblewrap: sandbox escape via /oldroot symlink traversal during setup — files created on host","repro_type":"security","status":"published","severity":"high","description":"Bubblewrap versions before 0.12.0 can follow an attacker-controlled parent-directory symlink while creating files or directories under /newroot during sandbox setup. A symlink can redirect creation through /oldroot onto the host filesystem before any sandboxed code runs.","root_cause":"# RCA Report — GHSA-pxhw-h44j-8pfx: bubblewrap sandbox escape via /oldroot symlink traversal during setup\n\n## Summary\n\nDuring sandbox setup, bubblewrap (bwrap) mounts the host filesystem at `/oldroot`\nand builds the sandbox root at `/newroot`. When a setup operation that creates a\nfile or directory (e.g. `--dir`, `--file`, `--bind-data`, `--ro-bind-data`) has a\ndestination whose parent components contain a symbolic link, vulnerable versions\nresolve that symlink with plain `mkdir()`/`open()` path semantics. If the symlink\nlives in attacker-controlled filesystem content (such as a malicious Flatpak app\nimage bound at `/`) and points at an absolute path under `/oldroot/...`, the\ncreation is redirected out of the sandbox and onto the **host filesystem**. This\nhappens during setup, before any sandboxed code runs. We reproduced the escape\nend-to-end with the real `bwrap` CLI: a directory and an attacker-content marker\nfile were created on the host in 2/2 vulnerable attempts, while the fixed version\nfailed closed in 2/2 attempts.\n\n## Impact\n\n- **Package/component:** `bubblewrap` (`bwrap`), used by Flatpak and similar app\n  frameworks.\n- **Affected versions:** `< 0.12.0` (confirmed on v0.11.0,\n  commit `9ca3b05ec787acfb4b17bed37db5719fa777834f`). No backport exists; the fix\n  is only in v0.12.0, which also drops setuid build support.\n- **Risk level and consequences:** High (advisory scores 8.8). An attacker who\n  controls filesystem content that a launcher binds into the sandbox can cause\n  bwrap to create directories and attacker-controlled files at arbitrary\n  host paths writable by the launching uid/gid (generally unprivileged). This can\n  overwrite/seed config files, startup scripts, SSH keys, etc., enabling further\n  compromise. If bwrap is invoked by a privileged (setuid) launcher, the write is\n  privileged.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** sandbox escape — files created on the\n  host outside the sandbox during setup (CWE-22 path traversal).\n- **Reproduced impact from this run:** sandbox escape — `--dir` created\n  `/subdir/newdir` on the host and `--file` wrote a fully attacker-controlled\n  marker file (`ESCAPE_MARKER.txt`) into it, outside the sandbox, through the\n  real `bwrap` CLI entrypoint, in both vulnerable attempts.\n- **Parity:** `full`\n- **Not demonstrated:** privileged (root) file creation — the run used an\n  unprivileged launcher, matching the advisory's typical (non-setuid) scenario;\n  this is a deployment precondition, not a gap in the vulnerability proof.\n\n## Root Cause\n\nIn v0.11.0, `setup_newroot()` (bubblewrap.c:1189) processes each setup op and,\nfor ops with a destination, computes `dest = get_newroot_path(op->dest)` which\nreturns the absolute path `/newroot/<dest>` (utils.c:868). It then calls\n`mkdir_with_parents(dest, parent_mode, false)` (bubblewrap.c:1233) and, for\nfile-creating ops, `ensure_dir()`/`ensure_file()`/`open()` on the resulting path.\n`mkdir_with_parents()` (utils.c:705) walks the path component by component calling\n`mkdir()`, which **follows symlinks** in parent components with full host-kernel\nresolution.\n\nAt that point in setup, bwrap has already `pivot_root()`ed into a scratch tmpfs\n(bubblewrap.c:3345) so that the namespace root contains `oldroot/` (a bind of the\nhost filesystem) and `newroot/` (the sandbox root under construction). An\nabsolute symlink inside attacker-controlled content that was bound at `/`\n(= `/newroot`) therefore resolves against the setup namespace root: a symlink\n`<untrusted>/subdir -> /oldroot/tmp/.../host_target` makes\n`mkdir /newroot/subdir/newdir` create `/oldroot/tmp/.../host_target/newdir`,\ni.e. a directory on the **host**. The same applies to `--file`/`--bind-data`/\n`--ro-bind-data`, which additionally write attacker-controlled content.\n\n`resolve_symlinks_in_ops()` (bubblewrap.c:1615) only realpaths the **source**\npaths of bind mounts, not the **destination** paths of file/dir-creating ops,\nso it does not mitigate this.\n\n**Fix:** v0.12.0 (tag `2a76602a8c71f36c1527cf9fc3417d9149822e0c`) resolves all\ndestination paths with `openat2(RESOLVE_IN_ROOT)` via `safe_openat()` imported\nfrom crun (commit `67d4be103b18706b5b4e3f495daa35e89e47b163`, with a\n`chroot_realpath.c` fallback for pre-5.6 kernels) together with commit\n`ea185f6fb135782cabab342e33432e8482a2f5c9` (\"Inline the privileged ops\"), so\nsymlink resolution is confined to `/newroot` and escaping symlinks fail with\nENOENT instead of being followed onto the host.\n\nAdvisory: https://github.com/containers/bubblewrap/security/advisories/GHSA-pxhw-h44j-8pfx\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; run from any directory,\n   exit 0 = confirmed).\n2. What the script does:\n   - Reads `bundle/project_cache_context.json` and reuses the prepared project\n     cache (`<project_cache_dir>/repo`), falling back to `bundle/artifacts/`.\n   - Clones `containers/bubblewrap`, verifies the vulnerable commit\n     (v0.11.0 = `9ca3b05e...`) lacks fix commits `67d4be10...`/`ea185f6f...` and\n     the fixed commit (v0.12.0 = `2a76602a...`, the ticket's fixed tag) contains\n     them; also checks the source trees (`safe_openat.c` absent/present).\n   - Builds both versions with the system `gcc` (no sudo in this environment:\n     libcap headers are obtained by downloading the `libcap-dev` .deb and\n     extracting it; the only required `config.h` macro, `PACKAGE_STRING`, is\n     generated; no sanitizer is used — this is a plain product build).\n   - Smoke-tests that unprivileged user namespaces work.\n   - Runs the advisory recipe hermetically, twice per build:\n     creates `untrusted/subdir -> /oldroot<abs path of host_target>` and invokes\n     `bwrap --bind <untrusted> / --ro-bind /usr /usr --ro-bind /lib /lib\n     --ro-bind /lib64 /lib64 --dir /subdir/newdir\n     --file 3 /subdir/newdir/ESCAPE_MARKER.txt /usr/bin/true` with a known\n     marker content on fd 3.\n   - Verifies per attempt: vulnerable builds must exit 0 **and** leave\n     `<host_target>/newdir/ESCAPE_MARKER.txt` on the host with the exact\n     attacker content; fixed builds must exit non-zero **and** leave nothing.\n   - Writes `bundle/repro/runtime_manifest.json` with SHA-256 of every proof\n     artifact.\n3. Expected evidence: vulnerable attempts show the marker file on the host;\n   fixed attempts log `bwrap: Can't mkdir parents for /subdir/newdir: No such\n   file or directory` and create nothing on the host.\n\n## Evidence\n\n- `bundle/logs/attempt-vuln-1.log`, `bundle/logs/attempt-vuln-2.log` —\n  bwrap v0.11.0 exits 0, sandboxed `/usr/bin/true` runs, and the post-run\n  listing shows `host_target/newdir/ESCAPE_MARKER.txt` with the\n  attacker-controlled content `BWRAP_OLDROOT_ESCAPE_<timestamp>`.\n- `bundle/logs/attempt-fixed-1.log`, `bundle/logs/attempt-fixed-2.log` —\n  bwrap v0.12.0 exits 1 with `Can't mkdir parents for /subdir/newdir: No such\n  file or directory`; host target remains empty.\n- `bundle/repro/work/vuln-{1,2}/host_target/newdir/ESCAPE_MARKER.txt` — the\n  actual marker files created on the host by the vulnerable binary.\n- `bundle/repro/proof_summary.txt` — per-attempt verdicts and binary SHA-256s.\n- `bundle/repro/runtime_manifest.json` — entrypoint `cli_command`,\n  `target_path_reached=true`, proof artifacts with SHA-256 hashes.\n- Environment: Linux 6.8.0-138-generic x86_64, uid 1000 (unprivileged),\n  `kernel.unprivileged_userns_clone=1`, user namespaces functional (see\n  `bundle/logs/smoke_test.log`). Openat2-capable kernel (>= 5.6), so the fixed\n  build exercises the real `RESOLVE_IN_ROOT` path.\n\nKey excerpt (vulnerable attempt):\n\n```\ncommand: timeout 30 .../bwrap-v0.11.0 --bind .../untrusted / --ro-bind /usr /usr ... --dir /subdir/newdir --file 3 /subdir/newdir/ESCAPE_MARKER.txt /usr/bin/true\n--- exit_code: 0\nhost_target (post-run):\n.../host_target/newdir:\n-rw-rw-rw- 1 pruva pruva ... ESCAPE_MARKER.txt\nhost marker content: BWRAP_OLDROOT_ESCAPE_20260826T200804Z\n```\n\nKey excerpt (fixed attempt):\n\n```\n--- exit_code: 1\nbwrap: Can't mkdir parents for /subdir/newdir: No such file or directory\nhost marker content: <absent>\n```\n\n## Recommendations / Next Steps\n\n- **Fix approach (upstream, already shipped):** confine all destination path\n  resolution to the new root using `openat2(RESOLVE_IN_ROOT)`\n  (`safe_openat()`), with the `chroot_realpath` fallback on pre-5.6 kernels —\n  commits `67d4be103b18706b5b4e3f495daa35e89e47b163` and\n  `ea185f6fb135782cabab342e33432e8482a2f5c9` in v0.12.0.\n- **Upgrade guidance:** upgrade to bubblewrap >= 0.12.0. There is no backport\n  for versions that support setuid builds; users relying on setuid bwrap should\n  migrate to unprivileged user namespaces. Until upgraded, avoid binding\n  attacker-controlled filesystem content into sandboxes (audit Flatpak-style\n  launchers for `--bind <untrusted> /` combined with file/dir-creating options).\n- **Testing recommendations:** regression-test every file/dir-creating option\n  (`--dir`, `--file`, `--bind-data`, `--ro-bind-data`, `--chmod`, bind dest\n  auto-creation) with parent symlinks targeting both `/oldroot/...` (absolute)\n  and relative `..` chains; the variant-analysis stage covers these.\n\n## Additional Notes\n\n- **Idempotency:** the script was run four consecutive times (two before the\n  manifest-ordering fix, two after), all exiting 0; per-attempt work directories\n  are wiped and recreated (`rm -rf`) so runs are hermetic and repeatable. Cache\n  reuse (repo/build/tools) makes reruns take ~2 s.\n- **No sudo/pip in this environment:** the build avoids meson (not installable\n  without root) by compiling the four (vuln) / six (fixed) upstream C files\n  directly with gcc; this is byte-identical upstream source at the anchored\n  commits, and binary SHA-256s are recorded in `proof_summary.txt`.\n- **Non-sanitized product proof:** the primary oracle is real product behavior\n  (host filesystem state + CLI exit codes), not ASAN/UBSAN.\n- **\"Host\" scope:** bwrap was executed directly on this machine (not nested in\n  Docker), so `/oldroot` is the real machine root and the marker files land in\n  the real filesystem outside any bwrap namespace.\n- **Edge cases not covered here (delegated to variant analysis):** `--file`,\n  `--bind-data`, `--ro-bind-data` as the *primary* creating op without `--dir`;\n  relative (`../../oldroot`) symlink targets; `--symlink`-created parents;\n  pre-5.6 kernel fallback path of the fix.\n","ghsa_id":"GHSA-PXHW-H44J-8PFX","cwe_id":"CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)","source_url":"https://github.com/containers/bubblewrap/security/advisories/GHSA-pxhw-h44j-8pfx","reproduced_at":"2026-08-26T21:14:47.510161+00:00","duration_secs":1108.0,"tool_calls":119,"handoffs":3,"total_cost_usd":2.801822,"agent_costs":{"judge":0.616399,"learning_policy":0.010066,"repro":1.946525,"support":0.228832},"cost_breakdown":{"judge":{"gpt-5.6-sol":0.616399},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.010066},"repro":{"accounts/fireworks/models/kimi-k3":1.946525},"support":{"accounts/fireworks/models/kimi-k3":0.228832}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-26T21:14:48.175883+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10404,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":17745,"category":"reproduction_script"},{"path":"bundle/logs/attempt-fixed-1.log","filename":"attempt-fixed-1.log","size":1584,"category":"log"},{"path":"bundle/logs/attempt-fixed-2.log","filename":"attempt-fixed-2.log","size":1584,"category":"log"},{"path":"bundle/logs/attempt-vuln-1.log","filename":"attempt-vuln-1.log","size":1846,"category":"log"},{"path":"bundle/logs/attempt-vuln-2.log","filename":"attempt-vuln-2.log","size":1846,"category":"log"},{"path":"bundle/logs/smoke_test.log","filename":"smoke_test.log","size":268,"category":"log"},{"path":"bundle/repro/markers/ESCAPE_MARKER-vuln-1.txt","filename":"ESCAPE_MARKER-vuln-1.txt","size":38,"category":"other"},{"path":"bundle/repro/markers/ESCAPE_MARKER-vuln-2.txt","filename":"ESCAPE_MARKER-vuln-2.txt","size":38,"category":"other"},{"path":"bundle/repro/proof_summary.txt","filename":"proof_summary.txt","size":1176,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2315,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":945,"category":"other"}]}