kernel-race-condition-object-manager-slowdown.md (22766B)
1 --- 2 title: "Kernel Race Condition Exploitation via Object Manager Slow Paths" 3 section: "Windows" 4 sectionSlug: "windows-hardening" 5 sourcePath: "src/windows-hardening/windows-local-privilege-escalation/kernel-race-condition-object-manager-slowdown.md" 6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/windows-hardening/windows-local-privilege-escalation/kernel-race-condition-object-manager-slowdown.md" 7 sha: "188de82beb54e70956b2952367a0af91d26758b8" 8 isIndex: false 9 modified: true 10 license: "CC-BY-NC-4.0" 11 --- 12 13 # Kernel Race Condition Exploitation via Object Manager Slow Paths 14 15 ## Why stretching the race window matters 16 17 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.<sup>[[1]](#references)</sup> 18 19 ## Object Manager lookup internals in a nutshell 20 21 * **OMNS structure** – Names such as `\BaseNamedObjects\Foo` are 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. 22 * **UNICODE_STRING limit** – OM paths are carried inside a `UNICODE_STRING` whose `Length` is 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. 23 * **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.<sup>[[1]](#references)</sup> 24 25 ## Slowdown primitive #1 – Single maximal component 26 27 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). 28 29 ```cpp 30 std::wstring path; 31 while (path.size() <= 32000) { 32 auto result = RunTest(L"\\BaseNamedObjects\\A" + path, 1000); 33 printf("%zu,%f\n", path.size(), result); 34 path += std::wstring(500, 'A'); 35 } 36 ``` 37 38 *Practical notes* 39 40 - You can hit the length limit using any named kernel object (events, sections, semaphores…). 41 - Symbolic links or reparse points can point a short “victim” name to this giant component so the slowdown is applied transparently. 42 - Because everything lives in user-writable namespaces, the payload works from a standard user integrity level.<sup>[[1]](#references)</sup> 43 44 ## Slowdown primitive #2 – Deep recursive directories 45 46 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. 47 48 ```cpp 49 ScopedHandle base_dir = OpenDirectory(L"\\BaseNamedObjects"); 50 HANDLE last_dir = base_dir.get(); 51 std::vector<ScopedHandle> dirs; 52 for (int i = 0; i < 16000; i++) { 53 dirs.emplace_back(CreateDirectory(L"A", last_dir)); 54 last_dir = dirs.back().get(); 55 if ((i % 500) == 0) { 56 auto result = RunTest(GetName(last_dir) + L"\\X", iterations); 57 printf("%d,%f\n", i + 1, result); 58 } 59 } 60 ``` 61 62 Tips: 63 64 * Alternate the character per level (`A/B/C/...`) if the parent directory starts rejecting duplicates. 65 * Keep a handle array so you can delete the chain cleanly after exploitation to avoid polluting the namespace.<sup>[[1]](#references)</sup> 66 67 ## Slowdown primitive #3 – Shadow directories, hash collisions & symlink reparses (minutes instead of microseconds) 68 69 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: 70 71 1. Create two directories under `\BaseNamedObjects`, e.g. `A` (shadow) and `A\A` (target). Create the second using the first as the shadow directory (`NtCreateDirectoryObjectEx`), so missing lookups in `A` fall through to `A\A`. 72 2. Fill each directory with thousands of **colliding names** that land in the same hash bucket (e.g., varying trailing digits while keeping the same `RtlHashUnicodeString` value). Lookups now degrade to O(n) linear scans inside a single directory. 73 3. 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. 74 4. 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. 75 76 ```cpp 77 ScopedHandle shadow = CreateDirectory(L"\\BaseNamedObjects\\A"); 78 ScopedHandle target = CreateDirectoryEx(L"A", shadow.get(), shadow.get()); 79 CreateCollidingEntries(shadow, 16000, dirs); 80 CreateCollidingEntries(target, 16000, dirs); 81 CreateSymlinkChain(shadow, LongSuffix(L"\\A", 16000), 63); 82 printf("%f\n", RunTest(LongSuffix(L"\\A", 16000) + L"\\0", 1)); 83 ``` 84 85 *Why it matters*: A minutes-long slowdown turns one-shot race-based LPEs into deterministic exploits.<sup>[[1]](#references)</sup> 86 87 ### 2025 retest notes & ready-made tooling 88 89 - 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.<sup>[[1]](#references)</sup> 90 - You can script setup using the public `symboliclink-testing-tools` bundle: `CreateObjectDirectory.exe` to spawn the shadow/target pair and `NativeSymlink.exe` in a loop to emit the 63-hop chain. This avoids hand-written `NtCreate*` wrappers and keeps ACLs consistent.<sup>[[2]](#references)</sup> 91 92 ## Measuring your race window 93 94 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`.<sup>[[1]](#references)</sup> 95 96 ```cpp 97 static double RunTest(const std::wstring name, int iterations, 98 std::wstring create_name = L"", HANDLE root = nullptr) { 99 if (create_name.empty()) { 100 create_name = name; 101 } 102 ScopedHandle event_handle = CreateEvent(create_name, root); 103 ObjectAttributes obja(name); 104 std::vector<ScopedHandle> handles; 105 Timer timer; 106 for (int i = 0; i < iterations; ++i) { 107 HANDLE open_handle; 108 Check(NtOpenEvent(&open_handle, MAXIMUM_ALLOWED, &obja)); 109 handles.emplace_back(open_handle); 110 } 111 return timer.GetTime(iterations); 112 } 113 ``` 114 115 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). 116 117 ## Exploitation workflow 118 119 1. **Locate the vulnerable open** – Trace the kernel path (via symbols, ETW, hypervisor tracing, or reversing) until you find an `NtOpen*`/`ObOpenObjectByName` call that walks an attacker-controlled name or a symbolic link in a user-writable directory. 120 2. **Replace that name with a slow path** 121 - Create the long component or directory chain under `\BaseNamedObjects` (or another writable OM root). 122 - 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. 123 3. **Trigger the race** 124 - Thread A (victim) executes the vulnerable code and blocks inside the slow lookup. 125 - 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. 126 - When Thread A resumes and performs the privileged action, it observes stale state and performs the attacker-controlled operation. 127 4. **Clean up** – Delete the directory chain and symbolic links to avoid leaving suspicious artifacts or breaking legitimate IPC users.<sup>[[1]](#references)</sup> 128 129 ## Applied chain: mutable Cloud Files placeholders + Object Manager path switching 130 131 [ShieldBreak](https://github.com/MSNightmare/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.<sup>[[3]](#references)[[4]](#references)</sup> 132 133 ### 1. Substitute content through Cloud Files hydration 134 135 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.<sup>[[4]](#references)</sup> 136 137 ```cpp 138 CfRegisterSyncRoot(sync_root, ®istration, &policies, flags); 139 CfConnectSyncRoot(sync_root, callbacks, &state, connect_flags, &connection); 140 CfCreatePlaceholders(sync_root, &placeholder, 1, 0, &created); 141 // First FETCH_DATA => detection trigger; later FETCH_DATA => payload. 142 CfGetTransferKey(placeholder_handle, &transfer_key); 143 opInfo.Type = CF_OPERATION_TYPE_RESTART_HYDRATION; 144 CfExecute(&opInfo, &restart_params); 145 CfHydratePlaceholder(placeholder_handle, {0}, CF_EOF, 0, NULL); 146 ``` 147 148 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.<sup>[[4]](#references)</sup> 149 150 ### 2. Switch an invariant path through a shadow-directory fallback 151 152 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.<sup>[[4]](#references)</sup> 153 154 ```text 155 \\.\globalroot\BaseNamedObjects\Restricted\WD_SHADOW_<GUID>\WD_SCAN\BERLIN 156 ``` 157 158 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.<sup>[[4]](#references)</sup> 159 160 ### 3. Capture the generated name and install a filename-specific link 161 162 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:<sup>[[4]](#references)</sup> 163 164 ```text 165 \??\UNC\127.0.0.1\C$\Windows\System32\phoneinfo.dll 166 ``` 167 168 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.<sup>[[4]](#references)</sup> 169 170 ### 4. Stabilize the cleanup race and trigger a privileged loader 171 172 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.<sup>[[4]](#references)</sup> 173 174 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.<sup>[[4]](#references)</sup> 175 176 ### Detection pivots 177 178 Useful correlations are more specific than any single temporary filename and cover all namespace transitions in the chain:<sup>[[4]](#references)</sup> 179 180 - A newly registered Cloud Files provider followed by EICAR detection and `CF_OPERATION_TYPE_RESTART_HYDRATION` on the same placeholder. 181 - Object Manager paths containing `WD_TARGET_*`, `WD_SHADOW_*`, or `WD_SCAN`, especially a scan path below `\\.\globalroot\BaseNamedObjects\Restricted\`. 182 - CLFS file creation followed by an exclusive whole-file lock and loopback access to `\\127.0.0.1\C$\Windows\System32\*.dll` from a privileged security process. 183 - Creation of a System32 DLL together with an NTFS ADS, followed by `SEC_IMAGE` mapping of the stream. 184 - An attacker-created WER queue entry followed by an unusual manual run of `\Microsoft\Windows\Windows Error Reporting\QueueReporting` and an image load of the planted DLL. 185 186 ## Applied chain: oplock-gated mount-point switch against privileged remediation 187 188 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.<sup>[[5]](#references)[[6]](#references)</sup> 189 190 ### Race layout 191 192 1. 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—to `bcrypt.dll`. Content-based detection triggers the remediation while the attacker-controlled basename is preserved for the later side-load.<sup>[[5]](#references)</sup> 193 2. Open the directories with broad sharing and `FILE_OPEN_REPARSE_POINT`, then request an asynchronous RH oplock on the trigger with `FSCTL_REQUEST_OPLOCK`, `OPLOCK_LEVEL_CACHE_READ | OPLOCK_LEVEL_CACHE_HANDLE`, and `REQUEST_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.<sup>[[5]](#references)[[7]](#references)</sup> 194 3. After the break, remove the leaf directory with `FileDispositionInformationEx` (information class 64) using delete plus POSIX-semantics flags, close its handle, and apply an `IO_REPARSE_TAG_MOUNT_POINT` to the now-empty parent with `FSCTL_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.<sup>[[5]](#references)[[8]](#references)</sup> 195 4. 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.dll` read/write from the original process; this distinguishes the confused-deputy write primitive from the later code-execution stage.<sup>[[5]](#references)</sup> 196 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.<sup>[[5]](#references)[[9]](#references)</sup> 197 6. 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.<sup>[[5]](#references)</sup> 198 199 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.<sup>[[1]](#references)[[8]](#references)</sup> 200 201 ### Detection and PoC triage 202 203 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.<sup>[[5]](#references)</sup> 204 205 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.<sup>[[5]](#references)</sup> 206 207 ## Operational considerations 208 209 - **Combine primitives** – You can use a long name *per level* in a directory chain for even higher latency until you exhaust the `UNICODE_STRING` size. 210 - **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. 211 - **Side effects** – The slowdown only affects the malicious path, so overall system performance remains unaffected; defenders will rarely notice unless they monitor namespace growth. 212 - **Cleanup** – Keep handles to every directory/object you create so you can call `NtMakeTemporaryObject`/`NtClose` afterwards. Unbounded directory chains may persist across reboots otherwise. 213 - **File-system races** – If the vulnerable path ultimately resolves through NTFS, you can stack an Oplock (e.g., `SetOpLock.exe` from the same toolkit) on the backing file while the OM slowdown runs, freezing the consumer for additional milliseconds without altering the OM graph.<sup>[[2]](#references)</sup> 214 215 ## Defensive notes 216 217 - 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). 218 - Enforce upper bounds on OM path depth/length before dereferencing user-controlled names. Rejecting overly long names forces attackers back into the microsecond window. 219 - Instrument object manager namespace growth (ETW `Microsoft-Windows-Kernel-Object`) to detect suspicious thousands-of-components chains under `\BaseNamedObjects`. 220 221 ## References 222 223 - [1] [Project Zero – Windows Exploitation Techniques: Winning Race Conditions with Path Lookups](https://projectzero.google/2025/12/windows-exploitation-techniques.html) 224 - [2] [googleprojectzero/symboliclink-testing-tools](https://github.com/googleprojectzero/symboliclink-testing-tools) 225 - [3] [MSNightmare/ShieldBreak](https://github.com/MSNightmare/ShieldBreak) 226 - [4] [ShieldBreak.cpp (commit be016d8)](https://github.com/MSNightmare/ShieldBreak/blob/be016d8c18c8355a12753286c1ce9d5a48a0dab4/ShieldBreak.cpp) 227 - [5] [FalconFlank.cpp (commit 702b574)](https://github.com/MSNightmare/FalconFlank/blob/702b57477a9f0a99ddabef56e7ebe6c1e99c2435/FalconFlank.cpp) 228 - [6] [MSNightmare/FalconFlank](https://github.com/MSNightmare/FalconFlank) 229 - [7] [Microsoft Learn - FSCTL_REQUEST_OPLOCK](https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ni-winioctl-fsctl_request_oplock) 230 - [8] [Microsoft Learn - FSCTL_SET_REPARSE_POINT_EX](https://learn.microsoft.com/en-us/windows-hardware/drivers/ifs/fsctl-set-reparse-point-ex) 231 - [9] [Microsoft Learn - How to Use Transactional NTFS](https://learn.microsoft.com/en-us/windows/win32/fileio/how-to-use-transactional-ntfs)