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.
- Identity lives in an access token, attached to your process (or thread). It's a bag of SIDs: who you are, what groups you're in, what privileges you hold, what integrity level you run at.
- Authority lives in a security descriptor, attached to the object. It says who is allowed to do what to this specific thing.
- The decision is made by the SRM, and only by the SRM.
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:
- 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. - 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:
- Walk to
\??. It's a directory. Fine. - Next segment is
C:. Found an object, and its type isSymbolicLink, which has a parse procedure. Call it. ObpParseSymbolicLinkreads the target\Device\HarddiskVolume3, glues the remaining\Windows\System32\drivers\etc\hostsonto it, and returnsSTATUS_REPARSE.- The lookup restarts from scratch with
\Device\HarddiskVolume3\Windows\System32\drivers\etc\hosts. - Walk to
\Device, thenHarddiskVolume3. That's a device object, and its parse procedure isIopParseDevice. IopParseDevicetakes the entire rest of the string, builds anIRP_MJ_CREATE, and ships it to NTFS. The Object Manager is now done. It never parsed\Windows\System32\drivers\etcat 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:
hosts.andhostsopen the real file through Win32, and fail through\\?\. Same bytes on disk, different string, both work. The trailing junk was silently deleted bykernel32. With\\?\it reaches NTFS intact and NTFS says "no such file". So a blocklist that checks for the literal stringhostscan be defeated byhosts., and a path canonicalizer that "already normalized" a\\?\path just corrupted it.\\?\with..returnsERROR_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.\\?\GLOBALROOT\Device\HarddiskVolume3\...opens the exact same file asC:\..., 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. (AndHarddiskVolume1is my EFI partition, which is why it returns ACCESS_DENIED instead of not-found, that's a real access check firing.)- 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:
- A NULL DACL (absent) means "there is no policy here". The loop has nothing to iterate.
RemainingAccessnever gets emptied... except the spec is explicit that a NULL DACL means any access check will succeed. Everyone gets everything. - An empty DACL (present, zero ACEs) means "policy exists, and it grants nothing". Nobody gets access, except the owner via that implicit
READ_CONTROL | WRITE_DACin step (3).
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:
- The default is NO_WRITE_UP at Medium. Almost nothing on your disk has an explicit integrity label. That
ELSEbranch 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. - 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. - 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:
- Rewritten by
kernel32before the kernel sees it (trailing dots, spaces,.., slashes). - Resolved through symbolic links you can create, that can be swapped between two calls.
- Interpreted against a per-session
\??\view, so the same string means different things to different users. - Reachable by infinitely many spellings,
C:\x,\\?\C:\x,\\?\GLOBALROOT\Device\HarddiskVolume3\x, an 8.3 short name, a hardlink, a mounted volume path, a UNC path to\\localhost\C$\x.
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:
- Make the check run against a different object than the one that gets used (symlink, junction, race).
- Make the check run with a different token than yours (impersonation, the entire Potato family).
- Get someone else's handle, which already passed a check you'd have failed (handle leaks, inherited handles, duplication from a privileged process).
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:
kernel32rewrites your string, collapsing dots and stripping trailing junk, unless you used\\?\. The kernel never sees what you typed.RtlDosPathNameToNtPathName_Uturns it into\??\C:\secret.txt, where\??\is a per-session namespace view.ObpLookupObjectNamewalks that namespace.C:is a symbolic link, soObpParseSymbolicLinkreturnsSTATUS_REPARSEand the whole lookup restarts against\Device\HarddiskVolume3\secret.txt.- The device's parse procedure
IopParseDeviceswallows the rest of the path and hands it to the filesystem. The Object Manager stops at the volume. - 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 fromRemainingAccessand deny-ACEs exiting immediately. IfRemainingAccesshits zero, you're in. - 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:
- Do not use PowerShell's
Get-Itemor .NET'sFile.Openfor path experiments. Both normalize paths before the call and will happily tell you that\\?\C:\Windows\..\Windowsworks. It doesn't. P/InvokeCreateFileWdirectly or you're testing the wrong thing. I wasted a good half hour on exactly this. NtQueryObjectwithObjectBasicInformationwants a buffer of exactly 56 bytes. Give it more and it returnsSTATUS_INFO_LENGTH_MISMATCH(0xC0000004), which is a delightfully counterintuitive way to say "your buffer is too big".
References
- MS-DTYP 2.5.3.2 — the Access Check algorithm pseudocode, straight from Microsoft
- MS-DTYP 2.5.3.1.1 — the MandatoryIntegrityCheck algorithm pseudocode
- MSDN — Windows Kernel-Mode Object Manager — objects, types, the namespace
- MSDN — Reparse Points and Hard Links and Junctions
- Jeremy Kuhne — DOS to NT: A Path's Journey
- Jeremy Kuhne — Path Normalization
- MSDN — ACCESS_MASK format
- csandker — A Windows Authorization Guide
- James Forshaw — Tyranid's Lair, and symboliclink-testing-tools
- Project Zero — Windows 10 Symbolic Link Mitigations
- WinObjEx64 — browse the NT object namespace yourself, do this, it's fun