Detecting Windows Persistence: Run Keys, WMI Event Subscriptions, and Malicious Services (T1547.001, T1546.003, T1543.003) with KQL and SPL
Scheduled tasks get all the attention, but they are only one of the autostart extensibility points (ASEPs) that show up in intrusion reporting. Three others appear in almost every hands-on-keyboard intrusion: a value written to a registry Run key, a WMI event filter bound to a command-line consumer, and a service installed pointing at something that has no business being a service.
All three are well-instrumented. The problem is not visibility — it is that most SOCs deploy a rule like "alert on any Run key write", watch it fire 400 times a day from Teams, OneDrive, and the software deployment agent, and quietly route it to a dashboard nobody reads. This post gives you queries shaped for production: narrow enough to page on, with the tuning levers spelled out.
Why these three belong in one detection package
Each of the three fires under a different trigger model, which is exactly why attackers pick between them:
- T1547.001 — Registry Run Keys / Startup Folder runs at user logon, in user context. Cheap, noisy, and the default for commodity loaders and infostealers.
- T1546.003 — WMI Event Subscription runs on an arbitrary trigger (uptime threshold, process start, a specific time) as SYSTEM, and lives in the WMI repository rather than the filesystem. It is the fileless option, and it is the one most likely to survive a rebuild-from-image decision made on incomplete data.
- T1543.003 — Create or Modify System Process: Windows Service runs at boot as SYSTEM. It is also the artifact left behind by remote service execution (T1569.002), so the same telemetry does double duty for lateral movement.
Because the trigger models differ but the intent is identical, they are worth building as one rule package with shared allowlists — and worth correlating, which is where the real signal lives.
T1547.001 — Registry Run keys
In Microsoft Defender XDR and Sentinel, DeviceRegistryEvents gives you the writing process, which is the field that actually decides the verdict. Note the use of contains rather than has_any for the key paths: has operators are term-based, and a multi-token path like \CurrentVersion\Run does not match reliably as a term. contains is a slower substring scan, but it is correct.
let Lookback = 7d;
DeviceRegistryEvents
| where Timestamp > ago(Lookback)
| where ActionType in ('RegistryValueSet', 'RegistryKeyCreated')
| where RegistryKey contains '\\CurrentVersion\\Run'
or RegistryKey contains '\\CurrentVersion\\Explorer\\User Shell Folders'
or RegistryKey contains '\\CurrentVersion\\Winlogon'
or RegistryKey contains '\\Policies\\Explorer\\Run'
| extend Value = tolower(tostring(RegistryValueData))
| where Value has_any ('appdata', 'temp', 'programdata', 'public', 'perflogs')
or Value has_any ('powershell', 'wscript', 'cscript', 'mshta', 'rundll32', 'regsvr32', 'certutil')
or Value matches regex @'-e(nc|c|ncodedcommand)\s|frombase64string|-w\s+hidden|-nop'
| project Timestamp, DeviceName, InitiatingProcessAccountName, InitiatingProcessFileName,
InitiatingProcessCommandLine, RegistryKey, RegistryValueName, RegistryValueData
| sort by Timestamp descThe interpreter list overlaps heavily with T1059.001 tradecraft — if a Run value invokes PowerShell with an encoded command, you have two techniques in one artifact and should escalate accordingly.
Content-based filters miss novel tooling, so pair the rule above with a rarity query. This one asks a different question: which autostart values exist on almost no machines in the fleet?
DeviceRegistryEvents
| where Timestamp > ago(30d)
| where ActionType == 'RegistryValueSet'
| where RegistryKey contains '\\CurrentVersion\\Run'
| summarize HostCount = dcount(DeviceName), Hosts = make_set(DeviceName, 10),
FirstSeen = min(Timestamp), LastSeen = max(Timestamp),
Writers = make_set(InitiatingProcessFileName, 5)
by RegistryValueName, tostring(RegistryValueData)
| where HostCount <= 2 and FirstSeen > ago(3d)
| sort by FirstSeen ascThe Splunk equivalent uses Sysmon Event ID 12 (key create/delete) and 13 (value set). The Details field holds the value data.
index=windows sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational"
EventCode IN (12,13) TargetObject="*\\CurrentVersion\\Run*"
| eval details_lc=lower(Details)
| search details_lc="*\\appdata\\*" OR details_lc="*\\temp\\*"
OR details_lc="*\\programdata\\*" OR details_lc="*\\users\\public\\*"
OR details_lc="*powershell*" OR details_lc="*mshta*" OR details_lc="*rundll32*"
OR details_lc="*regsvr32*" OR details_lc="*wscript*" OR details_lc="*certutil*"
| stats min(_time) AS firstSeen, max(_time) AS lastSeen,
values(Image) AS writing_process, values(User) AS user,
dc(host) AS host_count, values(host) AS hosts
BY TargetObject, Details
| where host_count <= 2
| convert ctime(firstSeen) ctime(lastSeen)
| sort + firstSeenT1546.003 — WMI event subscriptions
This is the highest-fidelity of the three. Legitimate permanent WMI subscriptions in a typical enterprise number in the low dozens and come from a short list of vendors — SCCM/Configuration Manager, Dell and HP management agents, some backup products. Everything else deserves a look.
In Defender XDR, the binding event is surfaced through DeviceEvents:
DeviceEvents
| where Timestamp > ago(7d)
| where ActionType == 'WmiBindEventFilterToConsumer'
| extend Fields = parse_json(AdditionalFields)
| extend FilterQuery = tostring(Fields.Query),
ConsumerName = tostring(Fields.ConsumerName),
ConsumerType = tostring(Fields.ConsumerType),
ConsumerCmd = tostring(Fields.Command)
| project Timestamp, DeviceName, InitiatingProcessAccountName, InitiatingProcessFileName,
InitiatingProcessCommandLine, ConsumerType, ConsumerName, ConsumerCmd, FilterQuery,
RawFields = AdditionalFields
| sort by Timestamp descKey names inside AdditionalFields vary between sensor versions. Run the query once without the extend block, read the raw JSON in your own tenant, and pin the field names to what you actually see before you promote this to an analytics rule. Prioritise CommandLineEventConsumer and ActiveScriptEventConsumer — those two execute code directly. LogFileEventConsumer and NTEventLogEventConsumer are lower risk.
If you are ingesting the Microsoft-Windows-WMI-Activity/Operational channel into Sentinel instead, Event ID 5861 records permanent consumer registration and carries the same detail in its message body.
For Splunk, Sysmon Event IDs 19, 20, and 21 map to filter, consumer, and binding respectively. Because legitimate subscriptions are few and stable, an allowlist lookup is the right tuning mechanism here rather than content filters:
index=windows sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational"
EventCode IN (19,20,21)
| eval artifact=case(EventCode==19,"EventFilter",
EventCode==20,"EventConsumer",
EventCode==21,"FilterToConsumerBinding")
| eval payload=coalesce(Query, Destination, Consumer)
| search NOT [ | inputlookup wmi_subscription_allowlist.csv | fields Name ]
| table _time, host, artifact, Operation, User, Name, Type, payload, Filter, Consumer
| sort - _timeBuild wmi_subscription_allowlist.csv by running the query with the subsearch removed for 30 days and reviewing every distinct Name. That review is a one-off cost of an hour or two and it is what makes this rule pageable.
T1543.003 — Malicious service installs
Service creation is logged in three places: System Event ID 7045, Security Event ID 4697 (requires the Audit Security System Extension subcategory), and the Defender XDR ServiceInstalled action. The most durable indicator is not the path — it is the name. Several remote-execution tools generate a random alphabetic service name per execution, which produces a fleet-wide population of service names that appear exactly once.
DeviceEvents
| where Timestamp > ago(7d)
| where ActionType == 'ServiceInstalled'
| extend Fields = parse_json(AdditionalFields)
| extend ServiceName = tostring(Fields.ServiceName),
ImagePath = tolower(tostring(Fields.ImagePath)),
StartType = tostring(Fields.ServiceStartType)
| where ImagePath has_any ('temp', 'appdata', 'programdata', 'public', 'perflogs')
or ImagePath has_any ('powershell', 'rundll32', 'regsvr32', 'mshta', 'cscript', 'wscript')
or ImagePath endswith '.ps1' or ImagePath endswith '.bat' or ImagePath endswith '.cmd'
or ServiceName matches regex @'^[a-zA-Z]{8}$'
| project Timestamp, DeviceName, ServiceName, ImagePath, StartType,
InitiatingProcessAccountName, InitiatingProcessFileName, InitiatingProcessCommandLine
| sort by Timestamp desc(index=windows sourcetype="WinEventLog:System" EventCode=7045)
OR (index=windows sourcetype="WinEventLog:Security" EventCode=4697)
| eval svc_name=coalesce(Service_Name, ServiceName)
| eval svc_path=lower(coalesce(Service_File_Name, ServiceFileName))
| search svc_path="*\\windows\\temp\\*" OR svc_path="*\\appdata\\*"
OR svc_path="*\\programdata\\*" OR svc_path="*\\users\\public\\*"
OR svc_path="*powershell*" OR svc_path="*rundll32*" OR svc_path="*regsvr32*"
OR svc_path="*.ps1*" OR svc_path="*.bat*"
| stats count, min(_time) AS firstSeen, values(svc_path) AS paths,
values(Service_Start_Type) AS start_type, dc(host) AS host_count
BY svc_name
| where host_count <= 3
| convert ctime(firstSeen)
| sort + firstSeenAdd | regex svc_name="^[a-zA-Z]{8}$" as a separate saved search rather than folding it into the above — the random-name pattern is high-signal on its own and you want it to alert independently of path heuristics.
The correlation that makes this worth paging on
Any one of these events, in isolation, has a defensible benign explanation. The combination that rarely does: a binary that is new and rare across your fleet writes an autostart entry. This query unions all three techniques and joins against binaries seen on three or fewer hosts in the last 30 days.
let RareBinaries =
DeviceProcessEvents
| where Timestamp > ago(30d)
| summarize FirstSeenGlobally = min(Timestamp), HostSpread = dcount(DeviceName) by SHA256
| where HostSpread <= 3 and FirstSeenGlobally > ago(2d)
| project SHA256;
union
( DeviceRegistryEvents
| where Timestamp > ago(2d)
| where RegistryKey contains '\\CurrentVersion\\Run'
| project Timestamp, DeviceName, Technique = 'T1547.001',
SHA256 = InitiatingProcessSHA256,
Detail = strcat(RegistryValueName, ' => ', tostring(RegistryValueData)),
InitiatingProcessCommandLine ),
( DeviceEvents
| where Timestamp > ago(2d)
| where ActionType in ('ServiceInstalled', 'WmiBindEventFilterToConsumer')
| project Timestamp, DeviceName,
Technique = iff(ActionType == 'ServiceInstalled', 'T1543.003', 'T1546.003'),
SHA256 = InitiatingProcessSHA256,
Detail = tostring(AdditionalFields),
InitiatingProcessCommandLine )
| join kind=inner RareBinaries on SHA256
| summarize Techniques = make_set(Technique), Events = make_list(Detail, 10),
FirstEvent = min(Timestamp) by DeviceName, SHA256, InitiatingProcessCommandLine
| sort by FirstEvent ascA host appearing here with two or more distinct techniques in Techniques is redundant persistence — an operator hedging against one mechanism being cleaned up — and should go straight to containment triage.
Tuning and validation
Four levers, in the order they pay off:
- Allowlist by writing process, not by artifact. Excluding a specific Run value name means an attacker who reuses that name walks past you. Excluding
ccmexec.exeor your Intune management extension as the writer is durable. - Suppress during known change windows. Software deployment waves will dominate your false positives. Correlate against your deployment schedule before you widen content filters.
- Split by technique for alert severity. WMI subscription binding should be higher severity than a Run key write, because its benign base rate is orders of magnitude lower.
- Watch for the cleanup, too. Deletion of an ASEP entry immediately after a suspicious process exits is its own signal, and it pairs with T1562.001 defense-evasion activity.
Validate before you ship. The Atomic Red Team project publishes safe, self-cleaning test cases for all three technique IDs; run them on an instrumented host, confirm each query returns the event, and record the detection latency. A rule you have never seen fire on a known-true event is not a detection — it is a hypothesis.
Once these are live, the natural next step is coverage of the modification path rather than the creation path: registry edits to existing service ImagePath values (T1112) and service binary replacement, both of which bypass the creation-event telemetry these queries depend on. Pair this package with your scheduled task coverage and you have the four autostart mechanisms that account for the overwhelming majority of Windows persistence you will actually encounter.