Skip to content

Add WriteSPN support for computer objects#7

Closed
fkxdr wants to merge 4 commits intoshellinvictus:masterfrom
fkxdr:patch-1
Closed

Add WriteSPN support for computer objects#7
fkxdr wants to merge 4 commits intoshellinvictus:masterfrom
fkxdr:patch-1

Conversation

@fkxdr
Copy link
Copy Markdown
Contributor

@fkxdr fkxdr commented Apr 7, 2026

Mirror the existing WriteSPN(user) chain for computer objects, covering WriteSPN, GenericWrite and WriteDacl/DaclServicePrincipalName edges.

Enables detection of targeted Kerberoasting paths where an attacker controls the servicePrincipalName attribute of a computer account (e.g. clearing an SPN from one host and registering it on another to request a roastable TGS).

Mirror the existing WriteSPN(user) chain for computer objects, covering WriteSPN, GenericWrite and WriteDacl/DaclServicePrincipalName edges.

Enables detection of targeted Kerberoasting paths where an attacker controls the servicePrincipalName attribute of a computer account (e.g. clearing an SPN from one host and registering it on another to request a roastable TGS).
@shellinvictus
Copy link
Copy Markdown
Owner

Hi!

Thank you for the proposal. Are you talking about the SPN-Jacking? That's very interesting scenarios (Ghost + Live) that i never tried, i'll do it when i have time!

I see 4 problems with your patch.

1. Scenario priorities

I think we should keep easy scenarios to be proposed first. The config.ml executes paths in the order they are defined. It means that with your patch, if we have GenericWrite or WriteDACL, the SPNJacking will be proposed before RBCD and Shadow Credential. We can do instead something like that:

solution 1 -> ...
solution 2 -> ...
solution 3 -> ...
solution 4 -> ...
WriteSPN(computer) -> ...

GenericWrite -> solution 1
GenericWrite -> fall back solution 2
GenericWrite -> WriteSPN

WriteDACL -> solution 3
WriteDACL -> fall back 4
WriteDACL -> ::DaclServicePrincipalName

::DaclServicePrincipalName(computer) -> WriteSPN

We can also create a flag to force the execution of WriteSPN instead of all others.

2. Kerberoasting

The action ::Kerberoasting will not generate the intended commands after selecting the path. If i inderstand the SPN-Jacking, we don't need to crack the TGS (and probably we can't). Maybe we can create a new scenario with the tgssub.py to edit the service class or the hostname?

Also, if you write WriteSPN(computer) -> ::Kerberoasting it will not go into the path ::Kerberoasting(user) -> apply_with_cracked_passwd because the target is a computer, not a user. You need to define a ::my_action(computer) -> do_something. Here GriffonAD will see an undefined action for the target and will reject the path.

3. Require statement

If we have a WriteSPN on a computer, we need another computer where we are admin, and i think the full path generation is a bit more complicated.

So we need to write in config.ml a requirement statement. Maybe something like this:

# there are predefined require functions in lib/require.py
# is the unprotected_owned_with_spn sufficient? if not we can create a new one for it

WriteSPN(computer) -> ::SPN_Jacking \
    require_once unprotected_owned_with_spn \
    if not target.disabled

I think the require_once will be sufficient to specify the dependency (you can take a look into doc/require.md for a full explanation about all require* statements). It's not very clear in the documentation but:

  • require, require_once, require_for_auth: they return ONE object (the first found)
  • require_targets: it returns a LIST of objects

So the above path means: for a WriteSPN on a computer, take ONE arbitrary owned user or computer (from the owned file, or temporary owned before on the current path). Then pass the "require" object to SPN_Jacking.

4. Terminal statement

You need a terminal statement. If not, GriffonAD will reject the path and will never print it. Available terminal statements are:

  • every apply_*: means the target is now owned and continue with it
  • stop: we can't say if we now owned the target, stop the path but accept it until here
  • restart: means restart to reevaluate all rights (restart to the beginning of config.ml). It can lead to an infinite loop!

That could be amazing if you can rework the PR to implement SPN-Jacking scenarios :)

Thank you for your contribution!

fkxdr added 3 commits April 7, 2026 23:44
Add SPN-Jacking attack path for computer objects, covering WriteSPN, GenericWrite and WriteDacl/DaclServicePrincipalName edges.
Add Jinja2 template for the SPNJacking action. Covers all four auth contexts (krb, nthash, aeskey, password). Uses require['object'].
@fkxdr
Copy link
Copy Markdown
Contributor Author

fkxdr commented Apr 7, 2026

Hi!

