# RCA Report — GHSA-pxhw-h44j-8pfx: bubblewrap sandbox escape via /oldroot symlink traversal during setup

## Summary

During sandbox setup, bubblewrap (bwrap) mounts the host filesystem at `/oldroot`
and builds the sandbox root at `/newroot`. When a setup operation that creates a
file or directory (e.g. `--dir`, `--file`, `--bind-data`, `--ro-bind-data`) has a
destination whose parent components contain a symbolic link, vulnerable versions
resolve that symlink with plain `mkdir()`/`open()` path semantics. If the symlink
lives in attacker-controlled filesystem content (such as a malicious Flatpak app
image bound at `/`) and points at an absolute path under `/oldroot/...`, the
creation is redirected out of the sandbox and onto the **host filesystem**. This
happens during setup, before any sandboxed code runs. We reproduced the escape
end-to-end with the real `bwrap` CLI: a directory and an attacker-content marker
file were created on the host in 2/2 vulnerable attempts, while the fixed version
failed closed in 2/2 attempts.

## Impact

- **Package/component:** `bubblewrap` (`bwrap`), used by Flatpak and similar app
  frameworks.
- **Affected versions:** `< 0.12.0` (confirmed on v0.11.0,
  commit `9ca3b05ec787acfb4b17bed37db5719fa777834f`). No backport exists; the fix
  is only in v0.12.0, which also drops setuid build support.
- **Risk level and consequences:** High (advisory scores 8.8). An attacker who
  controls filesystem content that a launcher binds into the sandbox can cause
  bwrap to create directories and attacker-controlled files at arbitrary
  host paths writable by the launching uid/gid (generally unprivileged). This can
  overwrite/seed config files, startup scripts, SSH keys, etc., enabling further
  compromise. If bwrap is invoked by a privileged (setuid) launcher, the write is
  privileged.

## Impact Parity

- **Disclosed/claimed maximum impact:** sandbox escape — files created on the
  host outside the sandbox during setup (CWE-22 path traversal).
- **Reproduced impact from this run:** sandbox escape — `--dir` created
  `/subdir/newdir` on the host and `--file` wrote a fully attacker-controlled
  marker file (`ESCAPE_MARKER.txt`) into it, outside the sandbox, through the
  real `bwrap` CLI entrypoint, in both vulnerable attempts.
- **Parity:** `full`
- **Not demonstrated:** privileged (root) file creation — the run used an
  unprivileged launcher, matching the advisory's typical (non-setuid) scenario;
  this is a deployment precondition, not a gap in the vulnerability proof.

## Root Cause

In v0.11.0, `setup_newroot()` (bubblewrap.c:1189) processes each setup op and,
for ops with a destination, computes `dest = get_newroot_path(op->dest)` which
returns the absolute path `/newroot/<dest>` (utils.c:868). It then calls
`mkdir_with_parents(dest, parent_mode, false)` (bubblewrap.c:1233) and, for
file-creating ops, `ensure_dir()`/`ensure_file()`/`open()` on the resulting path.
`mkdir_with_parents()` (utils.c:705) walks the path component by component calling
`mkdir()`, which **follows symlinks** in parent components with full host-kernel
resolution.

At that point in setup, bwrap has already `pivot_root()`ed into a scratch tmpfs
(bubblewrap.c:3345) so that the namespace root contains `oldroot/` (a bind of the
host filesystem) and `newroot/` (the sandbox root under construction). An
absolute symlink inside attacker-controlled content that was bound at `/`
(= `/newroot`) therefore resolves against the setup namespace root: a symlink
`<untrusted>/subdir -> /oldroot/tmp/.../host_target` makes
`mkdir /newroot/subdir/newdir` create `/oldroot/tmp/.../host_target/newdir`,
i.e. a directory on the **host**. The same applies to `--file`/`--bind-data`/
`--ro-bind-data`, which additionally write attacker-controlled content.

`resolve_symlinks_in_ops()` (bubblewrap.c:1615) only realpaths the **source**
paths of bind mounts, not the **destination** paths of file/dir-creating ops,
so it does not mitigate this.

**Fix:** v0.12.0 (tag `2a76602a8c71f36c1527cf9fc3417d9149822e0c`) resolves all
destination paths with `openat2(RESOLVE_IN_ROOT)` via `safe_openat()` imported
from crun (commit `67d4be103b18706b5b4e3f495daa35e89e47b163`, with a
`chroot_realpath.c` fallback for pre-5.6 kernels) together with commit
`ea185f6fb135782cabab342e33432e8482a2f5c9` ("Inline the privileged ops"), so
symlink resolution is confined to `/newroot` and escaping symlinks fail with
ENOENT instead of being followed onto the host.

Advisory: https://github.com/containers/bubblewrap/security/advisories/GHSA-pxhw-h44j-8pfx

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (self-contained; run from any directory,
   exit 0 = confirmed).
