the shed // ruby // windows wmi

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:

wmi_persistence_audit.rb

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)
Get the code

Full script + README on GitHub: ruby-devops-toolkit/win-wmi-persistence-audit

the problem

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.

Hunt WMI Persistence on Windows with Ruby: Audit Permanent Event Subscriptions pipeline diagram

How the pieces fit together
prerequisites

What you need

  • Ruby 3.0+ for Windows (RubyInstaller); win32ole is built in
  • Elevated prompt (administrator) to read root\subscription
  • Windows 10/11 or Server 2016+
the script

Complete code

wmi_persistence_audit.rbruby
#!/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)
walkthrough

Step by step

  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.
example output

What it prints

terminal outputtext
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)
troubleshooting

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). The snapshot_from_wmi function 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.
extending

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\default and root\cimv2 for consumers
  • Ship findings to your SIEM as JSON

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating