Windows has been logging every VBScript invocation on your estate for months, complete with the process tree that triggered it. Almost nobody is collecting it and it happens to be the fastest way to find your WMIC dependencies too.
Someone on a forum posted an event log warning earlier this year. They were annoyed that Adobe After Effects was throwing an error every time it launched. The chain looked like this:
AfterFX.exe -> GPUSniffer.exe -> wmic -> vbscript.dll
A third-party application calls a helper executable. The helper calls WMIC. WMIC pulls in VBScript. One user, one launch, two deprecated components in a single call stack that nobody in that thread recognised as a compatibility warning.
That is the entire premise of this post. WMIC and VBScript are being removed from Windows on separate timelines, and almost everyone is planning for them separately. In practice they fail together, because the same legacy code paths call both. If you sweep for them separately you run the same collection twice and lose the relationship between them, which is the part that actually tells you what to fix first.
This post covers what is being removed and when, the event Windows is already writing that most admins have never queried, the parsing gotcha that makes it look empty when it is not, and a working Intune Remediation package that inventories both dependencies in one pass. Everything here is deliverable from the Intune portal. Nothing requires touching a user device.
What is actually being removed, and when
Both components have been “deprecated” for years, which has trained everyone to ignore the notices. The status changed in August 2026.
WMIC
The Release Preview builds confirm that WMIC will not be included in Windows 11 after August 2026, it will not ship in the next feature update and it will not be available as a Feature on Demand. There is no path back. Worth being precise about the scope: this is the WMIC.exe command-line utility only. Windows Management Instrumentation itself is unaffected and fully supported.
| Release | WMIC status |
| Windows 10 21H2 | Deprecated |
| Windows 11 22H2 | Feature on Demand, preinstalled and enabled by default |
| Windows 11 23H2 / 24H2 | Disabled by default, still available as an FoD |
| Windows 11 25H2 | Removed on upgrade if installed can be re-added as an FoD |
| Next feature update | Removed entirely. No FoD. No way back. |
VBScript
VBScript is on a three-phase retirement. Phase 1, where most estates sit today keeps the VBScript Features on Demand installed and enabled by default. Phase 2 disables those FODs by default; an admin has to deliberately turn them back on. Phase 3 removes vbscript.dll from Windows entirely, at which point VBA calling out to a .vbs file stops working and any reference to the VBScript RegExp library breaks.
Microsoft’s public wording for Phase 2 is “approximately 2026 or 2027”. That vagueness is inconvenient, but it narrows the planning horizon enough that treating it as a 2026 possibility is the prudent posture.
THE DEPENDENCY WITH NO REPLACEMENT
Activation tooling still runs on VBScript. slmgr.vbs and ospp.vbs handle Windows and Office activation, and the PowerShell script Microsoft has placed in the OSPP folder can check licensing state but cannot currently activate a product. That makes activation callers a different class of finding from the rest. Most VBScript dependencies can be rewritten in PowerShell today. These cannot yet. The collection script in this post flags them separately for exactly that reason.
Printer management is the other cluster worth knowing about prncnfg.vbs, prndrvr.vbs, prnjobs.vbs and friends still ship in the Printing_Admin_Scripts folder and still get called by automation that predates PowerShell printing cmdlets.
The signal Windows is already giving you
Here is the part that makes this tractable. Windows writes an Application event every single time VBScript is invoked:
| Property | Value |
| Log | Application |
| Provider | VBScriptDeprecationAlert |
| Event ID | 4096 |
| Level | Warning |
| Trigger | Any VBScript execution on the system |
The payload is unusually generous for a deprecation warning. It does not just say “VBScript ran”, it tells you what ran it and how it got there:
| Field | Contents |
| ProcessName | The process that loaded vbscript.dll |
| ProcessTree | The launch chain, e.g. cscript.exe;cmd.exe;userinit.exe;winlogon.exe |
| ProcessTreeEnhanced | The same chain with full command lines |
| CallStack | Offsets into vbscript.dll and the calling binary |
ProcessTree is the field that turns this from a VBScript inventory into a combined one. Look at a real payload from a logon script:
ProcessName = "cscript.exe"
ProcessTree = "cscript.exe;cmd.exe;userinit.exe;winlogon.exe"
CallStack = "vbscript.dll+0x68C16;vbscript.dll+0x5571F;
vbscript.dll+0x40348;cscript.exe+0x114C;..."
ProcessTreeEnhanced = "cscript.exe(""cscript /nologo ..."")"
Now recall the After Effects chain. When WMIC sits in that tree, one event has just told you about a WMIC dependency and a VBScript dependency at the same time, on the same device, in the same application. You do not need a second sweep. You need to read the field.
The gotcha that makes it look empty
Open Event Viewer on most machines and the 4096 event renders like this:
The description for Event ID 4096 from source VBScriptDeprecationAlert
cannot be found. Either the component that raises this event is not
installed on your local computer or the installation is corrupted.
This is why the event gets dismissed. Search the string and you will find people asking whether their system is corrupted, whether they should run SFC, whether it is malware. The answer is none of the above the provider manifest simply is not registered on most builds, so Windows cannot render the message template.
THE DATA IS STILL THERE
The rendered description is missing. The EventData is not. Every field, ProcessName, ProcessTree, ProcessTreeEnhanced, CallStack is sitting in the event XML regardless of whether the manifest resolves. Practical consequence: any collection script that parses $Event.Message will return nothing on the majority of your fleet and you will conclude you have no VBScript dependencies. Parse the XML first and fall back to the message, never the other way round.
Building the sweep
The package is two scripts deployed as a single Intune Remediation. Neither of them remediates anything, nothing is uninstalled, blocked or repaired. This is an inventory, and it is safe to leave assigned on a recurring schedule.
| Script | Role |
| Detection | Sweeps 4096 events, WMIC state and static references. Writes a flat summary to HKLM. Exits 1 when it finds something, which triggers the second script. |
| Remediation | Writes one HKLM subkey per calling process, so the properties catalog can collect a per-caller breakdown across the fleet. |
Using the remediation slot for collection rather than repair is deliberate. Detection script output is truncated in the portal, and a single flat registry key cannot express “which caller, how often, launched by what”. Splitting the work gives you a scannable summary in the Remediations report and structured per-process detail in the registry.
The event parser
This is the core of it. Provider-scoped filter first, fall back to filtering by ID and matching the provider afterwards, then parse the XML with the message only as a last resort:
function Get-VbsDeprecationEvents {
param([datetime]$Since)
$events = @()
# Preferred path: provider-scoped filter. Fast, uses the ETW index.
try {
$events = @(Get-WinEvent -FilterHashtable @{
LogName = 'Application'
ProviderName = 'VBScriptDeprecationAlert'
Id = 4096
StartTime = $Since
} -ErrorAction Stop)
}
catch [System.Exception] {
# 'No events were found' is an exception, not an empty result. Also covers hosts
# where the provider manifest is not registered (common - the event still logs,
# the description just renders as "cannot be found").
$events = @()
}
# Fallback: some builds do not resolve the provider name in the filter. Query by ID
# only and match the provider afterwards.
if ($events.Count -eq 0) {
try {
$events = @(Get-WinEvent -FilterHashtable @{
LogName = 'Application'
Id = 4096
StartTime = $Since
} -ErrorAction Stop | Where-Object { $_.ProviderName -eq 'VBScriptDeprecationAlert' })
}
catch { $events = @() }
}
$parsed = New-Object System.Collections.Generic.List[object]
foreach ($e in $events) {
$blob = ''
try {
$xml = [xml]$e.ToXml()
$vals = @()
foreach ($d in @($xml.Event.EventData.Data)) {
if ($null -eq $d) { continue }
elseif ($d -is [string]) { $vals += $d }
elseif ($d.PSObject.Properties.Name -contains '#text') { $vals += [string]$d.'#text' }
}
$blob = ($vals -join "`n")
}
catch { }
# The rendered message carries the same fields when the manifest IS registered.
if ([string]::IsNullOrWhiteSpace($blob)) {
try { $blob = $e.Message } catch { $blob = '' }
}
if ([string]::IsNullOrWhiteSpace($blob)) { continue }
$proc = Get-QuotedField -Text $blob -Field 'ProcessName'
if ([string]::IsNullOrWhiteSpace($proc)) { $proc = 'unknown' }
$parsed.Add([pscustomobject]@{
TimeCreated = $e.TimeCreated
ProcessName = $proc
ProcessTree = Get-QuotedField -Text $blob -Field 'ProcessTree'
ProcessTreeEnhanced = Get-QuotedField -Text $blob -Field 'ProcessTreeEnhanced'
CallStack = Get-QuotedField -Text $blob -Field 'CallStack'
})
}
return $parsed
}
Two details that are easy to get wrong. First, Get-WinEvent throws a terminating exception when nothing matches. “No events were found” is an error, not an empty result so the try/catch is load-bearing, not defensive padding. Second, the regex for ProcessTree must not swallow ProcessTreeEnhanced. Requiring optional whitespace then an equals sign immediately after the field name handles that, because ProcessTreeEnhanced has an “E” where the equals sign would need to be.
WMIC state
Three signals, because none of them is sufficient alone: is the binary on disk, what does the Feature on Demand report, and does WMIC appear inside any VBScript call chain.
function Get-WmicState {
$sys32 = Join-Path $env:SystemRoot 'System32\wbem\WMIC.exe'
$wow64 = Join-Path $env:SystemRoot 'SysWOW64\wbem\WMIC.exe'
$present = (Test-Path -LiteralPath $sys32) -or (Test-Path -LiteralPath $wow64)
$fodState = 'Unknown'
try {
$cap = Get-WindowsCapability -Online -Name 'WMIC~~~~' -ErrorAction Stop
if ($cap) { $fodState = [string]$cap.State }
}
catch {
# On builds where WMIC is fully removed the capability no longer exists at all.
$fodState = if ($present) { 'NotAvailableAsFoD' } else { 'Removed' }
}
[pscustomobject]@{
Present = $present
Path = if (Test-Path -LiteralPath $sys32) { $sys32 } elseif (Test-Path -LiteralPath $wow64) { $wow64 } else { '' }
FodState = $fodState
}
}
On a build where WMIC is fully removed, Get-WindowsCapability does not return “NotPresent”, the capability no longer exists at all and the call throws. That is why the catch block distinguishes between “still on disk but no longer offered as an FoD” and “genuinely gone”.
Static references
Runtime events only catch what has actually executed inside your lookback window. A quarterly job will not appear in thirty days. The static scan covers the four places that actually break a managed fleet, scheduled tasks, HKLM Run keys, machine GPO scripts and the all-users startup folder. It is deliberately not a full disk scan; that turns a two-second remediation into a support ticket.
Risk scoring
| Level | Meaning |
| None | No events, no static references, WMIC already gone. Detection exits 0. |
| Low | Static references only, or WMIC installed but never observed running. |
| Medium | VBScript actually executed during the lookback window. |
| High | WMIC found inside a VBScript call chain, an activation dependency detected, or a static WMIC reference in a task, Run key or startup script. |
High is the population you remediate first, not because those devices are more broken today, but because those are the dependencies where a single removal takes out two components at once.
Deploying it
Devices → Remediations → Create script package.
| Setting | Value |
| Detection script | Detect-LegacyScriptEngine.ps1 |
| Remediation script | Remediate-LegacyScriptEngine.ps1 |
| Run using logged-on credentials | No |
| Enforce script signature check | No |
| Run script in 64-bit PowerShell | Yes |
| Schedule | Daily |
64-BIT IS NOT OPTIONAL HERE
Under 32-bit PowerShell the WMIC path checks get redirected to SysWOW64, and every registry write lands in Wow6432Node where the properties catalog will not find it. The scripts will run without error and report a clean estate. This is the single most likely way to get a silently wrong result, so verify the toggle before you trust the first report.
Registry layout
HKLM\SOFTWARE\Scudra\LegacyEngineAudit
LastScanUtc, OsBuild, RiskLevel, SchemaVersion
VbsEventCount, VbsDistinctCallers, VbsTopCaller, VbsCallers,
VbsLastSeenUtc, VbsActivationHits
WmicPresent, WmicPath, WmicFodState, WmicInVbsCallChain, WmicStaticRefs
StaticRefCount, StaticRefSources
\Callers
\<process name>
ProcessName, Count, FirstSeenUtc, LastSeenUtc,
UsesWmic, SampleTree, SampleStack, Classification
Collecting it with the properties catalog
The subkey layout is not stylistic. The Intune properties catalog can collect the same value across every subkey under an HKLM key, which means one device inventory policy gives you a per-process breakdown across the entire fleet without writing another line of script.
Devices → Device inventory → Create policy → Windows, then two collection rules:
- All values under a key, targeting HKLM\SOFTWARE\Scudra\LegacyEngineAudit. This is the per-device summary row: risk level, counts, WMIC state.
- Same value across subkeys, targeting HKLM\SOFTWARE\Scudra\LegacyEngineAudit\Callers, value Classification. Repeat for Count and UsesWmic.
Be honest about the limits when you plan around this. Collection is HKLM only, there is no HKCU equivalent, so anything a user launches from their own profile is invisible to this method. And collected inventory arrives on a service-side cadence rather than appearing the moment the script finishes, so do not expect the report to move within minutes of a remediation run.
Device Query
Once inventory has flowed, query across devices:
Registry
| where Path startswith "HKEY_LOCAL_MACHINE\\SOFTWARE\\Scudra\\LegacyEngineAudit"
| where Name in ("RiskLevel", "VbsEventCount", "WmicInVbsCallChain", "VbsActivationHits")
Covering the gap with Defender
The registry sweep tells you what depends on these components. Advanced hunting tells you what is executing them right now, including on devices the Remediation has not reached and in user contexts the SYSTEM-scoped script cannot see. Run both.
WMIC execution with parent process
This is the query that surfaces the third-party call chains, the After Effects pattern at fleet scale:
DeviceProcessEvents
| where Timestamp > ago(30d)
| where FileName =~ "WMIC.exe"
| summarize Executions = count(),
LastSeen = max(Timestamp),
CommandLines = make_set(ProcessCommandLine, 5)
by DeviceName, InitiatingProcessFileName, InitiatingProcessParentFileName
| order by Executions desc
VBScript hosts
DeviceProcessEvents
| where Timestamp > ago(30d)
| where FileName in~ ("cscript.exe", "wscript.exe")
or ProcessCommandLine has ".vbs"
| summarize Executions = count(),
LastSeen = max(Timestamp),
Samples = make_set(ProcessCommandLine, 5)
by DeviceName, InitiatingProcessFileName
| order by Executions desc
The overlap population
Devices where both appear. This is your remediation queue, ordered by the principle that fixing one call chain retires two dependencies:
let wmic = DeviceProcessEvents
| where Timestamp > ago(30d) and FileName =~ "WMIC.exe"
| distinct DeviceName;
let vbs = DeviceProcessEvents
| where Timestamp > ago(30d)
and (FileName in~ ("cscript.exe", "wscript.exe") or ProcessCommandLine has ".vbs")
| distinct DeviceName;
wmic
| join kind=inner vbs on DeviceName
| project DeviceName
Validating it in a lab
Do not deploy this on trust. Five steps, all reproducible on a single test VM:
- Establish the WMIC baseline. Check for %SystemRoot%\System32\wbem\WMIC.exe and run Get-WindowsCapability -Online -Name “WMIC~~~~”. Record which state your build reports, this is what tells you where in the removal timeline your fleet actually sits.
- Generate a 4096 event deliberately. Write a one-line .vbs, run it through cscript.exe, then open the Application log. Screenshot the General tab showing “description cannot be found” next to the Details tab showing the full EventData. That contrast is the most useful thing you can show a reader.
- Generate the overlap. Write a batch file that calls wmic, run it, and confirm wmic appears inside ProcessTree on the resulting event.
- Run the detection script interactively as SYSTEM using PsExec -s -i. Confirm the registry key populates and the summary line stays inside the portal display limit.
- Deploy to a pilot ring and check the Pre-remediation detection output column in the Remediations report. That column is where the summary surfaces, and it is the fastest way to triage a fleet before inventory catches up.
What this does not cover
Every inventory method has a blind spot, and the ones here are worth stating up front rather than discovering later.
- HKCU is out of scope. The script runs as SYSTEM by design, so per-user Run keys and per-user scheduled tasks are not scanned. Anything a user launches from their own profile will not appear. The Defender queries above are the mitigation.
- Static scanning is bounded to scheduled tasks, HKLM Run keys, machine GPO scripts and the all-users startup folder. It is not a full disk scan, and it will miss a dependency buried in an application directory.
- The 4096 event only fires when VBScript actually executes. An annual process will not appear in a thirty-day window. Widen the lookback before declaring a device clean.
- Properties catalog collection is HKLM only and lands on a service-side cadence, not in real time.
Where to start
If you do one thing this week, deploy the detection script to a pilot ring and look at the WmicInVbsCallChain count. That single number tells you whether your estate has the overlap problem or two independent ones, and it changes how you sequence the remediation work.
The wider point is that Windows has been instrumenting this for you the entire time. A deprecation warning that names the calling process, the full launch chain and the call stack is better telemetry than most organisations build deliberately. It has been sitting in the Application log, rendering as a corruption error, being ignored.
Appendix A – Detection script
<#
.SYNOPSIS
Intune Remediation DETECTION script. Inventories Windows 11 legacy script-engine
dependencies: VBScript (via Event ID 4096) and WMIC (runtime + static references).
.DESCRIPTION
Two deprecations, one sweep.
VBScript : Windows logs Application event ID 4096 (provider VBScriptDeprecationAlert)
every time VBScript is invoked. The event carries ProcessName, ProcessTree,
ProcessTreeEnhanced and CallStack - i.e. Windows already tells you exactly
which process pulled in vbscript.dll and what launched it.
WMIC : Removed entirely in the next Windows 11 feature update, with no Feature on
Demand path back. WMIC callers frequently appear INSIDE the 4096 process
trees (third-party app -> helper .exe -> wmic -> VBScript), so the same
sweep catches both.
Findings are written to HKLM (properties catalog can only collect HKLM) so they can be
picked up by an Intune device inventory policy without any further scripting.
NON-DESTRUCTIVE. This script only reads and writes its own registry key.
.NOTES
Run as : System
64-bit : Yes (required - 32-bit PowerShell redirects to SysWOW64 and to the
Wow6432Node registry hive, which breaks both the WMIC path checks and
the properties catalog collection)
Signed : No
Exit 0 : No legacy dependencies found
Exit 1 : Dependencies found -> remediation script runs and collects full detail
Author : scudra.ca
Version: 1.0.0
#>
[CmdletBinding()]
param(
# How far back to read the Application log. 30 days catches monthly/quarterly jobs.
[int] $LookbackDays = 30,
[string] $RegistryPath = 'HKLM:\SOFTWARE\Scudra\LegacyEngineAudit',
# Extra script directories to scan for static wmic/cscript/.vbs references.
[string[]]$ExtraScanPaths = @()
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2.0
$script:SchemaVersion = '1.0.0'
$script:MaxRegValue = 1024 # keep values short enough for clean properties-catalog display
#region helpers ---------------------------------------------------------------
function Get-QuotedField {
<#
Pulls Name = "value" out of the 4096 event payload.
'ProcessTree' will not match 'ProcessTreeEnhanced' because the regex requires
optional whitespace then '=' immediately after the field name.
#>
param(
[string]$Text,
[string]$Field
)
if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
$m = [regex]::Match($Text, ('{0}\s*=\s*"(.*?)"' -f [regex]::Escape($Field)), 'Singleline')
if ($m.Success) { return $m.Groups[1].Value.Trim() }
return ''
}
function Limit-Text {
param([string]$Text, [int]$Length = $script:MaxRegValue)
if ([string]::IsNullOrEmpty($Text)) { return '' }
if ($Text.Length -le $Length) { return $Text }
return $Text.Substring(0, $Length - 3) + '...'
}
function Set-AuditValue {
param(
[string]$Name,
$Value,
[string]$Path = $RegistryPath
)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -Path $Path -Force | Out-Null
}
$text = if ($null -eq $Value) { '' } else { [string]$Value }
New-ItemProperty -LiteralPath $Path -Name $Name -Value (Limit-Text $text) `
-PropertyType String -Force | Out-Null
}
#endregion
#region VBScript --------------------------------------------------------------
function Get-VbsDeprecationEvents {
param([datetime]$Since)
$events = @()
# Preferred path: provider-scoped filter. Fast, uses the ETW index.
try {
$events = @(Get-WinEvent -FilterHashtable @{
LogName = 'Application'
ProviderName = 'VBScriptDeprecationAlert'
Id = 4096
StartTime = $Since
} -ErrorAction Stop)
}
catch [System.Exception] {
# 'No events were found' is an exception, not an empty result. Also covers hosts
# where the provider manifest is not registered (common - the event still logs,
# the description just renders as "cannot be found").
$events = @()
}
# Fallback: some builds do not resolve the provider name in the filter. Query by ID
# only and match the provider afterwards.
if ($events.Count -eq 0) {
try {
$events = @(Get-WinEvent -FilterHashtable @{
LogName = 'Application'
Id = 4096
StartTime = $Since
} -ErrorAction Stop | Where-Object { $_.ProviderName -eq 'VBScriptDeprecationAlert' })
}
catch { $events = @() }
}
$parsed = New-Object System.Collections.Generic.List[object]
foreach ($e in $events) {
$blob = ''
try {
$xml = [xml]$e.ToXml()
$vals = @()
foreach ($d in @($xml.Event.EventData.Data)) {
if ($null -eq $d) { continue }
elseif ($d -is [string]) { $vals += $d }
elseif ($d.PSObject.Properties.Name -contains '#text') { $vals += [string]$d.'#text' }
}
$blob = ($vals -join "`n")
}
catch { }
# The rendered message carries the same fields when the manifest IS registered.
if ([string]::IsNullOrWhiteSpace($blob)) {
try { $blob = $e.Message } catch { $blob = '' }
}
if ([string]::IsNullOrWhiteSpace($blob)) { continue }
$proc = Get-QuotedField -Text $blob -Field 'ProcessName'
if ([string]::IsNullOrWhiteSpace($proc)) { $proc = 'unknown' }
$parsed.Add([pscustomobject]@{
TimeCreated = $e.TimeCreated
ProcessName = $proc
ProcessTree = Get-QuotedField -Text $blob -Field 'ProcessTree'
ProcessTreeEnhanced = Get-QuotedField -Text $blob -Field 'ProcessTreeEnhanced'
CallStack = Get-QuotedField -Text $blob -Field 'CallStack'
})
}
return $parsed
}
#endregion
#region WMIC ------------------------------------------------------------------
function Get-WmicState {
$sys32 = Join-Path $env:SystemRoot 'System32\wbem\WMIC.exe'
$wow64 = Join-Path $env:SystemRoot 'SysWOW64\wbem\WMIC.exe'
$present = (Test-Path -LiteralPath $sys32) -or (Test-Path -LiteralPath $wow64)
$fodState = 'Unknown'
try {
$cap = Get-WindowsCapability -Online -Name 'WMIC~~~~' -ErrorAction Stop
if ($cap) { $fodState = [string]$cap.State }
}
catch {
# On builds where WMIC is fully removed the capability no longer exists at all.
$fodState = if ($present) { 'NotAvailableAsFoD' } else { 'Removed' }
}
[pscustomobject]@{
Present = $present
Path = if (Test-Path -LiteralPath $sys32) { $sys32 } elseif (Test-Path -LiteralPath $wow64) { $wow64 } else { '' }
FodState = $fodState
}
}
#endregion
#region static references -----------------------------------------------------
function Get-StaticReferences {
<#
Bounded scan. Deliberately does NOT walk the whole disk - it targets the four
places that actually break a managed fleet: scheduled tasks, machine Run keys,
machine GPO scripts, and the all-users startup folder.
#>
param([string[]]$ExtraPaths = @())
$hits = New-Object System.Collections.Generic.List[object]
$pattern = '(?i)(\bwmic(\.exe)?\b|\bcscript(\.exe)?\b|\bwscript(\.exe)?\b|\.vbs\b)'
# --- scheduled tasks ---
try {
foreach ($task in @(Get-ScheduledTask -ErrorAction Stop)) {
foreach ($action in @($task.Actions)) {
$exec = ''
$args = ''
try { $exec = [string]$action.Execute } catch { }
try { $args = [string]$action.Arguments } catch { }
$line = ("{0} {1}" -f $exec, $args).Trim()
if ($line -and $line -match $pattern) {
$hits.Add([pscustomobject]@{
Source = 'ScheduledTask'
Name = ('{0}{1}' -f $task.TaskPath, $task.TaskName)
Detail = $line
})
}
}
}
}
catch { }
# --- HKLM Run / RunOnce (HKCU is intentionally out of scope: SYSTEM context) ---
$runKeys = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Run'
)
foreach ($key in $runKeys) {
if (-not (Test-Path -LiteralPath $key)) { continue }
try {
$props = Get-ItemProperty -LiteralPath $key -ErrorAction Stop
foreach ($p in $props.PSObject.Properties) {
if ($p.Name -like 'PS*') { continue }
$val = [string]$p.Value
if ($val -and $val -match $pattern) {
$hits.Add([pscustomobject]@{
Source = 'RunKey'
Name = ('{0}\{1}' -f $key, $p.Name)
Detail = $val
})
}
}
}
catch { }
}
# --- machine GPO scripts, all-users startup, plus any caller-supplied paths ---
$scanDirs = @(
(Join-Path $env:SystemRoot 'System32\GroupPolicy\Machine\Scripts')
(Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs\StartUp')
) + $ExtraPaths
foreach ($dir in $scanDirs) {
if ([string]::IsNullOrWhiteSpace($dir) -or -not (Test-Path -LiteralPath $dir)) { continue }
try {
$files = @(Get-ChildItem -LiteralPath $dir -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Extension -match '(?i)^\.(vbs|vbe|bat|cmd|ps1|js|ini)$' -and $_.Length -lt 2MB })
foreach ($f in $files) {
if ($f.Extension -match '(?i)^\.(vbs|vbe)$') {
$hits.Add([pscustomobject]@{
Source = 'ScriptFile'
Name = $f.FullName
Detail = 'VBScript file present'
})
continue
}
$content = ''
try { $content = Get-Content -LiteralPath $f.FullName -Raw -ErrorAction Stop } catch { continue }
if ($content -match $pattern) {
$hits.Add([pscustomobject]@{
Source = 'ScriptFile'
Name = $f.FullName
Detail = ('Matched: {0}' -f $Matches[0])
})
}
}
}
catch { }
}
return $hits
}
#endregion
#region main ------------------------------------------------------------------
try {
$since = (Get-Date).AddDays(-1 * [math]::Abs($LookbackDays))
$vbsEvents = Get-VbsDeprecationEvents -Since $since
$wmic = Get-WmicState
$static = Get-StaticReferences -ExtraPaths $ExtraScanPaths
# --- summarise VBScript callers ---
$vbsProcesses = @()
$vbsTop = ''
$vbsLastSeen = ''
if ($vbsEvents.Count -gt 0) {
$grouped = @($vbsEvents | Group-Object -Property ProcessName | Sort-Object Count -Descending)
$vbsProcesses = @($grouped | ForEach-Object { '{0}({1})' -f $_.Name, $_.Count })
$vbsTop = $grouped[0].Name
$vbsLastSeen = (($vbsEvents | Sort-Object TimeCreated -Descending)[0].TimeCreated).ToString('u')
}
# --- the overlap: WMIC appearing inside a 4096 process tree ---
$wmicInVbsTree = @($vbsEvents | Where-Object {
($_.ProcessTree -match '(?i)wmic') -or ($_.ProcessTreeEnhanced -match '(?i)wmic')
})
# --- activation dependencies have no working PowerShell replacement yet ---
$activationHits = @($vbsEvents | Where-Object {
($_.ProcessTreeEnhanced -match '(?i)(slmgr|ospp)\.vbs') -or
($_.ProcessTree -match '(?i)(slui|slmgr|ospp)')
})
$staticWmic = @($static | Where-Object { $_.Detail -match '(?i)\bwmic' })
# --- risk ---
$risk = 'None'
if ($static.Count -gt 0 -or ($wmic.Present -and $wmic.FodState -ne 'Removed')) { $risk = 'Low' }
if ($vbsEvents.Count -gt 0) { $risk = 'Medium' }
if ($wmicInVbsTree.Count -gt 0 -or $activationHits.Count -gt 0 -or $staticWmic.Count -gt 0) { $risk = 'High' }
# --- write to HKLM for properties catalog collection ---
Set-AuditValue -Name 'SchemaVersion' -Value $script:SchemaVersion
Set-AuditValue -Name 'LastScanUtc' -Value ((Get-Date).ToUniversalTime().ToString('u'))
Set-AuditValue -Name 'LookbackDays' -Value $LookbackDays
Set-AuditValue -Name 'OsBuild' -Value ([string](Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').CurrentBuildNumber)
Set-AuditValue -Name 'VbsEventCount' -Value $vbsEvents.Count
Set-AuditValue -Name 'VbsDistinctCallers' -Value (@($vbsProcesses).Count)
Set-AuditValue -Name 'VbsTopCaller' -Value $vbsTop
Set-AuditValue -Name 'VbsCallers' -Value ($vbsProcesses -join '; ')
Set-AuditValue -Name 'VbsLastSeenUtc' -Value $vbsLastSeen
Set-AuditValue -Name 'VbsActivationHits' -Value $activationHits.Count
Set-AuditValue -Name 'WmicPresent' -Value $wmic.Present
Set-AuditValue -Name 'WmicPath' -Value $wmic.Path
Set-AuditValue -Name 'WmicFodState' -Value $wmic.FodState
Set-AuditValue -Name 'WmicInVbsCallChain' -Value $wmicInVbsTree.Count
Set-AuditValue -Name 'WmicStaticRefs' -Value $staticWmic.Count
Set-AuditValue -Name 'StaticRefCount' -Value $static.Count
Set-AuditValue -Name 'StaticRefSources' -Value ((@($static | Select-Object -ExpandProperty Source -Unique)) -join ',')
Set-AuditValue -Name 'RiskLevel' -Value $risk
# --- stdout summary (Intune shows the first 2048 chars in the detection output column) ---
$summary = 'Risk={0}; VBScriptEvents={1}; DistinctCallers={2}; TopCaller={3}; WMICPresent={4}; WMICFoD={5}; WMICinVBSChain={6}; StaticRefs={7}; ActivationDeps={8}' -f `
$risk, $vbsEvents.Count, @($vbsProcesses).Count,
$(if ($vbsTop) { $vbsTop } else { 'n/a' }),
$wmic.Present, $wmic.FodState, $wmicInVbsTree.Count, $static.Count, $activationHits.Count
if ($vbsProcesses.Count -gt 0) {
$summary += ' | Callers: ' + ((@($vbsProcesses) | Select-Object -First 8) -join ', ')
}
Write-Output (Limit-Text $summary 2000)
if ($risk -eq 'None') { exit 0 }
exit 1
}
catch {
# Fail visibly rather than silently reporting a clean device.
Write-Output ('DETECTION ERROR: {0}' -f $_.Exception.Message)
exit 1
}
#endregion
Appendix B – Collection script
<#
.SYNOPSIS
Intune Remediation REMEDIATION script. Collects per-caller detail for the legacy
script-engine sweep and writes it into HKLM subkeys.
.DESCRIPTION
This script does NOT remediate anything. Nothing here uninstalls, blocks or repairs.
It exists because the detection script's stdout is truncated in the Intune portal and
a single flat registry key cannot express "which caller, how often, launched by what".
It writes one subkey per calling process:
HKLM\SOFTWARE\Scudra\LegacyEngineAudit\Callers\<process>
Count REG_SZ
FirstSeenUtc REG_SZ
LastSeenUtc REG_SZ
UsesWmic REG_SZ True/False
SampleTree REG_SZ the ProcessTreeEnhanced from the most recent event
Classification REG_SZ Activation / ThirdParty / Management / Unclassified
That layout is deliberate: the Intune properties catalog can collect the same value
across every subkey under an HKLM key, so one inventory policy targeting
...\Callers gives you a per-process breakdown across the fleet without any further
scripting.
Because this only ever reports, it is safe to leave assigned on a recurring schedule.
.NOTES
Run as : System
64-bit : Yes (required)
Signed : No
Exit 0 : detail written
Exit 1 : collection failed
Author : scudra.ca
Version: 1.0.0
#>
[CmdletBinding()]
param(
[int] $LookbackDays = 30,
[string] $RegistryPath = 'HKLM:\SOFTWARE\Scudra\LegacyEngineAudit'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2.0
$script:MaxRegValue = 1024
$CallersPath = Join-Path $RegistryPath 'Callers'
#region helpers ---------------------------------------------------------------
function Get-QuotedField {
param([string]$Text, [string]$Field)
if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
$m = [regex]::Match($Text, ('{0}\s*=\s*"(.*?)"' -f [regex]::Escape($Field)), 'Singleline')
if ($m.Success) { return $m.Groups[1].Value.Trim() }
return ''
}
function Limit-Text {
param([string]$Text, [int]$Length = $script:MaxRegValue)
if ([string]::IsNullOrEmpty($Text)) { return '' }
if ($Text.Length -le $Length) { return $Text }
return $Text.Substring(0, $Length - 3) + '...'
}
function ConvertTo-SafeKeyName {
# Registry key names cannot contain a backslash; keep it short and predictable.
param([string]$Name)
if ([string]::IsNullOrWhiteSpace($Name)) { return 'unknown' }
$clean = ($Name -replace '[\\/:\*\?"<>\|]', '_').Trim()
if ($clean.Length -gt 64) { $clean = $clean.Substring(0, 64) }
if ([string]::IsNullOrWhiteSpace($clean)) { return 'unknown' }
return $clean
}
function Get-CallerClassification {
param([string]$ProcessName, [string]$Tree)
$blob = ('{0} {1}' -f $ProcessName, $Tree)
# Activation first: slmgr.vbs and ospp.vbs have no working PowerShell replacement for
# activation yet, so these are the dependencies that cannot simply be rewritten.
if ($blob -match '(?i)(slmgr|ospp)\.vbs|slui\.exe') { return 'Activation' }
if ($blob -match '(?i)prn(cnfg|drvr|jobs|mngr|port|qctl)\.vbs') { return 'Printing' }
if ($blob -match '(?i)(cscript|wscript)\.exe') { return 'Management' }
if ($blob -match '(?i)(explorer|svchost|userinit|winlogon)\.exe') { return 'ThirdParty' }
return 'Unclassified'
}
#endregion
#region collection ------------------------------------------------------------
function Get-VbsDeprecationEvents {
param([datetime]$Since)
$events = @()
try {
$events = @(Get-WinEvent -FilterHashtable @{
LogName = 'Application'
ProviderName = 'VBScriptDeprecationAlert'
Id = 4096
StartTime = $Since
} -ErrorAction Stop)
}
catch { $events = @() }
if ($events.Count -eq 0) {
try {
$events = @(Get-WinEvent -FilterHashtable @{
LogName = 'Application'
Id = 4096
StartTime = $Since
} -ErrorAction Stop | Where-Object { $_.ProviderName -eq 'VBScriptDeprecationAlert' })
}
catch { $events = @() }
}
$parsed = New-Object System.Collections.Generic.List[object]
foreach ($e in $events) {
$blob = ''
try {
$xml = [xml]$e.ToXml()
$vals = @()
foreach ($d in @($xml.Event.EventData.Data)) {
if ($null -eq $d) { continue }
elseif ($d -is [string]) { $vals += $d }
elseif ($d.PSObject.Properties.Name -contains '#text') { $vals += [string]$d.'#text' }
}
$blob = ($vals -join "`n")
}
catch { }
if ([string]::IsNullOrWhiteSpace($blob)) {
try { $blob = $e.Message } catch { $blob = '' }
}
if ([string]::IsNullOrWhiteSpace($blob)) { continue }
$proc = Get-QuotedField -Text $blob -Field 'ProcessName'
if ([string]::IsNullOrWhiteSpace($proc)) { $proc = 'unknown' }
$tree = Get-QuotedField -Text $blob -Field 'ProcessTree'
$treeFull = Get-QuotedField -Text $blob -Field 'ProcessTreeEnhanced'
if ([string]::IsNullOrWhiteSpace($treeFull)) { $treeFull = $tree }
$parsed.Add([pscustomobject]@{
TimeCreated = $e.TimeCreated
ProcessName = $proc
Tree = $tree
TreeFull = $treeFull
CallStack = Get-QuotedField -Text $blob -Field 'CallStack'
})
}
return $parsed
}
#endregion
#region main ------------------------------------------------------------------
try {
$since = (Get-Date).AddDays(-1 * [math]::Abs($LookbackDays))
$events = Get-VbsDeprecationEvents -Since $since
# Rebuild the Callers tree each run so stale callers disappear once remediated.
if (Test-Path -LiteralPath $CallersPath) {
Remove-Item -LiteralPath $CallersPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -Path $CallersPath -Force | Out-Null
$written = 0
foreach ($group in @($events | Group-Object -Property ProcessName)) {
$ordered = @($group.Group | Sort-Object TimeCreated)
$latest = $ordered[-1]
$usesWmi = [bool](@($group.Group | Where-Object {
$_.Tree -match '(?i)wmic' -or $_.TreeFull -match '(?i)wmic'
}).Count)
$keyName = ConvertTo-SafeKeyName -Name $group.Name
$keyPath = Join-Path $CallersPath $keyName
New-Item -Path $keyPath -Force | Out-Null
$values = [ordered]@{
ProcessName = $group.Name
Count = $group.Count
FirstSeenUtc = $ordered[0].TimeCreated.ToUniversalTime().ToString('u')
LastSeenUtc = $latest.TimeCreated.ToUniversalTime().ToString('u')
UsesWmic = $usesWmi
SampleTree = $latest.TreeFull
SampleStack = $latest.CallStack
Classification = Get-CallerClassification -ProcessName $group.Name -Tree $latest.TreeFull
}
foreach ($k in $values.Keys) {
New-ItemProperty -LiteralPath $keyPath -Name $k `
-Value (Limit-Text ([string]$values[$k])) `
-PropertyType String -Force | Out-Null
}
$written++
}
New-ItemProperty -LiteralPath $RegistryPath -Name 'DetailCollectedUtc' `
-Value ((Get-Date).ToUniversalTime().ToString('u')) `
-PropertyType String -Force | Out-Null
New-ItemProperty -LiteralPath $RegistryPath -Name 'DetailCallerCount' `
-Value ([string]$written) `
-PropertyType String -Force | Out-Null
Write-Output ('Collected detail for {0} caller(s) from {1} event(s) over {2} days.' -f `
$written, $events.Count, $LookbackDays)
exit 0
}
catch {
Write-Output ('COLLECTION ERROR: {0}' -f $_.Exception.Message)
exit 1
}
#endregion