primaryGroupID - Security checks
primaryGroupID?
From the documentation, the Active Directory primaryGroupID attribute:
Contains the relative identifier (RID) for the primary group of the user.
An account with a primaryGroupID set to N is implicitly a member of the group with RID N (SID S-1-5-21-...-N). This is a well known backdoor and it must be checked.
Note the account is actually not marked as member of the group in the AD (multi-valued attribute members). UI, such as dsa.msc (Active Directory Users and Computers) usually abstract this information, and display account in members and the ones with a primaryGroupID set to the group.
For instance:
- The user
test'sprimaryGroupIDis set toExampleGroup:
- While the user is displayed as member of both
Domain UsersandExampleGroup, it is in the AD actually only a member ofDomain Users, with aprimaryGroupIDset toExampleGroup:
- Remember
memberOfis only a backlink; let's have a look at the group object, in the UI:
- And in the AD:
So, it looks like that if as an attacker I own an account, I can modify its attribute primaryGroupID and set it to 544, making it domain administrator.
Is that right? If no, what's the magic behind?
Required rights to modify the attribute primaryGroupID
In the schema, the primaryGroupID is defined in CN=Primary-Group-ID,CN=Schema,CN=Configuration,DC=WINDOMAIN,DC=LOCAL:
adminDescription: Primary-Group-ID;
adminDisplayName: Primary-Group-ID;
attributeID: 1.2.840.113556.1.4.98;
attributeSecurityGUID: 59ba2f42-79a2-11d0-9020-00c04fc2d3cf;
attributeSyntax: 2.5.5.9 = ( INTEGER );
cn: Primary-Group-ID;
distinguishedName: CN=Primary-Group-ID,CN=Schema,CN=Configuration,DC=WINDOMAIN,DC=LOCAL;
dSCorePropagationData: 0x0 = ( );
instanceType: 0x4 = ( WRITE );
isMemberOfPartialAttributeSet: TRUE;
isSingleValued: TRUE;
lDAPDisplayName: primaryGroupID;
name: Primary-Group-ID;
objectCategory: CN=Attribute-Schema,CN=Schema,CN=Configuration,DC=WINDOMAIN,DC=LOCAL;
objectClass (2): top; attributeSchema;
objectGUID: c1fd5693-ce54-40b5-b229-888cc29d4acd;
oMSyntax: 2 = ( INTEGER );
schemaFlagsEx: 1;
schemaIDGUID: bf967a00-0de6-11d0-a285-00aa003049e2;
searchFlags: 0x11 = ( INDEX | COPY );
showInAdvancedViewOnly: TRUE;
systemFlags: 0x12 = ( ATTR_REQ_PARTIAL_SET_MEMBER | SCHEMA_BASE_OBJECT );
systemOnly: FALSE;
uSNChanged: 966;
uSNCreated: 966;
whenChanged: 11/10/2017 7:25:44 PM Coordinated Universal Time;
whenCreated: 11/10/2017 7:25:44 PM Coordinated Universal Time;
If we look in the extended rights (CN=Extended-Rights,CN=Configuration,DC=WINDOMAIN,DC=LOCAL) for an entry with a rightsGUID set to the primaryGroupID's attributeSecurityGUID (ie. 59ba2f42-79a2-11d0-9020-00c04fc2d3cf), we found CN=General-Information,CN=Extended-Rights,CN=Configuration,DC=WINDOMAIN,DC=LOCAL:
appliesTo (2): 4828CC14-1437-45bc-9B07-AD6F015E5F28; bf967aba-0de6-11d0-a285-00aa003049e2;
cn: General-Information;
displayName: General Information;
distinguishedName: CN=General-Information,CN=Extended-Rights,CN=Configuration,DC=WINDOMAIN,DC=LOCAL;
dSCorePropagationData: 0x0 = ( );
instanceType: 0x4 = ( WRITE );
localizationDisplayId: 8;
name: General-Information;
objectCategory: CN=Control-Access-Right,CN=Schema,CN=Configuration,DC=WINDOMAIN,DC=LOCAL;
objectClass (2): top; controlAccessRight;
objectGUID: 994b8595-d175-43a4-b340-478ecd22f234;
rightsGuid: 59ba2f42-79a2-11d0-9020-00c04fc2d3cf;
showInAdvancedViewOnly: TRUE;
uSNChanged: 4192;
uSNCreated: 4192;
validAccesses: 48;
whenChanged: 7/14/2021 2:19:19 PM Coordinated Universal Time;
whenCreated: 7/14/2021 2:19:19 PM Coordinated Universal Time;
The validAccesses: 48 (ACTR_DS_READ_PROP | ACTRL_DS_WRITE_PROP) indicates that General-Information is a property set.
So, long story short, if someone has the right to write on either primaryGroupID or General-Information, it can the modify primaryGroupID value.
- Add
write propertyonWINDOMAIN\test General-InformationforWINDOMAIN\other:
- As
WINDOMAIN\other, set theprimaryGroupIDofWINDOMAIN\test:
- As expected, the
primaryGroupIDis changed
As an attacker owning the object, I can give myself that right. So is it game over?
Write validation
Let's try to actually write 544 to the primaryGroupID of WINDOMAIN\test:
Result:
***Call Modify...
ldap_modify_s(ld, 'CN=test,CN=Users,DC=WINDOMAIN,DC=LOCAL',[1] attrs);
Error: Modify: Unwilling To Perform. <53>
Server error: 00000529: SvcErr: DSID-031A1236, problem 5003 (WILL_NOT_PERFORM), data 0
Error 0x529 The specified user account is not a member of the specified group account.
The update hopefully fails!
If from the AD point of view the write is authorized, there is one last place where it can be refused: in the actual AD implementation.
Let's have a look at the function handling writes to information attributes: SampDsSetInformationUser in C:\Windows\System32\ntdsai.dll (I'm using ee24c940287aa96fb061feeced35fe1e3d41b8e3):
SampGetUlongFromAttrVal(v20, a4, v18, &attribute_asked);// Primary Group ID
if ( (UAC & 0x100000) != 0 && target_RID == 521 )// RODC
{
primarygroupid_asked = 521;
goto SHORTCUT_UPDT;
}
if ( (UAC & 0x100) != 0 && target_RID == 516 )// DC
{
primarygroupid_asked = 516;
SHORTCUT_UPDT:
new_primarygroupid = attribute_asked;
ret_status = attribute_asked != primarygroupid_asked ? 0xC00002D0 : 0;
}
else
{
new_primarygroupid = attribute_asked;
ret_status = SampAssignPrimaryGroup(target, attribute_asked);
}
if ( ret_status < 0 )
goto ERROR;
if ( new_primarygroupid != target_RID )
{
target_RID = new_primarygroupid;
keep_previous = 1;
goto DO_UPDATE;
}
...
DO_UPDATE:
ret_status = SampReplaceUserV1aFixed(target, user_attrib);
...
if ( target_RID != previous_RID )
ret_status = SampDsMaintainPrimaryGroupIdChange(target, target_RID, previous_RID, keep_previous);
After a special case for domain-controllers (DC) and read-only DC (RODC) explained below, SampAssignPrimaryGroup is called.
This function is implemented in C:\Windows\System32\samsrv.dll (d630f03e9373758c0b50a18cb4ff7875d5faf5b5):
__int64 __fastcall SampAssignPrimaryGroup(__int64 user, int new_primarygroupid)
{
__int64 v2; // rax
int user_1; // edi
int ret_value; // ebx
__int64 idx; // rcx
unsigned int group_cnt; // [rsp+30h] [rbp-88h] BYREF
HLOCAL hMem; // [rsp+38h] [rbp-80h] BYREF
char v10; // [rsp+40h] [rbp-78h] BYREF
_DWORD v1afixed_attribs[20]; // [rsp+50h] [rbp-68h] BYREF
v2 = *(unsigned int *)(user + 200);
user_1 = user;
group_cnt = 0;
hMem = 0i64;
if ( !*(_BYTE *)(0x550 * v2 + SampDefinedDomains + 0x538) )
goto LABEL_16;
ret_value = SampRetrieveUserV1aFixed(user, v1afixed_attribs);
if ( ret_value >= 0 )
{
if ( v1afixed_attribs[13] != new_primarygroupid )
ret_value = 0xC00000BB; // STATUS_NOT_SUPPORTED
if ( ret_value >= 0 )
{
LABEL_16:
LOBYTE(new_primarygroupid) = 1;
ret_value = SampRetrieveUserMembership(
user_1,
new_primarygroupid, // = 1
(unsigned int)&group_cnt,
(unsigned int)&v10,
(__int64)&hMem);
if ( ret_value >= 0 )
{
idx = 0i64;
ret_value = 0xC0000068; // STATUS_MEMBER_NOT_IN_GROUP
if ( group_cnt )
{
while ( new_primarygroupid != *((_DWORD *)hMem + 2 * idx) )
{
idx = (unsigned int)(idx + 1);
if ( (unsigned int)idx >= group_cnt )
goto LABEL_12;
}
ret_value = 0;
}
LABEL_12:
LocalFree(hMem);
}
}
}
return (unsigned int)ret_value;
}
Basically, this function reflects the error obtained previously. The new primaryGroupId value must be one of the current account's group.
So, to set WINDOMAIN\test's primaryGroupID to Domain Admins, the account must be already a member of Domain Admins (which requires the update of members, an attribute of the group, on which the attacker doesn't have rights).
Once checks have been made, the primaryGroupId is updated, and SampDsMaintainPrimaryGroupIdChange is called:
__int64 __fastcall SampDsMaintainPrimaryGroupIdChange(__int64 target, __int64 new_primarygroupid, unsigned int previous_primarygroupid, char keep_previous)
{
unsigned int ret_status; // ebx
unsigned int ret_adduser2group; // eax
ret_status = 0;
if ( new_primarygroupid != previous_primarygroupid )
{
SampRemoveUserFromGroup(target, new_primarygroupid, target->RID);
if ( keep_previous )
{
ret_adduser2group = SampAddUserToGroup(target, previous_primarygroupid, target->RID);
if ( ret_adduser2group + 0x3FFFFF9A <= 1 ) // NT_STATUS_NO_SUCH_GROUP | NT_STATUS_MEMBER_IN_GROUP
ret_adduser2group = 0;
ret_status = ret_adduser2group;
}
}
return ret_status;
}
Note: if ( ret_adduser2group + 0x3FFFFF9A <= 1 ) is a compiler optimization. Note that 0x3fffff9a + 0xc0000066 = 0, so the unsigned check corresponds to ret_adduser2group in [NT_STATUS_NO_SUCH_GROUP=0xc0000066, NT_STATUS_MEMBER_IN_GROUP=0xc0000067].
So, in addition to setting the primaryGroupID, the code also:
- remove the account from the new
primaryGroupID - add the account to the previous
primaryGroupID
From what we've seen at the beginning, this is an expected behavior. Indeed, group membership provided by primaryGroupID is implicit. This update keeps it coherent.
DC, RODC special case
Special checks are made for DC and RODC:
- If
USER_SERVER_TRUST_ACCOUNT=0x100flag is set (meaning the object is a DC, from [MS-SAMR]), the only acceptedprimaryGroupIDisDOMAIN_DOMAIN_CONTROLLERS=516(from [MS-DTYP]) - If
USER_PARTIAL_SECRETS_ACCOUNT=0x100000flag is set (meaning the object is a RODC), the only acceptedprimaryGroupIDisREADONLY_DOMAIN_CONTROLLERS=521
Indeed, if we try to changes this value for a DC, we get:
***Call Modify...
ldap_modify_s(ld, 'CN=DC,OU=Domain Controllers,DC=WINDOMAIN,DC=LOCAL',[1] attrs);
Error: Modify: Unwilling To Perform. <53>
Server error: 0000213A: SvcErr: DSID-031A1236, problem 5003 (WILL_NOT_PERFORM), data 0
Error 0x213A Cannot change the primary group ID of a domain controller account.
So, when this value is actually changed?
If we look for 521 and 516 values in samsrv.dll, looking for link with primaryGroupID, we end on SampDefaultPrimaryGroup:
__int64 __fastcall SampDefaultPrimaryGroup(__int64 target, int UAC)
{
bool v2; // cl
__int64 result; // rax
v2 = (*(_BYTE *)(target + 192) & 2) != 0;
if ( (UAC & 0x80u) != 0 && v2 ) // USER_WORKSTATION_TRUST_ACCOUNT
result = (UAC & 0x100000) != 0 ? 521 : 515; // USER_PARTIAL_SECRETS_ACCOUNT ? READONLY_DOMAIN_CONTROLLERS : DOMAIN_COMPUTERS
else
result = (v2 & _bittest(&UAC, 8u)) != 0 ? 516 : 513;// USER_SERVER_TRUST_ACCOUNT ? DOMAIN_DOMAIN_CONTROLLERS : DOMAIN_USERS
return result;
}
This function is called from SampSetUserAccountControl, so it seems to be updated on UserAccountControl update.
Let's try it:
- Initial state:
TEST$is a normal computer account
CN=TEST,CN=Computers,DC=WINDOMAIN,DC=LOCAL:
primaryGroupID: 515 = ( GROUP_RID_COMPUTERS );
userAccountControl: 0x1020 = ( PASSWD_NOTREQD | WORKSTATION_TRUST_ACCOUNT );
- Let's update it's
userAccountControlfor a DC (SERVER_TRUST_ACCOUNT=0x2000) from MSDN:
- As a result, the primaryGroupID has changed!
Modifying the primaryGroupID
What if one wants to actually modify the primaryGroupID without all of these restrictions? (Let's say it is only for testing purposes)
Well, one can either:
- Perform a live modification on a running DC, by injecting himself in
lsass.exe - Update
ntds.ditoffline, for instance with DSInternals::Set-ADDBPrimaryGroup - Force the change on a compliant DC, and asked for a replication, for instance using DCShadow
Here is what the result looks like:
- in the AD:
- in the UI (
dsa.msc), on the user:
- but it is still displayed in
Administratorsmembers:













