WMI event subscriptions let code run at boot or on a timer without a Run key, service or scheduled task — a favourite hiding place. This Ruby script lists them and flags the ones that execute commands.
Step through the build below:
A WMI permanent event subscription is three objects in root\subscription: an __EventFilter (the trigger), an __EventConsumer (the action) and a __FilterToConsumerBinding joining them. If the consumer is a CommandLineEventConsumer or ActiveScriptEventConsumer, Windows will run arbitrary code every time the filter fires, surviving reboots, and none of the usual autorun tools show it. Legitimate use is rare, so enumerating them is a high-signal audit. Ruby’s bundled win32ole can query WMI directly, no PowerShell required.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# wmi_persistence_audit.rb - list WMI permanent event subscriptions (a classic stealth persistence
# mechanism) and flag the ones that execute commands or scripts.
# Ruby 3.0+ on Windows (win32ole ships with Ruby). Run from an elevated prompt.
# Usage: ruby wmi_persistence_audit.rb [--json] [--input FIXTURE.json]
# --input read a saved snapshot instead of querying WMI (lets the logic be tested on any OS)
require 'json'
require 'optparse'
Finding = Struct.new(:severity, :consumer, :rule, :message, keyword_init: true)
# Consumer classes that run code. Others (LogFile, NTEventLog, SMTP) are noisy but passive.
EXEC_CLASSES = %w[CommandLineEventConsumer ActiveScriptEventConsumer].freeze
SUSPICIOUS = /powershell|pwsh|cmd\.exe|wscript|cscript|mshta|rundll32|regsvr32|certutil|bitsadmin|-enc|frombase64|downloadstring|\\appdata\\|\\temp\\|\\users\\public/i.freeze
# Microsoft's own subscription that ships with the SCM event log provider.
KNOWN_GOOD = ['SCM Event Log Consumer'].freeze
# Query root\subscription with the three classes that make up one persistence "triple".
def snapshot_from_wmi
require 'win32ole'
wmi = WIN32OLE.connect('winmgmts:\\\\.\\root\\subscription')
props = lambda do |cls, names|
wmi.ExecQuery("SELECT * FROM #{cls}").each.map do |o|
names.to_h { |n| [n, (o.send(n) rescue nil)&.to_s] }.merge('__CLASS' => o.Path_.Class)
end
end
{
'filters' => props.call('__EventFilter', %w[Name Query EventNamespace]),
'consumers' => wmi.ExecQuery('SELECT * FROM __EventConsumer').each.map do |o|
%w[Name CommandLineTemplate ExecutablePath ScriptText ScriptingEngine].to_h { |n| [n, (o.send(n) rescue nil)&.to_s] }
.merge('__CLASS' => o.Path_.Class)
end,
'bindings' => props.call('__FilterToConsumerBinding', %w[Filter Consumer])
}
end
def audit(snap)
findings = []
bindings = snap['bindings']
snap['consumers'].each do |c|
name = c['Name'].to_s
next if KNOWN_GOOD.include?(name)
add = ->(sev, rule, msg) { findings << Finding.new(severity: sev, consumer: name, rule: rule, message: msg) }
bound = bindings.any? { |b| b['Consumer'].to_s.include?("\"#{name}\"") }
if EXEC_CLASSES.include?(c['__CLASS'])
payload = [c['CommandLineTemplate'], c['ExecutablePath'], c['ScriptText']].compact.join(' ')
add.(:error, 'exec-consumer', "#{c['__CLASS']} runs: #{payload[0, 120]}")
add.(:error, 'suspicious-payload', 'payload matches shell/LOLBin/encoded/temp-path pattern') if payload =~ SUSPICIOUS
add.(:warn, 'script-consumer', "inline #{c['ScriptingEngine']} script stored in WMI repository") if c['__CLASS'] == 'ActiveScriptEventConsumer'
else
add.(:info, 'passive-consumer', "#{c['__CLASS']} (does not execute code)")
end
add.(:warn, 'orphan-consumer', 'consumer has no binding to a filter (dormant or half-removed)') unless bound
end
snap['filters'].each do |f|
add = ->(sev, rule, msg) { findings << Finding.new(severity: sev, consumer: f['Name'].to_s, rule: rule, message: msg) }
q = f['Query'].to_s
add.(:warn, 'boot-timer-filter', 'filter fires on timer/startup/logon events') if q =~ /__IntervalTimerInstruction|Win32_LocalTime|Win32_ComputerSystem|Win32_LogonSession|SystemUpTime/i
end
findings
end
opts = { json: false }
OptionParser.new do |o|
o.on('--json') { opts[:json] = true }
o.on('--input FILE') { |v| opts[:input] = v }
end.parse!
snap = opts[:input] ? JSON.parse(File.read(opts[:input])) : snapshot_from_wmi
findings = audit(snap)
if opts[:json]
puts JSON.pretty_generate(findings.map(&:to_h))
else
puts "Subscriptions: #{snap['filters'].size} filter(s), #{snap['consumers'].size} consumer(s), #{snap['bindings'].size} binding(s)"
findings.sort_by { |f| { error: 0, warn: 1, info: 2 }[f.severity] }.each do |f|
puts format('%-5s %-20s %-24s %s', f.severity.upcase, f.rule, f.consumer, f.message)
end
puts 'No findings: no WMI persistence subscriptions of concern.' if findings.empty?
end
exit(findings.any? { |f| f.severity == :error } ? 2 : 0)
1. Snapshot WMI — snapshot_from_wmi connects to winmgmts:\\.\root\subscription and runs three ExecQuery calls, converting each COM object into a plain Hash. Keeping the data as plain hashes is what makes the audit logic testable.
2. Join the triple — Bindings reference consumers by a WMI path string, so a consumer is considered bound if any binding contains its quoted name. Unbound consumers are reported as dormant or half-removed.
3. Classify consumers — Only CommandLineEventConsumer and ActiveScriptEventConsumer are errors, because they execute code. Passive consumers (log file, event log) are info.
4. Pattern-match payloads — The command line, executable path and script text are matched against shell interpreters, LOLBins, encoded-command flags and temp-path locations.
5. Check filters — Filters that fire on timers, logon or uptime are warned, because those are how persistence triggers.
$ ruby wmi_persistence_audit.rb (captured in sandbox)
Subscriptions: 2 filter(s), 4 consumer(s), 3 binding(s) ERROR exec-consumer UpdaterConsumer CommandLineEventConsumer runs: powershell.exe -nop -w hidden -enc SQBFAFgA C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe ERROR suspicious-payload UpdaterConsumer payload matches shell/LOLBin/encoded/temp-path pattern ERROR exec-consumer DormantScript ActiveScriptEventConsumer runs: WScript.Echo 1 ERROR suspicious-payload DormantScript payload matches shell/LOLBin/encoded/temp-path pattern WARN script-consumer DormantScript inline VBScript script stored in WMI repository WARN orphan-consumer DormantScript consumer has no binding to a filter (dormant or half-removed) WARN boot-timer-filter UpdaterFilter filter fires on timer/startup/logon events INFO passive-consumer AuditLog LogFileEventConsumer (does not execute code)
Full script + README on GitHub: ruby-devops-toolkit/win-wmi-persistence-audit
Why this matters
A WMI permanent event subscription is three objects in root\subscription: an __EventFilter (the trigger), an __EventConsumer (the action) and a __FilterToConsumerBinding joining them. If the consumer is a CommandLineEventConsumer or ActiveScriptEventConsumer, Windows will run arbitrary code every time the filter fires, surviving reboots, and none of the usual autorun tools show it. Legitimate use is rare, so enumerating them is a high-signal audit. Ruby’s bundled win32ole can query WMI directly, no PowerShell required.
What you need
- Ruby 3.0+ for Windows (RubyInstaller);
win32oleis built in - Elevated prompt (administrator) to read
root\subscription - Windows 10/11 or Server 2016+
Complete code
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# wmi_persistence_audit.rb - list WMI permanent event subscriptions (a classic stealth persistence
# mechanism) and flag the ones that execute commands or scripts.
# Ruby 3.0+ on Windows (win32ole ships with Ruby). Run from an elevated prompt.
# Usage: ruby wmi_persistence_audit.rb [--json] [--input FIXTURE.json]
# --input read a saved snapshot instead of querying WMI (lets the logic be tested on any OS)
require 'json'
require 'optparse'
Finding = Struct.new(:severity, :consumer, :rule, :message, keyword_init: true)
# Consumer classes that run code. Others (LogFile, NTEventLog, SMTP) are noisy but passive.
EXEC_CLASSES = %w[CommandLineEventConsumer ActiveScriptEventConsumer].freeze
SUSPICIOUS = /powershell|pwsh|cmd\.exe|wscript|cscript|mshta|rundll32|regsvr32|certutil|bitsadmin|-enc|frombase64|downloadstring|\\appdata\\|\\temp\\|\\users\\public/i.freeze
# Microsoft's own subscription that ships with the SCM event log provider.
KNOWN_GOOD = ['SCM Event Log Consumer'].freeze
# Query root\subscription with the three classes that make up one persistence "triple".
def snapshot_from_wmi
require 'win32ole'
wmi = WIN32OLE.connect('winmgmts:\\\\.\\root\\subscription')
props = lambda do |cls, names|
wmi.ExecQuery("SELECT * FROM #{cls}").each.map do |o|
names.to_h { |n| [n, (o.send(n) rescue nil)&.to_s] }.merge('__CLASS' => o.Path_.Class)
end
end
{
'filters' => props.call('__EventFilter', %w[Name Query EventNamespace]),
'consumers' => wmi.ExecQuery('SELECT * FROM __EventConsumer').each.map do |o|
%w[Name CommandLineTemplate ExecutablePath ScriptText ScriptingEngine].to_h { |n| [n, (o.send(n) rescue nil)&.to_s] }
.merge('__CLASS' => o.Path_.Class)
end,
'bindings' => props.call('__FilterToConsumerBinding', %w[Filter Consumer])
}
end
def audit(snap)
findings = []
bindings = snap['bindings']
snap['consumers'].each do |c|
name = c['Name'].to_s
next if KNOWN_GOOD.include?(name)
add = ->(sev, rule, msg) { findings << Finding.new(severity: sev, consumer: name, rule: rule, message: msg) }
bound = bindings.any? { |b| b['Consumer'].to_s.include?("\"#{name}\"") }
if EXEC_CLASSES.include?(c['__CLASS'])
payload = [c['CommandLineTemplate'], c['ExecutablePath'], c['ScriptText']].compact.join(' ')
add.(:error, 'exec-consumer', "#{c['__CLASS']} runs: #{payload[0, 120]}")
add.(:error, 'suspicious-payload', 'payload matches shell/LOLBin/encoded/temp-path pattern') if payload =~ SUSPICIOUS
add.(:warn, 'script-consumer', "inline #{c['ScriptingEngine']} script stored in WMI repository") if c['__CLASS'] == 'ActiveScriptEventConsumer'
else
add.(:info, 'passive-consumer', "#{c['__CLASS']} (does not execute code)")
end
add.(:warn, 'orphan-consumer', 'consumer has no binding to a filter (dormant or half-removed)') unless bound
end
snap['filters'].each do |f|
add = ->(sev, rule, msg) { findings << Finding.new(severity: sev, consumer: f['Name'].to_s, rule: rule, message: msg) }
q = f['Query'].to_s
add.(:warn, 'boot-timer-filter', 'filter fires on timer/startup/logon events') if q =~ /__IntervalTimerInstruction|Win32_LocalTime|Win32_ComputerSystem|Win32_LogonSession|SystemUpTime/i
end
findings
end
opts = { json: false }
OptionParser.new do |o|
o.on('--json') { opts[:json] = true }
o.on('--input FILE') { |v| opts[:input] = v }
end.parse!
snap = opts[:input] ? JSON.parse(File.read(opts[:input])) : snapshot_from_wmi
findings = audit(snap)
if opts[:json]
puts JSON.pretty_generate(findings.map(&:to_h))
else
puts "Subscriptions: #{snap['filters'].size} filter(s), #{snap['consumers'].size} consumer(s), #{snap['bindings'].size} binding(s)"
findings.sort_by { |f| { error: 0, warn: 1, info: 2 }[f.severity] }.each do |f|
puts format('%-5s %-20s %-24s %s', f.severity.upcase, f.rule, f.consumer, f.message)
end
puts 'No findings: no WMI persistence subscriptions of concern.' if findings.empty?
end
exit(findings.any? { |f| f.severity == :error } ? 2 : 0)
Step by step
- Snapshot WMI.
snapshot_from_wmiconnects towinmgmts:\\.\root\subscriptionand runs threeExecQuerycalls, converting each COM object into a plain Hash. Keeping the data as plain hashes is what makes the audit logic testable. - Join the triple. Bindings reference consumers by a WMI path string, so a consumer is considered bound if any binding contains its quoted name. Unbound consumers are reported as dormant or half-removed.
- Classify consumers. Only
CommandLineEventConsumerandActiveScriptEventConsumerare errors, because they execute code. Passive consumers (log file, event log) are info. - Pattern-match payloads. The command line, executable path and script text are matched against shell interpreters, LOLBins, encoded-command flags and temp-path locations.
- Check filters. Filters that fire on timers, logon or uptime are warned, because those are how persistence triggers.
What it prints
Subscriptions: 2 filter(s), 4 consumer(s), 3 binding(s)
ERROR exec-consumer UpdaterConsumer CommandLineEventConsumer runs: powershell.exe -nop -w hidden -enc SQBFAFgA C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
ERROR suspicious-payload UpdaterConsumer payload matches shell/LOLBin/encoded/temp-path pattern
ERROR exec-consumer DormantScript ActiveScriptEventConsumer runs: WScript.Echo 1
ERROR suspicious-payload DormantScript payload matches shell/LOLBin/encoded/temp-path pattern
WARN script-consumer DormantScript inline VBScript script stored in WMI repository
WARN orphan-consumer DormantScript consumer has no binding to a filter (dormant or half-removed)
WARN boot-timer-filter UpdaterFilter filter fires on timer/startup/logon events
INFO passive-consumer AuditLog LogFileEventConsumer (does not execute code)
When it misbehaves
- Honest testing note: win32ole and WMI cannot run on Linux, so the audit rules were verified against a hand-built JSON snapshot passed with
--input(Output tab). Thesnapshot_from_wmifunction itself was not executed in the sandbox. - WIN32OLERuntimeError access denied: not elevated.
- SCM Event Log Consumer is Microsoft’s own subscription and is allow-listed in
KNOWN_GOOD; extend it for your EDR/agents. - False positives: some management agents legitimately use CommandLine consumers; baseline once, then alert on change.
Where to take it next
- Save a baseline JSON and alert only on new subscriptions
- Remove a confirmed-bad triple with
Delete_after review - Also scan
root\defaultandroot\cimv2for consumers - Ship findings to your SIEM as JSON