Chapter 33

ACLs & Object Security

Security descriptors, DACL/SACL structure, ACE types, the access check algorithm, and how ACL analysis reveals backdoored service permissions and persistence paths

Scenario

An attacker with admin access wants persistence that survives password changes and account lockouts. They modify the DACL on a Windows service's registry key — granting "Everyone" full control. Now any low-privileged user (including future compromised users) can modify the service binary path and achieve SYSTEM execution on next service restart. This is ACL-based persistence, and the modification is invisible to users who don't actively audit object permissions.

Security Descriptor

Every securable Windows object (file, registry key, process, service, named pipe, token, etc.) has a Security Descriptor. The Security Descriptor is a binary structure containing:

SECURITY_DESCRIPTOR
  +Revision         (1)
  +Sbz1             (reserved)
  +Control          (flags: DACL present, SACL present, DACL defaulted, etc.)
  +Owner SID        (who owns the object; owner can always change the DACL)
  +Group SID        (primary group; rarely used on Windows)
  +DACL             (Discretionary ACL: controls who can access the object)
  +SACL             (System ACL: controls what access gets audited)

Binary format:
  Absolute SD: pointers to SIDs and ACLs
  Self-relative SD: single buffer with all offsets relative to start
    (used for storage: files, registry)

DACL and ACE Types

The DACL is a list of Access Control Entries (ACEs). Each ACE specifies what a SID can or cannot do to the object. The kernel's access check scans the DACL ACEs in order and decides ALLOW or DENY.

ACE TypeValueEffect
ACCESS_ALLOWED_ACE_TYPE 0x00 Grant the specified access rights to the named SID
ACCESS_DENIED_ACE_TYPE 0x01 Deny the specified access rights to the named SID
SYSTEM_AUDIT_ACE_TYPE 0x02 Generate an audit event (in SACL, not DACL)
ACCESS_ALLOWED_OBJECT_ACE_TYPE 0x05 Allow on a specific object/property (Active Directory)
ACCESS_DENIED_OBJECT_ACE_TYPE 0x06 Deny on a specific object/property (Active Directory)
ACCESS_ALLOWED_CALLBACK_ACE_TYPE 0x09 Allow, with a conditional expression (requires Central Access Policy)

Each ACE also carries inheritance flags that control whether the ACE propagates to child objects:

SACL and Auditing

The System ACL (SACL) contains audit ACEs. These ACEs define what access attempts generate Security event log entries. Unlike the DACL, the SACL requires SeSecurityPrivilege to read or modify. A null SACL means no auditing; an empty SACL also means no auditing. SACL entries generate Event IDs 4656 (handle request), 4663 (object access), 4670 (permissions change).

NULL DACL is dangerous

A NULL DACL (as opposed to an empty DACL) means no access control at all — every access to the object is allowed to everyone. Attackers and sometimes misconfigured software create objects with NULL DACLs, making them world-accessible. A NULL DACL on a service, named pipe, or shared memory object is a significant vulnerability. Detect with: icacls, PowerShell Get-Acl, or programmatic Security Descriptor inspection. An empty DACL (present but with no ACEs) denies all access except to the owner; this is different from NULL.

Access Check Algorithm

When a process tries to open a securable object, the kernel runs the access check:

  1. Collect the caller's token: user SID + all group SIDs + restricted SIDs (if applicable).
  2. Compare the token's integrity level against the object's mandatory label (MIC check — Chapter 31). If MIC blocks the access, DENY immediately.
  3. Check privileges: does the caller have a privilege that bypasses DACL? (SeBackupPrivilege for read, SeRestorePrivilege for write, SeTakeOwnershipPrivilege, etc.) If so, ALLOW.
  4. Walk the DACL from the first ACE:
  DACL Walk (simplified):
    remaining_access = requested_access

    For each ACE in DACL:
      If ACE SID matches any SID in the token:
        If ACE type == DENY:
          If ACE.AccessMask overlaps remaining_access:
            DENY the entire request immediately
        If ACE type == ALLOW:
          remaining_access &= ~ACE.AccessMask  (clear the allowed bits)
          If remaining_access == 0:
            ALLOW the request (all requested bits satisfied)

    If all ACEs processed and remaining_access != 0:
      DENY (some access bits not explicitly allowed)
    If DACL is NULL: ALLOW everything
  
DENY ACEs must come first

Windows puts DENY ACEs before ALLOW ACEs in the ACL order by convention (but not by enforcement). If an ALLOW ACE for "Everyone" appears before a DENY ACE for a specific user, the ALLOW fires first and access is granted before the DENY is ever evaluated. Tools like icacls enforce the correct ordering when modifying ACLs; direct binary manipulation can violate it.

APIs

// Read the DACL of a service registry key and print ACEs
void PrintServiceAcl(const char *serviceName)
{
    char regPath[256];
    snprintf(regPath, 256, "SYSTEM\\CurrentControlSet\\Services\\%s", serviceName);

    HKEY hKey;
    RegOpenKeyExA(HKEY_LOCAL_MACHINE, regPath, 0, READ_CONTROL, &hKey);

    DWORD sdLen = 0;
    // First call to get size
    RegGetKeySecurity(hKey, DACL_SECURITY_INFORMATION, NULL, &sdLen);
    PSECURITY_DESCRIPTOR pSd = (PSECURITY_DESCRIPTOR)LocalAlloc(LPTR, sdLen);
    RegGetKeySecurity(hKey, DACL_SECURITY_INFORMATION, pSd, &sdLen);

    BOOL hasDacl, daclDefaulted;
    PACL pDacl = NULL;
    GetSecurityDescriptorDacl(pSd, &hasDacl, &pDacl, &daclDefaulted);

    if (!hasDacl || !pDacl) {
        printf("  [!] NULL or missing DACL — world-accessible!\n");
        goto cleanup;
    }

    for (WORD i = 0; i < pDacl->AceCount; i++) {
        void *pAce;
        GetAce(pDacl, i, &pAce);
        ACE_HEADER *hdr = (ACE_HEADER*)pAce;
        ACCESS_ALLOWED_ACE *aa = (ACCESS_ALLOWED_ACE*)pAce;

        char sidStr[256] = {}, name[256] = {}, dom[256] = {};
        DWORD nl = 256, dl = 256;
        SID_NAME_USE use;
        LPSTR strSid = NULL;
        ConvertSidToStringSidA(&aa->SidStart, &strSid);
        LookupAccountSidA(NULL, &aa->SidStart, name, &nl, dom, &dl, &use);

        printf("  ACE[%d]: Type=%s SID=%s (%s\\%s) Mask=0x%08X\n",
            i,
            hdr->AceType == ACCESS_ALLOWED_ACE_TYPE ? "ALLOW" :
            hdr->AceType == ACCESS_DENIED_ACE_TYPE  ? "DENY"  : "OTHER",
            strSid, dom, name, aa->Mask);
        LocalFree(strSid);
    }
cleanup:
    LocalFree(pSd);
    RegCloseKey(hKey);
}

Detection: Hunting Weak ACLs

# PowerShell: find services where non-admins have write access
# This is a classic local privilege escalation hunting script

Get-WmiObject -Class Win32_Service | ForEach-Object {
    $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$($_.Name)"
    try {
        $acl = Get-Acl -Path $regPath -ErrorAction Stop
        foreach ($ace in $acl.Access) {
            $sid = $ace.IdentityReference
            # Flag write access for non-Admins/SYSTEM
            if ($ace.AccessControlType -eq 'Allow' -and
                $ace.RegistryRights -match 'FullControl|WriteKey|SetValue' -and
                $sid -notin @('NT AUTHORITY\SYSTEM','BUILTIN\Administrators','NT SERVICE\TrustedInstaller')) {
                Write-Host "[WEAK ACL] $($_.Name): $sid has $($ace.RegistryRights)"
            }
        }
    } catch {}
}

Q & A

Can a process's owner bypass the DACL to access its own object?

Partially. The owner of an object always has two rights that cannot be removed by the DACL: READ_CONTROL (read the security descriptor) and WRITE_DAC (modify the DACL). These rights are inherent to ownership — they allow the owner to fix an accidentally restrictive DACL. The owner does NOT automatically get full access. If the DACL has a DENY ALL ACE for the owner's SID, the owner cannot read or write the file's data — but can still change the DACL to remove the DENY ACE. This is why "take ownership" (SeTakeOwnershipPrivilege) is so powerful: once you own an object, you can write a new DACL granting yourself full access, even if the previous DACL denied all access. The sequence: SeTakeOwnershipPrivilege → SetNamedSecurityInfo to take ownership → SetNamedSecurityInfo to grant full control via new DACL → access the object freely. This is exactly how administrator account recovery from locked-out files works, and exactly how attackers use it for privilege escalation.

How does Active Directory extend the ACL model with object-specific ACEs, and why does this matter for AD security assessments?

Active Directory objects (users, groups, OUs, GPOs) support extended ACE types that control access to specific properties or child object types. ACCESS_ALLOWED_OBJECT_ACE_TYPE includes two optional GUIDs: (1) ObjectType GUID — identifies the specific property or extended right being controlled. For example, the GenericAll right on a User object grants full control, but a targeted ACE with ObjectType = {USER_FORCE_CHANGE_PASSWORD GUID} grants only the ability to force a password reset. (2) InheritedObjectType GUID — limits what child object types inherit this ACE. This matters for AD security assessment because: many over-permissive ACEs in AD are specific-object ACEs that look harmless in summary but enable powerful attacks: GenericWrite on a User object → write to most user attributes → set SPN (Kerberoasting), disable password requirements, modify Group Membership. WriteDACL on a Group → modify the group's ACL → add yourself to the group. WriteOwner on any object → take ownership → write new DACL. Tools like BloodHound/SharpHound enumerate these object-specific ACEs across the entire AD and map attack paths. A single overpermissive ACE on a high-value object (Domain Admins group, a GPO that applies to domain controllers) can create a direct path to domain compromise. This is why AD attack surface assessment must include ACL enumeration, not just group membership and trust analysis.