Yes exactly, as I spotted the path missing in a recent assessment.
Reworked the patch based on your feedback, I think this should be right now!

  • Replaced ::Kerberoasting with a new ::SPNJacking action and matching SPNJacking.jinja2 template using addspn.py + tgssub.py
  • require_once unprotected_owned_with_spn for the attacker-controlled machine dependenc
  • stop as terminal since post-exploitation depends on what the hijacked SPN grants
  • Ordering puts WriteSPN last under GenericWrite and WriteDacl, after RBCD and Shadow Credentials
  • x_SPNJacking.commit clears target.spn = [] rather than appending, to correctly simulate the victim SPN being removed during path search

ps. thanks for this awesome tool.

@shellinvictus
Copy link
Copy Markdown
Owner

Nice! Let me a few days to test in details the SPN Jacking before merging.

I've still some remarks:

1. stop + secretsdump replacement

I understand that the exploitation depends on the SPN we have hijacked. But in the case we can secretsdump the target, i think it would be better to do like if we have owned it because we can continue the chain. Or is it possible to always hijack a HOST/ SPN?

We can avoid to write the secretsdump command in SPNJacking.jinja2, we have instead 2 solutions:

# 1

# config.ml
::SPNJacking(computer) -> ::Secretsdump

# SPNJacking.jinja2
# remove the secretsdump line and put after the export: (like in _GetSTImpersonate.jinja2)
{{- set_attr(parent, 'krb_auth', True) }}
# 2

# config.ml
::SPNJacking(computer) -> apply_with_ticket

# SPNJacking.jinja2
# just remove the secretsdump line

I think the first solution is better because the user can see the result of the secretsdump. Moreover, i don't know if in the case of the SPN Jacking, the second will work.

2. addpspn.py: replace clear by flush

There is just a small mistake with my tool addspn.py: the option-clear doesn't exist, we must use -flush instead. I've originally written this script because (i thought) i found that after a clear there was still some residual data. I've just retested and it's seems ok, so i don't remember... I still prefer to use my tool instead of the krbrelayx/addspn.py for homogeneous parameters like most of the impacket commands.

The modification of msDS-AdditionalDnsHostName done by krbrelayx with -a, is not possible directly with mine. You can still use the attr.py which is a generic script to modify any attributes. The script AllowedToDelegateToAny.jinja2 do exactly this thing.


PS: thank you very much for the feedback :)

@shellinvictus
Copy link
Copy Markdown
Owner

It let me think that an interactive tool could be interesting to let the user choose between scenarios when more than one exists.

@shellinvictus
Copy link
Copy Markdown
Owner

Hi!

The SPN Jacking is not so easy to implement in Griffon as i thought... This scenario is interesting in the case we have the password of a computer with a KCD and when we have a WriteSPN on two objects: one with the constrained SPN and one on an other object (for the live SPN Jacking, the ghost requires only the first).

The problem with your proposal to handle this in the GenAll -> GenWrite -> WriteSPN, is that when we call the require statement we will get an other owned object: it means we have its password, ticket, nt or aes but we cannot add the SPN in the Step 2 (the controlled cannot modify its own SPNs). We need to know who is able to modify the controlled and it must be an owned object. I don't know if i'm very clear!

Because this scenario is interesting for constrained delegations, i think it would be better to handle it after the ::AllowedToDelegate and searching for writable targets.

I'm working to try to implement the live and the ghost.

@fkxdr
Copy link
Copy Markdown
Contributor Author

fkxdr commented Apr 11, 2026

Hi, thanks for the detailed breakdown!

It might be worth considering whether a simpler WriteSPN(computer) -> targeted Kerberoasting path should be handled separately as well. It doesn't require KCD or a second controlled object, just write access to the servicePrincipalName attribute of a computer account to set an arbitrary SPN and roast it, analogous to the existing WriteSPN(user) chain.

(This is also referenced in the post you linked, the targeted Kerberoasting as the baseline WriteSPN abuse case, with SPN-Jacking being the alternative edge case on top of it.)

shellinvictus pushed a commit that referenced this pull request Apr 12, 2026
@shellinvictus
Copy link
Copy Markdown
Owner

Hi!

I've finally implemented the Live + Ghost scenarios, not so easily... I needed to completely rethink about it. I've added you as a co-author of the commit.

This new version is able to search automatically all requirements to exploit a live or ghost spn jacking. It can also generates the full code to exploit it (-> secretsdump).

Thanks for the Kerberoasting approach, i maybe missed it. I will take a look a next time!

shellinvictus pushed a commit that referenced this pull request Apr 12, 2026
@fkxdr fkxdr closed this by deleting the head repository Apr 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants