CVE-2026-8933: snap-confine local privilege escalation on Ubuntu
CVE-2026-8933 is a local privilege escalation in snap-confine where a hardening migration preserved the caller UID long enough for a symlink race in a scratch rootfs path. The complete fix took four commits because O_NOFOLLOW alone closed only one leg of the bug.
CVE-2026-8933 is a local privilege escalation in snap-confine, fixed by Canonical in USN-8579-1. The bug sits in the path where snapd prepares a temporary root filesystem for a confined application, then copies ownership and mode across that tree.
The sharp part is how it got there. This race was introduced by a security hardening migration: snap-confine moved from a setuid-root model to a set-capabilities model. That change preserved the caller's real UID through setup instead of becoming root immediately, and the preserved-UID window made a world-writable scratch path attacker-reachable.
Evidence status
The vulnerable path
In the vulnerable revision, sc_bootstrap_mount_namespace() creates a scratch directory with mkdtemp() at /tmp/snap.rootfs_XXXXXX. That location matters because /tmp is shared and world-writable, so the attacker gets a place to race the setup code.
Qualys's advisory describes an additional step we did not reproduce against this repository: before the symlink race, the attacker mounts a FUSE filesystem over the scratch directory, which is what defeats the mount-namespace isolation snap-confine applies later in the same run. That is the advisory's account of why the race is reachable at all, not a claim we verified in source. It also sharpens the case for the second fix commit below: a root-owned, non-attacker-writable parent directory closes off that FUSE-mount setup, not just the symlink.
The file leg is in sc_replicate_base_rootfs(): open(full_path, O_CREAT|O_TRUNC, 0644) creates or truncates an entry without O_NOFOLLOW, then fchown(fd, 0, 0) runs two lines later. If the attacker has planted a symlink at full_path, open() follows it. The descriptor is the symlink target, and fchown() changes ownership on the attacker's chosen file.
| Step | What happens |
|---|---|
| 1 | snap-confine creates /tmp/snap.rootfs_XXXXXX under shared /tmp. |
| 2 | A local attacker races a symlink into the scratch tree at the future full_path. |
| 3 | `open(full_path, O_CREAT |
| 4 | The returned fd points at the symlink target. |
| 5 | fchown(fd, 0, 0) hands root ownership to that target while snap-confine still holds the needed capability. |
| 6 | Qualys's advisory describes riding a root-owned, attacker-controlled path to further escalation, for example by placing it where systemd-udevd will execute it as root. That escalation step is the advisory's account, not something reproduced against this repository. |
There is a second leg in the same function. The scratch directory and its entries also go through path-based chmod and chown operations, which can follow planted symlinks as well. Fixing only the fd path leaves the directory path alive.
The obvious fix is a trap
The obvious patch is to add O_NOFOLLOW to the open() call. That is necessary, and it is exactly one of the fixes Canonical shipped, but it is not sufficient by itself.
The bug class only closes when three ideas land together: get the scratch rootfs out of shared space, verify ownership before trusting that space, and refuse to follow symlinks at the vulnerable file open.
| Commit | What it closes | What it leaves open alone |
|---|---|---|
2cafc7a46ba77ad92725263c590470a63a7c8c6b | Adds O_NOFOLLOW to the vulnerable open(), so the direct file symlink path returns ELOOP. | The scratch directory is still in shared /tmp, and path-based chmod and chown on the directory leg can still follow planted symlinks. |
02cf64b882b4dc72d15294fa844655beba48c54b | Moves the scratch rootfs under /tmp/snap-private-tmp/snap.rootfs_XXXXXX. | The relocation helps only if the new parent is root-owned and not attacker-writable. That guarantee comes from the later packaging and runtime checks. |
cec05b3f0915e3ae5936e923214ce1cb0fb52b3d | Removes the old mkdir and fchown race-recovery logic. The private tmp parent is expected to exist already as root:root 0700. | Without the relocation and the hard validation, this is only a packaging expectation, not a complete runtime boundary. |
cc94fdb321d558362e806d8593b89a29737ac52c | Fails closed if the private tmp parent is not exactly root:root 0700, and adds spread tests for wrong-owner and wrong-permission cases. | Without the relocation, the check applies to the wrong path. Without O_NOFOLLOW, the file open still has a symlink-following primitive. |
Evidence status
The fix
Canonical's fix, released in tag 2.76.1, is a small set of changes with a larger design correction. The scratch space moves out of shared /tmp, the parent directory becomes a packaging-managed root-only directory, and snap-confine refuses to proceed if that invariant is missing.
That line closes the fd-based symlink handoff. The other commits close the surrounding setup problem: /tmp/snap.rootfs_XXXXXX becomes /tmp/snap-private-tmp/snap.rootfs_XXXXXX, the old mkdir and fchown recovery path is removed, and the runtime now hard-fails unless the private tmp parent is root:root 0700.
Evidence status
The bootstrap question matters because fail-closed fixes can quietly become availability bugs. Here the package ships data/systemd-tmpfiles/snapd.conf with D! /tmp/snap-private-tmp 0700 root root, so the directory is pre-created by systemd-tmpfiles before snap-confine depends on it.
There is a narrow window right after package install, before systemd-tmpfiles --create runs, where the new code would die() instead of self-healing. That is an intentional fail-closed tradeoff: if the root-only scratch parent cannot be proven, snap-confine stops.
How we verified it
Assurance ran this as a focused verification, not a full snapd end-to-end test. The security gate passed, the engineering gate passed, and the fidelity grade is FOCUSED.
Evidence status
On the red path, the harness planted a symlink at the target path before the vulnerable open(). open() succeeded, and Assurance proved the returned fd was the symlinked victim: readlink(/proc/self/fd/N) matched the victim path, and fstat(fd).st_ino == lstat(victim).st_ino.
The sandbox blocked fchown(fd, 0, 0) only because the harness lacked CAP_CHOWN. That does not change the security result. The descriptor was unambiguously the attacker-selected target, and snap-confine runs with the capability needed to complete the ownership change.
Evidence status
The green run drove the same kernel-level decision after the patch. With O_NOFOLLOW, the symlink is refused at the open call with ELOOP. That is the exact branch the exploit needs to win.
Scope
We drove the sharpest path: the kernel-level symlink decision that turns a scratch-path race into a root-ownership primitive. On vulnerable flags, the fd is the victim. On fixed flags, the call fails with ELOOP.
We also tested the directory-side behavior with a second harness and confirmed the related path-based chmod primitive. That is why the complete fix needs the private tmp parent and ownership checks around the O_NOFOLLOW change.
The boundary is the full snapd integration surface. Assurance did not have root or CAP_CHOWN in the sandbox, and the snap-confine build dependencies, including glib-2.0, libcap, libseccomp, libapparmor, libudev, and autotools, were not available to build and run snap-confine's own C unit and spread suites. The focused run still proves the exploit's critical decision with real libc: fd-is-victim before the fix, ELOOP after it.
Ubuntu 20.04 and earlier are not affected. This bug belongs to the newer set-capabilities hardening model, not the older setuid-root model.
Close
A hardening migration changed the privilege boundary. The fix had to secure the new boundary, not just patch the line where the race first showed up.