Skip to content

Refuse Defender exclusions for script interpreters, pin ps2exe - #100

Merged
TheAbider merged 1 commit into
masterfrom
security/defender-interpreter-guard
Aug 10, 2026
Merged

Refuse Defender exclusions for script interpreters, pin ps2exe#100
TheAbider merged 1 commit into
masterfrom
security/defender-interpreter-guard

Conversation

@TheAbider

Copy link
Copy Markdown
Owner

What

Two independent hardening changes surfaced while triaging the VirusTotal detections on v1.122.3.

1. Refuse Defender exclusions for script interpreters

A Defender process exclusion does not exempt one application — it exempts every payload that
process will ever execute. Excluding powershell.exe stops Defender inspecting all PowerShell on
the host, permanently. It is a catalogued attacker technique (MITRE T1562.001) and it is what
THOR's SUSP_PS1_SCRIPT_Defender_Exlusions_Interpreter rule looks for.

The custom path prompt has warned on OS directories since v1.98. The custom process prompt
had no validation at all — it read a name and excluded it.

The shipped defaults were never affected: the built-in list is vmms/vmwp/vmsp/vmcompute, all
Hyper-V, all Microsoft-recommended. This was a latent hole in what an operator could configure,
not a bad default.

Test-InterpreterProcessName refuses the known interpreters and LOLBAS hosts, matching on the
leaf name so powershell, POWERSHELL.EXE, "powershell.exe" and
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe all reach the same verdict. Fail
closed — no override prompt — and it points at a path exclusion scoped to the application's own
folder as the correct alternative.

2. Pin ps2exe to 1.0.18

Install-Module ps2exe -Force was unpinned. That module compiles the binary that ships to users,
so the released artifact could change without a commit — the same supply-chain exposure the
SHA-pinning policy already closes for actions, including transitive ones.

Tests

Section 208, 36 assertions: the guard exists and is consulted, the refusal is fail-closed and
never reaches Add-MpPreference, the built-in list contains no interpreter, every normalization
form is refused, and the must-not-over-block cases (vmms.exe, sqlservr.exe,
notpowershell.exe, empty, null) still pass.

Mutation-verified. Ten mutations: dropped .exe normalization, dropped path stripping,
dropped quote trimming, removed an entry from the list, always-true, substring-instead-of-exact,
deleted the guard, bypassed the guard, injected an override prompt, injected a silent exclusion.
All caught.

Two mutations initially escaped and both were run down:

  • Dropping .ToLowerInvariant() changed nothing — PowerShell's -contains is already
    case-insensitive (-ccontains is not). Equivalent mutant, not an escaped bug; switching the
    comparison to -ccontains does flip the assertion.
  • The first no-override assertion was genuinely blind. It matched
    REFUSED:[\s\S]{0,800}?else\s*\{ and asserted the result contained no Confirm-UserAction.
    Injecting a real override prompt pushed the branch past 800 characters, so the regex stopped
    matching — and '' -notmatch 'Confirm-UserAction' is vacuously true. It went green on the exact
    code it exists to forbid. It now proves the branch was located before trusting the result, and
    additionally asserts the refusal never reaches Add-MpPreference.

Structural tests 5438 → 5474. Full gate: 42 passed / 0 failed / 0 warnings, suite 5474/5474.

Not included

No version bump, so this does not cut a release on merge.

A Defender process exclusion does not exempt one application, it exempts
every payload that process will ever execute. Excluding powershell.exe
stops Defender inspecting all PowerShell on the host permanently, which is
why it is a catalogued attacker technique (MITRE T1562.001).

The custom path prompt has warned on OS directories since v1.98. The custom
process prompt had no validation at all: it read a name and excluded it. The
shipped defaults were always clean (vmms/vmwp/vmsp/vmcompute, all Hyper-V),
so this was a latent hole in what an operator could configure rather than a
bad default.

Test-InterpreterProcessName now refuses the known interpreters and LOLBAS
hosts, matching on the leaf name so a full path, a quoted value, or a bare
name without the extension all reach the same verdict. The refusal is fail
closed with no override prompt, and points at a path exclusion scoped to the
application's own folder as the correct alternative.

Also pins ps2exe to 1.0.18. That module compiles the binary that ships to
users, so an unpinned install let the released artifact change without a
commit, the same exposure the SHA-pinning policy closes for actions.

Run-Tests section 208 covers the guard, including normalization and the
must-not-over-block cases. The first draft of the no-override assertion was
killed by mutation testing: its regex stopped matching once the branch grew
past 800 characters, and an empty match satisfies -notmatch vacuously, so it
went green on the exact code it forbids. It now proves the branch was located
before trusting the result, and also asserts the refusal never reaches
Add-MpPreference.

Structural tests 5438 -> 5474.
@TheAbider
TheAbider merged commit 62215e4 into master Aug 10, 2026
6 checks passed
@TheAbider
TheAbider deleted the security/defender-interpreter-guard branch August 10, 2026 22:54
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant