Last month I wrote about how Chromium decides whether one page can touch another. This month I want to ask the same question one layer down, on the operating system itself: when you type CreateFile("C:\secret.txt"), who exactly decides that you can't? The answer is a 33-year-old piece of logic that almost nobody has actually read, and it's sitting under every single Windows privilege escalation you've ever seen.

Hello world! In my last post I did a deep dive into the SOP INTERNALS, how Chromium implements the Same Origin Policy. A bunch of people told me they liked that format, "no CVE, no crash, just open the source and understand how the thing really decides". So I want to do it again, but this time on Windows.

Here's the thing that got me started. The Same Origin Policy answers one question: "are you allowed to touch this?". And it answers it by comparing a tuple. Windows answers the exact same question, millions of times per second, on every file, every registry key, every process, every mutex, every named pipe. But it doesn't compare a tuple. It runs an algorithm. And that algorithm is published, in full, as pseudocode, by Microsoft, in a protocol spec that basically nobody reads.

So this post is the NT ACCESS INTERNALS: the complete journey from a string like "C:\secret.txt" to either a handle in your hand or ERROR_ACCESS_DENIED in your face. No assembly, no ROP, no shellcode. Pure logic. And at the end I'll show you why this is one of the most productive places to go bug hunting on Windows, with real output from my own machine. Let's go :D


Why does this even exist?

Same as last time, before we open anything, we need the why.

Windows NT was designed in the late 80s and shipped in 1993, and it was built by people who came from VMS and who had one very specific goal: get NT certified at C2 security level under the US DoD "Orange Book". That sounds like boring compliance trivia, but it's actually the single most important fact about how Windows security works, because C2 required things like discretionary access control, object reuse protection, and auditing, and the only sane way to build that is to put one component in charge of every access decision.

That component is the Security Reference Monitor (SRM), and it lives in the kernel. The design rule is beautifully simple:

Identity and authority are two different things, stored in two different places, and exactly one function is allowed to combine them.

Compare that to the SOP for a second. Chromium's IsSameOriginWith asks "are these two things the same?". Windows never asks that. There's no notion of "same user" as a security check. Windows asks: "does this bag of SIDs satisfy this list of rules?". It's not an identity comparison, it's a policy evaluation. That difference is why the Windows model is so much more expressive, and so much easier to get subtly wrong.

Everything is an object (and a handle is not a name)

Before the access check, we need to talk about what's actually being protected.

In NT, essentially everything the kernel manages is an object, and they all share the same shape. Files, directories, processes, threads, registry keys, mutants, semaphores, events, ALPC ports, tokens, desktops, symbolic links, jobs, sections. Every single one of them is a blob of type-specific data with a common OBJECT_HEADER glued to the front of it, and that header is where the security descriptor pointer lives.

This is why the same access check code can protect a file and a mutex. The Object Manager doesn't care what the object is. It cares that it has a header.

Now, the part that took me embarrassingly long to internalize, and that changes how you look at Windows bugs forever:

A handle is not a name. A handle is a capability.

When you open something, the access check runs once. If it passes, the kernel writes an entry into your process's handle table containing a pointer to the object and a frozen GrantedAccess mask. From that moment on, the security descriptor is never consulted again for that handle. Every subsequent operation just checks "does this handle's GrantedAccess contain the bit I need?".

Think about what that means. If you can get a handle with WRITE_DATA, and then the administrator fixes the DACL, your handle still works. If you can trick a privileged process into opening something on your behalf and handing you the handle, you inherit its decision, not its identity. The check is a one-time gate, and everything after it is trust.

Hold onto that. It comes back at the end.

NT ACCESS INTERNALS: the two layers

Just like the SOP had two layers that had to agree (url::Origin and blink::SecurityOrigin), the NT access path has two layers, and they are completely independent:

  1. Name resolution — turning a string into an object. This is the Object Manager's job: ObpLookupObjectName. It walks a namespace, follows symbolic links, and eventually hands off to a device driver.
  2. Access checking — turning a token plus a security descriptor into a granted access mask. This is the Security Reference Monitor's job: SeAccessCheck.

And here's the punchline of this whole blog post, so I'll say it early:

Layer 1 decides which object you get. Layer 2 decides what you can do to it. Almost every Windows logic privilege escalation lives in the seam between them, because the code that made a decision about a name is not the code that resolved that name into an object.

Let's do layer 1 first, because it's the one people underestimate.

NT ACCESS INTERNALS: a path's journey

Here's the full call stack for opening a file. This is our BindingSecurity::ShouldAllowAccessTo moment, the spine of the whole post:

CreateFileW("C:\Windows\System32\drivers\etc\hosts")
   -> RtlDosPathNameToNtPathName_U        // "C:\..." becomes "\??\C:\..."
   -> NtCreateFile(&OBJECT_ATTRIBUTES)
   -> ObOpenObjectByName
   -> ObpLookupObjectName                 // walks the NT namespace, segment by segment
        -> ObpParseSymbolicLink           // "\??\C:" is a SYMLINK -> STATUS_REPARSE
        -> ObpLookupObjectName (again)    // now resolving "\Device\HarddiskVolume3\..."
        -> IopParseDevice                 // the device swallows the rest of the path
             -> IRP_MJ_CREATE to NTFS
             -> SeAccessCheck             // <-- THE decision
   -> handle written to the handle table, with a frozen GrantedAccess

Let's take it apart.

Step 1: your path is a lie

C:\Windows is not a real path. The kernel has never heard of drive letters. Drive letters are a DOS compatibility layer that lives in the object namespace, and RtlDosPathNameToNtPathName_U in ntdll.dll is the function that translates your nostalgia into something NT understands. C:\Windows becomes \??\C:\Windows.

So what is \??\C:? It's a symbolic link object sitting in the object namespace, and you can read its target from user mode without any privileges. Real output from my machine:

PS> QueryDosDevice("C:")               -> \Device\HarddiskVolume3
PS> QueryDosDevice("PhysicalDrive0")   -> \Device\Harddisk0\DR0
PS> QueryDosDevice("Nul")              -> \Device\Null
PS> QueryDosDevice("Con")              -> \Device\ConDrv\Console

Look at Nul and Con. Those legendary "reserved filenames" that break every web upload form on the planet? They're not hardcoded magic. They are literally symbolic link objects in a directory, sitting right next to your C: drive. That's it. That's the whole mystery.

And one detail that matters enormously for security: \??\ is not one place. It's a per-session view. Each logon session gets its own DOS devices directory, which is merged on top of the machine-wide \GLOBAL??. Which means: a drive letter can mean different things to different users at the same time. Remember that when you see code that logs a path from one context and acts on it in another.

Step 2: the namespace walk and STATUS_REPARSE

Once you're in NT-land, ObpLookupObjectName takes over. It tokenizes the path on backslashes and walks it segment by segment, starting from the root. For each segment it either finds a directory entry, or it finds an object whose type has a parse procedure, in which case it calls that procedure and lets the object type take over the rest of the path.

This is the elegant part. The NT namespace is not a tree. It's a tree that delegates. Each OBJECT_TYPE can register its own parse procedure, and when the walk hits an object of that type, the remaining path string is handed over wholesale.

Two parse procedures matter to us. The first is the one for symbolic links, and its behaviour is the important part: it reads the link's target, concatenates whatever is left of the path onto it, and returns STATUS_REPARSE. That status code means something very specific: "throw away what you were doing and start the entire lookup over with this new string".

So resolving \??\C:\Windows\System32\drivers\etc\hosts actually goes like this:

  1. Walk to \??. It's a directory. Fine.
  2. Next segment is C:. Found an object, and its type is SymbolicLink, which has a parse procedure. Call it.
  3. ObpParseSymbolicLink reads the target \Device\HarddiskVolume3, glues the remaining \Windows\System32\drivers\etc\hosts onto it, and returns STATUS_REPARSE.
  4. The lookup restarts from scratch with \Device\HarddiskVolume3\Windows\System32\drivers\etc\hosts.
  5. Walk to \Device, then HarddiskVolume3. That's a device object, and its parse procedure is IopParseDevice.
  6. IopParseDevice takes the entire rest of the string, builds an IRP_MJ_CREATE, and ships it to NTFS. The Object Manager is now done. It never parsed \Windows\System32\drivers\etc at all.

That step 6 is important and constantly misunderstood: the kernel does not walk your directory tree. The filesystem does. The object namespace stops at the volume.

And since a link can point at another link, the reparse chain obviously has to be bounded, otherwise a two-link loop would hang the kernel. It is. That's why deeply chained reparse points fail with a status code instead of spinning forever.

Source note: Windows isn't open source, so nobody can show you the real ObpParseSymbolicLink. ReactOS has its own version, but that's an unofficial reimplementation, not Microsoft's code. Everything here comes from Microsoft's docs or from experiments I ran.

So let's do exactly that, because you can watch a reparse happen from PowerShell, with no debugger and no privileges at all. An NTFS junction is a reparse point, and creating one does not require administrator:

elevated = False

PS> cmd /c mklink /J C:\rp\link C:\rp\real
PS> type C:\rp\link\flag.txt         -> you landed in REAL

PS> (Get-Item C:\rp\link).Attributes -> Directory, ReparsePoint
PS> (Get-Item C:\rp\link).LinkType   -> Junction
PS> (Get-Item C:\rp\link).Target     -> C:\rp\real

PS> (repoint the junction at C:\rp\evil)
PS> type C:\rp\link\flag.txt         -> you landed in EVIL

Look at what that proves. The attribute flags say it out loud: Directory, ReparsePoint. It's not a directory that happens to forward, it's an object that carries a reparse tag, and the filesystem re-resolves the path when it hits one.

And then the last two lines: the exact same path string, byte for byte, resolved to a different file. No privileges. No exploit. No bug. That's just how name resolution works, and it's the entire foundation of the bug class we'll get to at the end of this post. Keep that image in your head: a path is not a thing, it's a question, and someone else may be answering it.

Step 3: normalization is a Win32 lie

Now here's where it gets fun, and where I stopped reading and started experimenting.

Everything above happens in the kernel. But before any of it, kernel32.dll silently rewrites your string. It collapses . and .., converts forward slashes to backslashes, and, my favourite, strips trailing dots and spaces. The kernel never sees what you typed.

Unless you tell it not to. The \\?\ prefix means "I know what I'm doing, pass this through verbatim". The \\.\ prefix looks almost identical and does not mean that.

I got tired of reading contradictory blog posts about this, so I just called CreateFileW directly through P/Invoke (no .NET or PowerShell path helpers in the way, those normalize behind your back and will lie to you) and asked the kernel. Real output, Windows 11:

err 2 = FILE_NOT_FOUND, 5 = ACCESS_DENIED, 123 = INVALID_NAME

  OK         C:\Windows\System32\drivers\etc\hosts
  OK         C:\Windows\System32\drivers\etc\hosts.
  OK         C:\Windows\System32\drivers\etc\hosts␣␣␣
  OK         C:\Windows\..\Windows\System32\drivers\etc\hosts

  OK         \\?\C:\Windows\System32\drivers\etc\hosts
  FAIL(2)    \\?\C:\Windows\System32\drivers\etc\hosts.
  FAIL(2)    \\?\C:\Windows\System32\drivers\etc\hosts␣␣␣
  FAIL(123)  \\?\C:\Windows\..\Windows\System32\drivers\etc\hosts
  OK         \\.\C:\Windows\..\Windows\System32\drivers\etc\hosts

  OK         \\?\GLOBALROOT\Device\HarddiskVolume3\Windows\System32\drivers\etc\hosts
  FAIL(5)    \\?\GLOBALROOT\Device\HarddiskVolume1\Windows\System32\drivers\etc\hosts

  FAIL(5)    C:\Windows\System32\config\SAM
  FAIL(5)    \\?\C:\Windows\System32\config\SAM

Read that table slowly, there are four separate lessons in it:

  1. hosts. and hosts    open the real file through Win32, and fail through \\?\. Same bytes on disk, different string, both work. The trailing junk was silently deleted by kernel32. With \\?\ it reaches NTFS intact and NTFS says "no such file". So a blocklist that checks for the literal string hosts can be defeated by hosts., and a path canonicalizer that "already normalized" a \\?\ path just corrupted it.
  2. \\?\ with .. returns ERROR_INVALID_NAME, but \\.\ with .. works. These two prefixes are constantly treated as synonyms. They are not. \\?\ skips normalization, \\.\ does not. If you write a path validator that special-cases one, test the other.
  3. \\?\GLOBALROOT\Device\HarddiskVolume3\... opens the exact same file as C:\..., with no drive letter anywhere in the string. That's the escape hatch out of the DOS namespace and straight into the raw NT namespace. Any sandbox or filter that reasons about drive letters is now decorative. (And HarddiskVolume1 is my EFI partition, which is why it returns ACCESS_DENIED instead of not-found, that's a real access check firing.)
  4. The SAM hive fails identically in every single form. Not found? No. Denied. Every path form resolved to the same object, and then layer 2 said no. That's the difference between the two layers, in one line of output.

Point 4 is the transition. Name resolution succeeded perfectly. Now let's look at the thing that actually stopped us.

NT ACCESS INTERNALS: the actual access check

This is the one. If you remember one thing from this post, make it this algorithm, the way IsSameOriginWith was the one thing to remember from the SOP post.

Here's the surprise: Microsoft published it. In full. As pseudocode. Not in the Windows Internals book, not in a leaked source drop, but in MS-DTYP, the Windows Data Types protocol specification, because remote protocols need to replicate the exact same semantics. It's been sitting on Microsoft Learn this whole time.

Here it is, trimmed to the parts that matter (I cut the Active Directory object-tree branches and the Central Access Policy stuff, which are their own rabbit hole):

// src: MS-DTYP 2.5.3.2 Access Check Algorithm Pseudocode
EvaluateTokenAgainstDescriptor(Token, SecurityDescriptor,
                               Access_Request_mask, ...)

    Set DACL to SecurityDescriptor Dacl field
    Set RemainingAccess to Access Request mask
    Set AllowedAccesses to 0
    Set DeniedAccesses to 0
    Set MaxAllowedMode to FALSE

    -- (1) SACL access is a PRIVILEGE, not a permission.
    IF RemainingAccess contains ACCESS_SYSTEM_SECURITY THEN
        IF Token.Privileges contains SeSecurityPrivilege THEN
            Remove ACCESS_SYSTEM_SECURITY from RemainingAccess
        ELSE
            Return access_denied
        END IF
    END IF

    -- (2) Taking ownership is also a privilege that bypasses the DACL.
    IF RemainingAccess contains WRITE_OWNER THEN
        IF Token.Privileges contains SeTakeOwnershipPrivilege THEN
            Remove WRITE_OWNER from RemainingAccess
        END IF
    END IF

    -- (3) The owner ALWAYS gets READ_CONTROL and WRITE_DAC.
    --     You can never lock yourself out of your own object.
    CALL SidInToken(Token, SecurityDescriptor.Owner, PrincipalSelfSubst)
    IF SidInToken returns True THEN
        IF DACL does not contain ACEs from object owner THEN
            Remove READ_CONTROL and WRITE_DAC from RemainingAccess
        END IF
    END IF

    IF RemainingAccess contains MAXIMUM_ALLOWED THEN
        Set MaxAllowedMode to TRUE
    END IF

    -- (4) The loop. In DACL order. No sorting.
    FOR each ACE in DACL DO
        IF ACE.AceFlags does not contain INHERIT_ONLY_ACE THEN
            CASE ACE.Type OF

                CASE Allow Access:
                    CALL SidInToken(Token, ACE.Sid, PrincipalSelfSubst)
                    IF SidInToken returns True THEN
                        IF MaxAllowedMode equals TRUE THEN
                            Set AllowedAccesses to AllowedAccesses or ACE.AccessMask
                        ELSE
                            Remove ACE.AccessMask from RemainingAccess
                        END IF
                    END IF

                CASE Deny Access:
                    CALL SidInToken(Token, ACE.Sid, PrincipalSelfSubst)
                    IF SidInToken returns True THEN
                        IF MaxAllowedMode equals TRUE THEN
                            Set DeniedAccesses to DeniedAccesses or ACE.AccessMask
                        ELSE
                            IF any bit of RemainingAccess is in ACE.AccessMask THEN
                                Set GrantedAccess to 0
                                Return access_denied        -- <-- immediate exit
                            END IF
                        END IF
                    END IF
            END CASE
        END IF
    END FOR

    IF MaxAllowedMode equals TRUE THEN
        Set GrantedAccess to AllowedAccesses and (not DeniedAccesses)
        IF GrantedAccess not equals 0 THEN Return success
        ELSE Return access_denied
    END IF

    -- (5) Did we satisfy every single bit that was asked for?
    IF RemainingAccess to 0 THEN
        Return success
    Else
        Return access_denied
    END IF

Beautiful, right? Let me walk the parts that surprised me.

It's a subtraction, not a comparison

This is the core mental model and it's completely different from the SOP. There is no "is this user allowed" boolean. There's an accumulator. RemainingAccess starts as everything you asked for, and every matching allow-ACE subtracts bits from it. At the end, if the bag is empty, you're in. If a single bit is left over, you're denied.

Which means access can be assembled from multiple ACEs. You want READ | WRITE, one ACE grants you READ because you're in group A, another grants WRITE because you're in group B, neither ACE alone would satisfy you, and you get in. Nobody wrote a rule saying "members of A and B can read and write". It emerged.

Deny is an immediate exit, and order is not enforced

Look at the deny branch again: Return access_denied. Not "remember this and keep going". It exits. Right there.

Combine that with the loop being in DACL order and you get the single most important operational fact about Windows ACLs:

"Deny always wins" is false. Deny wins if the kernel reaches it first. If an allow-ACE earlier in the list already subtracted the bit from RemainingAccess, the later deny-ACE finds nothing to match and does nothing.

The convention that deny-ACEs come first is called canonical order, and it is enforced by the Windows ACL editor, by AddAccessDeniedAce conventions, by the GUI. It is not enforced by the kernel. The kernel loops in whatever order the bytes are in.

So: any code that builds a DACL by hand, in the wrong order, silently produces a security descriptor whose deny rules are decorative. That's not a memory corruption bug. That's not even a crash. It's a piece of software that looks correct in the properties dialog and isn't. Go grep for AddAccessAllowedAce called before AddAccessDeniedAce and tell me how it goes.

Privileges are a bypass layer, evaluated before the DACL

Notice steps (1) and (2) happen before the loop even starts. SeSecurityPrivilege and SeTakeOwnershipPrivilege remove bits from the request before any ACE is consulted. The DACL literally cannot stop them.

And this pseudocode doesn't even show the biggest ones. SeBackupPrivilege and SeRestorePrivilege are handled further up, in SeAccessCheck's callers: if you hold backup privilege and pass FILE_FLAG_BACKUP_SEMANTICS, you get read access to anything, DACL be damned. That's not a bug, it's the documented purpose. It's also why "this account only has SeBackupPrivilege, it's basically read-only, low risk" is one of the most expensive sentences in Windows administration.

Empty DACL vs NULL DACL

A tiny detail with enormous consequences, and one of my favourite pieces of Windows trivia:

One is a wide-open door. The other is a sealed vault. They differ by a single null pointer, and they are trivially confused in code that does if (dacl == NULL || dacl->AceCount == 0). If you find that pattern in a privileged service, you have found something worth an afternoon.

NT ACCESS INTERNALS: the DACL isn't the whole story

Everything above is discretionary access control. The "D" in DACL. It's discretionary because the object's owner gets to decide it.

Post-Vista, there's a second, mandatory layer that the owner cannot opt out of: Mandatory Integrity Control. Every token has an integrity level (Untrusted, Low, Medium, High, System) and every object can carry a SYSTEM_MANDATORY_LABEL_ACE in its SACL. Microsoft published this pseudocode too:

// src: MS-DTYP 2.5.3.1.1 MandatoryIntegrityCheck Algorithm Pseudocode
-- Find the mandatory label ACE in the object's SACL (not the DACL!)
Call FindAceByType WITH ObjectSecurityDescriptor.Sacl,
      SYSTEM_MANDATORY_LABEL_ACE_TYPE, 0
   RETURNING MandatoryACE, FoundIndex

IF (ACE.AceFlags does not contain INHERIT_ONLY_ACE) THEN
    Set AceMask to ObjectIntegrityAceMask
    Set AceIntegritySID to the SID from the ACE
ELSE
    -- No label? Default policy: NO_WRITE_UP at Medium integrity.
    Set AceMask to SYSTEM_MANDATORY_LABEL_NO_WRITE_UP
    Set AceIntegritySID to DefaultMandatorySID    -- S-1-16-8192 (ML_MEDIUM)
END IF

CALL SidDominates(IntegrityLevelSID, AceIntegritySID)
  -- TokenDominates = "is my integrity >= the object's?"

IF TokenDominates is FALSE THEN
    IF AceMask & SYSTEM_MANDATORY_LABEL_NO_READ_UP THEN
        Remove GENERIC_READ from MandatoryInformation.AllowedAccess
    IF AceMask & SYSTEM_MANDATORY_LABEL_NO_WRITE_UP THEN
        Remove GENERIC_WRITE from MandatoryInformation.AllowedAccess
    IF AceMask & SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP THEN
        Remove GENERIC_EXECUTE from MandatoryInformation.AllowedAccess
END IF

IF Token.Privileges contains SeRelabelPrivilege THEN
    Add WRITE_OWNER to MandatoryInformation.AllowedAccess
END IF

Three things worth burning into your memory:

  1. The default is NO_WRITE_UP at Medium. Almost nothing on your disk has an explicit integrity label. That ELSE branch is the common case, not the exception. It's why a Low integrity sandboxed process (browser renderer, AppContainer) can read most of your disk but not write to it.
  2. It produces a ceiling, not a verdict. The output is AllowedAccess, a mask that constrains what the DACL is subsequently allowed to grant. Both layers must agree. MIC can only ever take away.
  3. Read-up is allowed by default. This is not Bell-LaPadula. Integrity protects integrity, not confidentiality. A Low process reading your documents is the designed behaviour. That surprises people constantly.

The security descriptor is itself a securable object

Okay, story time, because this one made me laugh.

I wanted to dump some real security descriptors for this post, so I ran Get-Acl on a few things as a normal user. Real output:

  [C:\Windows\System32\drivers\etc\hosts]
    SDDL : O:SYG:SYD:(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;0x1200a9;;;BU)
                     (A;ID;0x1200a9;;;AC)(A;ID;0x1200a9;;;S-1-15-2-2)

  [C:\Windows\System32\config\SAM] -> UnauthorizedAccessException
  [C:\Windows\Temp]                -> UnauthorizedAccessException

  SACL of anything                 -> PrivilegeNotHeldException

Look at what just happened. I couldn't read the SAM's DACL. Not modify it, read it. Why?

Because reading a security descriptor requires READ_CONTROL, and READ_CONTROL is an access right like any other, so it goes through the access check. The rules are protected by the rules. It's recursive, and the recursion bottoms out at step (3) of the pseudocode: the owner always gets READ_CONTROL, so the system can never fully brick an object.

And the SACL failure is even better. It threw PrivilegeNotHeldException, a different exception, because reading a SACL doesn't require a permission at all, it requires ACCESS_SYSTEM_SECURITY, which requires SeSecurityPrivilege. Which is literally the first branch of the pseudocode we just read. I didn't set out to test that. I just tried to dump an ACL and the very first ten lines of MS-DTYP reached out and slapped me.

Now look at the hosts SDDL, because we're about to need it: (A;ID;0x1200a9;;;BU). BUILTIN\Users get access mask 0x1200a9. Remember that number.

NT ACCESS INTERNALS: MAXIMUM_ALLOWED, the DACL scanner

Remember MaxAllowedMode in the pseudocode? I skipped past it. Let's go back, because this is the part I actually want you to steal for your own research.

Normally you open something by asking a question: "can I have WRITE_DATA?" Yes or no. If you want to know everything you can do to an object, you'd have to brute force it, one bit at a time, generating a pile of audit events and taking forever.

MAXIMUM_ALLOWED flips it into a request for information. Look at what the algorithm does in that mode: it stops early-exiting on deny, walks the entire DACL, accumulates every allow into AllowedAccesses and every deny into DeniedAccesses, and returns:

Set GrantedAccess to AllowedAccesses and (not DeniedAccesses)

One syscall. Complete answer. "Here is precisely what you are permitted to do to this object." Microsoft's own docs say its use is "not recommended". For a defender writing an application, sure. For someone mapping an attack surface, it's the single best primitive on the platform.

So I wrote a tiny prober: open with MAXIMUM_ALLOWED, then NtQueryObject(ObjectBasicInformation) to read back the GrantedAccess the kernel actually stamped on my handle. Real output, standard non-elevated user, Windows 11:

  C:\Windows\System32\drivers\etc\hosts
      0x001200A9  READ_DATA READ_EA EXECUTE READ_ATTR READ_CONTROL SYNCHRONIZE

  C:\Windows\System32\kernel32.dll
      0x001200A9  READ_DATA READ_EA EXECUTE READ_ATTR READ_CONTROL SYNCHRONIZE

  C:\Windows\System32\config\SAM
      DENIED (err 5)

  C:\Windows\Temp
      0x001000A6  WRITE_DATA APPEND EXECUTE READ_ATTR SYNCHRONIZE

  C:\ProgramData
      0x001201BF  READ_DATA WRITE_DATA APPEND READ_EA WRITE_EA EXECUTE
                  READ_ATTR WRITE_ATTR READ_CONTROL SYNCHRONIZE

  ...\blog\README.md
      0x001F01FF  READ_DATA WRITE_DATA APPEND READ_EA WRITE_EA EXECUTE
                  READ_ATTR WRITE_ATTR DELETE READ_CONTROL WRITE_DAC
                  WRITE_OWNER SYNCHRONIZE

First, the satisfying part: hosts came back 0x001200A9. Go scroll up to the SDDL. (A;ID;0x1200a9;;;BU). The kernel handed me the exact hex value written in the ACL, because I'm in BUILTIN\Users and that's my ACE. The theory and the experiment closed the loop perfectly and I may have made a noise out loud.

But now look at C:\Windows\Temp, and look at it carefully:

  0x001000A6  WRITE_DATA APPEND EXECUTE READ_ATTR SYNCHRONIZE
              ^^^^^^^^^^        no READ_DATA. no READ_CONTROL.

I can write files there. I cannot list them. I can't even read the DACL. That's a deliberate "drop box" pattern, and it is exactly the shape of directory that Windows privilege escalation research lives on: a place where a low-privileged user can plant a file that a high-privileged process will later touch, in a directory the low-privileged user can't enumerate but the attacker doesn't need to, because they know the filename they're planting.

Now imagine running that prober not over seven paths I picked by hand, but over every directory a privileged service touches, every registry key under HKLM\SYSTEM\CurrentControlSet\Services, every named pipe in \Device\NamedPipe, every object in \BaseNamedObjects. Filter for anything where a Medium-integrity token gets WRITE_DATA, WRITE_DAC, or DELETE on something owned by SYSTEM. That's a weekend project, it's about two hundred lines of code, and it is a genuinely productive way to find real bugs. It works on every object type, because remember: they all share the same header, so they all go through the same check.

NT ACCESS INTERNALS: why handle, not path?

In the SOP post I hammered one point: make security decisions on origins, not URLs. Chromium has an entire doc calling URL-as-origin an anti-pattern, and a whole class of Chromium bugs is just someone comparing strings instead of comparing url::Origin.

Windows has the exact same bug class, and it's arguably worse. Here it is:

Make security decisions on handles, not paths.

Look at what we established. A path is:

A handle is none of those things. A handle is a direct pointer to the object the access check already ran against. That's the whole difference.

So the canonical Windows logic bug looks like this, and once you've seen it you see it everywhere:

// The eternal bug. A privileged service, doing something reasonable.

if (IsPathSafe(userSuppliedPath))          // (1) decision made on a STRING
{
    h = CreateFile(userSuppliedPath, ...); // (2) resolved AGAIN, later,
                                           //     through symlinks the
                                           //     attacker controls
    WriteFile(h, ...);                     // (3) with SYSTEM's token
}

Between (1) and (2), the string gets resolved a second time. Nothing guarantees it lands on the same object. The check was real. The check was also completely irrelevant, because it validated a name and the write happened to an object.

And we already proved the attacker's half of that, way back in the path section. Scroll up to the junction experiment: same path string, different file, elevated = False. That wasn't a party trick, that was step (2) being answered differently than step (1). The whole exploit is those four lines of PowerShell, aimed at a service that trusted a string.

This is TOCTOU, but I want to be precise about why Windows is so rich in it, because "it's a race condition" undersells it. It's because name resolution is a re-runnable, attacker-influenceable computation, and the platform gives unprivileged users first-class tools to influence it: object manager symlinks in \??\, NTFS junctions (no admin required), hardlinks, and \RPC Control as a writable directory to plant links in. James Forshaw's symboliclink-testing-tools exists precisely because this is a whole ecosystem, not a one-off trick.

And now the thing I asked you to hold onto at the very start comes back. The access check runs once, at open time, and the handle carries the frozen result. Which means the attacker's goal is never "bypass the access check". It's:

Three doors. None of them involve defeating the algorithm. The algorithm is fine. It's been fine since 1993. Everything interesting happens at its edges.

Putting it all together

Let's zoom all the way out. What happens when you open C:\secret.txt:

  1. kernel32 rewrites your string, collapsing dots and stripping trailing junk, unless you used \\?\. The kernel never sees what you typed.
  2. RtlDosPathNameToNtPathName_U turns it into \??\C:\secret.txt, where \??\ is a per-session namespace view.
  3. ObpLookupObjectName walks that namespace. C: is a symbolic link, so ObpParseSymbolicLink returns STATUS_REPARSE and the whole lookup restarts against \Device\HarddiskVolume3\secret.txt.
  4. The device's parse procedure IopParseDevice swallows the rest of the path and hands it to the filesystem. The Object Manager stops at the volume.
  5. The filesystem calls SeAccessCheck. MIC computes a ceiling from the SACL's integrity label (defaulting to NO_WRITE_UP at Medium). Then privileges are subtracted, then the owner's implicit rights, then the DACL is walked in stored order, allow-ACEs subtracting from RemainingAccess and deny-ACEs exiting immediately. If RemainingAccess hits zero, you're in.
  6. A handle is written into your handle table with a frozen GrantedAccess, and the security descriptor is never consulted for that handle again.

That's it. That's the whole thing. A design from 1993, built to satisfy a DoD checklist, that separates who you are from what may be done and puts exactly one function in charge of combining them. It's genuinely elegant. It's also thirty-three years of accumulated compatibility layers stacked on top of each other, and every single one of those layers is a place where two pieces of code can disagree about what a name means.

No bug in this one either, same as last time. But that's not the point. The point is that now, when you're reading some privileged Windows service and you see it validate a path as a string and open it later, or build a DACL with the allow-ACE first, or check dacl == NULL the same way it checks AceCount == 0, or trust a handle somebody handed it, a little alarm should go off in your head.

That alarm is the entire deliverable. It's worth more than any single CVE. :D

See you in the next one. Bye!


Appendix: reproduce it yourself

Everything in this post is from my own machine, unelevated, on Windows 11. Two things if you want to redo it:

References