Get in Touch

Tell us a bit about yourself — someone from our team will reach out shortly.

Max file size 10MB.
Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Send Request
Send Request
Request sent!
Thanks for reaching out - we've got your details. Someone from the Emphere team will reach out shortly.
Typical response:
within one business day
Done
Done
Oops! Something went wrong while submitting the form.

Emphere Engineering

Assurance

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.

Table of content
1.
Lorem ipsum dolor
  • July 27, 2026
  • 5 min read

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.

Emphere Evidence

[
  ["CVE", "CVE-2026-8933"],
  ["Component", "snap-confine, Canonical snapd"],
  ["Impact", "local privilege escalation"],
  ["CWE", "CWE-250"],
  ["Severity", "CVSS 3.1 7.8 High, AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"],
  ["Published", "2026-07-21"],
  ["Discovery", "Saeed Abbasi, Qualys Threat Research Unit"],
  ["Ubuntu advisory", "USN-8579-1, priority High"],
  ["Fixed versions", "snapd 2.76+ubuntu26.04.3, 2.76+ubuntu24.04.1, 2.76+ubuntu22.04.1"]
]

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 happens1snap-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_CREATO_TRUNC, 0644) follows the symlink because O_NOFOLLOW` is absent.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.

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 alone2cafc7a46ba77ad92725263c590470a63a7c8c6bAdds 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.

Emphere Evidence

[
  ["Single-commit result", "None of the four commits is sufficient alone"],
  ["File leg", "Requires O_NOFOLLOW on the open path"],
  ["Directory leg", "Requires leaving shared /tmp and removing self-repair logic"],
  ["Trust boundary", "Requires root:root 0700 validation on the private tmp parent"],
  ["Maintainer decision", "Fail closed on wrong ownership or permissions"]
]

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.

Emphere Diff

{
  "file": "cmd/snap-confine/mount-support.c",
  "diff": "@@ -448,7 +448,9 @@ static void sc_replicate_base_rootfs(const char *scratch_dir, const char *rootfs\n             // Create an empty file which can be used as a mount point, no need\n             // for 0000, parent directory already owned and writable by root\n             // only.\n-            int fd = open(full_path, O_CREAT | O_TRUNC, 0644);\n+            // Use O_NOFOLLOW to prevent symlink attacks during the TOCTOU window\n+            // between directory creation and chown.\n+            int fd = open(full_path, O_CREAT | O_TRUNC | O_NOFOLLOW, 0644);\n             if (fd < 0) {\n                 die(\"cannot create mount point for file \\\"%s\\\"\", full_path);\n             }"
}

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.

Emphere Evidence

[
  ["Fix tag", "snapd 2.76.1"],
  ["Author", "Zygmunt Krynicki, Canonical"],
  ["Commit 1", "2cafc7a46ba77ad92725263c590470a63a7c8c6b, add O_NOFOLLOW"],
  ["Commit 2", "02cf64b882b4dc72d15294fa844655beba48c54b, relocate scratch dir to /tmp/snap-private-tmp"],
  ["Commit 3", "cec05b3f0915e3ae5936e923214ce1cb0fb52b3d, remove mkdir/fchown race recovery"],
  ["Commit 4", "cc94fdb321d558362e806d8593b89a29737ac52c, 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.

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.

Emphere Evidence

[
  ["Security gate", "PASS"],
  ["Engineering gate", "PASS"],
  ["Fidelity", "FOCUSED"],
  ["Red target", "pre-fix flags O_CREAT|O_TRUNC, real libc"],
  ["Green target", "post-fix flags with O_NOFOLLOW, real libc"],
  ["Bootstrap lockout", "CLEAR"]
]

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.

Emphere Evidence

[
  ["RED, file leg", "open() followed the planted symlink"],
  ["RED, fd proof", "readlink(/proc/self/fd/N) matched the victim path"],
  ["RED, inode proof", "fstat(fd).st_ino == lstat(victim).st_ino"],
  ["RED, fchown", "blocked only by missing CAP_CHOWN in the sandbox"],
  ["RED, directory leg", "a second harness showed path-based chmod flipping a victim from 0600 to 0755 through a planted symlink"],
  ["GREEN, file leg", "open() returned -1 ELOOP with O_NOFOLLOW"],
  ["GREEN, consequence", "fchown 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.

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.