Detecting DLL Side-Loading and Search Order Hijacking (T1574.001, T1574.002): KQL and SPL Detection Rules
DLL side-loading is one of the few techniques that reliably survives contact with a well-instrumented SOC. The reason is simple: nothing in the process tree looks wrong. A digitally signed, legitimately published executable starts, and it loads a DLL it is genuinely designed to load. The only thing the attacker changed is which DLL got resolved first. Your process-creation alerts see a signed binary. Your allow-list sees a known publisher. Your parent-child heuristics see a normal launch. The malicious code runs inside that trusted process for its entire lifetime.
This post covers detection engineering for T1574.001 (DLL Search Order Hijacking) and T1574.002 (DLL Side-Loading) with concrete KQL for Microsoft Sentinel and Defender XDR, and concrete SPL for Splunk. Every query below is written to be tuned, not deployed blind — image-load telemetry is high volume, and a rule that fires on every software installer will be muted within a week.
What the technique actually looks like on disk
Windows resolves an unqualified DLL name through a defined search order. When safe DLL search mode is enabled — the default — the application's own directory is searched before the system directories. That single ordering rule is the entire attack surface.
Three variants dominate real intrusions:
- Classic side-loading. The attacker copies a legitimate signed EXE into a writable directory and drops a malicious DLL with the exact name the EXE imports alongside it. The DLL loads, usually proxies its exports to the real library so the application still works, and executes the payload. Signed installers, updaters, and bundled utilities from security vendors, chat clients, and remote-support tools have all been abused this way, precisely because they are commonly allow-listed.
- Phantom DLL hijacking. The target application imports a DLL that does not exist on that system or in that path. Any writable directory earlier in the search order becomes an execution primitive — no overwrite required, so file-integrity monitoring on system directories never fires.
- Relative-path and WinSxS abuse. A binary launched with a working directory the attacker controls, or a manifest that resolves a side-by-side assembly to an attacker-writable location.
The practical takeaway for detection: stop looking at the process and start looking at the module. The anomaly is not the EXE, it is the pairing of a legitimate EXE with a DLL that has the wrong path, the wrong signer, or the wrong prevalence.
Telemetry prerequisites
You need image-load events. In Microsoft Defender for Endpoint that is DeviceImageLoadEvents, which is available in Advanced Hunting and can be exported to Sentinel. In Splunk you need Sysmon Event ID 7 (Image Loaded), which is disabled in most default configs because of its volume. Enable it with a filtering config that includes unsigned modules and modules outside System32/SysWOW64/WinSxS, rather than logging every load. Pair it with Event ID 11 (File Create) so you can correlate the drop with the load.
Detection 1: signed process loading an unsigned, co-located DLL
This is the highest-signal single rule for T1574.002. The DLL sits in the same directory as the host executable, that directory is user-writable, and the DLL is unsigned or its signature is untrusted.
// T1574.002 - side-loaded DLL co-located with host binary in a writable path
let lookback = 14d;
DeviceImageLoadEvents
| where Timestamp > ago(lookback)
| where FileName endswith '.dll'
| where FolderPath contains @'\Users\'
or FolderPath contains @'\ProgramData\'
or FolderPath contains @'\Public\'
or FolderPath contains @'\Windows\Temp\'
| extend DllDir = tolower(parse_path(FolderPath).DirectoryPath)
| extend ExeDir = tolower(parse_path(InitiatingProcessFolderPath).DirectoryPath)
| where DllDir == ExeDir
| join kind=leftouter (
DeviceFileCertificateInfo
| project SHA1, IsSigned, IsTrusted, Signer
) on SHA1
| where isempty(Signer) or IsTrusted == false
| summarize LoadCount = count(), HostCount = dcount(DeviceId),
FirstSeen = min(Timestamp), LastSeen = max(Timestamp),
Devices = make_set(DeviceName, 15)
by FileName, FolderPath, SHA256,
HostBinary = InitiatingProcessFileName,
HostBinaryPath = InitiatingProcessFolderPath, Signer
| where HostCount <= 5
| order by FirstSeen descThe HostCount <= 5 clause is doing the real work. Legitimate applications that ship unsigned helper DLLs next to their EXE exist on hundreds of endpoints; a side-load lands on a handful. Tune this threshold to your fleet size — on a 50,000-seat estate, 5 is generous; on a 300-seat estate, drop it to 2.
The Splunk equivalent, using Sysmon EID 7:
index=win_sysmon sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=7
(ImageLoaded="*\\Users\\*" OR ImageLoaded="*\\ProgramData\\*"
OR ImageLoaded="*\\Public\\*" OR ImageLoaded="*\\Windows\\Temp\\*")
(Signed="false" OR SignatureStatus!="Valid")
| eval dll_parts=split(ImageLoaded,"\\"), exe_parts=split(Image,"\\")
| eval dll_dir=lower(mvjoin(mvindex(dll_parts,0,mvcount(dll_parts)-2),"\\"))
| eval exe_dir=lower(mvjoin(mvindex(exe_parts,0,mvcount(exe_parts)-2),"\\"))
| where dll_dir==exe_dir
| stats count AS load_count, dc(host) AS host_count,
min(_time) AS first_seen, max(_time) AS last_seen,
values(host) AS hosts, values(Signature) AS signer
BY Image, ImageLoaded, Hashes
| where host_count<=5
| convert ctime(first_seen) ctime(last_seen)
| sort - first_seenDetection 2: commonly abused module names loading from outside the system directories
A short list of modules accounts for a disproportionate share of hijacks, because they are small, widely imported, and frequently resolved by name. Any load of these from a path that is not System32, SysWOW64, or WinSxS deserves a look.
// T1574.001 - frequently hijacked modules resolving outside system directories
let AbusedModules = dynamic(['version.dll','dbghelp.dll','dbgcore.dll','wtsapi32.dll',
'winmm.dll','secur32.dll','sspicli.dll','cryptsp.dll',
'profapi.dll','dwmapi.dll','userenv.dll','edputil.dll',
'textinputframework.dll','vcruntime140.dll','msvcp140.dll']);
DeviceImageLoadEvents
| where Timestamp > ago(7d)
| where FileName in~ (AbusedModules)
| where not(FolderPath has_any (@'\Windows\System32\', @'\Windows\SysWOW64\',
@'\Windows\WinSxS\', @'\Program Files\',
@'\Program Files (x86)\'))
| summarize LoadCount = count(), HostCount = dcount(DeviceId),
Paths = make_set(FolderPath, 10), Hosts = make_set(DeviceName, 10)
by FileName, SHA256, HostBinary = InitiatingProcessFileName
| where HostCount <= 3
| order by LoadCount ascindex=win_sysmon EventCode=7
(ImageLoaded="*\\version.dll" OR ImageLoaded="*\\dbghelp.dll" OR ImageLoaded="*\\dbgcore.dll"
OR ImageLoaded="*\\wtsapi32.dll" OR ImageLoaded="*\\winmm.dll" OR ImageLoaded="*\\secur32.dll"
OR ImageLoaded="*\\sspicli.dll" OR ImageLoaded="*\\cryptsp.dll" OR ImageLoaded="*\\profapi.dll"
OR ImageLoaded="*\\dwmapi.dll" OR ImageLoaded="*\\userenv.dll" OR ImageLoaded="*\\vcruntime140.dll")
NOT (ImageLoaded="*\\Windows\\System32\\*" OR ImageLoaded="*\\Windows\\SysWOW64\\*"
OR ImageLoaded="*\\Windows\\WinSxS\\*")
| stats count AS load_count, dc(host) AS host_count, values(ImageLoaded) AS paths,
values(host) AS hosts BY Image, Signed, Hashes
| where host_count<=3
| sort load_countDo not treat this list as complete. Build your own by extracting every DLL name loaded from a non-system path across 30 days, ranking by host prevalence, and reviewing the long tail. That list is environment-specific and far more valuable than any published one.
Detection 3: drop-then-load within minutes
The strongest behavioural signal is temporal. A DLL written to disk by one process and loaded by a different process minutes later is a staging pattern, not a software update. This rule catches loader chains that Detections 1 and 2 miss because the DLL happens to carry a stolen or valid signature.
// Drop-then-load correlation - DLL written, then loaded by a different binary within 10 minutes
let window = 1d;
let drops = DeviceFileEvents
| where Timestamp > ago(window)
| where FileName endswith '.dll'
| where ActionType in ('FileCreated','FileModified')
| project DropTime = Timestamp, DeviceId, DeviceName, SHA256, FileName, FolderPath,
Dropper = InitiatingProcessFileName, DropperCmd = InitiatingProcessCommandLine;
let loads = DeviceImageLoadEvents
| where Timestamp > ago(window)
| where FileName endswith '.dll'
| project LoadTime = Timestamp, DeviceId, SHA256,
Loader = InitiatingProcessFileName, LoaderPath = InitiatingProcessFolderPath,
LoaderCmd = InitiatingProcessCommandLine;
drops
| join kind=inner loads on DeviceId, SHA256
| where LoadTime between (DropTime .. DropTime + 10m)
| where tolower(Dropper) != tolower(Loader)
| extend DeltaSeconds = datetime_diff('second', LoadTime, DropTime)
| project DropTime, LoadTime, DeltaSeconds, DeviceName, Dropper, DropperCmd,
FileName, FolderPath, Loader, LoaderPath, LoaderCmd, SHA256
| order by DropTime descindex=win_sysmon (EventCode=11 OR EventCode=7)
| eval dll_path=coalesce(TargetFilename, ImageLoaded)
| search dll_path="*.dll"
| eval phase=if(EventCode==11,"drop","load")
| stats min(eval(if(phase=="drop",_time,null()))) AS drop_time,
min(eval(if(phase=="load",_time,null()))) AS load_time,
values(eval(if(phase=="drop",Image,null()))) AS dropper,
values(eval(if(phase=="load",Image,null()))) AS loader
BY host, dll_path
| where isnotnull(drop_time) AND isnotnull(load_time)
| eval delta=load_time-drop_time
| where delta>=0 AND delta<=600 AND dropper!=loader
| table host, dll_path, dropper, loader, delta, drop_time, load_time
| convert ctime(drop_time) ctime(load_time)
| sort deltaTuning: what will generate false positives
Expect noise from four sources, and handle each with an explicit exclusion rather than by raising thresholds:
- Software installers and updaters that extract to
%TEMP%or%ProgramData%and load from there. Exclude by dropper signer, not by path — excluding\Windows\Temp\outright hands attackers a blind spot. - Developer toolchains. Build outputs, test harnesses, and package managers load unsigned DLLs constantly. Scope those exclusions to developer device groups, not globally.
- Electron and Python applications that ship interpreter and native extension modules alongside the EXE. These are high-volume and stable, so prevalence filtering removes most of them automatically.
- Portable applications run from user profiles. Worth investigating once, then allow-listing by hash.
Keep every exclusion in a watchlist or lookup with an owner and a review date. Side-loading detections rot faster than most rules because the environment changes underneath them.
Triage workflow
When one of these fires, answer four questions in order. First, is the host binary in its expected location? A signed vendor EXE running from C:\Users\<user>\AppData\Local\Temp\ is a stronger indicator than the DLL itself. Second, does the DLL export the same functions as the legitimate module, plus extras? Proxy DLLs almost always forward exports. Third, what did the host process do next? Pivot to network connections and child processes — side-loaded implants typically beacon, so correlate against your application-layer C2 detections. Fourth, how did the pair land on disk? The dropping process and its command line usually reveal the initial access vector.
Validation and coverage
Validate with Atomic Red Team tests for T1574.001 and T1574.002 in a controlled lab, and confirm three things separately: that Sysmon EID 7 or DeviceImageLoadEvents actually captured the load, that your signature enrichment resolved, and that the alert reached the queue. A rule that works in Advanced Hunting but has no matching data in Sentinel because of an ingestion filter is a common and expensive gap.
Side-loading rarely appears alone. It is an execution and defense-evasion primitive, so build your coverage around it: pair these rules with detections for masquerading as a legitimate name or location, signed binary proxy execution, process injection, and run key persistence. The full technique pages for T1574.001 and T1574.002 include additional query variants and data-source mappings.
If you deploy only one of the three rules above, deploy the first. Co-located unsigned DLL plus low host prevalence is a small, cheap query that catches the majority of commodity loaders — and unlike most module-level detections, it produces an alert an analyst can actually action in under five minutes.