# Root Cause Analysis — CVE-2026-5674 (PipeWire PulseAudio-compatibility sandbox escape)

## Summary
PipeWire's PulseAudio compatibility daemon (`pipewire-pulse`) implements the PulseAudio
native protocol `LOAD_MODULE` command. The request handler only checks the
`pulse.allow-module-loading` server property (default: `true`) and then loads any of the
built-in PulseAudio compatibility modules with fully attacker-controlled arguments. One of
those modules, `module-ladspa-sink`, forwards the attacker-supplied `plugin=` argument to
PipeWire's filter-chain LADSPA loader, which calls `dlopen()` on the value verbatim when it
is an absolute path. Because Flatpak-style sandboxes deliberately expose the PulseAudio
unix socket to sandboxed applications, an attacker confined in a bubblewrap/Flatpak sandbox
can make the out-of-sandbox `pipewire-pulse` daemon `dlopen()` an attacker-controlled shared
library, executing its ELF constructors in the daemon process and thereby escaping the
sandbox.

## Impact
- Package/component affected: `pipewire` / `pipewire-pulse`
  (`libpipewire-module-protocol-pulse`, pulse `module-ladspa-sink`/`module-ladspa-source`,
  SPA filter-graph LADSPA plugin).
- Affected versions: verified on PipeWire 1.6.2 (Ubuntu 26.04, `1.6.2-1ubuntu1.1`). The
  Debian security tracker lists every release as vulnerable (bullseye 0.3.19 through
  sid 1.6.8); no upstream fix exists at the time of this run.
- Risk level and consequences: Important (CVSS 8.8 per Amazon ALAS). Any sandboxed
  application with access to the PulseAudio socket (default for audio-playing Flatpaks)
  can execute arbitrary code in the user's unsandboxed `pipewire-pulse` session daemon,
  i.e. a full sandbox escape with the daemon's privileges.

## Impact Parity
- Disclosed/claimed maximum impact: sandbox escape — arbitrary code execution outside the
  sandbox (bubblewrap/Flatpak-style namespace sandbox) via the PulseAudio compatibility
  layer.
- Reproduced impact from this run: attacker-controlled code executed in the host-side
  `pipewire-pulse` daemon from a client confined in a real bubblewrap sandbox with separate
  mount and user namespaces. Proof: the loaded library's constructor wrote fresh markers
  (`CVE-2026-5674-PWNED pid=<daemon pid> uid=1000 comm=pipewire-pulse`) into a host-only
  oracle directory that the sandboxed client could neither see nor write.
- Parity: `full`.
- Not demonstrated: nothing material — a constructor can contain arbitrary code; the marker
  write is representative attacker-controlled code execution in the daemon.

## Root Cause
1. `src/modules/module-protocol-pulse/pulse-server.c` — `do_load_module()` (line 5051)
   handles `COMMAND_LOAD_MODULE` from the PulseAudio native protocol. Its only guard is
   `if (!impl->defs.allow_module_loading) return -EACCES;`. The property defaults to true
   (`/usr/share/pipewire/pipewire-pulse.conf`: `#pulse.allow-module-loading = true`).
2. `src/modules/module-protocol-pulse/modules/module-ladspa-sink.c` — the registered pulse
   module `module-ladspa-sink` accepts `plugin=` and `label=` arguments and builds a
   filter.graph node `{ type = ladspa plugin = "<plugin>" label = "<label>" }`, loaded via
   `pw_context_load_module(..., "libpipewire-module-filter-chain", ...)` inside the
   `pipewire-pulse` process.
3. `spa/plugins/filter-graph/plugin_ladspa.c` — `load_ladspa_plugin()`: when the plugin
   string starts with `/`, it is passed directly to `ladspa_handle_load_by_path()`, which
   calls `dlopen(path, RTLD_NOW)` (line 196) with no allowlist, no confinement check, and
   no consideration of whether the requesting client is sandboxed. `dlopen()` executes the
   library's constructors, yielding arbitrary code execution in the daemon. (The subsequent
   `dlsym("ladspa_descriptor")` failure only rejects the module *after* attacker code has
   already run.)
- Fix commit: none upstream at the time of this run (Debian tracker: "unfixed"). The
  disclosed mitigation is `pulse.allow-module-loading = false`, which makes
  `do_load_module()` return `-EACCES` ("Failure: Access denied") — used as this run's
  negative control.

