Set a user account's primaryGroupID attribute to 512 and it becomes a full member of Domain Admins on every logon. This is primaryGroupID spoofing: hidden Domain Admins Active Directory itself computes straight into the account's Kerberos token, but that ADSI Edit, a raw LDAP (member=...) filter, and most audit exports never see — because only half of how AD computes group membership is actually stored on the group object.
What Is primaryGroupID
Every Active Directory user and computer object carries a primaryGroupID attribute: a single integer that stores the Relative Identifier (RID) of the account's primary group. It is a holdover from Windows NT's POSIX-compliance requirements, from before AD supported arbitrary nested groups, and today it mostly sits untouched at its default value — enough so that a recent r/activedirectory thread simply asked what the "Primary Group" field on a user object is actually for.
The defaults, confirmed against Microsoft's own event-auditing documentation and Semperis's research on the attribute:
| Object type | Default primaryGroupID | Group name |
|---|---|---|
| User | 513 | Domain Users |
| Computer | 515 | Domain Computers |
| Domain Controller | 516 | Domain Controllers |
| Read-only DC | 521 | Read-only Domain Controllers |
The one that matters for this article: RID 512 is Domain Admins.
Unlike ordinary group membership — which lives in the group object's multi-valued member attribute, with a matching memberOf back-link on the user — primary-group membership is stored only on the account, as one integer. The group object itself records nothing about it.
How Active Directory Actually Computes "Who Is In This Group"
"Who is in Domain Admins" is not a single lookup. Effective membership is the union of two separate things:
- Every account listed in the Domain Admins group's
memberattribute. - Every account whose
primaryGroupIDattribute equals 512 — Domain Admins' RID — regardless of what thememberattribute says.
Only the first half is stored on the group object. The second half is stored entirely on the account, and nothing on the group records that the link exists. Tools that resolve membership by asking the group object directly reconstruct both halves and show the full picture. Tools that just read the group's raw member value see only the first half.
TrustedSec's research on this behavior (Brandon Colley, published January 2026) demonstrates the split directly: querying Get-ADGroupMember "Domain Admins" against the group returns an account whose Domain Admins access comes purely from primaryGroupID — the cmdlet resolves the union. Active Directory Users and Computers and Active Directory Administrative Center do the same when you open the group's Members tab. But the same research shows two tools land on the other side of the split:
- ADSI Edit, browsing the
memberattribute directly, does not list the account. - A raw LDAP filter such as
(member=CN=...)— the kind most home-grown export scripts, CSV-based reporting jobs, and SIEM ingest pipelines are built on — misses it for the same reason: it is querying the attribute that was never touched.
There is a second, narrower gap in the same research: Get-ADGroupMember -Recursive walks nested groups to expand membership, but it does not catch an account whose primary group is set to a group nested inside Domain Admins rather than Domain Admins itself. Recursion expands the member chain; it does not re-run the primaryGroupID union at every nesting level — the same transitive blind spot covered in dangerous group nesting.
primaryGroupID Spoofing: Hidden Domain Admins Active Directory Never Lists
An attacker (or a script) with write access to a target user's primaryGroupID attribute — a GenericWrite, GenericAll, or scoped WriteProperty right delegated somewhere down an OU, the same ACL abuse class of edge BloodHound already maps on user objects — sets it to 512. Active Directory only accepts this write on an account that is already an explicit member of the target group — Microsoft's own guidance on setting primary group programmatically and Tenable's research on this attribute both confirm a bare Set-ADUser -Replace @{primaryGroupID=512} fails against an account that isn't already a member, and that only a directory-service bypass such as DCShadow can set it on an account that was never a member at all. The ordinary path is three commands, not one:
Add-ADGroupMember -Identity "Domain Admins" -Members jdoe
Set-ADUser -Identity jdoe -Replace @{primaryGroupID=512}
Remove-ADGroupMember -Identity "Domain Admins" -Members jdoe -Confirm:$false
On the account's next Kerberos TGT request, the primary group RID is folded into the token exactly like any other group SID. The account now authenticates with Domain Admins-equivalent access. Once the third command completes, the Domain Admins member attribute is back to its original list, so a scheduled diff of group exports or a static read of the member attribute has nothing left to catch after the fact — though the brief add and remove each raise a standard group-membership-change event on the way there, so a real-time alert on those specific events (unlike a periodic export diff) does have a moment to catch. This is why the technique reads as primaryGroupID spoofing: hidden Domain Admins Active Directory itself computes into every token, but the control built to catch new admins never sees.
Security researcher Yuval Gordon documents, in a post on Semperis's blog, a further extension of this idea: pairing the primaryGroupID change with a deny-read Access Control Entry on the user's own primaryGroupID attribute, so that even the union-aware tools (ADUC, Get-ADGroupMember) can no longer read the value and stop reporting the membership at all. It is a real technique — but Gordon is explicit about where it stops working:
"This cool trick cannot work on members of protected groups such as Domain Admins... this technique will not work on members of any group that is protected by the SDPROP process and AdminSDHolder, as we are relying on DACL, which in that case will just be reverted to the DACL of AdminSDHolder every hour or so."
AdminSDHolder's SDProp cycle (roughly every 60 minutes) re-applies a template ACL to any account it considers a member of a protected group, which erases the deny-ACE. Because a plain primaryGroupID=512 change does not touch the account's DACL at all, SDProp does not revert it — it only defeats the deny-ACE hiding trick, not the underlying spoof. Gordon's own working example targets a non-protected test group instead, not DnsAdmins itself — the same post separately notes the technique also works against ordinary groups like DnsAdmins, which has its own well-known path to Domain Admins. The plain version — set primaryGroupID straight to 512, skip the ACE — is unaffected by AdminSDHolder either way, which is exactly why it is the one worth auditing for directly.
Detection
This is a case where Windows Event ID monitoring actually works in your favor. Microsoft's own security-auditing reference for Event ID 4738 ("A user account was changed," under the Audit User Account Management subcategory) documents a Primary Group ID field in the event's changed-attributes data, and its own monitoring recommendation table states it plainly:
| Field to track | Reason to track |
|---|---|
| Primary Group ID is not 513 | Typically, the Primary Group value is 513 for domain and local users. Other values should be monitored. |
Two independent checks, matching the two things you actually need to catch:
Real-Time — Audit the Change Itself
With Audit User Account Management enabled, every primaryGroupID write raises Event ID 4738 on the DC that processed it, with the new RID in the Primary Group ID field. Alert on any 4738 where that field is populated and the new value is not 513 (or 515/516/521 for computers and DCs).
Point-in-Time — Sweep for Accounts Already Spoofed
TrustedSec's research gives a direct one-liner for this:
Get-ADUser -property primaryGroup,primaryGroupID -Filter {-not(primaryGroupID -like "513")}
Run against a domain with a clean baseline, the only hits should be domain controllers (516), RODCs (521), and — if your environment still provisions them — POSIX-mapped service accounts with a documented reason. Any ordinary user account showing 512, or any privileged RID it should not have, is the finding.
Neither check depends on reading the Domain Admins group object at all — which is the point. A review built on Get-ADGroupMember "Domain Admins" run directly against the group would also catch it, per the same research; a review built on an LDAP export of the member attribute, or a tool that treats that export as ground truth, will not.
Remediation
- Run the sweep above across every domain in the forest, not just the one hosting Domain Admins — primaryGroupID abuse against a nested/local group in a child domain is just as invisible to
member-attribute exports. - Reset any non-compliant account to its correct default (513 for standard users) unless there is a documented, current business reason for a different value.
- Enable Audit User Account Management on all domain controllers if it is not already on, so Event ID 4738 is actually being generated and forwarded to your SIEM.
- Rebuild any automated privileged-access review that queries
memberdirectly — via ADSI Edit, a raw(member=...)LDAP filter, or an export script built the same way — to instead callGet-ADGroupMemberagainst the group, or explicitly union in aprimaryGroupIDsweep. The review that was supposed to be the safety net is precisely the control this technique defeats. - Audit who holds
WriteProperty/GenericWrite/GenericAllon user objects, and who can add members to Domain Admins itself — the ordinary path to this technique needs both: rights to briefly add the target account to Domain Admins, and rights to write itsprimaryGroupID. Either one delegated below the built-in admin OUs is a finding on its own.
How EtcSec Detects This
EtcSec's PRIMARYGROUPID_SPOOFING check runs the same class of sweep as an ongoing control: every audit compares each account's primaryGroupID against its expected default and flags accounts whose primary group differs without a documented reason, closing exactly the gap that a member-attribute-only export leaves open. Findings surface alongside the write-permission review (ACL_WRITE_PROPERTY_EXTENDED) that shows who could make the change in the first place, and alongside privileged-group monitoring (PRIVILEGED_GROUP_MEMBER_CHANGES) so you can see, side by side, which privilege-escalation paths your member-attribute alerting actually covers.
Explore the identity security pages that support this topic
