Qualys Security Advisory
Local Privilege Escalation in set-capabilities versions of snap-confine
(CVE-2026-8933)
========================================================================
Contents
========================================================================
Summary
Analysis
Exploitation
Acknowledgments
Timeline
========================================================================
Summary
========================================================================
We discovered a vulnerability (a Local Privilege Escalation from any
user to full root) in snap-confine, "a program used internally by snapd
to construct the execution environment for snap applications."
Counter-intuitively, only set-capabilities versions of snap-confine are
vulnerable, set-uid-root versions are not; in particular:
- default installations of Ubuntu Desktop 26.04 and 25.10 are vulnerable
and exploitable (they ship a set-capabilities
/usr/lib/snapd/snap-confine);
- default, up-to-date installations of Ubuntu Desktop 24.04 are also
vulnerable and exploitable (they ship a set-capabilities
/snap/snapd/current/usr/lib/snapd/snap-confine).
========================================================================
Analysis
========================================================================
After the publication of CVE-2026-3888 and the release of Ubuntu 26.04
Beta, we decided to re-assess the security of snap-confine. One of the
most fundamental changes between Ubuntu 24.04 and Ubuntu 26.04 is that
/usr/lib/snapd/snap-confine is not set-uid-root anymore; instead, it is
set-capabilities now, undoubtedly for security reasons (the Principle of
Least Privilege):
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu Resolute Raccoon (development branch)"
VERSION_ID="26.04"
$ stat /usr/lib/snapd/snap-confine
Access: (0755/-rwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
$ getcap /usr/lib/snapd/snap-confine
/usr/lib/snapd/snap-confine
cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p
------------------------------------------------------------------------
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.3 LTS"
$ stat /usr/lib/snapd/snap-confine
Access: (4755/-rwsr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
$ stat /snap/snapd/current/usr/lib/snapd/snap-confine
Access: (0755/-rwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
$ getcap /snap/snapd/current/usr/lib/snapd/snap-confine
/snap/snapd/current/usr/lib/snapd/snap-confine
cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin=p
------------------------------------------------------------------------
Unfortunately, belatedly we realized that this change has a potentially
dangerous consequence: because snap-confine is not set-uid-root anymore,
it runs with the effective uid of our unprivileged user (but with near-
root capabilities), and all the files and directories that it creates
therefore do not belong to root anymore, but to our unprivileged user.
For example, snap-confine creates a /tmp/snap.rootfs_XXXXXX directory at
line 511, and a file inside this directory at line 450, and even though
snap-confine quickly changes the ownership of this directory and file to
root at lines 362 and 454, a small window of time exists when they are
under the complete control of our unprivileged user (between lines 511
and 362, and between lines 450 and 454):
------------------------------------------------------------------------
507 static void sc_bootstrap_mount_namespace(const struct sc_mount_config
*config) {
508 char scratch_dir[] = "/tmp/snap.rootfs_XXXXXX";
...
511 if (mkdtemp(scratch_dir) == NULL) {
...
523 sc_do_mount(scratch_dir, scratch_dir, NULL, MS_BIND, NULL);
...
530 sc_do_mount("none", scratch_dir, NULL, MS_UNBINDABLE, NULL);
...
535 sc_do_mount("none", scratch_dir, "tmpfs", 0, "uid=0,gid=0");
536 sc_replicate_base_rootfs(scratch_dir, config->rootfs_dir,
config->mounts);
------------------------------------------------------------------------
354 static void sc_replicate_base_rootfs(const char *scratch_dir, const char
*rootfs_dir,
355 const struct sc_mount *root_mounts) {
...
362 if (chown(scratch_dir, 0, 0) < 0) {
...
397 sc_must_snprintf(full_path, sizeof(full_path), "%s/%s",
scratch_dir, ent->d_name);
...
446 } else if (ent->d_type == DT_REG) {
...
450 int fd = open(full_path, O_CREAT | O_TRUNC, 0644);
...
454 if (fchown(fd, 0, 0) < 0) {
------------------------------------------------------------------------
========================================================================
Exploitation
========================================================================
Our basic idea for exploiting this vulnerability is to transform it into
an arbitrary file creation, by winning these two race conditions:
- first, immediately after the creation of the /tmp/snap.rootfs_XXXXXX
directory at line 511, and while this directory still belongs to our
unprivileged user, we quickly create a symlink inside this directory,
whose target is an arbitrary file in the filesystem, and whose name is
the name of one of the files that snap-confine will create at line 450
(for example, when setting up the sandbox for the firefox snap,
snap-confine will create a file named default256.png);
- second, immediately after the creation of our arbitrary file at line
450 (which succeeds because this open() does not specify O_NOFOLLOW,
and because snap-confine runs with near-root capabilities), and while
this file still belongs to our unprivileged user, we quickly change
its permissions to 0666 so that we can still write to it even after
snap-confine changes its ownership to root at line 454.
However, to implement this exploit in practice, we must overcome two
major problems:
1/ After the creation of the /tmp/snap.rootfs_XXXXXX directory at line
511, but before the creation of our arbitrary file at line 450, snap-
confine mounts a temporary filesystem onto /tmp/snap.rootfs_XXXXXX at
line 535; but we cannot create the symlink to our arbitrary file inside
this directory because it is not visible from outside the sandbox that
snap-confine is setting up (because of the two mounts at lines 523 and
530, which make this temporary filesystem private).
The solution to this first problem is surprisingly simple: immediately
after the creation of the /tmp/snap.rootfs_XXXXXX directory, and while
this directory still belongs to our unprivileged user, we quickly mount
a FUSE filesystem onto it (with fusermount), which allows us to unmount
(with fusermount -u -z) every filesystem that snap-confine later mounts
onto it, at lines 523 and 535.
As a result, when snap-confine reaches line 450, /tmp/snap.rootfs_XXXXXX
is simply back to the directory that was originally created at line 511
and that still belongs to our unprivileged user: inside this directory
we can create the symlink to our arbitrary file, and finally
snap-confine creates this arbitrary file at line 450.
2/ In reality, this arbitrary file creation is not entirely arbitrary,
because snap-confine itself is confined by a highly restrictive AppArmor
profile; for example, we cannot create or write to any file in /etc. To
solve this second problem we inspected snap-confine's AppArmor profile,
and one of the few allow-read-write rules caught our attention:
/run/udev/** rw,
We therefore exploit our arbitrary file creation to create a .rules file
in /run/udev/rules.d, write PROGRAM="/bin/sh -c id>>/tmp/pwned" into it,
and mount and unmount a FUSE filesystem (any FUSE filesystem) to trigger
the execution of this arbitrary command with full root privileges (via
the systemd-udevd daemon):
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu Resolute Raccoon (development branch)"
VERSION_ID="26.04"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ ./exploit
scratch directory for constructing namespace: /tmp/snap.rootfs_ZW0eZT
ndirs 1
nregs 0
-rw-r--r-- 1 root root 78 Apr 21 21:00 /tmp/pwned
/run/udev/rules.d:
total 12
-rw-rw-rw- 1 root root 36 Apr 21 21:00 2064396357-default256.png.rules
-rw-r----- 1 root root 265 Apr 21 20:20 90-netplan.rules
-rw-r----- 1 root root 98 Apr 21 20:20 99-netplan-enp0s3.rules
uid=0(root) gid=0(root) groups=0(root)
uid=0(root) gid=0(root) groups=0(root)
------------------------------------------------------------------------
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.3 LTS"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ ./exploit
scratch directory for constructing namespace: /tmp/snap.rootfs_AkYmf6
ndirs 1
nregs 0
-rw-r--r-- 1 root root 78 Apr 21 13:09 /tmp/pwned
/run/udev/rules.d:
total 8
-rw-rw-rw- 1 root root 36 Apr 21 13:09 429214770-default256.png.rules
-rw-r----- 1 root root 162 Apr 21 12:47 90-netplan.rules
uid=0(root) gid=0(root) groups=0(root)
uid=0(root) gid=0(root) groups=0(root)
------------------------------------------------------------------------
========================================================================
Acknowledgments
========================================================================
We thank everyone at Canonical who worked on this release (Eduardo
Barretto and Zygmunt Krynicki in particular). We also thank the members
of the linux-distros list (Caryl Takvorian in particular).
========================================================================
Timeline
========================================================================
2026-04-22: We sent our advisory and exploit to the Ubuntu Security
Team.
2026-07-13: The Ubuntu Security Team sent their patches to the
linux-distros list.
2026-07-14: We sent our advisory to the linux-distros list.
2026-07-21: Coordinated Release Date (14:00 UTC).