## Reproduction Steps
1. `bundle/repro/reproduction_steps.sh` (self-contained; exit 0 = confirmed).
2. What it does:
   - Builds the attacker library `shared/evil.so` (constructor writes
     `CVE-2026-5674-PWNED pid=... uid=... comm=...` into the host-only oracle dir; the
     oracle path is delivered via the daemon's `ORACLE_PATH` environment variable).
   - If user namespaces are blocked by the outer container seccomp (they are on this
     worker: `unshare(CLONE_NEWUSER)` → EPERM even for root), it re-executes itself inside
     a Docker container (`--security-opt seccomp=unconfined`, ubuntu:26.04 with
     pipewire/pipewire-pulse/pulseaudio-utils/bubblewrap) as unprivileged uid 1000.
   - Starts the real `pipewire` + `pipewire-pulse` daemons on the "host" side with a fresh
     `XDG_RUNTIME_DIR`; health-checks via `pactl info`.
   - Runs the attacker client inside `bwrap --unshare-user --unshare-ipc --unshare-uts
     --unshare-cgroup` with only: the PulseAudio runtime dir, the writable shared dir
     (containing `evil.so`), and read-only system binds. The host-only oracle directory is
     not bound.
   - The client proves it cannot see the oracle path and that a probe write never reaches
     the host, then issues the real protocol request
     `pactl load-module module-ladspa-sink sink_name=pwnsink plugin=<abs path> label=pwn`.
   - Host verifies a marker file `marker_<daemon pid>.txt` whose pid equals the live
     `pipewire-pulse` pid and whose comm is `pipewire-pulse`; namespace identity of client
     vs daemon is recorded both from inside the sandbox and from the host.
   - Two vulnerable attempts against fresh daemon processes, then two negative controls
     with `pulse.allow-module-loading=false` (request denied, no marker).
3. Expected evidence: markers `logs/marker_vuln_attempt{1,2}.txt`, client logs showing
   `ORACLE_PATH_NOT_VISIBLE` + LOAD_MODULE, fixed attempts showing `Failure: Access
   denied`, `RESULT: vuln_ok=2/2 fixed_ok=2/2`, exit 0.

## Evidence
- `bundle/logs/reproduction_steps.log` — full orchestration log; key lines:
  - `marker present: CVE-2026-5674-PWNED pid=37 uid=1000 comm=pipewire-pulse`
  - `marker pid/comm match live daemon (pid=37 comm=pipewire-pulse)`
  - `client runs in a different user namespace (user=user:[4026533274] vs daemon user:[4026533197])`
  - `RESULT: vuln_ok=2/2 fixed_ok=2/2` / `CVE-2026-5674 CONFIRMED` (two consecutive runs)
- `bundle/logs/client_vuln_attempt{1,2}.log` — sandboxed client: oracle not visible, probe
  write does not reach host, `pactl load-module module-ladspa-sink plugin=...evil.so`.
- `bundle/logs/marker_vuln_attempt{1,2}.txt` — host-only markers with live daemon pid/comm.
- `bundle/logs/client_fixed_attempt{1,2}.log` — `Failure: Access denied`;
  `bundle/logs/negative_control_obs_fixed_attempt{1,2}.json` — structured observation.
- `bundle/logs/ns_evidence_*.txt` / `ns_client_host_*.txt` — namespace/process identity:
  daemon `user:[4026533197]`, sandboxed clients `user:[4026533274/...275]`, distinct mount
  namespaces, observed both from inside the sandbox and from the host.
- `bundle/repro/runtime_manifest.json` — runtime evidence manifest (all flags true).
- Environment: Ubuntu 26.04 (worker) / ubuntu:26.04 (experiment container), pipewire and
  pipewire-pulse 1.6.2-1ubuntu1.1, pactl 17.0, bubblewrap, kernel 7.0.14-arch1-1.

## Recommendations / Next Steps
- Mitigation (per disclosure): set `pulse.allow-module-loading = false` in
  `pipewire-pulse.conf` (validated as this run's negative control).
- Suggested upstream fix: refuse absolute-path LADSPA plugins for pulse-protocol clients
  (or only allow plugins from trusted system directories), and/or default
  `pulse.allow-module-loading` to false; ideally the pulse server should apply
  Flatpak-style client confinement awareness (as PulseAudio's `client.access` model
  starts to do) before honoring `LOAD_MODULE`.
- Testing recommendations: regression test that `pactl load-module module-ladspa-sink
  plugin=/tmp/x.so` fails on hardened configs; CI sandbox test mirroring this script.

## Additional Notes
- Idempotency: the script is fully idempotent — it rebuilds the payload, regenerates
  configs, uses fresh runtime dirs per attempt, cleans oracle markers per attempt, and
  kills its daemons on exit. It passed three consecutive end-to-end runs on this worker.
- Outer-container limitation: the worker's seccomp blocks `CLONE_NEWUSER`/`CLONE_NEWNS`
  even for root, so the experiment runs inside a Docker sibling container with an
  unconfined seccomp profile. Inside it, the bubblewrap sandbox uses genuine, separate
  mount and user namespaces (verified via namespace inode evidence from both sides); the
  pipewire-pulse daemon runs outside that sandbox. This matches the ticket's required
  bubblewrap/Flatpak-style confinement (chroot was *not* used).
- `--unshare-pid` was omitted because a fresh `/proc` cannot be mounted inside rootless
  Docker (locked procfs mounts); the ticket requires mount+user namespace separation,
  which is provided and evidenced. `/proc/self/ns` is bind-mounted read-only into the
  sandbox solely for identity evidence.
- Edge case: the daemon answers `Failure: Missing implementation` for the malicious module
  because `evil.so` exports no `ladspa_descriptor`; code execution already happened during
  `dlopen()` (constructor), which is exactly why the marker exists despite the error.
