Why Windows Search Freezes for 30 Seconds After Every Reboot
Windows Search on four different Windows 11 machines was broken after every reboot — a 30-second blank window before results appeared. Different hardware, different user profiles, same symptom. Registry hacks, index rebuilds, driver updates, none of it helped. Custom Frida instrumentation on 46 SearchHost threads uncovered a cascade of four interacting bottlenecks — ending with a kernel minifilter deadlock between the search indexer and Dropbox that nobody had documented before. The common denominator across all four machines was Dropbox with Smart Sync enabled. The mitigations below reduce the stall but don't eliminate it; a second failure mode — orphan sync root registrations left behind by profile migrations — keeps the deadlock alive long after boot. The durable fix is further down: a replacement search engine that never opens a file.
Background
The primary test machine: Windows 11 Pro, build 26100, Ryzen 5 2400G, 14 GB RAM. But the same issue reproduced on three other Windows 11 PCs — a laptop, a workstation, and a family member's desktop. All different hardware, different Windows builds, different user profiles. The one thing they shared: Dropbox with Smart Sync.
The symptom was identical everywhere: hit Win+S, type something, stare at a blank pane for 20–30 seconds. Then suddenly results appear, and search works fine until the next reboot. Rebuilding the index, disabling Bing, re-registering AppX packages, resetting the WSearch service — the standard fixes from every forum thread did nothing. The problem always came back after reboot.
The breakthrough came from an observation on the primary machine: search started working exactly when the Dropbox tray icon finished loading. Every time. Once noticed, the same correlation was confirmed on the other three machines. That pointed to a boot-time dependency nobody was looking at.
Building the Instrumentation
Standard troubleshooting was useless because the symptom (blank search) has a hundred possible causes. I needed to see exactly what SearchHost.exe was doing during those 30 seconds. The approach:
- A Python tool using Windows APIs (
NtQueryInformationThread,EnumProcessModulesEx,MiniDumpWriteDump) to enumerate all SearchHost threads, map their start addresses to loaded modules, and create a minidump for offline analysis - Frida hooks (using
Process.getModuleByName().getExportByName()— the staticModule.getExportByName()is gone in Frida 17) on blocking calls:WaitForSingleObject,WaitForMultipleObjects,MsgWaitForMultipleObjectsEx,NtAlpcSendWaitReceivePort,CoCreateInstance,NdrClientCall3 - WPR (Windows Performance Recorder) with custom ETW profiles targeting
Microsoft-Windows-Search-Coreand related providers
The Python tracer kills SearchHost (simulating a cold boot), waits for it to respawn, attaches
Frida, triggers Win+S via keybd_event, types a query via SendKeys,
and records every blocking call per-thread for 20 seconds.
Thread categories:
ThreadPool : 16 threads // ntdll!TppWorkerThread
unknown : 7 threads // shcore, tquery, CoreMessaging
RPC/COM : 2 threads // combase.dll marshaling
Graphics : 2 threads // atidxx64.dll + directmanipulation
SearchUX : 1 thread // SearchHost.exe main
XAML : 1 thread // Windows.UI.Xaml.dll
WebView2 : 1 thread // EmbeddedBrowserWebView.dll
Top blocking threads:
TID 8380: 12,597ms shcore.dll+0x47c50 // UI message pump
TID 13628: 8,445ms tquery.dll+0x153350 // search query engine
Two threads account for 21 of 22 seconds of total blocked time. The other 28 threads are either idle thread pool workers or waiting on these two. The 16 thread pool threads are a red herring — standard Windows TP workers, mostly asleep.
The Minifilter Stack
To understand the root cause, you need to understand how file I/O works on Windows.
Every ReadFile call passes through a stack of minifilter drivers,
each at a specific altitude (Microsoft's actual term, not mine). Higher altitude
= intercepts first:
Filter Name Altitude Purpose
─────────────────── ────────── ─────────────────────────────
bindflt 409800 Container/virtualization binding
UCPD 385250 User-mode crash protection
WdFilter 328010 Windows Defender (antivirus band)
storqosflt 244000 Storage QoS
dbx 186500 Secure Boot revocation
CldFlt 180451 Cloud Files (Dropbox, OneDrive)
bfs 150000 Basic filesystem
Wof 40700 Windows Overlay Filter (compression)
FileInfo 40500 File information/metadata
The metaphor: an I/O request is a ball falling from userspace to disk. Each filter is a net stretched across at a certain height. Higher altitude catches the ball first. Microsoft assigns altitude ranges by category — antivirus gets 320000–329999, cloud storage gets 180000–189999. You must register your altitude with Microsoft; no approval, no altitude.
The altitude is stored in the registry at
HKLM\SYSTEM\CurrentControlSet\Services\{FilterName}\Instances\{Instance}\Altitude.
Bottleneck 1: Defender I/O Amplification
WdFilter.sys at altitude 328010 intercepts every file open/read/write from every
process. When SearchIndexer reads a file for content indexing:
SearchIndexer ReadFile("main.rs")
→ WdFilter intercepts, sends to MsMpEng for scanning // I/O #2
→ Defender approves, original read completes
→ SearchFilterHost extracts text content
→ Writes extracted text to Windows.db // I/O #3
→ WdFilter intercepts the DB write too // I/O #4
Result: 4× I/O amplification per file indexed
With thousands of .rs, .js, .py files in dev repos,
this creates sustained disk thrashing. Defender was accumulating 270 seconds of CPU while
SearchIndexer was at 250 seconds — both hammering the same files simultaneously.
On this machine, Defender exclusions existed for C:\Users\Marty\ (old profile) but
not C:\Users\marty.CHOPIN\ (current profile). Every dev file was being double-scanned.
Fix: Add-MpPreference -ExclusionProcess SearchIndexer.exe breaks the chain. Defender
drops from hammering CPU to 0.02s over 3 seconds.
Bottleneck 2: The 1.6 GB Search Index
Windows 11 stores the search index in C:\ProgramData\Microsoft\Search\Data\Applications\Windows\Windows.db
— a SQLite database. On this machine: 1,641 MB. Normal is 100–300 MB.
The bloat was caused by full-text content indexing of dev repos: every .rs,
.js, .json, .py file's contents stored in the DB.
The Frida trace showed tquery.dll (Microsoft Tripoli Query Engine) blocking for
8.4 seconds in a single WaitForMultipleObjects call while
scanning this massive DB.
Fix: register the null persistent handler ({098f2470-bae0-11cd-b579-08002b30bfeb})
for code extensions. This tells SearchFilterHost to index filenames only, skip content extraction.
Files are still findable by name; the DB drops from 1.6 GB to ~50 MB.
Bottleneck 3: WebView2 Cold Start
Windows 11's search UI is a web app. SearchHost.exe renders it inside an embedded
Chromium browser via WebView2. Every boot, it spawns 6 msedgewebview2.exe processes
consuming ~370 MB:
| Process | Purpose |
|---|---|
| Browser | Main coordinator, manages WebView2 lifecycle |
| GPU | D3D/DirectX compositing for the search UI |
| Renderer | Executes the JS search app from Cortana.UI/cache/WV2Local/ |
| Utility | Network/services |
| Crashpad | Chromium crash handler |
| Spare renderer | Pre-spawned for next navigation |
The Frida trace caught SearchHost polling registry keys in a loop during initialization:
// Polled 21+ times each during the 5-second init window:
IsWebView2 OK // "is WebView2 ready?"
SnrBundleVersion OK // "is the JS bundle loaded?"
WebView2Version OK // version check
MsbBundleVersion MISS // dead feature flag, fails every time
BINGIDENTITY_PROP_AUTHORITY OK // Bing identity (even with Bing disabled!)
There is no registry key or ViVeTool feature ID that disables WebView2 in SearchHost on
build 26100. IsWebView2=0 gets immediately overridden by the process. The XAML
fallback was removed. This is a fixed ~5 second cost.
Bottleneck 4: The CldFlt Deadlock (Root Cause)
The previous three bottlenecks added up to maybe 15 seconds. The remaining time — and the reason it correlated with Dropbox — came from the cloud files minifilter.
Dropbox Smart Sync uses the Windows Cloud Files API (cfapi) to show
placeholder files. These files appear in the filesystem with their original
names and sizes, but have no local content. Every file has
FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS (0x400000) set —
reading the file content triggers a download through CldFlt.sys.
# Check Dropbox placeholder state
$ Get-ChildItem "$env:USERPROFILE\Dropbox" -Recurse -File |
Select -First 200 | ForEach {
[uint32][IO.File]::GetAttributes($_.FullName) -band 0x400000
} | Group | Select Count, Name
Count Name
200 True # ALL 200 sampled files: RECALL_ON_DATA_ACCESS
On this machine: 9,234 Dropbox files, every single one a placeholder.
The Deadlock Chain
At boot, WSearch starts before Dropbox finishes loading. SearchIndexer
crawls the Dropbox folder and tries to extract content from each file for full-text indexing.
Here's what happens in the minifilter stack:
SearchIndexer ReadFile("proposal.docx")
→ 409800 bindflt (pass-through)
→ 385250 UCPD (pass-through)
→ 328010 WdFilter Defender scans → MsMpEng re-reads
→ 244000 storqosflt (pass-through)
→ 180451 CldFlt "This is a placeholder!"
→ issues CF_CALLBACK_TYPE_FETCH_DATA
→ sends callback to Dropbox sync provider
→ Dropbox is still starting
→ no provider connected to CldFlt
→ I/O BLOCKS (up to 60s timeout)
→ 150000 bfs (never reached while blocked)
→ NTFS / Disk (never reached)
SearchIndexer.exe lives under %systemroot%, which means it
bypasses the cloud file access denial (STATUS_CLOUD_FILE_ACCESS_DENIED,
0xC000CF18) that blocks most services from triggering hydration. It's
"privileged" enough to trigger a download on every placeholder file.
The CldFlt FETCH_DATA callback has no receiver because Dropbox
hasn't connected to the minifilter yet. Each blocked I/O can wait up to 60 seconds before
timing out. With SearchFilterHost spawning threads for multiple files simultaneously,
the thread pool saturates. This is why the Application Event Log showed
66 Event ID 10024 entries: "The filter host process did not respond and
is being forcibly terminated."
The deadlock resolves itself when Dropbox finishes starting and connects its sync provider
to CldFlt. Pending callbacks get serviced, blocked threads unblock, and search
starts working. The user sees this as: search works as soon as the Dropbox tray icon appears.
Why Everything Search Is Instant
voidtools Everything indexes a million files in seconds and returns results instantly. It achieves this by never entering the minifilter stack at all:
Windows Search (SearchIndexer.exe):
CreateFile("proposal.docx") // enters minifilter stack
→ WdFilter scans // Defender re-reads file
→ CldFlt hydrates // downloads from Dropbox
→ ReadFile() contents // more I/O
→ IFilter extracts text // CPU-intensive
→ writes to Windows.db // DB I/O + Defender scan
Everything (Everything.exe):
DeviceIoControl(FSCTL_ENUM_USN_DATA) // reads raw NTFS MFT records
→ gets filename, size, timestamps // metadata only, from MFT
→ never calls CreateFile/ReadFile // BYPASSES ENTIRE FILTER STACK
→ no Defender, no CldFlt, no hydration, no content extraction
The NTFS Master File Table (MFT) is the filesystem's internal allocation table. Every file
on the volume has a ~1 KB record containing its name, parent directory, timestamps, and size.
Everything reads these records directly via FSCTL_ENUM_USN_DATA, which goes straight
to the NTFS driver without passing through any minifilter. It then monitors the USN
(Update Sequence Number) journal for real-time changes.
This is why Everything can index an entire drive in seconds: it never opens a file, never reads file contents, never triggers Defender, and never triggers cloud file hydration.
The Fix
The fix targets all four bottlenecks:
| Bottleneck | Fix | Survives reboot? |
|---|---|---|
| CldFlt/Dropbox deadlock | attrib +I on Dropbox folder (NTFS NOT_CONTENT_INDEXED) |
Yes (NTFS metadata) |
| Defender I/O amplification | Add-MpPreference -ExclusionProcess SearchIndexer.exe |
Yes |
| 1.6 GB index bloat | Null persistent handler on 60 code extensions | Until Windows Update |
| WebView2 cold start | Scheduled task pre-warms search at login | Yes |
The critical fix is the first one. FILE_ATTRIBUTE_NOT_CONTENT_INDEXED
(0x2000) tells SearchIndexer: index filenames and metadata (which doesn't
trigger hydration), but skip content extraction (which does). Dropbox files are still
findable by name. The attribute is NTFS metadata — it survives reboots, index
rebuilds, and Windows Updates.
attrib +I "C:\Users\%USERNAME%\Dropbox" /S /D
Stronger alternative. attrib +I tells the indexer to skip
content extraction, but the tree is still enumerated and every placeholder's metadata is
still touched on each incremental crawl — enough to keep the
cldflt probe path warm. A Crawl Scope Manager URL exclusion
(ISearchCrawlScopeManager::AddUserScopeRule with fInclude=0)
removes the Dropbox tree from the indexer's scope entirely: no walk, no metadata probe,
no CF_CALLBACK_TYPE_FETCH_DATA. attrib +I is per-file and
propagates automatically to files Dropbox creates later; a CSM exclusion is set-and-forget
at the path level. Both are covered in more detail below.
Update: The Orphan Sync Root Variant
The mitigations above were applied on 2026-04-15 and the boot stall appeared resolved. Three days later the same machine regressed: Win+S back to 30-second blanks, even with Dropbox already running for hours. Not a boot-timing issue this time. The cause was a stale sync root registration from a profile migration.
When a Windows account is renamed or re-created, Windows keeps the old profile directory
on disk — and keeps the old Dropbox sync root registration in the registry.
On this machine an old Marty account had been replaced by
marty.CHOPIN (the .CHOPIN suffix is what Windows appends when
an existing profile folder is still present). Two artifacts survived: the
C:\Users\Marty\ directory tree, and the sync root entry under
SyncRootManager:
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\SyncRootManager\
Dropbox!S-1-5-21-1443736254-2868974255-1052510127-1000!dbid:AAAfD... ← ghost SID
Dropbox!S-1-5-21-1443736254-2868974255-1052510127-1003!dbid:AAAfD... ← current user
SID -1000 was not in ProfileList — the
profile itself had been deleted, only the sync root registration survived. When
cldflt resolves a placeholder under the ghost tree, it consults the sync
root table, finds a registered provider for
C:\Users\Marty\Dropbox\*, and issues a CF_CALLBACK_TYPE_FETCH_DATA
to a provider that no process will ever connect. This is a permanent version
of the boot-time deadlock. The original cascade resolves when Dropbox connects to
CldFlt; the orphan variant never resolves, because there is no provider
coming. Users see it as "Windows Search randomly stopped working again," and nothing
about it is random.
Every subfolder of the ghost tree was a cloud-files reparse point — reparse tag
0x9000601A, IO_REPARSE_TAG_CLOUD_A — so any enumeration
from Explorer, Defender, or SearchIndexer that crossed the tree handed the I/O to
cldflt, which blocked on the orphan provider lookup. Deleting files via
Explorer took minutes per file for exactly this reason: the shell asks the cloud provider
to dehydrate before unlinking, and no provider answers.
Two-step fix
# Step 1: remove the orphan Dropbox tree via native rd
# (Explorer delete hangs on cloud-placeholder dehydration callbacks; rd bypasses the shell)
cmd /c "rd /s /q C:\Users\Marty\Dropbox"
# Step 2: unregister the ghost sync root - the step most writeups miss
Remove-Item "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\SyncRootManager\Dropbox!S-1-5-21-1443736254-2868974255-1052510127-1000!dbid:*" -Recurse
# Optional: restart the indexer so cldflt sees the cleaner view immediately
Restart-Service WSearch -Force
Step 1 alone is insufficient. Deleting the folder reclaims disk space but the sync root
registration still exists, and cldflt still has a table entry pointing at a
path whose placeholders can no longer resolve. Step 2 is what actually stops the blocking.
Any machine that has gone through a profile rename is a candidate for this variant,
independent of whether the original boot cascade ever fired.
A CSM exclusion as belt-and-suspenders
Whether the stall is the boot cascade or the orphan sync root, excluding the Dropbox
tree from the indexer's scope prevents either pattern from running. The documented
path is ISearchCrawlScopeManager::AddUserScopeRule, but on Windows 11 build
26100 the interface chain is harder than it looks:
ISearchManager::GetCatalog returns an object whose
QueryInterface refuses both the published
ISearchCatalogManager IID
(AB310581-AC80-11D1-8DF3-00C04FB6EF6A) and the v2 IID
(7D722555-D82F-4FEC-B511-AD5BB0237CD7) with
E_NOINTERFACE. Windows has rolled the interface forward to a GUID that
isn't yet in the public headers.
The fallback is a direct registry write to
HKLM\SOFTWARE\Microsoft\Windows Search\CrawlScopeManager\Windows\SystemIndex\WorkingSetRules\N
with URL, Include=0, Default=0. The key's DACL is
locked to TrustedInstaller — elevated administrators cannot open it for write even
after enabling SeTakeOwnershipPrivilege, and Get-Acl itself
refuses to read the security descriptor. The working approach: dispatch the write from
a one-shot schtasks task running as NT AUTHORITY\SYSTEM, which
has the raw ACL rights the key demands.
# Write the payload that runs inside the SYSTEM task
@'
$ws = 'HKLM:\SOFTWARE\Microsoft\Windows Search\CrawlScopeManager\Windows\SystemIndex\WorkingSetRules'
$n = ((Get-ChildItem $ws).PSChildName | ForEach {[int]$_} | Measure-Object -Max).Maximum + 1
$k = "$ws\$n"
New-Item -Path $k -Force | Out-Null
Set-ItemProperty -Path $k -Name URL -Value 'file:///C:\Users\<you>\Dropbox\*' -Type String
Set-ItemProperty -Path $k -Name Include -Value 0 -Type DWord
Set-ItemProperty -Path $k -Name Default -Value 0 -Type DWord
'@ | Set-Content C:\Windows\Temp\csm-exclude.ps1
# Register, trigger, clean up
Register-ScheduledTask -TaskName "TmpCsmExclude" -Force `
-Action (New-ScheduledTaskAction -Execute powershell.exe -Argument "-File C:\Windows\Temp\csm-exclude.ps1") `
-Principal (New-ScheduledTaskPrincipal -UserId SYSTEM -RunLevel Highest -LogonType ServiceAccount) `
-Trigger (New-ScheduledTaskTrigger -Once -At (Get-Date).AddYears(1))
Start-ScheduledTask -TaskName "TmpCsmExclude"
Start-Sleep -Seconds 3
Unregister-ScheduledTask -TaskName "TmpCsmExclude" -Confirm:$false
Restart-Service WSearch -Force
Once the rule is in WorkingSetRules, the crawl scope manager treats the
Dropbox tree as out of scope for all future queries. The walk never starts,
cldflt is never consulted for placeholders under that path, and neither
the boot cascade nor the orphan-sync-root variant can fire. This buys stability until
the replacement engine described below is fully installed. A Dropbox folder excluded
this way is no longer searchable via Win+S — which is the core reason replacement
rather than exclusion is the direction the work ultimately took.
The Full Cascade
What makes this bug hard to diagnose is that it's not one problem — it's four independent issues that amplify each other:
t=0s Windows boots
t=2s WSearch service starts (Automatic)
SearchIndexer begins crawling user profile
Hits Dropbox folder: 9,234 placeholder files
t=3s SearchFilterHost tries content extraction
CldFlt blocks → Dropbox not ready → threads hang
WdFilter scans each file Defender tries to read → more blocking
t=5s FilterHost threads pile up, start getting killed (Event 10024)
Meanwhile: SearchHost spawning 6 WebView2 processes (370 MB)
WebView2 polling IsWebView2 / SnrBundleVersion in a loop
t=8s WebView2 initialized, but no search results available
tquery.dll waiting on index that's being rebuilt
Windows.db growing (content extraction still running on non-Dropbox files)
t=25s Dropbox tray icon appears → provider connects to CldFlt
Blocked I/O unblocks → pending FilterHost work completes
t=30s Search starts working
Sidebar: The Altitude Arms Race
The minifilter altitude system is a "gentleman's agreement" enforced by Microsoft's altitude registration process. It works because everyone plays by the rules at the filter stack level. The real arms race — particularly in anti-cheat vs cheat — happens below:
Ring 3 Userspace apps, cheats (DLL inject, memory edit)
Ring 0 Kernel drivers, anti-cheat, minifilters (WdFilter, CldFlt)
Ring -1 Hypervisor VT-x / AMD-V (Hyper-V, Riot Vanguard)
Ring -2 SMM System Management Mode (Domas "Memory Sinkhole" 2015)
Ring -3 ME / PSP separate CPU, own OS, DMA to everything
Ring -3 is Intel Management Engine (a full Minix 3 OS running on a separate x86 core inside every Intel CPU since 2008) or AMD Platform Security Processor (ARM Cortex-A5 with TrustZone). Both have DMA access to all system memory, run when the PC is "off," and execute firmware signed by the vendor's RSA key. The chipset encryption key was extracted in 2020 (CVE-2019-0090), so the firmware can be read, but the signing key has not been found — modified firmware won't execute.
Positive Technologies also discovered a hidden HAP (High Assurance Platform) bit that disables most ME functionality — traced to an NSA program. The NSA asked Intel for a kill switch for their own machines while everyone else runs ME unmodified.
How Everything Search Reads the MFT
The previous section showed that Everything bypasses the minifilter stack entirely. Here is the exact mechanism, because understanding it matters for building alternatives.
Everything opens the volume directly with CreateFile("\\\\.\\C:", ...), obtaining
a volume handle — not a file handle. It then calls
DeviceIoControl with FSCTL_ENUM_USN_DATA to walk the NTFS
Master File Table record by record. Each record returns three fields:
FileReferenceNumber, ParentFileReferenceNumber, and
FileName. That is the complete dataset — metadata only, no file content
is read at any point.
// Open volume handle (not a file handle)
HANDLE hVol = CreateFileW(
L"\\\\.\\C:",
GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL, OPEN_EXISTING, 0, NULL
);
// Walk the MFT record by record
MFT_ENUM_DATA_V0 med = { 0, 0, maxUsn };
while (DeviceIoControl(hVol, FSCTL_ENUM_USN_DATA,
&med, sizeof(med), buf, bufSize, &bytesReturned, NULL)) {
// Each USN_RECORD contains:
// FileReferenceNumber — unique MFT index
// ParentFileReferenceNumber — parent directory MFT index
// FileName — file/directory name
// NO content read. NO file handle opened. NO minifilter traversal.
}
The distinction is critical: minifilters like WdFilter and CldFlt register callbacks on
IRP_MJ_CREATE (file open) and IRP_MJ_READ (file read) for
individual file handles. A FSCTL_ENUM_USN_DATA call on a volume handle is
a device I/O control request to the NTFS driver. It is not
IRP_MJ_CREATE. The entire filter stack — every minifilter at every
altitude — is bypassed. No Defender scan. No CldFlt hydration check. No content
extraction. The NTFS driver reads its own internal data structures and returns the results
directly.
For real-time updates after the initial scan, Everything calls
DeviceIoControl(FSCTL_READ_USN_JOURNAL), which returns a stream of change
records (file created, renamed, deleted) as they happen. This too operates on the volume
handle, not on individual files.
As the Everything developer (voidtools handle "void")
explained on the voidtools forum:
FSCTL_ENUM_USN_DATA "does not walk through the change journal — this call
walks through the MFT to identify files." The change journal is a separate structure that
records modifications; the MFT is the filesystem's master allocation table. Everything reads
the allocation table directly, then subscribes to the journal for incremental updates.
The performance difference is staggering. A fresh Windows 11 install has roughly 120,000 files. Everything indexes them all in approximately 1 second. Windows Search takes minutes to hours, because it opens every file individually, passes each through the minifilter stack, extracts text content via IFilter plugins, and writes the results to a SQLite database — which itself passes through the minifilter stack on every write.
| Everything | Windows Search | |
|---|---|---|
| Initial scan | ~1 second (120k files) | Minutes to hours |
| Content indexing | None (filenames only) | Full text via IFilter plugins |
| Minifilter interaction | None (volume-level FSCTL) | Every file traverses full stack |
| Hydration trigger | Never (no file open) | Every cloud placeholder |
| Defender interaction | None | Scans every file opened + every DB write |
| Database format | Custom binary (typically <50 MB) | SQLite (Windows.db, often >1 GB) |
| Update mechanism | USN journal monitoring (FSCTL) | Filesystem change notifications + periodic recrawl |
Replacing the Search Engine
The mitigations above reduce the stall on a machine that's already in a bad state, but
every new Dropbox placeholder, every profile rename, every Windows feature update can
re-introduce a minifilter path that hands file I/O back to cldflt. The
durable fix isn't a configuration knob. It's a replacement search engine that never
opens a file in the first place, wired into Windows' own Win+S interface so nothing
downstream notices the swap. That is what the rest of this post describes and what is
currently being built — a Rust daemon called supersearch that reads
the NTFS Master File Table directly and serves results to SearchHost.exe
via an injected detour in oleaut32.
Knowing that MFT enumeration sidesteps the entire minifilter problem, the logical next question is: can you replace the Windows Search engine entirely, without losing the native Win+S interface? The answer requires understanding two things — how to get code into the search pipeline, and what architecture to use once you are there.
Injection Vectors
There are many vectors for intercepting the Windows Search pipeline, ranging from high-level window hooks to kernel callbacks. The following is a comprehensive survey organized by privilege level:
Keyboard Level
WH_KEYBOARD_LL SetWindowsHookEx low-level keyboard hook
Raw Input RegisterRawInputDevices (WM_INPUT messages)
Window Level
WH_CBT SetWindowsHookEx CBT hook (window creation/activation)
SetWinEventHook Accessibility event hook (focus, state changes)
DLL Injection
SetWindowsHookEx WH_CBT / WH_GETMESSAGE injects DLL into target process
CreateRemoteThread + LoadLibrary in target process address space
AppInit_DLLs Registry key: DLL loaded into every user32.dll process
IFEO Image File Execution Options debugger attach
DLL search order Place DLL in application directory before system32
Function Hooking (once inside the process)
IAT patching Overwrite Import Address Table entries
Inline / Detours 5-byte prologue patch: JMP to hook function
VTable hooking Replace COM interface method pointers
Hot-patching MOV EDI,EDI preamble → short JMP to hook
COM Level
CLSID hijack Registry: redirect InprocServer32 to custom DLL
CoCreateInstance hook Intercept COM object creation in-process
IClassFactory replace Register custom factory for target CLSID
IPC Interposition
Named pipe proxy Sit between client and server on a named pipe
ALPC interception Advanced Local Procedure Call port redirection
RPC filter RPC firewall or custom RPC interface registration
Search Protocol
ISearchProtocol Custom protocol handler registered with SearchIndexer
IFilter Custom content filter for specific file types
Kernel
Minifilter driver FltRegisterFilter at a chosen altitude
Callback objects PsSetCreateProcessNotifyRoutine, ObRegisterCallbacks
Windhawk, a popular Windows modding framework, uses
a combination of two of these vectors: SetWindowsHookEx(WH_CBT) for global
DLL injection (the CBT hook causes Windows to load the Windhawk DLL into every process that
creates a window), followed by Microsoft Detours-style inline hooking once inside the
target process. The inline hook overwrites the first 5 bytes of the target function's
prologue with a JMP rel32 instruction that redirects execution to the hook
function. A trampoline preserves the original bytes so the real function can still be called.
Any of these vectors could work as a standalone approach. The choice depends on what you need to intercept and at what level. The natural first guess is a COM CLSID hijack — register your DLL at the search backend's CLSID and let Windows load it automatically. That turned out to be wrong on several counts.
Architecture: What Actually Works
A COM trace of SearchHost.exe during live queries revealed two critical facts
that invalidate the obvious approach:
First, SearchHost.exe does not use ISearchManager. It calls
CoCreateInstance directly on the Windows Search OLE DB provider
(CLSID {9e175b8b-f52a-11d8-b9a5-505054503030}, implemented by
tquery.dll). The SQL captured from a simple "notepad" search is a full OLE DB
query with CONTAINS(), RANK BY, and scope filters:
SELECT TOP 15
"System.Kind", "System.Search.Rank", path,
"System.DateModified", "System.ItemNameDisplay", ...
FROM SystemIndex
WHERE (CONTAINS(System.ItemNameDisplay, '"notepad*"')
RANK BY COERCION(ABSOLUTE, 960))
OR (CONTAINS(System.Title, '"notepad*"') ...)
AND Scope = 'file://'
ORDER BY "System.Search.Rank" DESC
Second, the tquery.dll CLSID is protected by Windows Resource Protection (WRP).
Writing to HKLM\SOFTWARE\Classes\CLSID\{9e175b8b-...}\InprocServer32 fails with
access denied even as LocalSystem with SeTakeOwnershipPrivilege.
The registry key is locked by the TrustedInstaller SID and the system-level ACL cannot be
modified from user mode. The CLSID hijack path is a dead end.
The approach that works combines DLL injection + inline detours, the same technique
Windhawk uses. A small launcher
calls OpenProcess + VirtualAllocEx + WriteProcessMemory
+ CreateRemoteThread(LoadLibraryW) to inject a DLL. One AppContainer detail matters:
the DLL must live in a path the sandbox can read — copying it to
C:\Windows\System32 makes it loadable. Once inside, the DLL patches
SysAllocString / SysAllocStringLen in
oleaut32.dll with a 14-byte absolute JMP to intercept every BSTR
allocation. SearchHost builds the SQL query as a BSTR before passing it to
tquery.dll, so the hook sees the full query text and can parse out the search
term.
Win+S keystroke
|
v
+---------------------+
| SearchHost.exe | (AppContainer, packaged)
+---------------------+
|
builds BSTR with SQL:
"SELECT ... CONTAINS('notepad*') ..."
|
SysAllocString(sql) ← INTERCEPTED
|
+---------------------+
| supersearch_hook | injected via CreateRemoteThread
| (14-byte JMP patch) | DLL lives in System32
+---------------------+
| |
parse SQL call original
extract term (trampoline)
| |
v v
+------------------+ +-------------+
| Named Pipe | | tquery.dll | (original flow)
| \\.\pipe\ | | |
| supersearch | +-------------+
+------------------+
|
v
+---------------------------+
| MFT Search Daemon | (Rust, zero deps)
| |
| FSCTL_ENUM_USN_DATA | ← initial MFT scan
| FSCTL_READ_USN_JOURNAL | ← real-time updates
| Trigram index (flat u32) | ← 88 MB for 877k files
| String pool (shared buf) | ← no per-string alloc
| Nystrom-style SQL parser | ← handles CONTAINS()
+---------------------------+
|
v
+---------------------------+
| NTFS Volume |
| (MFT read via volume |
| handle — no minifilter |
| traversal) |
+---------------------------+
Component 1: MFT Search Daemon. A Rust process that reads the MFT via
FSCTL_ENUM_USN_DATA on startup, building a complete filename index for all NTFS
volumes. It monitors the USN journal via FSCTL_READ_USN_JOURNAL for real-time
updates. The index is a trigram map with flat storage — one Vec<u32>
for posting lists, one String buffer for all filenames. For 877,000 files on a
Ryzen 2400G the daemon uses about 181 MB of RAM, indexes the full volume in 3 seconds,
and answers queries in under 2 milliseconds. The query interface is a named pipe
(\\.\pipe\supersearch) with a length-prefixed binary protocol.
Component 2: Injected Detour DLL. A DLL installed to
C:\Windows\System32\supersearch_hook.dll (required by the AppContainer sandbox
— DLLs in user-profile directories fail to load). A small launcher injects it into
SearchHost.exe via CreateRemoteThread(LoadLibraryW). The DLL patches
SysAllocString and SysAllocStringLen in
oleaut32.dll with a 14-byte absolute JMP instruction. The patched
function inspects every BSTR being allocated, matches SQL queries against SystemIndex,
parses out the search term, queries the daemon over the named pipe, and (for the full
implementation) returns an IRowset built from the daemon's response in the
format SearchHost's WebView2 UI expects.
For queries that bypass the MFT engine (email search, web results, settings, calculator),
the hook returns control to the original SysAllocString via the trampoline and
the real tquery.dll processes the query normally. Only file-search queries get
intercepted; everything else falls through untouched.
No injection framework is needed. No SetWindowsHookEx (which AppContainer
sandboxes ignore), no hooking library, no compiled detours library. The DLL manually patches
the function prologue with a FF 25 00 00 00 00 absolute JMP followed by the
target address — 14 bytes total, guaranteed to fit in any standard function prologue
on x64. The original bytes go into a trampoline allocated with
VirtualAlloc(PAGE_EXECUTE_READWRITE) so the real function can still be called.
Methodology
Environment: Windows 11 Pro 10.0.26100, Ryzen 5 2400G (Vega 11), 14 GB RAM, Dropbox with Smart Sync (9,234 cloud-only files)
Tools used:
- Frida 17.9.1 (Python bindings) — runtime instrumentation of SearchHost.exe.
Hooks on
WaitForSingleObject,WaitForMultipleObjects,MsgWaitForMultipleObjectsEx,NtAlpcSendWaitReceivePort,CoCreateInstance,CreateFileW,RegQueryValueExW,CreateProcessW,LoadLibraryExW - Custom Python tooling (ctypes) —
NtQueryInformationThreadfor thread start addresses,EnumProcessModulesExfor module mapping,MiniDumpWriteDumpfor offline analysis - WPR (Windows Performance Recorder) — kernel ETW tracing with custom profile targeting Search-Core, Search-UI, and RPC providers
- PowerShell — process monitoring, registry analysis, NTFS attribute manipulation, Defender configuration, scheduled task management
- fltmc — minifilter enumeration and instance mapping