Kernel Race Condition Exploitation via Object Manager Slow Paths
Why stretching the race window matters
Many Windows kernel LPEs follow the classic pattern check_state(); NtOpenX("name"); privileged_action();. On modern hardware a cold NtOpenEvent/NtOpenSection resolves a short name in ~2 µs, leaving almost no time to flip the checked state before the secure action happens. By deliberately forcing the Object Manager Namespace (OMNS) lookup in step 2 to take tens of microseconds, the attacker gains enough time to consistently win otherwise flaky races without needing thousands of attempts.[1]
Object Manager lookup internals in a nutshell
- OMNS structure – Names such as
\BaseNamedObjects\Fooare resolved directory-by-directory. Each component causes the kernel to find/open an Object Directory and compare Unicode strings. Symbolic links (e.g., drive letters) may be traversed en route. - UNICODE_STRING limit – OM paths are carried inside a
UNICODE_STRINGwhoseLengthis a 16-bit value. The absolute limit is 65 535 bytes (32 767 UTF-16 codepoints). With prefixes like\BaseNamedObjects\, an attacker still controls ≈32 000 characters. - Attacker prerequisites – Any user can create objects underneath writable directories such as
\BaseNamedObjects. When the vulnerable code uses a name inside, or follows a symbolic link that lands there, the attacker controls the lookup performance with no special privileges.[1]
Slowdown primitive #1 – Single maximal component
The cost of resolving a component is roughly linear with its length because the kernel must perform a Unicode comparison against every entry in the parent directory. Creating an event with a 32 kB-long name immediately increases the NtOpenEvent latency from ~2 µs to ~35 µs on Windows 11 24H2 (Snapdragon X Elite testbed).
std::wstring path;
while (path.size() <= 32000) {
auto result = RunTest(L"\\BaseNamedObjects\\A" + path, 1000);
printf("%zu,%f\n", path.size(), result);
path += std::wstring(500, 'A');
}
Practical notes
- You can hit the length limit using any named kernel object (events, sections, semaphores…).
- Symbolic links or reparse points can point a short “victim” name to this giant component so the slowdown is applied transparently.
- Because everything lives in user-writable namespaces, the payload works from a standard user integrity level.[1]
Slowdown primitive #2 – Deep recursive directories
A more aggressive variant allocates a chain of thousands of directories (\BaseNamedObjects\A\A\...\X). Each hop triggers directory resolution logic (ACL checks, hash lookups, reference counting), so the per-level latency is higher than a single string compare. With ~16 000 levels (limited by the same UNICODE_STRING size), empirical timings surpass the 35 µs barrier achieved by long single components.
ScopedHandle base_dir = OpenDirectory(L"\\BaseNamedObjects");
HANDLE last_dir = base_dir.get();
std::vector<ScopedHandle> dirs;
for (int i = 0; i < 16000; i++) {
dirs.emplace_back(CreateDirectory(L"A", last_dir));
last_dir = dirs.back().get();
if ((i % 500) == 0) {
auto result = RunTest(GetName(last_dir) + L"\\X", iterations);
printf("%d,%f\n", i + 1, result);
}
}
Tips:
- Alternate the character per level (
A/B/C/...) if the parent directory starts rejecting duplicates. - Keep a handle array so you can delete the chain cleanly after exploitation to avoid polluting the namespace.[1]
Slowdown primitive #3 – Shadow directories, hash collisions & symlink reparses (minutes instead of microseconds)
Object directories support shadow directories (fallback lookups) and bucketed hash tables for entries. Abuse both plus the 64-component symbolic-link reparse limit to multiply slowdown without exceeding the UNICODE_STRING length:
- Create two directories under
\BaseNamedObjects, e.g.A(shadow) andA\A(target). Create the second using the first as the shadow directory (NtCreateDirectoryObjectEx), so missing lookups inAfall through toA\A. - Fill each directory with thousands of colliding names that land in the same hash bucket (e.g., varying trailing digits while keeping the same
RtlHashUnicodeStringvalue). Lookups now degrade to O(n) linear scans inside a single directory. - Build a chain of ~63 object manager symbolic links that repeatedly reparse into the long
A\A\…suffix, consuming the reparse budget. Each reparse restarts parsing from the top, multiplying the collision cost. - Lookup of the final component (
...\\0) now takes minutes on Windows 11 when 16 000 collisions are present per directory, providing a practically guaranteed race win for one-shot kernel LPEs.
ScopedHandle shadow = CreateDirectory(L"\\BaseNamedObjects\\A");
ScopedHandle target = CreateDirectoryEx(L"A", shadow.get(), shadow.get());
CreateCollidingEntries(shadow, 16000, dirs);
CreateCollidingEntries(target, 16000, dirs);
CreateSymlinkChain(shadow, LongSuffix(L"\\A", 16000), 63);
printf("%f\n", RunTest(LongSuffix(L"\\A", 16000) + L"\\0", 1));
Why it matters: A minutes-long slowdown turns one-shot race-based LPEs into deterministic exploits.[1]
2025 retest notes & ready-made tooling
- James Forshaw republished the technique with updated timings on Windows 11 24H2 (ARM64). Baseline opens remain ~2 µs; a 32 kB component raises this to ~35 µs, and shadow-dir + collision + 63-reparse chains still reach ~3 minutes, confirming the primitives survive current builds. Source code and perf harness are in the refreshed Project Zero post.[1]
- You can script setup using the public
symboliclink-testing-toolsbundle:CreateObjectDirectory.exeto spawn the shadow/target pair andNativeSymlink.exein a loop to emit the 63-hop chain. This avoids hand-writtenNtCreate*wrappers and keeps ACLs consistent.[2]
Measuring your race window
Embed a quick harness inside your exploit to measure how large the window becomes on the victim hardware. The snippet below opens the target object iterations times and returns the average per-open cost using QueryPerformanceCounter.[1]
static double RunTest(const std::wstring name, int iterations,
std::wstring create_name = L"", HANDLE root = nullptr) {
if (create_name.empty()) {
create_name = name;
}
ScopedHandle event_handle = CreateEvent(create_name, root);
ObjectAttributes obja(name);
std::vector<ScopedHandle> handles;
Timer timer;
for (int i = 0; i < iterations; ++i) {
HANDLE open_handle;
Check(NtOpenEvent(&open_handle, MAXIMUM_ALLOWED, &obja));
handles.emplace_back(open_handle);
}
return timer.GetTime(iterations);
}
The results feed directly into your race orchestration strategy (e.g., number of worker threads needed, sleep intervals, how early you need to flip the shared state).
Exploitation workflow
- Locate the vulnerable open – Trace the kernel path (via symbols, ETW, hypervisor tracing, or reversing) until you find an
NtOpen*/ObOpenObjectByNamecall that walks an attacker-controlled name or a symbolic link in a user-writable directory. - Replace that name with a slow path
- Create the long component or directory chain under
\BaseNamedObjects(or another writable OM root). - Create a symbolic link so that the name the kernel expects now resolves to the slow path. You can point the vulnerable driver’s directory lookup to your structure without touching the original target.
- Create the long component or directory chain under
- Trigger the race
- Thread A (victim) executes the vulnerable code and blocks inside the slow lookup.
- Thread B (attacker) flips the guarded state (e.g., swaps a file handle, rewrites a symbolic link, toggles object security) while Thread A is occupied.
- When Thread A resumes and performs the privileged action, it observes stale state and performs the attacker-controlled operation.
- Clean up – Delete the directory chain and symbolic links to avoid leaving suspicious artifacts or breaking legitimate IPC users.[1]
Applied chain: mutable Cloud Files placeholders + Object Manager path switching
ShieldBreak, published as a bypass for RoguePlanet (CVE-2026-50656), demonstrates a broader exploitation pattern: make a privileged scanner classify one representation of a logical file, then change both its bytes and namespace resolution before remediation uses it. The PoC combines a Cloud Files hydration TOCTOU, an Object Manager shadow-directory fallback, CLFS-generated-name capture, and a local administrative-share link to turn Defender cleanup into a protected DLL write.[3][4]
1. Substitute content through Cloud Files hydration
Register an attacker-writable directory as a Cloud Files sync root, connect a CF_CALLBACK_TYPE_FETCH_DATA callback, and create a placeholder whose advertised size matches a deterministic detection trigger such as the EICAR ZIP. The first fetch returns the trigger and flips callback state; later fetches return the payload. After the scanner has classified the first representation, obtain the transfer key and restart hydration with payload-sized metadata, then force hydration to EOF.[4]
CfRegisterSyncRoot(sync_root, ®istration, &policies, flags);
CfConnectSyncRoot(sync_root, callbacks, &state, connect_flags, &connection);
CfCreatePlaceholders(sync_root, &placeholder, 1, 0, &created);
// First FETCH_DATA => detection trigger; later FETCH_DATA => payload.
CfGetTransferKey(placeholder_handle, &transfer_key);
opInfo.Type = CF_OPERATION_TYPE_RESTART_HYDRATION;
CfExecute(&opInfo, &restart_params);
CfHydratePlaceholder(placeholder_handle, {0}, CF_EOF, 0, NULL);
The security boundary fails if scan, verdict, and remediation refer only to a pathname or placeholder identity: neither guarantees that a later hydration returns the bytes that were inspected.[4]
2. Switch an invariant path through a shadow-directory fallback
Create a target Object Manager directory and a second directory with NtCreateDirectoryObjectEx, passing the target handle as its shadow/fallback directory. Put a same-named WD_SCAN entry in both resolution layers: the visible entry points to the normal working directory, while the fallback entry points to \CLFS\??\<working-directory>. Supply Defender only the invariant path below; deleting the visible link while the operation is active makes the same string fall through to the CLFS-backed entry.[4]
\\.\globalroot\BaseNamedObjects\Restricted\WD_SHADOW_<GUID>\WD_SCAN\BERLIN
This is distinct from using shadow directories only to slow lookup: the attacker changes the meaning of a previously accepted path without modifying its string.[4]
3. Capture the generated name and install a filename-specific link
Monitor the working directory with ReadDirectoryChangesW. On the first FILE_ACTION_ADDED, remove the visible WD_SCAN link to activate fallback lookup. Capture the second generated filename, open that CLFS-related file, and lock the range 0..MAXLONGLONG with LockFileEx. While the privileged operation is stalled, replace WD_SCAN in the visible directory with a real Object Manager directory and create a child symbolic link named from the observed filename (the PoC strips its final four characters). Point it to the protected destination through local SMB:[4]
\??\UNC\127.0.0.1\C$\Windows\System32\phoneinfo.dll
The unprivileged process cannot write that destination itself, but Defender’s SYSTEM context can traverse the loopback administrative share. Combining generated-name observation with a filename-specific Object Manager link avoids having to predict the remediation artifact in advance.[4]
4. Stabilize the cleanup race and trigger a privileged loader
Before scanning, the PoC stores a valid PE (ntdll.dll) in the placeholder’s :stream NTFS alternate data stream. After redirection creates the protected base file, it opens phoneinfo.dll:stream with execute access and keeps a PAGE_EXECUTE_READ | SEC_IMAGE mapping alive while cleanup resumes; the live file/section objects constrain deletion or replacement during the final race. The restarted hydration now returns the payload DLL rather than EICAR, so the protected base file contains attacker-controlled code.[4]
A protected write is then converted to SYSTEM execution by placing a crafted Report.wer under C:\ProgramData\Microsoft\Windows\WER\ReportQueue\... and invoking \Microsoft\Windows\Windows Error Reporting\QueueReporting through the Task Scheduler COM API. In this chain, privileged WER processing loads the planted C:\Windows\System32\phoneinfo.dll; a named-pipe connection is used as the payload execution signal.[4]
Detection pivots
Useful correlations are more specific than any single temporary filename and cover all namespace transitions in the chain:[4]
- A newly registered Cloud Files provider followed by EICAR detection and
CF_OPERATION_TYPE_RESTART_HYDRATIONon the same placeholder. - Object Manager paths containing
WD_TARGET_*,WD_SHADOW_*, orWD_SCAN, especially a scan path below\\.\globalroot\BaseNamedObjects\Restricted\. - CLFS file creation followed by an exclusive whole-file lock and loopback access to
\\127.0.0.1\C$\Windows\System32\*.dllfrom a privileged security process. - Creation of a System32 DLL together with an NTFS ADS, followed by
SEC_IMAGEmapping of the stream. - An attacker-created WER queue entry followed by an unusual manual run of
\Microsoft\Windows\Windows Error Reporting\QueueReportingand an image load of the planted DLL.
Applied chain: oplock-gated mount-point switch against privileged remediation
A reusable LPE pattern appears when a privileged scanner checks an attacker-controlled file and later remediates it by reopening the pathname rather than continuing through validated handles. FalconFlank is a public example targeting CrowdStrike Falcon’s Office macro-removal workflow; the repository claims testing on Windows 11 25H2 and Windows Server 2025 with the relevant policy enabled, but publishes no CVE, affected-build range, vendor advisory, or patch status, so treat the product-specific claim as unverified and build-dependent.[5][6]
Race layout
- Build a writable tree whose final relative name is useful at the intended destination. The example uses
%TEMP%\\Flanker_{GUID}\\WindowsPowerShell\\v1.0\\bcrypt.dll, but initially writes an OLE macro document—not a PE DLL—tobcrypt.dll. Content-based detection triggers the remediation while the attacker-controlled basename is preserved for the later side-load.[5] - Open the directories with broad sharing and
FILE_OPEN_REPARSE_POINT, then request an asynchronous RH oplock on the trigger withFSCTL_REQUEST_OPLOCK,OPLOCK_LEVEL_CACHE_READ | OPLOCK_LEVEL_CACHE_HANDLE, andREQUEST_OPLOCK_INPUT_FLAG_REQUEST. Wait for the overlapped event and use its completion as the path-switch cue. An RH oplock-break notification is advisory rather than proof that every conflicting operation is blocked, so exploitability still depends on the victim’s exact open/remediation sequence.[5][7] - After the break, remove the leaf directory with
FileDispositionInformationEx(information class 64) using delete plus POSIX-semantics flags, close its handle, and apply anIO_REPARSE_TAG_MOUNT_POINTto the now-empty parent withFSCTL_SET_REPARSE_POINT_EX. The mount point redirects the unchanged suffix into a protected tree such as\\SystemRoot\\System32\\WindowsPowerShell; setting a reparse point fails if the directory is not empty, which explains the preceding deletion step.[5][8] - Resume the privileged workflow. If it resolves the string again without proving that the directory chain and final object are the ones previously inspected, the same logical pathname now reaches the attacker-selected protected directory. In the example, success is tested by reopening
C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\bcrypt.dllread/write from the original process; this distinguishes the confused-deputy write primitive from the later code-execution stage.[5] - Replace the resulting file with the real DLL and activate a privileged loader. The PoC uses
CreateTransaction+CreateFileTransacted, truncates the file, maps the DLL-sized replacement, copies the PE, and commits; TxF binds the file handle and subsequent handle-based operations to the transaction, but it is a post-race replacement mechanism rather than the source of the privilege boundary failure.[5][9] - Finally, run an existing privileged scheduled task whose executable probes the planted adjacent filename. FalconFlank invokes
\\Microsoft\\Windows\\Application Experience\\MareBackup, waits for the DLL to connect to\\??\\pipe\\FALCONFLANK, and then deletes the planted file. Do not assume a particular resulting token solely from the task name—verify the launched process, module path, integrity level, and token on the tested build.[5]
The core audit question is therefore not “does the service validate the original input path?” but “does every privileged mutation remain bound to the same opened file and directory objects that were validated?” Holding handles across check and use, opening child objects relative to a trusted directory handle, rejecting unexpected reparse tags, and revalidating file identity before mutation close this class of pathname-substitution bug.[1][8]
Detection and PoC triage
High-signal detection correlates the namespace transition with the privileged consumer: an OLE header under a DLL basename in a GUID-named temporary tree, an oplock break, POSIX-style removal of the leaf directory, creation of a mount point targeting a protected Windows directory, and creation or modification of the same basename below that destination. For the public example, add C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\bcrypt.dll, manual execution of MareBackup, and the FALCONFLANK named pipe as narrower pivots; none is sufficient alone.[5]
When reproducing the PoC, account for three reliability defects in the published source: it calls FlushFileBuffers with the embedded byte-array pointer rather than the file handle, tests a stale HRESULT after GetFolder, GetTask, and Run, and uses unbounded retry/wait loops for directory deletion, reparse creation, the oplock event, and pipe connection.[5]
Operational considerations
- Combine primitives – You can use a long name per level in a directory chain for even higher latency until you exhaust the
UNICODE_STRINGsize. - One-shot bugs – The expanded window (tens of microseconds to minutes) makes “single trigger” bugs realistic when paired with CPU affinity pinning or hypervisor-assisted preemption.
- Side effects – The slowdown only affects the malicious path, so overall system performance remains unaffected; defenders will rarely notice unless they monitor namespace growth.
- Cleanup – Keep handles to every directory/object you create so you can call
NtMakeTemporaryObject/NtCloseafterwards. Unbounded directory chains may persist across reboots otherwise. - File-system races – If the vulnerable path ultimately resolves through NTFS, you can stack an Oplock (e.g.,
SetOpLock.exefrom the same toolkit) on the backing file while the OM slowdown runs, freezing the consumer for additional milliseconds without altering the OM graph.[2]
Defensive notes
- Kernel code that relies on named objects should re-validate security-sensitive state after the open, or take a reference before the check (closing the TOCTOU gap).
- Enforce upper bounds on OM path depth/length before dereferencing user-controlled names. Rejecting overly long names forces attackers back into the microsecond window.
- Instrument object manager namespace growth (ETW
Microsoft-Windows-Kernel-Object) to detect suspicious thousands-of-components chains under\BaseNamedObjects.
References
- [1] Project Zero – Windows Exploitation Techniques: Winning Race Conditions with Path Lookups
- [2] googleprojectzero/symboliclink-testing-tools
- [3] MSNightmare/ShieldBreak
- [4] ShieldBreak.cpp (commit be016d8)
- [5] FalconFlank.cpp (commit 702b574)
- [6] MSNightmare/FalconFlank
- [7] Microsoft Learn - FSCTL_REQUEST_OPLOCK
- [8] Microsoft Learn - FSCTL_SET_REPARSE_POINT_EX
- [9] Microsoft Learn - How to Use Transactional NTFS