2. What the script does:
   - Reads `bundle/project_cache_context.json` and reuses the prepared project
     cache (`<project_cache_dir>/repo`), falling back to `bundle/artifacts/`.
   - Clones `containers/bubblewrap`, verifies the vulnerable commit
     (v0.11.0 = `9ca3b05e...`) lacks fix commits `67d4be10...`/`ea185f6f...` and
     the fixed commit (v0.12.0 = `2a76602a...`, the ticket's fixed tag) contains
     them; also checks the source trees (`safe_openat.c` absent/present).
   - Builds both versions with the system `gcc` (no sudo in this environment:
     libcap headers are obtained by downloading the `libcap-dev` .deb and
     extracting it; the only required `config.h` macro, `PACKAGE_STRING`, is
     generated; no sanitizer is used — this is a plain product build).
   - Smoke-tests that unprivileged user namespaces work.
   - Runs the advisory recipe hermetically, twice per build:
     creates `untrusted/subdir -> /oldroot<abs path of host_target>` and invokes
     `bwrap --bind <untrusted> / --ro-bind /usr /usr --ro-bind /lib /lib
     --ro-bind /lib64 /lib64 --dir /subdir/newdir
     --file 3 /subdir/newdir/ESCAPE_MARKER.txt /usr/bin/true` with a known
     marker content on fd 3.
   - Verifies per attempt: vulnerable builds must exit 0 **and** leave
     `<host_target>/newdir/ESCAPE_MARKER.txt` on the host with the exact
     attacker content; fixed builds must exit non-zero **and** leave nothing.
   - Writes `bundle/repro/runtime_manifest.json` with SHA-256 of every proof
     artifact.
3. Expected evidence: vulnerable attempts show the marker file on the host;
   fixed attempts log `bwrap: Can't mkdir parents for /subdir/newdir: No such
   file or directory` and create nothing on the host.

## Evidence

- `bundle/logs/attempt-vuln-1.log`, `bundle/logs/attempt-vuln-2.log` —
  bwrap v0.11.0 exits 0, sandboxed `/usr/bin/true` runs, and the post-run
  listing shows `host_target/newdir/ESCAPE_MARKER.txt` with the
  attacker-controlled content `BWRAP_OLDROOT_ESCAPE_<timestamp>`.
- `bundle/logs/attempt-fixed-1.log`, `bundle/logs/attempt-fixed-2.log` —
  bwrap v0.12.0 exits 1 with `Can't mkdir parents for /subdir/newdir: No such
  file or directory`; host target remains empty.
- `bundle/repro/work/vuln-{1,2}/host_target/newdir/ESCAPE_MARKER.txt` — the
  actual marker files created on the host by the vulnerable binary.
- `bundle/repro/proof_summary.txt` — per-attempt verdicts and binary SHA-256s.
- `bundle/repro/runtime_manifest.json` — entrypoint `cli_command`,
  `target_path_reached=true`, proof artifacts with SHA-256 hashes.
- Environment: Linux 6.8.0-138-generic x86_64, uid 1000 (unprivileged),
  `kernel.unprivileged_userns_clone=1`, user namespaces functional (see
  `bundle/logs/smoke_test.log`). Openat2-capable kernel (>= 5.6), so the fixed
  build exercises the real `RESOLVE_IN_ROOT` path.

Key excerpt (vulnerable attempt):

```
command: 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
--- exit_code: 0
host_target (post-run):
.../host_target/newdir:
-rw-rw-rw- 1 pruva pruva ... ESCAPE_MARKER.txt
host marker content: BWRAP_OLDROOT_ESCAPE_20260826T200804Z
```

Key excerpt (fixed attempt):

```
--- exit_code: 1
bwrap: Can't mkdir parents for /subdir/newdir: No such file or directory
host marker content: <absent>
```

## Recommendations / Next Steps

- **Fix approach (upstream, already shipped):** confine all destination path
  resolution to the new root using `openat2(RESOLVE_IN_ROOT)`
  (`safe_openat()`), with the `chroot_realpath` fallback on pre-5.6 kernels —
  commits `67d4be103b18706b5b4e3f495daa35e89e47b163` and
  `ea185f6fb135782cabab342e33432e8482a2f5c9` in v0.12.0.
- **Upgrade guidance:** upgrade to bubblewrap >= 0.12.0. There is no backport
  for versions that support setuid builds; users relying on setuid bwrap should
  migrate to unprivileged user namespaces. Until upgraded, avoid binding
  attacker-controlled filesystem content into sandboxes (audit Flatpak-style
  launchers for `--bind <untrusted> /` combined with file/dir-creating options).
- **Testing recommendations:** regression-test every file/dir-creating option
  (`--dir`, `--file`, `--bind-data`, `--ro-bind-data`, `--chmod`, bind dest
  auto-creation) with parent symlinks targeting both `/oldroot/...` (absolute)
  and relative `..` chains; the variant-analysis stage covers these.

## Additional Notes

- **Idempotency:** the script was run four consecutive times (two before the
  manifest-ordering fix, two after), all exiting 0; per-attempt work directories
  are wiped and recreated (`rm -rf`) so runs are hermetic and repeatable. Cache
  reuse (repo/build/tools) makes reruns take ~2 s.
- **No sudo/pip in this environment:** the build avoids meson (not installable
  without root) by compiling the four (vuln) / six (fixed) upstream C files
  directly with gcc; this is byte-identical upstream source at the anchored
  commits, and binary SHA-256s are recorded in `proof_summary.txt`.
- **Non-sanitized product proof:** the primary oracle is real product behavior
  (host filesystem state + CLI exit codes), not ASAN/UBSAN.
- **"Host" scope:** bwrap was executed directly on this machine (not nested in
  Docker), so `/oldroot` is the real machine root and the marker files land in
  the real filesystem outside any bwrap namespace.
- **Edge cases not covered here (delegated to variant analysis):** `--file`,
  `--bind-data`, `--ro-bind-data` as the *primary* creating op without `--dir`;
  relative (`../../oldroot`) symlink targets; `--symlink`-created parents;
  pre-5.6 kernel fallback path of the fix.
