Vulnerability research

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

CVECVE-2026-8933
Componentsnap-confine, Canonical snapd
Impactlocal privilege escalation
CWECWE-250
SeverityCVSS 3.1 7.8 High, AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Published2026-07-21
DiscoverySaeed Abbasi, Qualys Threat Research Unit
Ubuntu advisoryUSN-8579-1, priority High
Fixed versionssnapd 2.76+ubuntu26.04.3, 2.76+ubuntu24.04.1, 2.76+ubuntu22.04.1
01

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.

StepWhat happens
1snap-confine creates /tmp/snap.rootfs_XXXXXX under shared /tmp.
2A local attacker races a symlink into the scratch tree at the future full_path.
3`open(full_path, O_CREAT
4The returned fd points at the symlink target.
5fchown(fd, 0, 0) hands root ownership to that target while snap-confine still holds the needed capability.
6Qualys'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.

02

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.

CommitWhat it closesWhat it leaves open alone
2cafc7a46ba77ad92725263c590470a63a7c8c6bAdds 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.
02cf64b882b4dc72d15294fa844655beba48c54bMoves 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.
cec05b3f0915e3ae5936e923214ce1cb0fb52b3dRemoves 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.
cc94fdb321d558362e806d8593b89a29737ac52cFails 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

Single-commit resultNone of the four commits is sufficient alone
File legRequires O_NOFOLLOW on the open path
Directory legRequires leaving shared /tmp and removing self-repair logic
Trust boundaryRequires root:root 0700 validation on the private tmp parent
Maintainer decisionFail closed on wrong ownership or permissions
03

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.

cmd/snap-confine/mount-support.c
11 // Create an empty file which can be used as a mount point, no need
22 // for 0000, parent directory already owned and writable by root
33 // only.
4- int fd = open(full_path, O_CREAT | O_TRUNC, 0644);
4+ // Use O_NOFOLLOW to prevent symlink attacks during the TOCTOU window
5+ // between directory creation and chown.
6+ int fd = open(full_path, O_CREAT | O_TRUNC | O_NOFOLLOW, 0644);
57 if (fd < 0) {
68 die("cannot create mount point for file \"%s\"", full_path);
79 }

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

Fix tagsnapd 2.76.1
AuthorZygmunt Krynicki, Canonical
Commit 12cafc7a46ba77ad92725263c590470a63a7c8c6b, add O_NOFOLLOW
Commit 202cf64b882b4dc72d15294fa844655beba48c54b, relocate scratch dir to /tmp/snap-private-tmp
Commit 3cec05b3f0915e3ae5936e923214ce1cb0fb52b3d, remove mkdir/fchown race recovery
Commit 4cc94fdb321d558362e806d8593b89a29737ac52c, die on wrong owner or permissions

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.

04

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

Security gatePASS
Engineering gatePASS
FidelityFOCUSED
Red targetpre-fix flags O_CREAT|O_TRUNC, real libc
Green targetpost-fix flags with O_NOFOLLOW, real libc
Bootstrap lockoutCLEAR

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

RED, file legopen() followed the planted symlink
RED, fd proofreadlink(/proc/self/fd/N) matched the victim path
RED, inode prooffstat(fd).st_ino == lstat(victim).st_ino
RED, fchownblocked only by missing CAP_CHOWN in the sandbox
RED, directory lega second harness showed path-based chmod flipping a victim from 0600 to 0755 through a planted symlink
GREEN, file legopen() returned -1 ELOOP with O_NOFOLLOW
GREEN, consequencefchown never executed, victim untouched

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.

05

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.

06

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.