CVE-2026-112766: How a Standard User Races a Rename into a Kernel Pool Overflow in Windows FastFAT and exFAT
A note on disclosure. This finding was reported to Microsoft and is tracked as CVE-2026-112766 (Microsoft advisory, CVE record).
Introduction
This post documents a kernel out-of-bounds write I found in two Windows file-system drivers: fastfat.sys, the driver for FAT12, FAT16 and FAT32 volumes, and exfat.sys, the driver for exFAT volumes. Both ship with Windows 10, Windows 11 and Windows Server, and both load automatically whenever a volume of their format is mounted, which on a desktop usually means a USB stick or an SD card.
The bug is a double fetch. A double fetch reads the same value from memory twice when it should read it once: it sizes a buffer from the first read, then uses the second read to decide how many bytes to put in that buffer. If another thread changes the value in between, the two reads disagree and the copy overruns the buffer. This is a time-of-check-to-time-of-use race (TOCTOU, CWE-367) in the specific form that produces an out-of-bounds write (CWE-787).
Microsoft’s advisory files the bug under CWE-416 (use after free). Part 11 shows that the same race can also make the copy read a buffer that the rename has just freed, which matches that classification. That link is my reading, because the advisory does not describe the mechanism.
Both drivers statically link the same Encrypting File System (EFS) integration code, so one copy of the same defect is compiled into each binary. The routine is EfsPostCreateCallback, which runs every time a file on such a volume is opened. It copies the file’s full path out of the kernel’s bookkeeping structure into a fresh pool buffer. It reads the length of that path once to size the allocation and a second time to drive the copy, and it holds no lock while it does so. A separate feature, rename, rebuilds that same path under a lock the callback never takes: it frees the old buffer and stores a new one. A standard user opens their own EFS-encrypted file in a loop and, on another thread, renames it between a short path and a long one. When a rename falls between the callback’s two length reads, the kernel sizes a chunk for the short name and copies the long name into it.
Here are the exact bounds, stated as facts.
- What I demonstrated, on a running Windows 11 virtual machine, on both drivers, is a standard-user-triggered kernel paged-pool out-of-bounds write. It reproduces as a real kernel crash dump: bug check
0x50, access type write, faulting insidememcpy, withEfsPostCreateCallback+0xf0as the return address on the stack. The faulting process runs as a plain standard user. The same race also reliably crashes the machine through a second, more frequent path (a copy from a NULL source), and a negative control with the rename turned off runs clean. - What I did not demonstrate is turning that controlled overflow into code running as SYSTEM. The overflow length and the chunk size are attacker-chosen, and the bytes written come from the attacker’s path (Part 5 gives the exact limits). A paged-pool write of this kind is the usual starting point for kernel privilege escalation. Part 10 describes what remains open. Microsoft’s advisory classifies the bug as elevation of privilege and states that an attacker who exploits it could gain SYSTEM privileges. That assessment is Microsoft’s. I established the primitive, not the full exploit, and I state that boundary precisely throughout.
The two drivers reach the same proof. Both carry the identical defect in statically linked code, and I crashed both: the out-of-bounds write is dump-confirmed once per driver, and every run with the rename thread enabled ended in a crash.
Microsoft fixed the bug in the November 2026 security update. On Windows 11 24H2, 25H2 and 26H2, KB5132061 ships both drivers as 10.0.26100.9974. Part 11 lists the update and the driver version for every other affected Windows version, back to Windows 10 1809 and Windows Server 2019. In the fixed version the callback takes the lock that the rename holds, in shared mode, and it reads the name length once. The new code sits behind a feature flag: the update keeps the new code and the old code in the binary, and a small gate function decides which one runs. The flag is on by default. Part 11 shows the fix instruction by instruction and compares it with the remedy I proposed.
Everything below is anchored to exact binaries. Both drivers are version 10.0.26100.9711, analyzed at image base 0x140000000, so every static address is that base plus a relative virtual address (RVA), the offset of the code or data from the start of the driver image. The on-disk identities are fastfat.sys SHA-256 35DFF4D16867DFCCF613403A74800E23F222F8AFC3AB342C058F08183C161B3C and exfat.sys SHA-256 6F9A38DE8DFC3FCBF95E2D34B17CC507B2299D33331406BD7B3FC25F660D2A23, on Windows 11 OS build 10.0.26300.9711. Part 11 compares them with the fixed version of both drivers, 10.0.26100.9974.
Part 1: Background
This part defines every term the rest of the post uses. If you already know what EFS on a FAT volume is, what an FCB and its full name are, and why a double fetch of a counted string is dangerous, skip to Part 2.
EFS on a FAT volume, and the shared library
EFS (the Encrypting File System) encrypts individual files so only an authorized user can read their contents. It normally lives on NTFS, where the encryption metadata is an NTFS attribute. FAT and exFAT have no such attribute, so Windows stores an EFS file on those volumes as an ordinary file with a special on-disk layout whose name ends in .PFILE. To support that, fastfat.sys and exfat.sys statically link the same EFS helper library, which I will call EfsLib, and call into it on the paths that matter: create, read, write and rename. A standard user creates such a file with the normal EncryptFileW call on a FAT or exFAT volume that reports FILE_SUPPORTS_ENCRYPTION. One of EfsLib’s entry points is EfsPostCreateCallback, the subject of this post.
The FCB and the full name it keeps
When any file is opened, the file system builds a File Control Block (FCB), the in-memory structure that records everything about one open file: its size, its locks, and its name. The FCB stores the file’s full path as a UNICODE_STRING, the standard Windows counted-string structure: a 16-bit Length in bytes, a 16-bit MaximumLength, and a pointer to the character buffer. The two drivers keep this string at different offsets inside their FCBs:
| Field | fastfat.sys | exfat.sys |
|---|---|---|
FullFileName.Length | Fcb+0x210 | Fcb+0x190 |
FullFileName.MaximumLength | Fcb+0x212 | Fcb+0x192 |
FullFileName.Buffer | Fcb+0x218 | Fcb+0x198 |
The important property is that this is not a fixed field written once. The full name is rebuilt whenever the path changes: the old buffer is freed and a new buffer of a new length is allocated. A rename changes the path, so a rename rebuilds the name. The copy in the callback and the rebuild in the rename path therefore read and write the same three fields, and the rest of the post is about what happens when they do so at the same time.
The create path and the two names on it
An open is an IRP_MJ_CREATE request (an I/O request packet with the create major function). It carries its own FileObject, which has a FileName field, and that name is the one the caller asked to open. Separately, the driver builds the FCB’s FullFileName by joining the names of the parent-directory FCBs with backslash separators. These are two different strings: the create request’s own name, which is fixed for the life of that request, and the FCB’s full name, which the driver can rebuild. Part 9 depends on exactly this distinction, which is why NTFS and ReFS are not affected and the FAT drivers are.
The double fetch, concretely
A double fetch has three moments: the code reads a value, acts on that first read, then reads the same value again and acts on the second read, and nothing stops another thread from changing the value in between. When the two reads feed two decisions that must agree (here, the size of an allocation and the number of bytes copied into it), a change between them breaks the agreement. The code is correct only under the assumption that the value cannot change. Here the value is FullFileName.Length, and a concurrent rename is exactly the thing that changes it. A related point will matter in Part 4: the {Length, Buffer} pair is two separate fields, and the rename does not update them together, so a lock-free reader can see a long length paired with a short or NULL buffer.
Earlier research on the Windows kernel, the Bochspwn project by Mateusz Jurczyk and Gynvael Coldwind, found many double fetches by recording the kernel’s memory access patterns. In those, the kernel read memory that a user-mode thread could change. In this bug both reads are of kernel memory, and the other thread changes that memory through a rename.
The lock the reader skips
When two threads touch the same file, the driver uses a reader/writer lock called an ERESOURCE to keep them from colliding. An ERESOURCE can be held shared (many readers) or exclusive (one writer). The rename path takes the FCB lock exclusive while it rebuilds the name. In the 10.0.26100.9711 drivers, the post-create callback takes no FCB lock at all (Part 11 shows the fixed version). A lock serializes only the threads that acquire it, so a reader that never acquires it is never excluded from the writer’s work.
One nearby distinction matters. Before the callback runs, the create path leaves a critical region: a state of one thread in which the kernel does not deliver the thread’s normal kernel APCs or user APCs. An APC (asynchronous procedure call) is a function that the kernel queues to run in one specific thread. Special kernel APCs are still delivered inside a critical region. A critical region is not a resource lock and excludes no other thread, so leaving it does not serialize anything against the rename.
Paged pool, pool tags, and special pool
The allocation the callback makes comes from paged pool, the part of the kernel heap whose pages can be swapped out. The overflow is therefore a linear write past the end of a paged-pool chunk. Every pool allocation carries a four-character pool tag; the FCB-name allocation is tagged 'Fatn' in FastFAT and 'Fppn' in exFAT. To observe the first out-of-bounds byte at the instant it is written, I ran the drivers under Driver Verifier special pool, a kernel validation mode that places each allocation at the end of its own page and marks the next page as no-access, so the first write past the chunk faults immediately on that guard page. Special pool changes only where the chunk sits, not the two lengths the callback reads or the bytes it copies. Without it, the same instruction sequence writes the overflowing bytes into whatever paged-pool allocation follows the chunk.
Part 2: The attack surface
Before any internal detail matters, one question decides whether the finding is real: can an unprivileged user reach the vulnerable callback at all? The answer is yes, and the reasons are measured rather than assumed.
The binaries
| Item | Value |
|---|---|
| Windows 11 OS build | 10.0.26300.9711 (DisplayVersion 26H2) |
fastfat.sys version | 10.0.26100.9711 |
exfat.sys version | 10.0.26100.9711 |
fastfat.sys SHA-256 | 35DFF4D16867DFCCF613403A74800E23F222F8AFC3AB342C058F08183C161B3C |
exfat.sys SHA-256 | 6F9A38DE8DFC3FCBF95E2D34B17CC507B2299D33331406BD7B3FC25F660D2A23 |
| image base | 0x140000000 |
fixed fastfat.sys (KB5132061) | 10.0.26100.9974, SHA-256 C046046AE5D4A5C5DEFC63237B07F8027DD059D83660D90AC431348C90676D39 |
fixed exfat.sys (KB5132061) | 10.0.26100.9974, SHA-256 85A826DD07E408AD8E3B54EF7B81E27FE5B1968F8212F2C1AEB4DC5D1F0D77DA |
The callback runs on every open, before the EFS verdict
EfsPostCreateCallback is called for every successful IRP_MJ_CREATE on a file the driver treats as an EFS file. In fastfat.sys the path is FatFsdCreate to FatPostProcessEfsCreate (0x14002e6d8) to the callback (called at 0x14002e7a5); exfat.sys has the identical Fpp* structure. The EFS access decision, which denies a reader who lacks the file’s key, is made by the EFS code that the callback calls after the copy (the indirect call at 0x140003cc1). FatPostProcessEfsCreate acts on that verdict at 0x14002e7b1, after the callback returns. The name copy is already done by then. So on the code path the copy precedes the key check, which means reaching the copy does not depend on holding the file’s EFS key. That is a static observation about the order of two operations; my proof-of-concept opens its own EFS file, which it owns and whose key it holds, so I did not need to exercise the keyless path, and the attack does not depend on it (an attacker races their own file).
No per-file permissions, and a standard user can bring the volume
On NTFS every file carries an access-control list. FAT and exFAT are different: the on-disk format has no per-file security descriptor at all, so any local user can open any file on a mounted FAT or exFAT volume. The code path needs a mounted, writable FAT or exFAT volume, which in real-world use is ordinary: exFAT is the default format of SDXC cards and many USB drives, and Windows allows writing to removable media by default. In my lab the volume was a FAT32 or exFAT VHD attached by an administrator as a drive letter, standing in for a removable card. Attaching a VHD needs administrator rights, but that is one-time environment setup, not an attack step; inserting a real card needs no privilege. Every step of the attack itself runs as the standard user.
The one precondition, measured
The race needs the rename to run concurrently with the open, so the first thing I checked was whether a standard user can even rename an open EFS file across directories while another handle to it is open with delete sharing. As lowpriv1, with one handle open for FILE_READ_DATA and another for DELETE, both sharing read, write and delete, a rename of the file from a short path to a deep cross-directory long path returned success, and the rename back returned success. Nothing serializes or forbids that rename. If the driver refused this rename, the race could not happen. It does not refuse it, so the two operations can run at once.
Part 3: The reader copies the name, and reads its length twice
The reader is fastfat!EfsPostCreateCallback (0x140003b74). exfat.sys has the same routine at 0x1400063dc. The two compiled copies differ only in constants: the FCB offsets, the pool tag, the bit tested in the driver’s EFS flags and the node-type codes (Part 11 lists them). The callback needs its own copy of the full file name, so it allocates a paged-pool buffer and copies the name into it. Here is the core, with rdi holding the FCB throughout (it is loaded once at 0x140003bfd and never reloaded):
cmp qword [rdi+218h], 0
movzx edx, word [rdi+210h]
mov ecx, 411h
mov r8d, 6E746146h
call [ExAllocatePoolWithTag]
movzx r8d, word [rdi+210h]
mov rdx, qword [rdi+218h]
call fastfat!memcpy
movzx eax, word [rdi+210h]
Line 1, the buffer null check (0x140003c1d): cmp qword [rdi+218h], 0 reads FullFileName.Buffer and checks it against zero. This is a presence test only; the value it reads here is not the value the copy later uses.
Line 2, the first length read (0x140003c27): movzx edx, word [rdi+210h] reads FullFileName.Length into edx. Call this value L1. It becomes the size argument to the allocator.
Lines 3 to 5, the allocation (0x140003c39): ExAllocatePoolWithTag is called with pool type 0x411 and tag 6E746146h, which is 'Fatn' in little-endian bytes. Pool type 0x411 decodes to paged pool with the zero-on-allocation and raise-on-failure flags set, so the result is a zeroed paged-pool chunk and a failed allocation raises rather than returning NULL. The chunk is exactly big enough for the name as it was a moment ago, and its pointer is saved to the stack at 0x140003c48. exfat.sys uses the tag 'Fppn' (6E707046h) here.
Line 6, the second length read (0x140003c4d): movzx r8d, word [rdi+210h] reads FullFileName.Length a second time, into r8d. Call this value L2. It is a fresh load from the same field, not a reuse of edx. L2 becomes the byte count for the copy.
Line 7, the source (0x140003c55): mov rdx, qword [rdi+218h] loads FullFileName.Buffer, the source address of the copy. This is the second time the routine reads Buffer; the first read, at the null check, is not kept.
Line 8, the copy (0x140003c5f): call fastfat!memcpy copies L2 bytes from the source into the chunk that was sized for L1. The call instruction is at EfsPostCreateCallback+0xeb; the instruction after it, the return address every crash dump in Part 7 shows on the stack, is at +0xf0.
Line 9, the third length read (0x140003c68): a further movzx eax, word [rdi+210h] reads the length once more to set the copied name’s descriptor. Even the output descriptor is built from a re-read, not from a saved value.
Nothing in this routine ties L2 to L1. There is no L2 <= L1 comparison, no bounds check on the copy, and no snapshot of the {Length, Buffer} pair taken under any lock. The allocation is sized by one read of a mutable field, and the copy is driven by a second read of the same field. That is the whole defect, in nine instructions.
And the callback holds nothing while it runs. On the create path, FatFsdCreate leaves its critical region at 0x14002c551, before it calls FatPostProcessEfsCreate. Inside FatPostProcessEfsCreate, the only call before the callback is FatDecodeFileObject (0x14002e73e); the first resource acquire, FatAcquireExclusiveFcb (0x14002e7ff), comes after the callback returns. So between the first length read and the second, the callback owns no Vcb (Volume Control Block) resource and no FCB resource, and the allocation call in the middle can block and reschedule the thread, which widens the window.
The exFAT routine is the same sequence at its own offsets: the first length read at 0x14000648f, the 'Fppn' allocation, the second length read at 0x1400064b5, the buffer load at 0x1400064bd, and the copy at 0x1400064c7 whose return address is again the callback+0xf0. The FCB fields are +0x190 for the length and +0x198 for the buffer. One source defect, compiled into two drivers.
Part 4: Rename rebuilds the name, under the lock the reader never takes
The other half of the race is the rename. When a FileRenameInformation request arrives, fastfat.sys handles it in FatSetRenameInfo (0x14003e330) and exfat.sys in FppSetRenameInfo (0x14003c740). The rename frees the old name buffer and rebuilds the full name with the new length. It does this work while holding the FCB exclusive, the lock the reader in Part 3 never takes.
Two routines rebuild the name, and the detail that makes a lock-free reader unsafe is that they store the {Length, Buffer} pair in opposite orders.
The FastFAT rename path rebuilds through FatSetFullNameInFcb (0x14002eb8c), which stores the new length before the new buffer:
cmp edx, 0FFFEh
mov word [rbx+210h], dx
call [ExAllocatePoolWithTag]
mov qword [rbx+218h], rax
Line 1, the length cap (0x14002ec32): cmp edx, 0FFFEh bounds the name: a length above 0xFFFE raises STATUS_NAME_TOO_LONG (0xC0000106) at 0x14002ec43. The comparison is on the full 32-bit length before it is truncated into the 16-bit field, so there is no integer wrap here; the attacker-chosen length is capped at 0xFFFE bytes, which still leaves room for a large overflow.
Lines 2 to 4, length before buffer (0x14002ec65, then 0x14002ec78): FatSetFullNameInFcb stores the new Length at 0x14002ec65, calls the allocator at 0x14002ec6c, and stores the new Buffer at 0x14002ec78. In the rename, this routine runs after FatSetRenameInfo has already freed the old buffer and stored NULL (next paragraph). So for a window between those two stores the FCB holds the new length with a NULL buffer.
The second routine is FatSetFullFileNameInFcb (0x140046f9c). This post calls it the lazy rebuild, because it builds a full name that the FCB does not have yet. It does the reverse: it stores the new Buffer at 0x14004706f or 0x1400470b2 and the new Length at 0x1400470ce, length last. And FatSetRenameInfo itself first frees the old buffer at 0x14003f5bf and sets Buffer to NULL at 0x14003f5cb before the rebuild runs. Between those writes the FCB holds a non-zero length with a NULL buffer.
Figure 1. Two writers store the pair in opposite orders. FastFAT’s rename stores the length first. The lazy rebuild, which the exFAT rename also uses, stores the buffer first. A lock-free reader that samples the length first can read a mixed pair from one of them, and a reader that samples the buffer first can read a mixed pair from the other, so no fixed read order is safe.
So a lock-free reader never sees the length and the buffer change together, and the two rebuild routines flip the order of the two stores. There is no lock-free order in which the reader can sample the pair safely against both writers. The exFAT routines match: FppSetFullNameInFcb (0x140033954) stores length before buffer, and FppSetFullFileNameInFcb (0x140045944) stores buffer before length (the buffer at 0x1400459d4 or 0x140045a16, the length at 0x140045a27).
The exFAT rename uses the second routine. FppSetRenameInfo frees the old buffer at 0x14003db3f, stores NULL at 0x14003db4b, and rebuilds the name through FppSetFullFileNameInFcb when it prepares its change notification (0x14003deae, 0x14003ded9 or 0x14003df04). So the two drivers’ renames publish the pair in opposite orders.
The crashes in Part 7 all came from the rename path. The FastFAT rename rebuilds the name through FatSetFullNameInFcb, the length-before-buffer writer. The exFAT rename rebuilds it through FppSetFullFileNameInFcb, the buffer-before-length writer, so the exFAT dump came from that order.
Part 5: From the race to the kernel write, and its second outcome
Put the two halves on one timeline. The open thread reads Length, sees the short name, and allocates a small chunk. The attacker’s second thread renames the file so the name becomes long, and the long {Length, Buffer} pair is stored under the FCB lock. The open thread, which never took that lock, reads Length again, sees the long value, loads the new long Buffer, and copies that many bytes into the chunk it already sized for the short name. The copy runs off the end of the chunk.
Figure 2. The allocation is sized by the first length read, the copy by the second. Between the two reads, which the callback performs with no FCB lock, a rename grows the name under that very lock, so the copy of the long name overruns the chunk sized for the short one.
Every input to the overflow is the attacker’s:
- The size class is L1, the short name’s length, which the attacker picks.
- The overflow length is L2 minus L1, up to about
0xFFFEminus L1, set by how deep and how long the renamed path is. In the proof runs the copy length followed the path exactly: L2 equals (path characters minus 6) times 2, so a 821-character path gives0x65ebytes and a 1463-character path gives0xb62. - The content is the UTF-16 path itself, made of file and directory names the attacker created, within the character set a FAT long name allows. Those units carry no null word, and the backslash separator appears only at the component boundaries, which are at most 255 units apart. Part 10 returns to what that character-set limit does and does not permit. One qualification: both rebuild routines publish the new buffer before they finish filling it, and the buffer is allocated zeroed, so a copy that overlaps the rebuild’s own fill copies zero bytes for the part not yet written. In
FatSetFullNameInFcb, for example, the buffer is published at0x14002ec78and the last part of the name is copied in at0x14002ecce. I infer this from the order of the code and did not check it in a dump. - The adjacent pool memory can be influenced. The attacker creates, sizes and frees paged-pool allocations of chosen sizes, for example FCB full names, by creating, renaming and closing files. I did not build or test a pool layout.
The same race produces a second outcome, and it is the more frequent one. FatSetRenameInfo frees the old buffer and sets Buffer to NULL before it stores the new buffer. If the callback’s second read loads Buffer while it is NULL, the copy reads from a NULL source and faults at once. This NULL-source read is a reliable kernel crash from the same defect; in the recorded runs it was the common outcome and the out-of-bounds write was the rarer one. That split is partly a property of how the test drives the race rather than a statement that the write is hard to reach: the opener loop opens both the short and the long name, so the FCB often sits in the long-name state, in which an overflow is structurally impossible (the allocation is already long). When the FCB is in the short-name state, the state in which an overflow can occur, both outcomes are reachable in that state. The write is the severe outcome of one bug, and the NULL-source crash is the same bug when the buffer load happens at a different moment. Part 11 adds two more outcomes of the same race that I derived from the code: a copy from a buffer that the rename has already freed, and a name descriptor whose stated length is longer than the allocation.
Part 6: The proof of concept
The proof is one self-contained executable that a standard user runs. It creates the fixture, checks the precondition, and then runs the race; a separate invocation with -control runs the same thing with the rename disabled. The lab administrator enables Driver Verifier special pool so the overflow faults on the guard page at once, but the defect and the overflow exist without it.
The fixture
The tool creates a directory chain and one file on the FAT or exFAT volume, writes a small amount of data, and calls EncryptFileW on it, so the file is a real EFS file (FILE_ATTRIBUTE_ENCRYPTED set). Because a non-interactive logon cannot use the user’s EFS keys, the tool takes the user’s own password with -p and performs an interactive logon of the same account for the fixture step. That logon is the same account at the same Medium integrity level with the same privileges, so no privilege is gained; it only makes the EFS keys usable. The two names the file is renamed between are a short path, race\x.txt, and a deep long path, race\<component>\<component>\...\y.txt, where each component is a run of one repeated letter. The path depth and component length set the long name’s length, and therefore L2: the tool computes the long name so that its byte length is the overflow count the operator wants.
The threads
Every run used three kinds of thread, pinned to processors so that the reader and the writer run on different cores:
- Openers loop
CreateFileWwithFILE_READ_DATAon the short name and then on the long name, closing each handle. Each open is anIRP_MJ_CREATEthat reachesFatPostProcessEfsCreateand the callback, which reads the FCB full name. - A spinner runs at time-critical priority on the openers’ processor. It busy-waits for about 120 microseconds and then yields, which preempts an opener partway through the callback and widens the window between the first and second length reads.
- A renamer repeats three steps in a loop. It opens the file with
DELETEaccess and delete sharing. Through that handle it renames the file to the long name and back to the short name withSetFileInformationByHandle(FileRenameInfo). Then it closes the handle. This is the writer: it drivesFatSetRenameInfothroughFatSetFullNameInFcb(on exFAT,FppSetRenameInfothroughFppSetFullFileNameInFcb), which rebuilds the FCB full name under the FCB lock.
The opener and renamer loops are small. This is the body of the opener loop, with the counters removed:
HANDLE h = CreateFileW(g_shortW,
FILE_READ_DATA | SYNCHRONIZE,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
NULL, OPEN_EXISTING, 0, NULL);
if (h != INVALID_HANDLE_VALUE) CloseHandle(h);
Lines 1 to 4: open the file by its short name for FILE_READ_DATA. The three share flags let other handles read, write and delete the file. Delete sharing is the flag that lets this open succeed while the renamer holds its DELETE handle.
Line 5: close the handle if the open succeeded. The loop then makes the same open with the long name, which the listing leaves out.
This is the body of the renamer loop, with the counters and the recovery branch removed:
HANDLE h = CreateFileW(g_shortW,
DELETE | SYNCHRONIZE,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
NULL, OPEN_EXISTING, 0, NULL);
rename_to(h, g_longNt);
rename_to(h, g_shortNt);
CloseHandle(h);
Lines 1 to 4: open the file by its short name with DELETE access, which a rename requires. The share flags are the same as in the opener.
Lines 5 and 6: rename the file to the long name and then back to the short name, through the same handle.
Line 7: close the handle. The next pass of the loop opens a new one.
rename_to fills a FILE_RENAME_INFO with the target path and calls SetFileInformationByHandle(h, FileRenameInfo, ...). Nothing here is crafted on disk; the only crafted input is the timing of ordinary opens and renames on a plain EFS file the attacker owns.
Reading the result
The tool does not classify the crash. It writes its progress lines to a log file opened with FILE_FLAG_WRITE_THROUGH, so the last progress line is still on disk when the machine restarts after the crash. The operator reads the crash offline from the minidump. The outcome is read from the bug check: a 0x50 with a write access type is the out-of-bounds store, and a 0x3B from a NULL source is the NULL-source read. The -control run spawns no renamer, so the FCB full name never changes and the window never opens, which is the comparison that isolates the rename as the cause.
Part 7: What the crash dumps show
The reason to be confident that this is a real out-of-bounds write and not a prediction from the disassembly is that it reproduces as a kernel crash on a running machine, on both drivers, and the fault occurs at exactly the instruction the disassembly identifies. I ran the race under special pool, which turns the first out-of-bounds byte into an immediate guard-page fault.
The FastFAT out-of-bounds write
The clearest dump is a FastFAT crash. The bug check is 0x50, which Windows names PAGE_FAULT_IN_NONPAGED_AREA. That name refers to the guard page the write ran into, not to the pool the chunk came from: the overflowed chunk is paged pool, and the bug-check name is an artifact of how special pool lays out the guard page. The four bug-check parameters are a page-aligned address (the guard page), 0x2 (meaning the faulting access was a write), the address of the faulting instruction, and zero. The triage names the access type Write, buckets the crash as AV_VRF_W_(null)_fastfat!memcpy, and the pool tag on the memory-manager frame is 0x6e746146, which is 'Fatn'. The stack, abbreviated (some filter-manager and object-manager frames between the create and the fault are left out):
nt!KeBugCheckEx
nt!MiSystemFault
nt!MmAccessFault
nt!KiPageFault
fastfat!memcpy+0x122
fastfat!EfsPostCreateCallback+0xf0
fastfat!FatPostProcessEfsCreate+0xd2
fastfat!FatFsdCreate+0xf4
nt!IofCallDriver
FLTMGR!FltpCreate
nt!IopParseDevice
nt!NtCreateFile
That is the path from Part 3: a file open (NtCreateFile) reaches FatPostProcessEfsCreate, which calls the callback, which calls memcpy, which runs off the end of the 'Fatn' chunk. The faulting instruction is movaps xmmword ptr [rcx-30h], xmm2, a 16-byte SSE store, writing to the guard page. The trap frame (the register values the processor saved at the fault) shows the mismatch directly. The destination register points 0x20 bytes before the end of the chunk’s page, so the chunk the allocator returned is at most 0x20 bytes; the copy length computed from the source and end registers is 0x65e. A 0x65e-byte copy into a chunk of at most 0x20 bytes is the overflow, and both lengths came from the same word [rdi+0x210] load with the FCB pointer unchanged between them, so the only thing that could make them differ is the concurrent rename.
The faulting thread’s token is a plain standard user. The dump captures the impersonation token of the crashing thread as lowpriv1, SID S-1-5-21-702198220-2065934428-4228756745-1005, integrity level Medium (S-1-16-8192), not a member of BUILTIN\Administrators, holding only the five privileges a standard user carries by default (SeShutdownPrivilege, SeChangeNotifyPrivilege, SeUndockPrivilege, SeIncreaseWorkingSetPrivilege, SeTimeZonePrivilege), with no SeTcbPrivilege, SeDebugPrivilege or SeImpersonatePrivilege. The process name is the proof-of-concept. The Driver Verifier state in the same dump shows special pool on with zero pool-allocation failures, so the guard-page fault is placement, not an injected allocation failure.
The exFAT crash
The exFAT side produced the matching crash: bug check 0x50, access type write, bucket AV_VRF_W_(null)_exfat!memcpy, pool tag 0x6e707046 ('Fppn'), with the stack running exfat!memcpy+0x122 to exfat!EfsPostCreateCallback+0xf0 to exfat!FppPostProcessEfsCreate+0xd2 to exfat!FppFsdCreate. The copy length in that run is 0xb62, read from the trap frame the same way as the FastFAT 0x65e: the source-end register minus the source pointer gives 0xb62, cross-checked against the unwound FCB’s FullFileName.Length of 0x0b62 (the value the configured 1463-character path produces). The exFAT dump independently captures a standard-user token on its faulting thread (impersonation token SID S-1-5-21-702198220-2065934428-4228756745-1005, Medium integrity S-1-16-8192, not a member of BUILTIN\Administrators), from the same proof-of-concept program run in a separate reboot. The crash is the same defect in the exFAT copy of the shared library.
The control and the count
| Driver | Outcome | Bug check | Bucket | Copy length |
|---|---|---|---|---|
| fastfat | out-of-bounds write | 0x50 (write) | AV_VRF_W_(null)_fastfat!memcpy | 0x65e |
| exfat | out-of-bounds write | 0x50 (write) | AV_VRF_W_(null)_exfat!memcpy | 0xb62 |
| fastfat / exfat | NULL-source read | 0x3B | AV_VRF_fastfat!memcpy / AV_VRF_exfat!memcpy | copy equals allocation |
Across the recorded runs with the rename thread enabled, the race produced eight crashes: the two out-of-bounds writes above, one per driver, and six NULL-source reads (faulting at memcpy+0x26 or memcpy+0x92, where the source register is NULL). The negative control is the decisive comparison. The same build with the rename thread disabled ran 45,457 short-name data opens of the EFS file in 55 seconds with zero crashes. Only the concurrent rename breaks it. The out-of-bounds write itself I caught once per driver, and every run with the rename enabled ended in a crash, most often the NULL-source crash.
Special pool catches the first store past the chunk, so what the dump proves directly is the first out-of-bounds write. On an ordinary machine with no special pool, the same 0x65e-byte copy into a 0x20-byte chunk writes the remaining bytes into the adjacent paged-pool allocation; that contiguous corruption follows from the same memcpy running the same length, and it would appear later as a pool-corruption bug check rather than at the copy. I did not record a no-special-pool overflow dump; the adjacent-pool write is the predicted behavior on real systems, stated as a consequence of the same instruction sequence rather than as a separate observation.
Part 8: Ruling out the simpler explanations
A crash on the right stack is only evidence if the simpler causes are excluded. Each of these was checked.
- Not a Driver Verifier fault injection. The dump reports special pool on with zero pool-allocation failures, and the low-resources and fault-injection options off. The guard-page fault is placement, not an injected failure.
- Not an elevated caller. The faulting thread’s token is a standard user with no administrator membership and no
SeTcbPrivilege, and the process is the standard-user proof-of-concept. - Not the open or rename traffic by itself. The control issues the same open loop at the same rate for 55 seconds with the rename disabled and does not crash. Only the presence of the concurrent rename changes the outcome.
- Not the wrong build. The loaded-module version in every dump is
10.0.26100.9711, matching the analyzed binaries. - Not pre-existing corruption. The EFS file in each run was freshly created, deleted and recreated each time, and confirmed to carry
FILE_ATTRIBUTE_ENCRYPTEDafterEncryptFileWbefore the race began. - Not a read past the end of the source. The faulting access is a store, not a load; the source is a valid chunk of the copy length; and the FCB pointer is unchanged across the allocation call, so the overflow is a write into the short destination, not an over-read of the source.
- Not a deterministic, non-race mismatch. The two size reads are the identical
word [rdi+0x210]load. With no concurrent writer they return the same value and the copy fits. Only a concurrent rename can make L1 and L2 differ, which is exactly why the control is clean and the renamer run is not. - Not a 16-bit length wrap. The
0xFFFEcap is applied to the full 32-bit length before it is truncated into the 16-bit field, so the length does not wrap; the overflow count is bounded at0xFFFE, not unbounded.
Part 9: Why FAT and exFAT, and not NTFS or ReFS
All four of ntfs.sys, refs.sys, exfat.sys and fastfat.sys host the same EFS library core. Only the two FAT-family drivers carry this bug, and the reason is the name that each driver passes to the post-create step.
In the builds I compared, ntfs.sys and refs.sys run the shared post-create code on the create request’s own FileObject name. That name is fixed for the duration of the open, so there is no mutable counted string to read twice and no rename that can grow it underneath the copy. The FAT-family host code, which both FAT drivers statically link, instead rebuilds the name out of the FCB and copies it, and that is the name a rename rebuilds. The read-only formats cdfs.sys and udfs.sys do not host EfsLib at all. So the defect is confined to the one shared FAT host routine, compiled into two drivers, that copies a rename-mutable name without snapshotting it. One more control sits inside the FAT drivers themselves. The FAT EfsLib glue has twelve entry points, all reached through the same version check, and I checked each one. Only EfsPostCreateCallback reads the FCB name fields at Fcb+0x210 and Fcb+0x218; the other eleven never rebuild an FCB name from those fields, so none of them carries this double fetch. The NTFS and ReFS comparison is static analysis of their post-create paths; I did not run EFS on NTFS or ReFS to confirm the absence of the bug there, because the distinguishing construct, using the stable request name instead of the mutable FCB name, is visible directly in the code.
Part 10: Impact, the ceiling, and the remedy
What was proven
A local standard user drives the in-box FastFAT and exFAT drivers into a controlled out-of-bounds write in kernel paged pool. The length of the overflow and the size class of the chunk are attacker-chosen. The overflow content comes from the attacker’s path, within the character set a FAT long name allows (Part 5 notes when zero bytes can appear instead). This reproduces as a real kernel crash on both drivers, with the faulting thread dump-proven on both drivers to be a non-administrative standard user, and a clean negative control isolates the concurrent rename as the cause. The write crosses from an unprivileged user-mode process into kernel memory. Microsoft’s advisory classifies the bug as elevation of privilege, and that classification is Microsoft’s (Part 11).
What was not proven
I did not build the step from the write to code running as SYSTEM, and I do not claim code execution. One concrete limit on content is that the overflow bytes are UTF-16 code units a FAT long name permits, or zero bytes when the copy overlaps the rebuild’s fill. So whether an attacker can place kernel-pointer-shaped bytes through this path is an open question, not an established capability. The alphabet is the set of UTF-16 code units in the renamed path \<component>\<component>\...\<leaf>. That set carries no null word. The backslash separator (U+005C) appears only at the component boundaries. Code units a FAT long name forbids are excluded. The one fact that would settle the question is whether a FAT long name may contain U+FFFF, an unpaired surrogate, or the range U+0001 to U+001F. If those are allowed, an attacker can write the repeated high bytes a kernel pointer is made of; if they are not, the overflow content stays limited to printable path characters and the zero bytes described above. I did not test which of those code units the drivers accept in a long name, so this stays unresolved. The demonstration reaches a controlled kernel out-of-bounds write and a reliable crash, and whether that primitive leads to code execution is not established.
The precedent and the remedy
The Linux kernel had the same class in inotify_handle_event racing vfs_rename over a dentry name (CVE-2017-7533, recorded as allowing local users to gain privileges). It was fixed by taking a snapshot of the name before using it, with take_dentry_name_snapshot. The remedy I proposed for these drivers was the same idea:
- In
EfsPostCreateCallback, take one snapshot of{Length, Buffer}under the FCB resource, then size the allocation, the copy, and the output descriptor from that single snapshot. - Guard the copy explicitly: require a non-NULL buffer and
copyLength <= allocLength, which also closes the NULL-source crash. - Apply the fix to the shared FAT host code, so that both
fastfat.sysandexfat.sysare covered by one change.
Microsoft shipped a fix of this shape in 10.0.26100.9974. Part 11 shows it instruction by instruction and compares it with each point above.
Part 11: The fix in 10.0.26100.9974
Microsoft fixed this bug in the November 2026 security update. I compared the fixed drivers with the 9711 drivers that the rest of this post analyzes: first function by function, then instruction by instruction inside every function that changed. This part reports what changed, where it changed, and why the change stops the race. In this part, 9711 and 9974 are short for the FileVersions 10.0.26100.9711 and 10.0.26100.9974. Every address is a virtual address at image base 0x140000000, except in the paragraph on older builds. The sentence or table row around each address names its driver and its FileVersion.
Where the fix shipped
Microsoft published CVE-2026-112766 on 2026-11-10, in the November 2026 security release. On the servicing line of Windows 11 24H2, 25H2 and 26H2, the fix is in KB5132061 (OS builds 26100.9974, 26200.9974 and 26300.9974). That update replaces both drivers with FileVersion 10.0.26100.9974. The new exfat.sys is 449,880 bytes, and the new fastfat.sys is 458,072 bytes. Their SHA-256 hashes are:
| File | SHA-256 |
|---|---|
exfat.sys | 85A826DD07E408AD8E3B54EF7B81E27FE5B1968F8212F2C1AEB4DC5D1F0D77DA |
fastfat.sys | C046046AE5D4A5C5DEFC63237B07F8027DD059D83660D90AC431348C90676D39 |
I took both files from the Windows component store (%SystemRoot%\WinSxS) of a machine on which KB5132061 was installed but the required restart had not yet happened. Their hashes match the x64 rows of Microsoft’s file list for KB5132061. The advisory title names only the exFAT file system. The same update makes the same change in fastfat.sys, which the subsection on the FastFAT copy compares with the exFAT routine instruction by instruction.
The advisory lists the fixed build for every affected Windows version. The driver versions in the last column come from Microsoft’s file lists for each update:
| Windows version | Update | OS build | exfat.sys and fastfat.sys |
|---|---|---|---|
| Windows 11 24H2, 25H2, 26H2 | KB5132061 | 26100.9974, 26200.9974, 26300.9974 | 10.0.26100.9974 |
| Windows 11 26H1 | KB5132059 | 28000.3501 | 10.0.28000.3501 |
| Windows Server 2025 | KB5131080 | 26100.33822 | 10.0.26100.33822 |
| Windows 11 23H2 | KB5131087 | 22631.7711 | 10.0.22621.7711 |
| Windows Server 2022 | KB5131090 | 20348.5870 | 10.0.20348.5866 |
| Windows 10 21H2, 22H2 | KB5131096 | 19044.7846, 19045.7846 | 10.0.19041.7844 |
| Windows 10 1809, Windows Server 2019 | KB5131085 | 17763.9487 | 10.0.17763.9479 |
Two details in this table matter when you check a machine. First, on several lines the driver FileVersion is not equal to the OS build. For example, OS build 20348.5870 carries drivers 10.0.20348.5866. So compare the driver against the driver version, not against the OS build. Second, the advisory also lists hotpatch updates. A hotpatch update changes the code of a running system and takes effect without a restart. They are KB5132043 for Windows 11 24H2, 25H2 and 26H2 (OS builds 26100.9911, 26200.9911 and 26300.9911) and KB5131077 for Windows Server 2025 (OS build 26100.33730). These updates do not replace exfat.sys and fastfat.sys. Microsoft’s file lists show that they add hotpatch images named exfat_hotpatch.sys and fastfat_hotpatch.sys, at 10.0.26100.9911 in KB5132043 and at 10.0.26100.33730 in KB5131077. Of the November drivers, I examined the code of the 10.0.26100.9974 drivers only. For the other lines, the table reports Microsoft’s data, not my analysis.
The defect is old. I also read the callback in two earlier builds, whose image base is 0x1c0000000. In Windows 10 1809, driver version 10.0.17763.9376 (from before the November update), both drivers already make the same unlocked copy. exfat.sys keeps the full name at Fcb+0x180 (length) and Fcb+0x188 (buffer). Its callback tests the buffer at 0x1c000555d. It reads the length for the size at 0x1c0005567, again for the copy count at 0x1c000558c, and a third time for the descriptor at 0x1c00055a7. It calls no lock routine. fastfat.sys does the same at Fcb+0x200 and Fcb+0x208, with its length reads at 0x1c0002b52, 0x1c0002b78 and 0x1c0002b93. In Windows 10 1607, driver version 10.0.14393.9611, EfsPostCreateCallback in both drivers is a 94-byte routine that only forwards the call. It allocates nothing and copies no name. That is consistent with the advisory, which lists Windows 10 1809 and later and does not list Windows 10 1607 or Windows Server 2016.
The update of October 28, KB5127936 (OS builds 26200.9818 and 26300.9818), ships both drivers as 10.0.26100.9818. Its EfsPostCreateCallback is the 9711 routine. The only bytes that differ are relative displacements: 8 bytes in exfat.sys and 6 in fastfat.sys. In fastfat.sys the routine stayed at 0x140003b74. Four of its RIP-relative operands, which address their target as an offset from the instruction pointer, changed because the data they read moved by 0x20 bytes. In exfat.sys the routine itself moved by 8 bytes, to 0x1400063e4. There, six RIP-relative operands and two direct calls changed their displacement. Their targets stayed in place, except one called routine that moved by 0x10 bytes. A machine on 10.0.26100.9818 still has the unlocked copy.
The advisory classifies the bug as follows. Its title is “Windows exFAT File System Elevation of Privilege Vulnerability”. Its description is “Use after free in Windows exFAT File System allows an authorized attacker to elevate privileges locally.” The weakness is CWE-416 (Use After Free) and the severity is Important. The CVSS 3.1 vector is AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H/E:U/RL:O/RC:C, with a base score of 7.0 and a temporal score of 6.1. The CVE record shows the same score with the CVSS rating High. That rating is the CVSS band for a score of 7.0. Important is Microsoft’s own severity scale.
Two questions in the advisory’s FAQ add detail. Microsoft explains the high attack complexity this way: “Successful exploitation of this vulnerability requires an attacker to win a race condition.” That is the race this post describes. Microsoft also states that “An attacker who successfully exploited this vulnerability could gain SYSTEM privileges.” That assessment is Microsoft’s. What I demonstrated is unchanged by the advisory: a standard user drives a kernel paged-pool out-of-bounds write and a reliable crash, and I did not build code execution as SYSTEM.
How I found the change
Patch diffing is the comparison of a binary before and after a security update, to find the code the update changed and to work out from that code which defect it removes. The 9974 drivers had no public symbols when I analyzed them. Microsoft’s symbol server returned HTTP 404 for both PDBs on 2026-11-12. The 9711 drivers do have public symbols. So every function name below for a 9974 function is a name I transferred from the matching 9711 function.
The comparison works on functions, not on bytes of the whole file. An x64 Windows function has an entry in the .pdata section if it allocates stack space, calls another function, saves a non-volatile register or uses exception handling. The entry records where the function begins and ends and where its unwind information is. Unwind information is the data that tells the system how to undo the function’s stack changes when an exception passes through the function. A small leaf function does none of these things, so it can have no entry. For that reason I also disassembled the code bytes that no .pdata entry covers. In both builds of both drivers, these bytes are five multi-byte NOP instructions used as padding and two jump stubs in a small section named fothk. They are the same in both builds apart from the stubs’ relocated targets.
I took every .pdata function of both builds of each driver and disassembled it. Then I masked only the fields that move when unrelated code moves. These fields are the targets of calls and jumps that leave the function, RIP-relative displacements and relocated 64-bit constants. I compared everything else byte for byte. That includes opcodes and registers, immediate values, and structure and stack offsets. So the comparison also finds a change that keeps a function’s size. For every pair, the masked references must also agree. A call must reach the same function. An import must have the same name. A data reference must reach the same object.
A function can use __try and __finally. This is the Microsoft C form of a protected block, and its __finally code runs whether the block ends normally or through an exception. Such a function has an exception scope table with one entry per protected range. For the exception path, the compiler places each __finally body in a small separate function, called a funclet. I compared the unwind information and the exception scope tables of both builds as well.
I compared everything outside the code too. Each driver imports the same functions in both builds, in the same order: 353 entries in exfat.sys and 371 in fastfat.sys. So the fix calls only routines that the 9711 drivers already imported. The text strings in the data sections are the same except the FileVersion. Control Flow Guard (CFG) is a protection in which the compiler and the kernel check each indirect call against a table of valid targets. That table has the same number of entries in both builds (113 in exfat.sys, 111 in fastfat.sys), and they are the same functions. The new data is small. For each new feature flag there is one 56-byte descriptor, one state word and one slot for usage reports. Some other data objects moved, and the ones I compared after the move are equal. Each file is 4,088 bytes larger. One section grew past a page boundary: INIT in exfat.sys and .rdata in fastfat.sys. That moves the sections after it by 0x1000 bytes. The signature block at the end of the file is 8 bytes smaller.
The pool tag gives an independent check of where the change is. Part 3 showed that the callback allocates its copy with the tag 'Fppn' in exFAT and 'Fatn' in FastFAT. I counted every instruction that carries the tag as an immediate value:
| Driver | Tag | Sites in 9711 | Sites in 9974 | Sites inside EfsPostCreateCallback |
|---|---|---|---|---|
exfat.sys | 'Fppn' (0x6E707046) | 10 | 11 | 1 in 9711 (0x14000649b), 2 in 9974 (0x140006503, 0x140006585) |
fastfat.sys | 'Fatn' (0x6E746146) | 13 | 14 | 1 in 9711 (0x140003c33), 2 in 9974 (0x140003c93, 0x140003d15) |
Each driver gained exactly one tag site, and in each driver it is inside the callback. Every other site is in the same function as before, either at the same address or moved together with its function.
To map a 9974 function to its 9711 name, my comparison first lines up the two function lists in address order. It pairs functions whose masked bytes are equal. It pairs the rest by the similarity of their instruction sequences, and then it requires that their calls, imports and data references agree. In neither driver did one data object map to two different objects. For the callback, three independent facts confirm the mapping. The first is the RVA. FastFAT’s callback is at RVA 0x3b74 in both builds. The exFAT callback moved by 8 bytes, from RVA 0x63dc to RVA 0x63e4, because code before it grew. That move is already present in the 9818 driver. The second is the call graph. The 9974 exFAT function is called only from 0x140033479 in FppPostProcessEfsCreate (0x1400333ac). That is the same call site as in 9711, and no data refers to the function. The third is the content. The function has the same entry checks, globals, pool type and tag as the 9711 callback.
The function-level result is small:
| Driver | .pdata functions, 9711 to 9974 | Identical | Changed | Added | Removed |
|---|---|---|---|---|---|
exfat.sys | 835 to 840 | 820 | 15 | 5 | 0 |
fastfat.sys | 918 to 921 | 911 | 7 | 3 | 0 |
The next two tables list the changed and added functions, one table per driver. Each size is the length of the function’s .pdata range. The functions of the feature-flag library are listed after each table instead. The functions whose changes are trivial are described there too. Several rows mention the feature flag from the Introduction. The subsection “The feature flag around the fix” describes it.
These are the changed and added functions in exfat.sys:
| Address in 9711, 9974 | Function | What changed |
|---|---|---|
0x1400063dc, 0x1400063e4 | EfsPostCreateCallback | the fix, described in the rest of this part; 406 to 643 bytes |
0x140013ea3, 0x140014054 | its __finally funclet | the fix: stack-frame offsets only; 71 bytes |
none, 0x1400066b8 | feature gate | the fix: new; 50 bytes |
none, 0x1400066f0 | feature gate fallback | the fix: new; 22 bytes |
none, 0x140014023 | second __finally funclet | the fix: new, releases the lock; 49 bytes |
0x14000b1f0, 0x14000b394 | FppIsBootSectorFatPlusPlus | a separate mount-time check behind its own feature flag; 352 to 374 bytes |
0x140040f00, 0x140040f00 | FppMountVolume | the same separate mount-time check; 4628 to 4679 bytes |
0x14005f149, 0x14005f179 | FppMountVolume __finally funclet | stack-frame offsets only; 440 to 443 bytes |
none, 0x14000ab9c | second feature gate | the separate mount-time check: new; 50 bytes |
none, 0x14000abd4 | second feature gate fallback | the separate mount-time check: new; 22 bytes |
0x14000cdd8, 0x14000cf98 | FppMarkVolume | register allocation only; 771 to 767 bytes |
0x14004a310, 0x14004a340 | FppCheckDirtyBit | register allocation only; 311 to 304 bytes |
0x1400602d2, 0x140060305 | FppCheckDirtyBit __finally funclet | one stack-frame offset; 38 bytes |
The table names 13 of the 20 changed or added exFAT functions. Four more belong to the feature-flag library:
wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState:0x140005be8in both builds, 680 to 690 bytes.wil_details_ReevaluateOnFeatureConfigurationChange:0x14003a490in both builds, 292 bytes. The constant0x206became0x40206.wil_details_PopulateInitialConfiguredFeatureStates: 97110x140061e84, 99740x140061eb8, 371 bytes. The constant0x206became0x40206.wil_InitializeFeatureStaging:0x140061d28in both builds, 342 to 394 bytes.
The last three changed exFAT functions keep their size. The only difference in each is the value that it passes as Parameter 1 of bug check 0x12C. Microsoft documents that parameter as source file and line number information. So the line numbers moved, because lines were added earlier in the same source files.
The second exFAT feature flag guards a stricter check of the boot sector when an exFAT volume is mounted. This group has no connection to the name copy. With the flag on, FppIsBootSectorFatPlusPlus (gate call at 0x14000b43e) checks the 64-bit volume length at offset 0x48 of the boot sector. It rejects the boot sector if that length is larger than the partition length counted in sectors. The 9711 code compared only the lowest byte of that field with the lowest byte of the partition length (0x14000b2a5). With the flag on, FppMountVolume (gate call at 0x140041272) also requires the boot sector’s sector size to equal the device’s sector size, and the volume to fit on the device. Otherwise the mount fails with STATUS_UNRECOGNIZED_VOLUME (0xC000014F). The two functions listed as register allocation only, FppMarkVolume and FppCheckDirtyBit, both call FppIsBootSectorFatPlusPlus.
These are the changed and added functions in fastfat.sys:
| Address in 9711, 9974 | Function | What changed |
|---|---|---|
0x140003b74, 0x140003b74 | EfsPostCreateCallback | the fix; 406 to 643 bytes |
0x14000e609, 0x14000e77a | its __finally funclet | the fix: stack-frame offsets only; 71 bytes |
none, 0x140003ec0 | feature gate | the fix: new; 50 bytes |
none, 0x140003ef8 | feature gate fallback | the fix: new; 22 bytes |
none, 0x14000e749 | second __finally funclet | the fix: new, releases the lock; 49 bytes |
The table names 5 of the 10 changed or added FastFAT functions. Four more belong to the same feature-flag library:
wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState:0x140001cb4in both builds, 264 to 267 bytes.wil_details_BuildFeatureStateCacheFromQueryResults: 97110x14003bac0, 99740x14003cac0, 98 bytes. The constant0x206became0x40206.wil_details_RegisterFeatureUsageProvider: 97110x14003bdac, 99740x14003cdac, 115 to 172 bytes.wil_details_PopulateInitialConfiguredFeatureStates: 97110x140063d7c, 99740x140064d7c, 226 bytes. The constant0x206became0x40206.
The last changed FastFAT function keeps its size. Its only difference is two image-relative constants that locate data objects. They changed because the .data section moved by 0x1000.
The feature-flag library changes are of the same kinds in both drivers, and they are not tied to any one feature. So I read them as a newer version of that library, compiled into both drivers. They were already present in the 9818 drivers of October 28. If I compare 9818 with 9974 instead, the November change in fastfat.sys is only the callback, its funclets, the new gate pair and the function whose data constants moved.
The fixed reader, in exFAT
Here is the core of the exFAT 9711 routine (0x1400063dc) as pseudo-C, so that the two versions can be read side by side. rdi in Part 3 is Fcb here:
Fcb = FileObject->FsContext;
if (Fcb != NULL &&
(Fcb->NodeTypeCode == 0xD02 ||
Fcb->NodeTypeCode == 0xD03) &&
Fcb->FullFileName.Buffer != NULL) {
P = ExAllocatePoolWithTag(0x411,
Fcb->FullFileName.Length,
0x6E707046);
memcpy(P, Fcb->FullFileName.Buffer,
Fcb->FullFileName.Length);
FileObject->FileName.Buffer = P;
FileObject->FileName.MaximumLength =
FileObject->FileName.Length =
Fcb->FullFileName.Length;
}
Lines 5 to 10: the buffer test reads Buffer (0x140006485), the size reads Length (0x14000648f), the copy count reads Length again (0x1400064b5) and the source reads Buffer again (0x1400064bd). Four reads of two shared fields, no lock.
Lines 11 to 14: the routine points FileObject->FileName at the copy, and a third read of Length (0x1400064d0) builds the descriptor of the copy that the EFS code receives.
And here is the same code in exfat.sys 10.0.26100.9974, function 0x1400063e4:
Fcb = FileObject->FsContext;
if (Fcb != NULL &&
(Fcb->NodeTypeCode == 0xD02 ||
Fcb->NodeTypeCode == 0xD03)) {
if (FixFeatureEnabled()) {
Length = 0;
KeEnterCriticalRegion();
ExAcquireResourceSharedLite(Fcb->Header.Resource,
TRUE);
__try {
if (Fcb->FullFileName.Buffer != NULL) {
Length = Fcb->FullFileName.Length;
P = ExAllocatePoolWithTag(0x411, Length,
0x6E707046);
memcpy(P, Fcb->FullFileName.Buffer, Length);
}
} __finally {
ExReleaseResourceLite(Fcb->Header.Resource);
KeLeaveCriticalRegion();
}
if (P != NULL) {
FileObject->FileName.Buffer = P;
FileObject->FileName.MaximumLength = Length;
FileObject->FileName.Length = Length;
}
} else if (Fcb->FullFileName.Buffer != NULL) {
P = ExAllocatePoolWithTag(0x411,
Fcb->FullFileName.Length,
0x6E707046);
memcpy(P, Fcb->FullFileName.Buffer,
Fcb->FullFileName.Length);
FileObject->FileName.Buffer = P;
FileObject->FileName.MaximumLength =
FileObject->FileName.Length =
Fcb->FullFileName.Length;
}
}
The listing is reconstructed from the disassembly, and five names in it are mine. FixFeatureEnabled() stands for the new function 0x1400066b8. Fcb->Header.Resource stands for the pointer at Fcb+8. Length stands for register r15d. The compiler also stores it to an unused stack slot, which I left out. FullFileName stands for the counted string at Fcb+0x190. P stands for register r14, which the code also stores to the stack slot that the outer __finally frees (0x140006520). The __try and __finally blocks are rebuilt from the function’s exception scope table. Everything outside this block is unchanged from 9711. That includes the checks at entry and the save of the original FileObject->FileName. It also includes the call into the EFS code with the same nine arguments. The outer __finally does the same work as before: it frees the copy and restores the saved name.
Line 1: mov rsi, qword [r9+18h] at 0x140006486 loads the FCB from FileObject->FsContext. In 9974 the FCB lives in rsi, not in rdi as in 9711, and rsi is not written again before the copy. The function also stores the FCB to a stack slot at 0x14000648a, which the new exception handler reads (Lines 17 to 20).
Lines 2 to 4: the node-type test is unchanged (0x14000649e to 0x1400064ad). Type 0xD02 is a file and 0xD03 is a directory. The old version tested Buffer in the same condition. The new version moves that test inside the lock.
Line 5: call 0x1400066b8 at 0x1400064b3, then test eax, eax and je 0x140006570 at 0x1400064ba and 0x1400064bc. A zero result sends execution to the old code, which starts at 0x140006570 and is Lines 26 to 35. A non-zero result runs the new code. The subsection “The feature flag around the fix” covers this gate.
Line 6: movzx r15d, cx at 0x1400064c2 sets the length to zero, because ecx was cleared at 0x1400064b8. If the buffer turns out to be NULL, the length stays zero and nothing is copied.
Line 7: call [KeEnterCriticalRegion] at 0x1400064cb. Part 1 defined the critical region and the APC. The documentation of ExAcquireResourceSharedLite makes this step a requirement: “Normal kernel APC delivery must be disabled before calling this routine. Disable normal kernel APC delivery by calling KeEnterCriticalRegion. Delivery must remain disabled until the resource is released, at which point it can be reenabled by calling KeLeaveCriticalRegion.” My reading of the reason is as follows. A normal kernel APC can stop the thread, because thread suspension is delivered that way. If a thread is stopped while it owns the resource, every rename of that file waits until the thread runs again. The routine has to enter the region itself because its callers have already left theirs. FppFsdCreate calls KeLeaveCriticalRegion at 0x140031445 before it calls FppPostProcessEfsCreate at 0x140031474 (the same addresses in 9711 and 9974). FppFspDispatch runs a create that the driver passed to a worker thread. It has the same order (9974 0x140044fe7, then 0x140045022). FastFAT has the same order too (Part 3).
Lines 8 and 9: the acquisition takes three instructions. mov dl, r12b at 0x1400064d7 sets the second argument. mov rcx, qword [rsi+8] at 0x1400064da sets the first argument. The call to ExAcquireResourceSharedLite is at 0x1400064de. The first argument is the ERESOURCE whose pointer sits at Fcb+8. The FCB begins with the standard file-system header (FSRTL_COMMON_FCB_HEADER), and offset 8 of that header is its Resource field. It is the same resource that the driver’s own FCB lock routines use. In 9711, FppAcquireExclusiveFcb (0x1400471a4) calls ExAcquireResourceExclusiveLite(*(Fcb+8), Wait) at 0x1400471c5. Its shared counterpart takes the same pointer. Both routines are unchanged in 9974, where FppAcquireExclusiveFcb is at 0x1400471d4. The second argument is Wait. It comes from r12b, which has held the value 1 since 0x14000648f. With Wait set to TRUE, the call does not fail when the resource is busy. It puts the thread into a wait until the resource is free. So the return value needs no test, and the code tests none. Part 1 introduced the ERESOURCE as a reader/writer lock. The two modes matter here, so this is their exact meaning. A thread that acquires the resource shared may hold it together with any number of other shared owners. A thread that acquires it exclusive holds it alone. Microsoft’s documentation says it in two sentences: “Any number of threads can acquire an ERESOURCE as shared. Only one thread can acquire an ERESOURCE exclusively.” A shared request also waits when another thread already waits for exclusive access. So when a rename already waits for the resource, a reader that arrives later waits until the rename has released it.
Line 10: the new inner __try begins at 0x1400064eb, after the acquisition returns. Its scope ends at 0x14000653f. The resource is therefore held across the whole protected range.
Line 11: cmp qword [rsi+198h], 0 at 0x1400064eb, and je 0x14000653f at 0x1400064f3. The Buffer test now runs while the resource is held. A NULL buffer jumps straight to the release, with no allocation and no copy. In that case P stays NULL, the code leaves FileObject->FileName as it was, and the EFS code receives the name from the create request itself, as it did in 9711 when the buffer was NULL.
Line 12: movzx r15d, word [rsi+190h] at 0x1400064f5. This is the only read of FullFileName.Length on the new path. r15 is a non-volatile register. A called function must preserve it, so it keeps its value across the allocation call. One value now serves four purposes: the allocation size, the copy count, and the two fields of the copy’s descriptor.
Lines 13 and 14: the allocation takes four instructions. mov r8d, 6E707046h at 0x140006503 sets the tag. mov edx, r15d at 0x140006509 sets the size from r15d. mov ecx, 411h at 0x14000650c sets the pool type. The call to ExAllocatePoolWithTag is at 0x140006511. The tag is still 'Fppn'. The pool type is still 0x411, the same constant as in 9711. A POOL_TYPE value is a pool kind combined with flag bits. Decoded against wdm.h of the Windows Driver Kit 10.0.26100.0:
| Bit | Value | Name | Set in 0x411 |
|---|---|---|---|
| 0 (pool kind) | 0x1 | PagedPool | yes |
| 1 | 0x2 | must-succeed kind (NonPagedPoolMustSucceed is kind 2) | no |
| 2 | 0x4 | cache-aligned kind | no |
| 3 | 0x8 | POOL_QUOTA_FAIL_INSTEAD_OF_RAISE | no |
| 4 | 0x10 | POOL_RAISE_IF_ALLOCATION_FAILURE | yes |
| 5 | 0x20 | session pool | no |
| 8 | 0x100 | POOL_COLD_ALLOCATION | no |
| 9 | 0x200 | POOL_NX_ALLOCATION | no |
| 10 | 0x400 | POOL_ZERO_ALLOCATION | yes |
So the copy comes from paged pool. The allocator fills it with zeros, so there is no separate memset. An allocation that cannot be satisfied raises an exception instead of returning NULL. The raise flag is why the code copies into P without testing it for NULL first. The later test of P at 0x14000655b (Line 21) only detects the case in which Buffer was NULL and nothing was allocated.
Line 15: mov r8d, r15d at 0x140006525 sets the copy count from the same register. mov rdx, qword [rsi+198h] at 0x140006528 reads Buffer a second time for the source, and this read is also under the resource. call memcpy is at 0x140006532. The copy cannot run past P, because the count and the size are one value.
Lines 17 to 20: on the normal path, the compiler places the body of the __finally inline, after the protected range. mov rcx, qword [rsi+8] at 0x14000653f loads the resource pointer. The call to ExReleaseResourceLite is at 0x140006543, and the call to KeLeaveCriticalRegion is at 0x14000654f. The order is the reverse of the acquisition. The resource is released after memcpy has returned, so the copy runs completely inside the shared hold. On the exception path, the new 49-byte funclet at 0x140014023 does the same work. It reads the FCB from the stack slot written at 0x14000648a. Then it releases *(Fcb+8) and leaves the critical region. The scope table now has two entries, where 9711 had one:
| Scope | Range | Handler | Purpose |
|---|---|---|---|
| 0 (new) | 0x1400064eb to 0x14000653f | 0x140014023 | release the resource, leave the critical region |
| 1 | 0x140006481 to 0x14000661e | 0x140014054 | free the copy, restore the original name (the 9711 handler) |
Both entries have a jump target of zero, which marks them as __finally handlers, not __except handlers. On the exception path, a __finally funclet runs only while an exception unwinds the stack, and that happens only if a frame above the callback handles the exception. If the allocation fails in that case, scope 0 releases the lock first. Then scope 1 restores the original FileObject->FileName. P is still NULL at that point, so nothing is freed. No frame inside either driver handles such an exception. In exfat.sys, FppPostProcessEfsCreate has only a __finally scope. FppFsdCreate has an __except scope from 0x140031345 to 0x1400313fe, and FppFspDispatch has one from 0x140044dc7 to 0x140044fa0. Neither scope covers the function’s own call to FppPostProcessEfsCreate, at 0x140031474 and 0x140045022. FastFAT has the same structure. I did not check the frames above the driver. The 9711 allocation used the same pool type and had the same callers, so this path did not change.
Lines 21 to 24: test r14, r14 at 0x14000655b tests P. mov qword [rdi+60h], r14 at 0x140006560 stores the buffer pointer. mov word [rdi+5Ah], r15w at 0x140006564 stores MaximumLength, and mov word [rdi+58h], r15w at 0x140006569 stores Length. In 9974, rdi holds the FILE_OBJECT. So the descriptor of the copy takes MaximumLength and Length from the same r15. These stores happen after the release. That is safe, because they write the FILE_OBJECT of this one open, not the shared FCB.
Lines 26 to 35: the 9711 sequence, kept in the binary at 0x140006570 to 0x1400065c9. It tests Buffer at 0x140006570 and reads Length for the size at 0x140006579. It allocates at 0x14000658b. It reads Length again for the count at 0x14000659f and Buffer again for the source at 0x1400065a7. It copies at 0x1400065b1, and it reads Length a third time for the descriptor at 0x1400065ba. It takes no lock. The registers are renamed and one compare uses a zeroed register instead of an immediate zero, but the logic is the 9711 logic.
The frame of the function grew from 0x70 to 0x80 bytes, and the prologue from 0x22 to 0x25 bytes.
Here is the new path as disassembly, in the style of Part 3. Stack accesses and NOP instructions are left out:
call [KeEnterCriticalRegion]
mov dl, r12b
mov rcx, qword [rsi+8]
call [ExAcquireResourceSharedLite]
cmp qword [rsi+198h], 0
je release
movzx r15d, word [rsi+190h]
mov r8d, 6E707046h
mov edx, r15d
mov ecx, 411h
call [ExAllocatePoolWithTag]
mov r14, rax
mov r8d, r15d
mov rdx, qword [rsi+198h]
mov rcx, rax
call exfat!memcpy
release:
mov rcx, qword [rsi+8]
call [ExReleaseResourceLite]
call [KeLeaveCriticalRegion]
Lines 1 to 4 (0x1400064cb to 0x1400064de): enter the critical region, then take *(Fcb+8) shared with Wait equal to 1.
Lines 5 and 6 (0x1400064eb, 0x1400064f3): the Buffer test, under the lock.
Line 7 (0x1400064f5): the single Length read. Compare it with Part 3, where the same field was loaded three times. Here it is loaded once, into r15d, and every later use copies the register.
Lines 8 to 11 (0x140006503 to 0x140006511): the allocation, sized by edx, which is r15d.
Lines 12 to 16 (0x14000651d to 0x140006532): the copy. The count r8d is r15d again, and the source is a second Buffer read, under the same lock as the first.
Lines 18 to 20 (0x14000653f to 0x14000654f): the release and the exit from the critical region.
The FastFAT copy
The FastFAT routine in fastfat.sys 10.0.26100.9974 stays at the same address, 0x140003b74. It grows by the same amount, from 406 to 643 bytes. I compared it with the exFAT routine instruction by instruction, writing branch targets as offsets from the start of each function. The two 9974 copies have the same instruction count and the same layout. Each has 147 instructions, counting the one-byte padding instruction at the end. Each 9711 copy has 97. The two 9974 copies differ in exactly 12 instructions, and every difference is a constant. One is the bit tested in the driver’s EFS flags, and one is the node-type base. Eight are FCB-field offsets, and two are pool tags. The 9711 copies differ in 8 instructions of the same kinds. The two copies use the same registers and the same stack frame. They also have the same control flow, call sequence and scope tables. So the exFAT listing above is also the FastFAT listing, with these substitutions:
| Item | exFAT 10.0.26100.9974 | FastFAT 10.0.26100.9974 |
|---|---|---|
| function | 0x1400063e4 | 0x140003b74 |
| bit tested in the driver’s EFS flags at entry | bit 0x20 of 0x1400201a4 | bit 0x2 of 0x14001af0d |
| node types accepted | 0xD02, 0xD03 | 0x502, 0x503 |
FullFileName.Length | Fcb+0x190 | Fcb+0x210 |
FullFileName.Buffer | Fcb+0x198 | Fcb+0x218 |
| pool tag | 'Fppn' | 'Fatn' |
| feature gate call | 0x1400064b3 (to 0x1400066b8) | 0x140003c43 (to 0x140003ec0) |
| gate state word and descriptor | 0x140020430, 0x14001ed58 | 0x14001b180, 0x140018e90 |
KeEnterCriticalRegion | 0x1400064cb | 0x140003c5b |
ExAcquireResourceSharedLite(*(Fcb+8), TRUE) | 0x1400064de | 0x140003c6e |
Buffer test under the lock | 0x1400064eb | 0x140003c7b |
the single Length read | 0x1400064f5 | 0x140003c85 |
ExAllocatePoolWithTag(0x411, ...) | 0x140006511 | 0x140003ca1 |
copy count from r15d, then the Buffer read for the copy | 0x140006525, 0x140006528 | 0x140003cb5, 0x140003cb8 |
memcpy | 0x140006532 | 0x140003cc2 |
ExReleaseResourceLite | 0x140006543 | 0x140003cd3 |
KeLeaveCriticalRegion | 0x14000654f | 0x140003cdf |
stores into FileObject->FileName | 0x140006560 to 0x140006569 | 0x140003cf0 to 0x140003cf9 |
| call into the EFS code | 0x140006617 | 0x140003da7 |
| scope 0 and its handler | 0x1400064eb to 0x14000653f, 0x140014023 | 0x140003c7b to 0x140003ccf, 0x14000e749 |
| scope 1 and its handler | 0x140006481 to 0x14000661e, 0x140014054 | 0x140003c11 to 0x140003dae, 0x14000e77a |
| old code kept behind the gate | 0x140006570 to 0x1400065c9 | 0x140003d00 to 0x140003d59 |
old code: allocation and memcpy | 0x14000658b, 0x1400065b1 | 0x140003d1b, 0x140003d41 |
The callers did not change. FatPostProcessEfsCreate is still the only caller (9711 0x14002e6d8, 9974 0x14002f6d8). It is reached from FatFsdCreate and FatFspDispatch after each has left its critical region. The caller’s code is unchanged: the whole function moved by 0x1000 bytes, and its call at 0x14002f7a5 still targets 0x140003b74. In exFAT, FppPostProcessEfsCreate stays at 0x1400333ac. Its code is unchanged apart from the targets of calls to functions that moved. The callback is one of those functions (the call at 0x140033479). The two drivers use two different feature IDs for the same fix, so the flag is defined per driver, not once in shared code.
Why a shared acquisition is enough against the rename
The fix does not change the rename code at all. It works because the rename already held the right lock, and the reader now takes the same lock. These are the rename’s steps on the name, with the 9711 addresses. Every function named here is unchanged in 9974. Where a function moved, its code moved with it. For those functions I also give the 9974 addresses of the frees, the NULL stores and the rebuild stores:
- FastFAT.
FatCommonSetInformation(97110x14003c294, 99740x14003d2cc) handlesFileRenameInformation. It first acquires the volume’s resource exclusive throughFatAcquireExclusiveVcb_Real(call at0x14003c44a). Then it acquires the FCB exclusive throughFatAcquireExclusiveFcb(call at0x14003c4ec). That routine acquires*(Fcb+8)withExAcquireResourceExclusiveLiteat0x140048ae5. Only then doesFatCommonSetInformationcallFatSetRenameInfo(call at0x14003c7d5). Inside the rename, the old name buffer is freed at0x14003f5bf(99740x1400405ff).Bufferis set to NULL at0x14003f5cb(99740x14004060b). The new name is built byFatSetFullNameInFcb(call at0x14003f887, 99740x1400408c7). That routine stores the length at 99740x14002fc65and then the buffer at 99740x14002fc78. The rename then readsBufferagain at0x14003f8c8. If the name is still not set, the rename builds it throughFatSetFullFileNameInFcb, still under the same locks. - exFAT.
FppCommonSetInformation(0x14003a958in both builds) acquires the volume exclusive (call at0x14003ab11). Then it acquires the FCB exclusive throughFppAcquireExclusiveFcb(call at0x14003abb7). Then it callsFppSetRenameInfo(call at0x14003aec2). The old buffer is freed at0x14003db3f, andBufferis set to NULL at0x14003db4b. Both addresses are the same in 9974. The new name is built throughFppSetFullFileNameInFcbwhen the rename prepares its change notification. The call is one of those at0x14003deae,0x14003ded9and0x14003df04. That routine stores the buffer (0x1400459d4or0x140045a16) before the length (0x140045a27). In 9974 the routine starts0x30bytes later. There the same stores are at0x140045a04or0x140045a46, then0x140045a57. - Directory renames, both drivers. When the renamed object is a directory, the rename also walks every FCB below it. For each one, it acquires that FCB’s own resource exclusive and frees the FCB’s name buffer. Then it stores NULL and releases the resource. In FastFAT these are
0x14003e778,0x14003e78b,0x14003e797and0x14003e7a2(99740x14003f7b8to0x14003f7e2). In exFAT they are0x14003cb4f,0x14003cb62,0x14003cb6eand0x14003cb79, at the same addresses in 9974. The exFAT rename also releases the renamed directory’s own resource for this walk (0x14003cb19) and takes it again exclusive afterwards (0x14003cb90), before it frees and rebuilds that directory’s name.
Every free, every NULL store and every new {Length, Buffer} pair that a rename writes into an FCB is written while the rename owns that FCB’s *(Fcb+8) exclusive. The fixed reader owns the same resource shared from exFAT 0x1400064de (FastFAT 0x140003c6e) until exFAT 0x140006543 (FastFAT 0x140003cd3). An exclusive owner and a shared owner cannot hold one resource at the same time. So for a given open, the reader’s whole sequence runs only while the rename does not hold that FCB’s resource. That sequence is the Buffer test, the Length read, the allocation, the second Buffer read and the entire copy. It therefore never overlaps the rename’s free, NULL store or rebuild of that FCB’s name. The reader sees the pair as it was before the rename or as the rename left it, never a mixture of the two.
This also settles the store-order problem of Part 4 for the rename. The two drivers’ renames publish the pair in opposite orders. FastFAT’s FatSetFullNameInFcb writes the length first. The exFAT rename writes the buffer first, through FppSetFullFileNameInFcb. Before the fix, a reader without the lock could read a mixed pair under either order (Figure 1). With the fix, the reader never runs between the rename’s stores, so the order of those stores no longer matters for this race. This is also why the rename code needed no change: it already held the lock exclusive. The defect was that the reader did not take it.
Against the rename, a shared acquisition is enough for a reader that only copies. Many opens of the same file may copy the name at the same time, and none of them changes it. Shared mode lets those readers run in parallel and still excludes the rename.
Figure 3. The copy and the rename now take the same lock. The callback holds the FCB lock shared from before its single length read until after the copy returns, and the rename frees and rebuilds the name only while it holds that lock exclusive. Whichever side requests the lock second cannot proceed until the other side releases it. The single length value L sets both the chunk size and the copy length, so the copy fits the chunk.
Why the new lock does not deadlock
A deadlock is a state in which two threads each wait for a lock that the other one holds, so neither can continue. A fix that adds a lock to the create path has to be checked for this. Three facts of the 9974 code answer the question for the driver’s own locks.
- One lock, and nothing else while it is held. The callback takes exactly one resource,
*(Fcb+8), and it takes no other lock of the driver while it holds it. - No EFS work under the lock. It releases the resource before it calls into the EFS code: exFAT
0x140006543before0x140006617, FastFAT0x140003cd3before0x140003da7. - No driver lock held on entry.
FatCommonCreatereleases the volume’s resource at0x14002b782before it returns.FatFsdCreatethen leaves its critical region at0x14002d551and callsFatPostProcessEfsCreateat0x14002d57f, andFatFspDispatchdoes the same at0x140046f6dand0x140046fa8. InsideFatPostProcessEfsCreate, the callback (0x14002f7a5) runs before the routine takes its first lock (0x14002f7ff). The exFAT callers have the same order:FppFsdCreateat0x140031445and0x140031474, andFppFspDispatchat0x140044fe7and0x140045022.
So a thread that waits for this lock holds no other lock of the driver, and it cannot be one half of a wait cycle inside the driver. A thread that already owned the resource would not block on itself either. Microsoft’s documentation of ExAcquireResourceSharedLite states: “If the caller already has acquired the resource (for shared or exclusive access), the current thread is granted the same type of access recursively.”
What the fix closes
With a concurrent rename, the old routine allowed four different wrong results. The fix closes each of them against the rename, for different reasons:
| Outcome in 9711 | Cause in 9711 | Why a rename cannot cause it on the new path |
|---|---|---|
out-of-bounds write past P | the size (first Length read) and the count (second read) differ | one Length read sizes the allocation and drives the copy |
| copy from a NULL source | the rename’s NULL store lands between the Buffer test and the Buffer load for the copy | both Buffer reads run under the shared hold, and the rename’s NULL store needs the exclusive hold |
| copy from a buffer the rename has already freed | the rename frees the old buffer after the reader loaded the pointer and before the copy finished, or the reader loads the pointer between the free and the NULL store | the rename’s free needs the exclusive hold, and the reader keeps the shared hold until memcpy returns |
| a descriptor longer than the allocation | the third Length read, for the descriptor, sees a longer name than the allocation | the descriptor takes the same Length value |
The first row does not depend on the lock at all. The allocation size and the copy count now come from the same register, so the copy can never be longer than the allocation. The new path has no second read of Length.
The second and third rows are the two ways the old code could read through a stale Buffer value. My crash dumps showed the first two rows: the out-of-bounds write (bug check 0x50, write) and the NULL-source copy (bug check 0x3B). The third row I derived from the code, not from a dump. In 9711 the reader loads the source pointer at exFAT 0x1400064bd (FastFAT 0x140003c55) and reads through it inside memcpy (exFAT 0x1400064c7, FastFAT 0x140003c5f). The rename frees that buffer at exFAT 0x14003db3f (FastFAT 0x14003f5bf) and stores NULL only after the free. A free that lands after the reader’s pointer load and before the end of the copy makes memcpy read freed pool. With special pool enabled for the driver, I would expect that read to stop at once with bug check 0xD5 (a page fault in freed special pool), but I did not record such a dump. This third outcome is the one that matches the advisory’s classification as a use after free (CWE-416). That link is my reading: the advisory does not describe the mechanism.
The fourth row I also derived from the code. In 9711, a rename that lengthens the name between the first and third Length reads leaves the descriptor of the copy longer than the allocation. If it lands between the second and third reads, the descriptor is also longer than the bytes that were copied. The EFS code then receives a name whose stated length runs past the end of its buffer.
Here are the windows in 9711, with the reader’s addresses from Part 3 and the rename’s from Part 4. In FastFAT, the rename frees the old buffer at 0x14003f5bf and stores NULL at 0x14003f5cb. Then FatSetFullNameInFcb stores the new length at 0x14002ec65 and the new buffer at 0x14002ec78. Against those stores:
- Overflow: the buffer test (
0x140003c1d) runs before0x14003f5cb, the first length read (0x140003c27) runs before0x14002ec65, the second (0x140003c4d) runs after it, and the buffer load (0x140003c55) runs after0x14002ec78. - NULL source: the buffer test (
0x140003c1d) runs before0x14003f5cb, and the buffer load runs between0x14003f5cband0x14002ec78. - Freed buffer: the buffer load runs between
0x14003f5bfand0x14003f5cb, or the free at0x14003f5bfruns after the buffer load and beforememcpyreturns.
In exFAT, the rename frees the old buffer at 0x14003db3f and stores NULL at 0x14003db4b. Later the rebuild stores the new buffer (0x1400459d4 or 0x140045a16) before the new length (0x140045a27). The copy overflows under three conditions. The buffer test (0x140006485) does not run while the buffer is NULL. The first length read (0x14000648f) runs before 0x140045a27. The second length read (0x1400064b5) runs after it. The buffer load (0x1400064bd) then also returns the new buffer, because that store came first. The NULL and freed-buffer windows have the same shape at the exFAT addresses. I derived these windows from the code. The dumps in Part 7 show the overflow and the NULL source.
The fix compared with the remedy I proposed
Part 10 proposed three changes. This is how Microsoft’s change compares with each:
| Proposed | Shipped in 9974 | Comment |
|---|---|---|
one snapshot of {Length, Buffer} under the FCB resource, then size, copy and descriptor from it | yes: *(Fcb+8) held shared, one Length read in r15d, used four times | Buffer is read twice, but both reads are under the same hold, so they return the same pointer against a rename |
| a non-NULL check of the buffer and a check that the copy is not longer than the allocation | the non-NULL check, moved under the lock | no length comparison is needed, because the copy count and the size are the same register |
| one change for both drivers | yes, in the same update, as two compiled copies | each driver has its own feature flag for the change |
Microsoft added four things that I did not propose. The routine enters a critical region around the hold, as the ERESOURCE rules require. It releases the resource before it calls into the EFS code, so no EFS work runs while the FCB lock is held. The release is at exFAT 0x140006543 (FastFAT 0x140003cd3), and the call into the EFS code is at exFAT 0x140006617 (FastFAT 0x140003da7). A new __finally handler releases the lock on the exception path. And the old code stays in the binary, behind a feature flag. The allocation itself is unchanged: the same ExAllocatePoolWithTag call, the same pool type 0x411 and the same tag.
The feature flag around the fix
As the Introduction said, the fix sits behind a feature flag. The new code and the old code both stay in the binary. A small gate function returns whether the change is enabled, and the code branches on that result. In these drivers the mechanism is WIL feature staging. WIL (the Windows Implementation Library) provides the gate functions and the code that evaluates them. The 9711 drivers carry public symbol names for that code, such as wil_InitializeFeatureStaging and wil_details_IsEnabledFallback. The feature-staging part of WIL is not in Microsoft’s public WIL source repository. So the names here come from the 9711 symbols, and the meaning of each field comes from the code that reads it. Known Issue Rollback (KIR) is the Windows servicing mechanism that turns off one change from an update after release, without removing the rest of the update. Microsoft’s documentation of KIR states: “KIRs apply to only nonsecurity updates because rolling back a fix for a nonsecurity update doesn’t create a potential security vulnerability.”
The gate in front of the fix has the same 13 instructions as the WIL gates that the 9711 drivers already contain under public names. For example, FastFAT 9711 Feature_188638523__private_IsEnabledDeviceUsageNoInline (0x140001668) is instruction for instruction the same pattern. The exFAT gate 0x1400066b8 works like this. It reads one 32-bit state word at 0x140020430 (0x1400066c2). Bit 0x10 of that word is a fast-path bit. When it is set, the gate returns bit 0 of the word at once (0x1400066cc to 0x1400066d3). Otherwise it calls a 22-byte helper (0x1400066f0), which passes the feature’s descriptor (0x14001ed58) to wil_details_IsEnabledFallback (0x140005f04). Bit 0x2 of the state marks an evaluated state. If that bit is clear, the function evaluates the feature. It then records the use of the feature and returns bit 0 of the state. After it records the use, it calls wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath (0x140005ea0). That routine sets the fast-path bit with an atomic compare-and-exchange (0x140005ee5), but only if the state is evaluated and its bit 0 matches the value being returned. The FastFAT gate is 0x140003ec0, with its state word at 0x14001b180, its helper at 0x140003ef8 and its descriptor at 0x140018e90.
The descriptor is a 56-byte record in read-only data. Its fields, read from the bytes of both 9974 drivers and from the 9711 code that consumes them:
| Offset | exFAT 0x14001ed58 | FastFAT 0x140018e90 | Meaning |
|---|---|---|---|
+0x00 | 0x140020430 | 0x14001b180 | pointer to the state word |
+0x08 | 0x14001f410 | 0x14001a420 | pointer to the slot that records usage reports |
+0x10 | 0x140017a28 | 0x140011a48 | pointer to two 32-bit values, 0 and 2, that the library logs with the feature |
+0x18 | the exFAT feature ID | the FastFAT feature ID | the numeric ID passed to the system’s feature-configuration query |
+0x1c | 3 | 3 | the configuration type: for 2 and 3 the code passes type 0 to RtlQueryFeatureConfiguration (0x140005c3a to 0x140005c73) |
+0x1d, +0x1e | 0, 0 | 0, 0 | not a fixed-state feature |
+0x1f | 1 | 1 | enabled by default |
+0x20 | 0 | 0 | no other feature is required |
The default is on, and I read that from the code, not only from the byte. The evaluation in exFAT 10.0.26100.9974 queries the configuration for the ID (0x140005c73). When no configured state exists, it takes the enabled bit from byte +0x1f (0x140005cf1 to 0x140005cf9). Only an explicit configuration for the feature can change that result. The initialization pass applies the same rule. It compares byte +0x1f with zero at 0x14003a205, in the function at 0x14003a1a4. A second pass copies the result into bit 0 when the feature requires no other feature. That pass is the function at 0x14003a2f4, called at 0x14003a286. The FastFAT evaluation (0x140001e2c) implements the same rule. So on a machine with no configuration for these feature IDs, the gate returns 1 and the locked path runs.
When the gate returns 0, the routine runs the 9711 sequence described under Lines 26 to 35. That branch is part of the shipped binary. The documented KIR mechanism does not apply to security fixes. The binary contains no information about why this security fix has a flag. Three facts can be read from the binary: the flag exists, it is on by default, and the default path is the locked one.
Figure 4. A feature gate, on by default, selects the new locked copy. With the gate on, the callback takes the FCB lock shared and reads the length once. With the gate off, it runs the same copy as the 9711 driver, with no lock and with the double fetch.
Checking a machine
The direct check is the version of the two driver files in %SystemRoot%\System32\drivers. This PowerShell snippet prints both:
$d = "$env:SystemRoot\System32\drivers"
foreach ($f in 'exfat.sys', 'fastfat.sys') {
"$f " + (Get-Item "$d\$f").VersionInfo.FileVersionRaw
}
Line 1: sets $d to the folder that holds both drivers.
Lines 2 to 4: print each file name with its four-part file version.
On the Windows 11 24H2, 25H2 and 26H2 line, exfat.sys and fastfat.sys at FileVersion 10.0.26100.9974 carry the fix. The higher 10.0.26100 revisions that later updates of that line install carry it too. 10.0.26100.9711 and 10.0.26100.9818 do not carry it. KB5132061 needs a restart. Until the restart, the old files stay in that folder and the old drivers keep running. On my analysis machine, with KB5132061 installed and the restart not yet done, both files still read 10.0.26100.9818. For the other lines, compare against the driver versions in the table of fixed builds at the start of this part, not against the OS build. Check both files. The advisory names exFAT, but the same update changes fastfat.sys in the same way, and a FAT32 volume uses fastfat.sys.
This file check does not apply to a machine that receives hotpatch updates. There the November fix arrives in KB5132043 or KB5131077, as exfat_hotpatch.sys and fastfat_hotpatch.sys. The version of exfat.sys and fastfat.sys does not show it. On such a machine, check that the hotpatch update is installed. I did not examine the hotpatch images.
With no feature configuration for the fix’s feature IDs, the fixed drivers run the locked path described above. I expect that to be the normal state, but I did not survey other machines.
Takeaways
- A double fetch is safe only if the field cannot change between the two reads. Reading
FullFileName.Lengthonce to size a buffer and again to fill it is safe only if the length cannot change in between. A completely separate feature, rename, changes it, so the two reads disagree and the copy overruns. - The bug is at the boundary between two features. The rename path treats the FCB full name as a shared mutable object and guards every change with the FCB exclusive lock. The post-create callback reads the same object without that lock. Each path is reasonable on its own; the defect is in their interaction, which is where this class of bug recurs.
- One defect in shared code is present in every binary that links that code. Both FAT drivers statically link the same EFS code, so one lock-free read is two vulnerable drivers, and a fix has to go into the shared code, not one driver. Microsoft changed both copies in the same update, with the same code in each, but behind a separate feature flag in each driver.
- The fix is one lock and one read. In
10.0.26100.9974the callback takes the FCB resource shared, the same lock that the rename holds exclusive, and reads the length once. A rename can no longer change the length or free the buffer between the buffer test and the end of the copy. Check the version of both driver files, not the advisory title: the advisory names exFAT, and the same update changesfastfat.sysin the same way. - Who can reach it. The name copy runs on every open of an EFS file on a FAT or exFAT volume, before the EFS key is checked, on a format with no per-file permissions. A standard user who can write to a mounted FAT or exFAT volume can create such a file and run the race.
- For defenders. Install the November 2026 update for your Windows version, restart, and check the version of both
exfat.sysandfastfat.sysagainst the table in Part 11. On a machine that takes hotpatch updates, check that KB5132043 or KB5131077 is installed. On a machine that is not yet updated, the observable signal is a tightIRP_MJ_CREATEloop that opens aFILE_ATTRIBUTE_ENCRYPTEDfile on a FAT or exFAT volume. At the same time, the same process sends a high-rateFileRenameInformationstream for that file, alternating between a short path and a deep long path. The NULL-source copy crashes at once (bug check0x3B) insidefastfat!memcpyorexfat!memcpy, withEfsPostCreateCallback+0xf0as the return address. The out-of-bounds write crashes at the copy only under Driver Verifier special pool (bug check0x50). Without special pool, the damage would appear later as a pool-corruption bug check (Part 7).
References
- CVE-2026-112766 (Microsoft Security Update Guide)
- CVE-2026-112766 record (CVE.org)
- KB5132061 (Microsoft Support)
- KB5132061 file information (Microsoft, CSV)
- KB5132043, hotpatch (Microsoft Support)
- KB5131077, hotpatch (Microsoft Support)
- KB5132059 (Microsoft Support)
- KB5131080 (Microsoft Support)
- KB5131087 (Microsoft Support)
- KB5131090 (Microsoft Support)
- KB5131096 (Microsoft Support)
- KB5131085 (Microsoft Support)
- KB5127936 (Microsoft Support)
- November 2026 security update release notes (Microsoft)
- Hotpatch updates (Microsoft Learn)
- CVSS v3.1 specification (FIRST)
- CWE-416: Use After Free
- CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition
- CWE-787: Out-of-bounds Write
- Bug Check 0x50: PAGE_FAULT_IN_NONPAGED_AREA
- Bug Check 0x3B: SYSTEM_SERVICE_EXCEPTION
- Bug Check 0xD5: DRIVER_PAGE_FAULT_IN_FREED_SPECIAL_POOL
- Bug Check 0x12C: EXFAT_FILE_SYSTEM
- Special Pool (Driver Verifier)
- File Encryption (EFS) on Windows
- SetFileInformationByHandle function (fileapi.h)
- FILE_RENAME_INFO structure (winbase.h)
- ERESOURCE structure and routines
- ExAcquireResourceSharedLite function (wdm.h)
- ExAcquireResourceExclusiveLite function (wdm.h)
- ExReleaseResourceLite function (wdm.h)
- KeEnterCriticalRegion function (wdm.h)
- KeLeaveCriticalRegion function (wdm.h)
- Disabling APCs
- Types of APCs
- ExAllocatePoolWithTag function (wdm.h)
- POOL_TYPE enumeration (wdm.h)
- FSRTL_COMMON_FCB_HEADER structure (ntifs.h)
- FILE_OBJECT structure (wdm.h)
- x64 exception handling (Microsoft C++ documentation)
- x64 software conventions (Microsoft C++ documentation)
- try-finally statement (Microsoft C++ documentation)
- Control Flow Guard for platform security (Microsoft Learn)
- Windows Implementation Libraries (WIL) repository (GitHub)
- Use Group Policy to deploy a Known Issue Rollback
- CVE-2017-7533: Linux inotify/vfs_rename dentry-name race
- Bochspwn: identifying and exploiting Windows kernel race conditions via memory access patterns