Author: Marek Wesołowski (WESMAR)
Date: 29 May 2026
Target: Windows 11 Pro 26H1, build 28000, fully patched
Defender platform: 4.18.26040.7-0 (May 2026)

This screenshot demonstrates how to manipulate "Real-time protection" and "Tamper Protection" mechanisms via the CLI, bypassing the WinUI/XAML/UAC stack. It relies entirely on WinAPI, without using a kernel driver. This is one of three techniques I devised and implemented.
Abstract
This document records, in chronological order, my investigation into where and how Windows Defender stores the effective state of the Real-Time Protection (RTP) toggle switch shown in the Windows Security UI under windowsdefender://threatsettings. The investigation was triggered by a practical question: while writing the PowerShell edition of WinDefCtl (a companion to my native C++ utility, both released on the MyDigitalLife forum), I wanted to understand whether the slider stores its state in a file, in the registry, or in volatile memory of MsMpEng.exe, and whether a non-UI, programmatic toggle path exists when Tamper Protection is on.
Through ten phases of testing — registry write attempts, WMI cmdlet calls, hive-cycle restoration tricks, ACL takeovers, static analysis of MpSvc.dll with IDA Pro, a memory differential between two snapshots of the live MsMpEng.exe process, an in-process injection into the SecHealthUI UWP package, and finally a direct-RPC probe through the MpClient.dll client surface — I established three results that together answer the original question:
- The state byte. The RTP enable/disable flag in 26H1 lives at a single byte,
MpSvc.dll + 0x602F18, read by exactly one function (sub_180252504) which acts as the canonical scan gate. - The privileged front-end. The
Microsoft.SecHealthUI_8wekyb3d8bbweUWP package is the only user-mode caller whose token is on Tamper Protection's write-side allow-list. A small injector + payload pair, running as Administrator, can drop a DLL into the package's address space and call the same XAMLToggleSwitch::IsOn(bool)that a real human click would call — bypassing all of registry, WMI, GPO, and ACL gates without ever producing a Defender Operational event. - The architectural confirmation. Direct RPC through
MpManagerOpen+MpSetTPStatefrom an arbitrary admin process yields a cleanE_ACCESSDENIEDon the write side while the read side (MpManagerStatusQuery) succeeds. The check is insideMsMpEng's RPC dispatcher, per procedure, and it gates 0x95 (write) against the SecHealthUI package SID — exactly as Microsoft intend.
In total the document covers six negative results and four positive ones. The positive paths — the SecHealthUI inject and the kernel-mode byte patch — are the only viable channels for a programmatic Defender state change on modern Windows builds.
I'm writing this in the first person because it is, in part, a personal log — the kind of thing I'd like my grandchildren to be able to read one day and see how a problem gets taken apart layer by layer. The technical substance is intentionally preserved so that other reverse engineers can reproduce, verify, or build upon the findings.
1. Motivation
I have been maintaining WinDefCtl for several years. The v1.x line used UI Automation to flick the RTP and Tamper Protection sliders inside Windows Security; v2.0 added a real engine kill path: an IFEO block on MsMpEng.exe combined with a kernel kill of the running instance through the signed Topaz kvckiller.sys driver. Both versions ship with a fullscreen "PLEASE WAIT" overlay (Direct2D in the C++ build, WinForms in the PowerShell port) so the user does not see the Windows Security window briefly opening and closing on screen.
The UI-automation path works, but it has weaknesses I wanted to retire:
- It depends on a sleep-based wait loop because the Windows Security UI does not expose a deterministic "ready" event.
- It requires injecting a UAC-prompt suppression into the registry and restoring it afterwards.
- It briefly contests the foreground window even with an overlay above it.
If I could find the in-memory byte that actually controls the RTP state inside MsMpEng.exe, then I could write to it directly — bypassing the UI, the WMI WMI broker, and the registry filter that Tamper Protection installs. No more UAC, no more flicker, no more racy waits.
That is the question this document answers.
2. Background: Tamper Protection in Windows 11 26H1
Tamper Protection ("TP") is a kernel-mode minifilter feature of Windows Defender that prevents user-mode processes — even those running as NT AUTHORITY\SYSTEM or with TrustedInstaller impersonation — from disabling Defender's protections through any of the legacy channels (registry, WMI, Group Policy, PowerShell cmdlets, MSI custom actions, etc.).
In 26H1 TP intercepts at least five registry callbacks via the WdFilter.sys minifilter on HKLM\SOFTWARE\Microsoft\Windows Defender:
| Native call | Effect when blocked | Event ID |
|---|---|---|
NtSetValueKey |
Write rejected | 5013 ("Blocked") |
NtCreateKey |
Subkey creation rejected | 5013 |
NtSetSecurityObject |
ACL / owner change rejected | unauthorised op |
NtDeleteKey |
Delete rejected | 5013 |
NtRestoreKey |
REG_FORCE_RESTORE rejected |
access denied (no 5013) |
There is also a WMI provider check that returns 0x80004001 (E_NOTIMPL) when a caller attempts Set-MpPreference -DisableTamperProtection $true or any other tamper-protected property, and an unusual "Ignored" path for Group Policy writes: the write itself is allowed, but Defender ignores the value at evaluation time.
The state of TP itself is exposed by Get-MpComputerStatus as IsTamperProtected (boolean) and lives — partially — in:
HKLM\SOFTWARE\Microsoft\Windows Defender\Features\TamperProtection(DWORD)HKLM\SOFTWARE\Microsoft\Windows Defender\Features\TamperProtectionSource(DWORD)
…both of which are themselves write-protected by the very filter they describe.
e.g. powershell.exe] -->|NtSetValueKey| B(Object Manager) B --> C{Defender minifilter
WdFilter callback} C -->|caller token = WinDefend or
Microsoft.SecHealthUI Package SID| D[ALLOW] C -->|other| E[BLOCK] E --> F[event 5013 logged] D --> G[NtSetValueKey commits] G --> H[MsMpEng RPC notified] H --> I[in-memory state updated]
3. Research Questions
I went into the investigation with four questions:
- Does the RTP slider write to a known registry value, a file under
%ProgramData%\Microsoft\Windows Defender\, or only to volatile memory? - If only to memory, can the state be located precisely enough to be patched?
- Which user-mode and kernel-mode channels does Tamper Protection block, and does any of them have an exploitable gap?
- Can I confirm the result reproducibly so that a future PoC writes the same byte on the same address on every Windows 26H1 build of this Defender platform?
4. Methodology
4.1 Test environment
| Item | Value |
|---|---|
| OS | Windows 11 Pro 26H1, build 28000 |
| Defender platform | 4.18.26040.7-0 |
MsMpEng.exe size |
290 704 B (PE32+ x64) |
MpSvc.dll size |
6 559 232 B (6.25 MB) |
MpRtp.dll size |
1 254 792 B (1.20 MB) |
MpClient.dll size |
1 824 256 B (1.74 MB) |
| Privileges | Administrator (elevated PowerShell) |
| Symbol path | SRV*C:\Symbols*https://msdl.microsoft.com/download/symbols |
4.2 Tooling
Each phase used a specific tool. The full inventory:
| Tool | Phase | Purpose |
|---|---|---|
powershell.exe |
All | Driver script, registry probing, WMI calls |
reg.exe |
2 | Direct registry I/O, hive save/load/restore |
Set-MpPreference / Get-MpComputerStatus |
2 | WMI provider channel |
kvc trusted (KVC framework) |
2 | TrustedInstaller-elevated shell |
cmdt.exe |
2 | TrustedInstaller-elevated shell (interactive only) |
Get-WinEvent on Microsoft-Windows-Windows Defender/Operational |
2 | TP blocking event correlation |
inspect.exe / AccessibilityInsights.exe |
1 | UI tree visualisation (reference) |
System.Windows.Automation (.NET) |
1 | Programmatic UI tree dump |
IDA Pro 9.1 (ida.exe headless) |
4 | Static analysis of MpSvc.dll, MpRtp.dll, MpClient.dll |
| IDAPython (built-in) | 4 | Scripted scans: strings, xrefs, function names |
kvc dump |
5 | PPL-Antimalware process memory snapshot |
cdb.exe (Windows SDK) |
5 | Minidump inspection, .writemem extraction |
| PowerShell byte diff | 5 | .data section comparison |
Hex viewer (built-in db/dq in cdb) |
5 | Confirmation at target VA |
5. Phase 1 — Mapping the UI Automation Tree
Before I could test memory bypass paths I needed to know exactly which toggle controls which Defender feature in the current build. 26H1 added a Dev Drive protection slider that was not present in earlier builds, and I'd already had a moment of confusion on the MDL thread when a user pointed out that my tp command was now apparently flicking Dev Drive instead of Tamper Protection.
5.1 The host process is SecHealthUI.exe
The frame window in windowsdefender://threatsettings belongs to ApplicationFrameHost.exe (a UWP shell host), but the actual UI process is
C:\Program Files\WindowsApps\Microsoft.SecHealthUI_1000.29554.1001.0_x64__8wekyb3d8bbwe\SecHealthUI.exe
invoked with -ServerName:SecHealthUI.AppX8tam42xc7v2czs3s1nt0nkxvfjtepzp9.mca. Its Package Family Name (PFN) — Microsoft.SecHealthUI_8wekyb3d8bbwe — derives the Package SID S-1-15-3-1024-3153509613-960666767-3724611135-2725662640-12138253-543910227-1950414635-4190290187 which appears in nearly every ACL on HKLM\SOFTWARE\Microsoft\Windows Defender, but only with ReadKey rights — the package itself does not write the keys; it delegates to MsMpEng.exe via RPC.
5.2 The five sliders in 26H1 threatsettings
Walking the UIA tree with System.Windows.Automation.AutomationElement.FromHandle and filtering for IsTogglePatternAvailableProperty = true, I found:
| Index | Parent Name |
AutomationId | Class |
|---|---|---|---|
| 1 | Real-time protection | settingToggle |
ToggleSwitch |
| 2 | Dev Drive protection | settingToggle |
ToggleSwitch |
| 3 | Cloud-delivered protection | settingToggle |
ToggleSwitch |
| 4 | Automatic sample submission | settingToggle |
ToggleSwitch |
| 5 | Tamper Protection | settingToggle |
ToggleSwitch |
All five share the same AutomationId ("settingToggle") — distinguishing them requires the parent element's Name. The C++ build of WinDefCtl uses Find-FirstToggle for RTP and Find-LastToggle for TP, which happens to map correctly because the layout order in 26H1 places Tamper Protection last on this panel; in earlier builds the same heuristic accidentally hit different sliders, but on 26H1 it remains accurate.
Key takeaway: the UI exposes five independent feature toggles, but they are not stored as independent registry values — they all flow through a single RPC channel into
MsMpEng.exe.
6. Phase 2 — Registry Probing
The goal of this phase was to find any user-mode write path that survives Tamper Protection. I tested six approaches, each of which corresponds to a well-known technique from the AV-bypass literature.
6.1 Direct write to the live Real-Time Protection key
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows Defender\Real-Time Protection' `
-Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force
Result: Requested registry access is not allowed.
This is an ACL-level deny, not Tamper Protection. The key's owner is NT AUTHORITY\SYSTEM, and BUILTIN\Administrators is granted only ReadKey and CreateLink. FullControl is reserved for TrustedInstaller, WinDefend, and SYSTEM.
6.2 WMI cmdlet path
Set-MpPreference -DisableRealtimeMonitoring $true
Result: the cmdlet returns OK but the value does not stick. Get-MpComputerStatus.RealTimeProtectionEnabled remains True. Event log shows:
ID 5013 | Tamper Protection Blocked a change to Microsoft Defender Antivirus.
Value: HKLM\SOFTWARE\Microsoft\Windows Defender\Real-Time Protection\DisableRealtimeMonitoring = (Current)
The WMI broker hands the request off to MsMpEng.exe which, in turn, lets the WdFilter minifilter veto it. The cmdlet sees no error because the WMI provider intentionally returns success even when TP overrides.
6.3 Group Policy path
New-Item 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender\Real-Time Protection' -Force
Set-ItemProperty -Path '...' -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord
Result: the write succeeds. Effective state does not change. Event log:
ID 5013 | Tamper Protection Ignored a change to Microsoft Defender Antivirus.
Value: HKLM\SOFTWARE\Policies\Microsoft\Windows Defender\Real-Time Protection\DisableRealtimeMonitoring = 0x1 (Not Applicable)
This is genuinely novel behaviour. In Windows 10 and earlier 11 builds, a GPO override would have effective force; in 26H1 with TP enabled, GPO writes are permitted (for legacy compatibility) but explicitly ignored. The event ID is the same as for outright blocks; the substring is "Ignored" rather than "Blocked".
6.4 Hive cycle (offline reg save / reg restore)
This is the technique I successfully use in WinDefCtl's kill command to bypass TP on the IFEO key: save the live hive to a file, mount it elsewhere, modify it, then REG_FORCE_RESTORE the modified hive back over the original.
reg save "HKLM\SOFTWARE\Microsoft\Windows Defender\Real-Time Protection" rtp.hiv /y
reg load HKLM\TempRTP rtp.hiv
reg add HKLM\TempRTP /v DisableRealtimeMonitoring /t REG_DWORD /d 1 /f ← FAIL
Result: Access is denied on the reg add step. The ACL is carried with the hive file — the loaded temp key inherits the same restrictive security descriptor as the live key, and Administrator still has no KEY_SET_VALUE.
I retried by saving the parent key (Windows Defender root) instead of the specific Real-Time Protection subkey, on the hypothesis that the minifilter might only target the subkey by exact path. Save and load both succeeded; the temp-key write also succeeded this time (the parent has looser ACL). But the final reg restore came back as Access is denied — again no event 5013 was logged, indicating the deny happened at the kernel ACL layer rather than the TP minifilter.
6.5 Take ownership + ACL modification
The classic admin trick: with SeTakeOwnershipPrivilege and SeRestorePrivilege enabled, take ownership of the key, give Administrators FullControl, then write.
[Priv]::Enable("SeTakeOwnershipPrivilege")
[Priv]::Enable("SeRestorePrivilege")
$key = $baseKey.OpenSubKey($KeyPath, ReadWriteSubTree, TakeOwnership)
$sec = $key.GetAccessControl(Owner)
$sec.SetOwner($adminSid)
$key.SetAccessControl($sec)
Result: Attempted to perform an unauthorized operation.
NtSetSecurityObject is also hooked. Even with the privilege bits enabled, the minifilter rejects the SD change. This was the most interesting "no" of the entire phase, because it confirms that TP intercepts the security descriptor write — not just the value write — on protected keys.
6.6 TrustedInstaller via cmdt / kvc trusted
Both cmdt.exe (in System32, my own utility) and kvc trusted <cmd> (from KVC) spawn a child process under a token that includes the NT SERVICE\TrustedInstaller SID. TrustedInstaller has FullControl on the Real-Time Protection key.
kvc trusted "cmd /c reg add ""HKLM\SOFTWARE\Microsoft\Windows Defender\Real-Time Protection"" /v DisableRealtimeMonitoring /t REG_DWORD /d 1 /f"
Result: whoami confirms NT AUTHORITY\SYSTEM with the TI SID present. The reg save of the parent key now works. reg load works. The write to the temp hive works. reg unload works. The final reg restore of the parent — fails. The minifilter still rejects NtRestoreKey at the kernel level, regardless of caller token.
This was the closing argument for user-mode registry bypass: there is no combination of privilege, token, hive-cycle trick, or ACL manipulation that defeats Tamper Protection on the live key in 26H1.
7. Phase 3 — Mapping the Tamper Protection Attack Surface
Synthesising the results of Phase 2, the protection surface against admin/SYSTEM user-mode callers in 26H1 looks like this:
Every observable user-mode path is sealed. The next reasonable hypothesis was: the registry is not the source of truth; the in-memory state of MsMpEng.exe is the source of truth, and the only legitimate channel that writes it is the COM/RPC interface exposed to the SecHealthUI package. Let me confirm by looking at the binaries.
8. Phase 4 — Static Analysis of MpSvc.dll
The Defender platform ships in C:\ProgramData\Microsoft\Windows Defender\Platform\<version>\. The relevant binaries:
| Binary | Size | Role |
|---|---|---|
MsMpEng.exe |
284 KB | Service stub |
MpSvc.dll |
6.25 MB | Main service implementation |
MpRtp.dll |
1.20 MB | Real-time protection module (driver communication) |
MpClient.dll |
1.74 MB | Client-side API (Get-MpPreference etc.) |
I copied each to C:\Temp\ and ran IDA Pro 9.1 in headless mode with PDB auto-download from the Microsoft public symbol server:
$env:_NT_SYMBOL_PATH = 'SRV*C:\Symbols*https://msdl.microsoft.com/download/symbols'
ida.exe -A -B -S"scan.py" -L"ida.log" "MpSvc.dll"
MpRTP.pdb, MpSvc.pdb, and MpClient.pdb all downloaded cleanly to C:\Symbols\. IDAPython auto-analysis settled in 22–114 seconds depending on binary size.
8.1 String cross-reference scan
The scan script (full source in Appendix C) walked idautils.Strings() and flagged any UTF-8 or UTF-16 string containing any of the keywords:
RealTime, realtime, RtpEnabled, TamperProtection, TamperState,
DisableRealtime, BehaviorMonitor, SettingToggle, MpPreference, ...
MpRtp.dll returned 132 string matches. All of them turned out to be C++ RTTI mangled names for classes in the namespace RealtimeProtection:::
RealtimeProtection::CRtpFilterManager
RealtimeProtection::CFilterReloadHelper
RealtimeProtection::CConfigurationManager
RealtimeProtection::CFilterCommunicatorBase
RealtimeProtection::CProcessWatcher
RealtimeProtection::CFileSystemWatcher
…
These are the machinery of the RTP subsystem — process watchers, file-system watchers, filter communication threads — but they do not appear to hold the effective on/off state themselves.
MpSvc.dll was the goldmine. Pasting the most informative matches:
| String literal (UTF-16) | RVA | Significance |
|---|---|---|
MpFC_RtpEnableDefenderConfigMonitoring |
0x4B7CB0 | Feature code for RTP setting |
MpFC_RtpEnableDefenderConfigNotificationSave |
0x4B7D00 | Notification persistence for RTP |
Real-Time Protection |
0x4BDAE0 | Registry subkey name (10 xrefs in one function) |
MDE AV Realtime Protection |
0x4BEB80 | Defender for Endpoint variant |
TamperProtection |
0x4CF4E8 | TP setting name |
TamperProtectionSource |
0x4CF568 | TP origin (local/cloud/MDM) |
TamperState |
0x4F0220 | TP current state |
TamperStateSource |
0x4F0238 | TP state origin |
REALTIME_MONITOR |
0x4D4D80 | Enum label for the RTP feature (see §8.2) |
RealTimeSignatureDelivery |
0x4F5028 | Signature delivery flag |
allowrealtimemonitoring |
0x528390 | WMI property name (lowercase) |
AllowRealtimeMonitoring |
0x529E68 | WMI property name (PascalCase) |
SENSE is enabled and product disabled. Enabling product in passive mode. |
0x4CDDF0 | MDE/Sense passive-mode message |
8.2 The MpFC_* feature code enum
The function sub_18011D7E4 is a small 164-byte enum-to-string converter that I disassembled in full:
sub_18011D7E4:
lea rcx, aInvalid ; default = "INVALID"
cmp edx, 5 ; switch on edx (the MpFC index)
jg short loc_18011D840
jz short loc_18011D837 ; 5 = BEHAVIOR_MONITOR
test edx, edx
jz short loc_18011D82E ; 0 = AS_SIGNATURE
sub edx, 1
jz short loc_18011D825 ; 1 = AV_SIGNATURE
sub edx, 1
jz short loc_18011D81C ; 2 = REALTIME_MONITOR ← RTP
sub edx, 1
jz short loc_18011D813 ; 3 = ONACCESS_PROTECTION
cmp edx, 1
jnz short loc_18011D884
lea rcx, aIoavProtection ; 4 = IOAV_PROTECTION
jmp short loc_18011D884
…
; loc_18011D840 branch handles 6..10:
; 6 = AUTO_SCAN
; 7 = AUTO_SIGUPDATE
; 8 = IPC
; 9 = NRI
; 10 = ELAM
This established that, internally, Defender refers to RTP as feature code 2 of an MpFC enum, with the textual label REALTIME_MONITOR. The strings MpFC_RtpEnableDefenderConfigMonitoring and MpFC_RtpEnableDefenderConfigNotificationSave in .rdata are likely the symbolic names of two RTP-related entries in a dispatch table indexed by the same enum.
8.3 Candidate state-holding functions
By following xrefs from the string literals into code, I identified six functions of interest:
| Function | Size | Hint from string xrefs |
|---|---|---|
sub_18001BEA0 |
medium | uses "Real-Time Protection" 10× and "AllowRealtimeMonitoring" 1× — registry-key handler? |
sub_18001A830 |
medium | first xref of "Real-Time Protection" |
sub_180023490 |
medium | "AllowRealtimeMonitoring" PascalCase — WMI provider entry |
sub_18014DE40 |
medium | "Real-Time Protection" + MpClientUtilExportFunctions reference |
sub_18015087C |
medium | "Real-Time Protection" multiple times |
sub_1801C9C28 |
44 KB | bulk initialiser; uses TamperState, TamperStateSource, and dozens of others |
A scan of each function for writable global references (i.e. data references into .data or .bss whose section flags are 0xC0000040) produced this shortlist of candidate state-storage locations:
| IDA address | Used by | Initial guess |
|---|---|---|
0x1806060A8 |
sub_18001BEA0 |
RTP-related field |
0x1806060D0 |
sub_18001A830 |
RTP-related field |
0x180600538 |
sub_18014DE40 |
Settings array |
0x18060059C |
sub_18014DE40 |
Settings array |
0x180607270 |
sub_18014DE40 |
RTP flag candidate |
0x1805FE688/690 |
sub_18014DE40 |
CriticalSection (state lock) |
At this point static analysis alone could not tell me which one held the effective RTP state, because the .data content in the file image is just the post-link default (mostly 0xFF filler that gets overwritten at runtime). I needed to look at the live process.
9. Phase 5 — Live Memory Differential Analysis
This phase is the climax of the investigation. The approach is straightforward in concept: dump the process memory of MsMpEng.exe with RTP on, toggle RTP off via the legitimate UI channel (which TP permits), dump again, then compute a byte-level diff of the writable section of MpSvc.dll between the two snapshots.
9.1 The kvc dump procedure (PPL bypass)
MsMpEng.exe is a PPL (Protected Process Light) at signer level PsProtectedSignerAntimalware. Standard OpenProcess(PROCESS_VM_READ) from admin user mode fails with ERROR_ACCESS_DENIED because PPL enforcement is in the kernel scheduler. To dump it, the dumping process must itself be PPL of equal or greater signer authority.
KVC handles this by elevating its own process protection to PsProtectedSignerAntimalware before reading. The relevant excerpt of its output during the first dump:
[*] Found process: MsMpEng.exe (PID 21724)
[*] Target process protection: PPL-Antimalware
[+] Self-protection set to PPL-Antimalware
[*] Creating memory dump - this may take a while. Press Ctrl+C to cancel safely.
[+] Memory dump created successfully: C:\Users\Administrator\Downloads\MsMpEng.exe_21724.dmp
[*] Removing self-protection before cleanup...
Total time per dump: ~30 seconds. Dump format: Windows minidump with full memory (MiniDumpWithFullMemory), sizes 760 MB and 714 MB respectively.
9.2 Toggle, re-dump, bindiff
The same PID survived the toggle, which confirmed:
MsMpEng.exedoes not restart on RTP off — the flip is in-place- The module bases are identical between the two dumps (
MpSvcat0x7ffa0acc0000in both) - Any byte differences in
MpSvc.dll's.datasection must reflect runtime state mutation and nothing else
I extracted the .data section from both dumps with cdb's .writemem command:
.writemem mpsvc-on.data mpsvc+5DC000 L2CDE0
.writemem mpsvc-off.data mpsvc+5DC000 L2CDE0
.data for MpSvc.dll is 183 776 bytes wide (RVA 0x5DC000, virtual size 0x2CDE0).
9.3 The seven differing bytes
$a = [IO.File]::ReadAllBytes('mpsvc-on.data')
$b = [IO.File]::ReadAllBytes('mpsvc-off.data')
# … byte-by-byte compare …
| RVA in MpSvc | IDA address | ON byte | OFF byte | Δ |
|---|---|---|---|---|
| 0x5DC680 | 0x1805DC680 | 0x34 | 0x1D | -23 |
| 0x602F18 | 0x180602F18 | 0x00 | 0x01 | +1 |
| 0x607218 | 0x180607218 | 0xCE | 0xCD | -1 |
| 0x607230 | 0x180607230 | 0x04 | 0x03 | -1 |
| 0x608CE8 | 0x180608CE8 | 0x28 | 0x7D | (3-byte run) |
| 0x608CE9 | 0x180608CE9 | 0xAA | 0x49 | |
| 0x608CEA | 0x180608CEA | 0x4F | 0x6C |
Out of 183 776 bytes, only 7 changed. Of those, exactly one — 0x180602F18 — is a clean boolean flip from 0x00 to 0x01. The remaining six are either small counter decrements (likely heartbeat / reload-queue ticks), a heap pointer LSB, or a 24-bit timestamp.
10. Phase 6 — Target Confirmation: 0x180602F18
A diff alone is not yet a proof. The single-byte flip might be coincidence — some unrelated heartbeat byte that happens to alternate. To confirm I needed to see how this byte is read by the code.
A targeted IDA Python script (full source in Appendix C) located all xrefs to 0x180602F18. There is exactly one xref, from address 0x180252590 inside sub_180252504:
sub_180252504:
…
0x180252583 cmp cs:dword_180602F2C, 0 ; secondary boolean
0x18025258A jnz loc_180252B37 ; → bypass path
0x180252590 cmp cs:dword_180602F18, 0 ; ← OUR TARGET
0x180252597 jnz loc_180252B37 ; → bypass path
0x18025259D xor edx, edx ; happy path: continue
0x1802525A3 mov r8d, 338h ; 0x338 = 824 byte struct
…
Read as plain English: "if either of these two flags is non-zero, take the bypass branch; otherwise prepare an 0x338-byte structure and continue normal scan processing." In other words, this is the canonical RTP gate: a dual boolean check at the entry of the scan path. Setting dword_180602F18 to 1 is exactly equivalent to setting DisableRealtimeMonitoring = true in the preference store.
The sibling flag 0x180602F2C (offset +0x14) is almost certainly a related disable bit — most plausibly the IOAV or behaviour-monitor master switch, because they appear adjacent in the MpFC_* enum (indices 4 and 5 of the sub_18011D7E4 switch). I have not yet confirmed which it is, but it is the natural next target.
10.1 What this means for the patch vector
Given that:
- The state byte lives in writable
.dataofMpSvc.dll, image-loaded intoMsMpEng.exeat a known ASLR base - Tamper Protection's registry callbacks operate on
Nt*Key*paths and do not hook process memory writes - PPL only restricts who may call
NtWriteVirtualMemoryon the target — not what target locations are off-limits
…a kernel-mode driver with the right caller protection level can write 0x00 or 0x01 directly to MpSvc!dword_180602F18 and the change will take effect immediately in the next scan-gate evaluation, without producing any registry event 5007 or 5013.
This is the heart of the result.
11. Phase 7 — The SecHealthUI Inject Path (Follow-up)
The bindiff in Phase 5 told me where the state lives. It did not tell me how to write to it from a process that is not MsMpEng and that does not own a kernel driver. The kernel-mode option is always there; it is just heavy. I wanted to know whether there was a user-mode path that did not require loading a .sys at all.
The clue was sitting in the front-end the whole time. SecHealthUI.exe — the WinUI host that paints the sliders — runs as a UWP package (Microsoft.SecHealthUI_8wekyb3d8bbwe) whose Package SID is in the ACL of every Defender registry key I touched in Phase 2. The slider, when a real human clicks it, drives the same RPC channel I tried to talk to directly in WMI. Tamper Protection allows it because of the package identity of the caller. So: if I can inject code into SecHealthUI.exe, that code inherits the package identity, and from inside the process I can call the same XAML API the slider uses internally — ToggleSwitch.IsOn(bool) — and the RPC call goes through cleanly.
11.1 Why SecHealthUI is the privileged front-end
Every register-side experiment in Phase 2 confirmed the same thing: Tamper Protection's gate is the caller token, not the contents of the registry. The single caller it whitelists for Defender-control RPC is Microsoft.SecHealthUI_8wekyb3d8bbwe!App — visible in ACLs as the package SID S-1-15-3-1024-3153509613-960666767-3724611135-2725662640-12138253-543910227-1950414635-4190290187. This SID is dispensed by AppContainer when the UWP package is activated; it cannot be borrowed or forged by another process under normal user-mode rules. But it can be acquired by executing inside that process — either by injecting code into a running instance, or by spawning a child of the package that hosts our code.
11.2 The DefenderDirect tool
I built a two-binary tool, WinDefCtl\:
-
injector.exe— 8 KB C executable, no CRT,/NODEFAULTLIB. Steps:- Enumerate processes; look for
SecHealthUI.exe. If missing, fire
ShellExecuteW(L"open", L"windowsdefender://threatsettings", SW_HIDE)
and poll until it appears. OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid)(SE_DEBUG_NAMEenabled).GetPackageFamilyName(handle, …)→ returns the PFN.- Copy
payload.dllto
%LOCALAPPDATA%\Packages\<PFN>\LocalState\payload.dll— this is the
one directory in the file system that the package can read freely. - Write a one-line
target.txtnext to it carrying the UIA label and
the desired state (e.g.Real-time protection\noff). VirtualAllocEx + WriteProcessMemory + CreateRemoteThread(LoadLibraryW)
on the package process. The remote thread loads the DLL by path.
- Enumerate processes; look for
-
payload.dll— C++/WinRT DLL. OnDLL_PROCESS_ATTACHit spawns a worker thread that:- Reads
target.txtfromApplicationData::Current().LocalFolder(). - Polls
CoreApplication::MainView().Dispatcher()and posts a closure
that walks the visual tree fromWindow::Current().Content()via
VisualTreeHelper::GetChildrenCount/GetChild. - For each
ToggleSwitchencountered, reads
AutomationProperties::GetName(element)— the UIA Name —
and compares it against the requested target. - On match, calls
ToggleSwitch::IsOn(bool). The call returns
synchronously; the RPC reaches MsMpEng under the package's identity
and the state changes immediately. - After the toggle succeeds, the payload calls
CoreApplication::Exit()so the host shuts down cleanly with no
leftover taskbar entry.
- Reads
The whole flow takes about five seconds end to end on warm SecHealthUI, fifteen on a cold start where the URI handler has to fire the package up first.
11.3 Important detail: FrameworkElement.Name ≠ AutomationProperties.GetName
My first attempt walked the tree, harvested FrameworkElement.Name for every named child, and used the nearest non-empty Name as the candidate label. The walk dutifully reported eight ToggleSwitches per pass, each with the same Name string — "settingToggle". Of course it did: the SecHealthUI developer named every slider <ToggleSwitch x:Name="settingToggle" /> because the code-behind needs only an indexed handle to each one, while the human-readable label sits on a different property entirely.
The trick is to walk for ToggleSwitch instances and read their UIA Name, not their XAML Name. UIA Name is what assistive technology and our out-of-process System.Windows.Automation probe in Phase 1 actually saw. Once I switched the comparison to AutomationProperties::GetName(fe), the match succeeded on the first hit.
11.4 Six sliders, not five
The probe in Phase 1 reported five ToggleSwitch elements. The injector running inside the process reports six, because the panel scrolls and the out-of-process Automation API only enumerated the elements that were actually rendered at the moment of the snapshot. From inside the process I get the complete logical tree regardless of viewport. The full set in 26H1:
| UIA Name | Internal MpFC_* correspondent |
|---|---|
| Real-time protection | RTP master switch |
| Cloud-delivered protection | MAPS cloud lookup |
| Block suspicious behaviors | Behaviour monitor — new in 26H1 |
| Automatic sample submission | Sample submission consent |
| Dev Drive protection | Dev Drive scan asynchronisation |
| Tamper Protection | TP master switch |
The "Block suspicious behaviors" toggle is a genuine 26H1 addition. I did not see it on any earlier build, and the MDL forum has not surfaced it yet either. Its UIA Name lines up with MpFC_BehaviourMonitorEnabled from the enum scan in §8.2.
11.5 Why the window cannot be hidden
I tried every layered-window trick I knew on the ApplicationFrameWindow that hosts SecHealthUI. The lot:
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
SetLayeredWindowAttributes(hwnd, 0, 0, LWA_ALPHA); // alpha 0
DwmSetWindowAttribute(hwnd, DWMWA_CLOAK, &one, 4); // DWM cloak
SetWindowPlacement(hwnd, {/* off-screen */ -4000,-4000 });
SetWindowPos(hwnd, NULL, -4000, -4000, 0, 0, SWP_NOSIZE | SWP_NOZORDER);
ShowWindow(hwnd, SW_SHOWNOACTIVATE);
This exact combination was the v1.0 Ghost Mode in my earlier WinDefCtl release — and on Windows 10 it worked, and on early Windows 11 it worked, and on 26H1 it does nothing. The flicker still appears for roughly a second. The reason is that ApplicationFrameWindow is not "my" window — it is owned by ApplicationFrameHost.exe, and the Windows Shell increasingly arbitrates what user-mode code outside that host is allowed to do to it. After several years of malware abusing UWP cloaking, Microsoft consolidated the controls; cloak, alpha and placement all silently fail for a window not created in your own process.
The honest answer to "can the SecHealthUI inject path also hide the window?" is: no, not in user mode on 26H1. Anyone who wants silence has two remaining options:
- Cover the flicker. Pop a fullscreen
WS_EX_LAYERED | WS_EX_TOPMOSTwindow at black before injecting, hold it until the payload signals completion, then dismiss it. This is the same technique my v2.0 native build uses to cover the slider during automation: the user sees a one-second black "PLEASE WAIT" splash instead of a one-second SecHealthUI flicker. UX-wise indistinguishable from a tray-app status check. - Skip SecHealthUI entirely and reach the state byte from the kernel side. See §12.2.
11.6 The inject path summarised
End-to-end with no UAC, no registry write, no event 5013. The injected DLL inherits the trust boundary that Tamper Protection respects.
12. Phase 8 — The Direct-RPC Question (Conclusive Negative)
The inject path solved the practical problem, but it left an architectural question open: how strictly is the server-side check enforced? If MsMpEng simply trusts whatever DCOM/RPC binding arrives over its named endpoint, then a process holding SE_DEBUG_NAME and connecting to the endpoint directly should be able to flip the same state byte by hand without going anywhere near the UWP front-end. If, conversely, the server walks the caller's token and demands the SecHealthUI package SID before dispatching the procedure, then the injection path is not a shortcut — it is the only path.
This was worth nailing down. Tamper-protection bypass research is full of half-investigated claims, and I wanted a clean answer.
12.1 The MpClient.dll surface
MpClient.dll (1.74 MB) in C:\ProgramData\Microsoft\Windows Defender\Platform\4.18.26040.7-0\ is the user-mode RPC client library that everything — Get-MpPreference, Set-MpPreference, MpCmdRun, SecHealthUI itself — uses to talk to the daemon. Its PDB is on Microsoft's public symbol server.
I enumerated all 272 exports with an IDAPython pass and filtered for Tamper-relevant names:
| Ordinal | Symbol | Role |
|---|---|---|
| 83 | MpConfigInitialize |
Initialise the client subsystem. Pre-condition for any other call. |
| 88 | MpConfigOpen |
(LPCWSTR scope, PVOID* outCtx) — opens a COM-style config context for a named scope |
| 89 | MpConfigQueryProtection |
Query overall product protection state |
| 92 | MpConfigSetValue |
Generic configuration setter |
| 93 | MpConfigUninitialize |
Tear-down |
| 183 | MpGetTPStateInfo |
RPC proc 0x94 — read TP state |
| 197 | MpManagerOpen |
(DWORD reserved, PVOID* outCtx) — opens the Manager context used by TP and similar product-level operations |
| 198 | MpManagerStatusQuery |
RPC — read Manager status (16-byte struct) |
| 199 | MpManagerStatusQueryEx |
RPC — extended status read |
| 209 | MpOpen |
(DWORD flag, PVOID* outCtx) — accepts flag 0..4; lower-level than MpManagerOpen |
| 235 | MpSetTPState |
RPC proc 0x95 — write TP state (1 = disabled, 0 = enabled) |
The signature of MpConfigOpen came out of a quick disassembly of the worker sub_1800E1FD4:
; sub_1800E1FD4(rcx = scope, rdx = OUT ctx*)
mov qword ptr [rdx], 0 ; clear OUT
lea rdx, "Miscellaneous Configuration"; ← fallback default
call sub_18005DE90 ; named-scope lookup
Scanning all 53 internal callers of MpConfigOpen gave me the valid scope names: "." (the root, used 13× — the implicit "everything" scope), "Real-Time Protection", "Features", "Signature Updates", "SpyNet", and a handful of one-off subkeys like "Threats\\ThreatIDDefaultAction".
12.2 First attempt — MpOpen
The disassembly of MpSetTPState showed exactly what the setter needs from its context object:
cmp dword ptr [rcx], 1 ; magic must be 1 at offset 0
mov rdx, [rcx+8] ; handle at offset 8
xor ecx, ecx ; flag = 0
call sub_180078724 ; resolve binding
…
mov edx, 0x95 ; RPC proc number 149
call cs:NdrClientCall3
So I need an opener that returns a context with:
[+0x0] = 1(the magic the setter validates)[+0x8] = some handle thatsub_180078724` can resolve into a real binding pointer[+0x18]of the resolved binding must itself be non-null
I tried MpOpen(0..4, &ctx). Open succeeded for every flag; ctx[0] was 1 (magic OK); ctx[8] was a small integer (0x1C8, 0x20C, 0x210, 0x214, 0x218 for flags 0..4 respectively) which I initially mistook for a process-local handle index.
MpSetTPState(ctx, 1) produced an access violation 0xC0000005, with no event 5013, no event 5007, no Defender operational log line at all. So this was a client-side crash, not a server-side reject.
12.3 The binding resolver, sub_180078724
The function the setter calls to dereference the handle is small — 163 bytes of assembly. Cleaned up:
HRESULT sub_180078724(DWORD flag, PVOID handle, PVOID *outBindingPtr) {
PVOID innerCtx = NULL;
HRESULT hr = sub_1800793F8(handle, &innerCtx, /*?*/);
if (FAILED(hr)) return hr;
PVOID binding;
switch (flag) {
case 0: binding = *(PVOID*)((BYTE*)innerCtx + 0x18); break;
case 1: binding = *(PVOID*)((BYTE*)innerCtx + 0xC0); break;
case 2: binding = *(PVOID*)((BYTE*)innerCtx + 0xC8); break;
case 3: binding = *(PVOID*)((BYTE*)innerCtx + 0xD0); break;
default: ReleaseMutex(...); return E_INVALIDARG;
}
if (!binding) return E_NOINTERFACE;
*outBindingPtr = binding;
HANDLE mtx = *(HANDLE*)((BYTE*)innerCtx + 8);
if (mtx) ReleaseMutex(mtx);
return S_OK;
}
So the layout of innerCtx after sub_1800793F8 returns it is roughly:
struct InnerCtx {
DWORD type; // 0x00
DWORD pad; // 0x04
HANDLE mutex; // 0x08 ← released at exit
// 0x10..0x17 unused?
PVOID binding_flag0; // 0x18 ← used by MpSetTPState / MpGetTPStateInfo
BYTE filler[0xA0];
PVOID binding_flag1; // 0xC0
PVOID binding_flag2; // 0xC8
PVOID binding_flag3; // 0xD0
};
sub_1800793F8 is a small wrapper whose first action is to call WaitForMultipleObjects on a mutex pulled out of the handle, then populate the OUT parameter. In other words it is a lock-and-fetch: the handle the caller hands it has to identify a record that already exists in the client-side bookkeeping table for this connection. If the record is absent — because the connection has not been initialised by the higher-level opener responsible for this particular subsystem — then the lookup returns zero and the subsequent [+0x18] dereference falls into garbage.
12.4 The right opener: MpManagerOpen
The fact that the resolver branches on a flag, and that MpSetTPState always uses flag 0 (= the [+0x18] slot), told me that each flavour of subsystem populates a different slot. MpOpen(0) filled the slot for its own product API, not the slot for Manager-level RPCs. The export named MpManagerOpen — which I had overlooked because the keyword scan in §12.1 did not yet include it — is the matching opener.
typedef HRESULT (WINAPI *MpManagerOpen_t)(DWORD reserved, PVOID *outCtx);
typedef HRESULT (WINAPI *MpManagerStatusQuery_t)(PVOID ctx, PVOID outBuf);
typedef HRESULT (WINAPI *MpSetTPState_t)(PVOID ctx, DWORD newState);
// pInit, pManagerOpen, pStatusQuery, pSetTP, pUninit resolved via GetProcAddress
CoInitializeEx(NULL, COINIT_MULTITHREADED);
pInit(); // hr = 0
PVOID ctx = NULL;
pManagerOpen(0, &ctx); // hr = 0; ctx[0] = 1 magic, ctx[8] = real binding entry
This time the ctx came back with the magic in place and a context that the resolver can walk. As a sanity check before doing anything write-side, I called the matching read:
BYTE outBuf[1024] = {0};
HRESULT hr = pStatusQuery(ctx, outBuf);
// hr = 0
// outBuf: 00 00 08 00 00 00 00 00 01 00 00 00 02 00 00 00 ...
That output is the public Tamper Protection state struct — the first four bytes are a structure size (0x80000 = 524 288, a 32-bit MIDL size descriptor), the next eight are reserved/padding, then byte 8 == 1 (TamperEnabled = true) and byte 12 == 2 (TamperSource = 2, i.e. local). These two values match exactly what the registry reflects in HKLM\SOFTWARE\Microsoft\Windows Defender\Features\{TamperProtection, TamperProtectionSource}. So the read direction reaches MsMpEng cleanly through our arbitrary admin-mode process. No package SID required, no special token.
12.5 The decisive test: write
With everything set up correctly, I issued the write:
HRESULT hr = pSetTP(ctx, 1); // request: disable Tamper Protection
Result:
hr = 0x80070005 (E_ACCESSDENIED)
Defender Operational log: no new events
Tamper state on next Get-MpComputerStatus: unchanged (IsTamperProtected = True)
This is a clean server-side reject. No exception, no garbage unwind, no event 5013 either — because event 5013 is the registry minifilter's block log, and we never touched the registry here. The server-side RPC dispatcher in MsMpEng simply refuses to dispatch procedure 0x95 to a caller whose token is not on the per-procedure allow-list. The read procedure 0x94 has a more permissive ACL and goes through.
12.6 What this means
The answer to the architectural question is now precise:
Tamper Protection in 26H1 enforces a per-procedure access-control list inside MsMpEng's RPC dispatcher, not just inside the
WdFilterregistry minifilter. Read-side procedures (status query, get-info) are open to any elevated admin caller. Write-side procedures (set-state, manager-disable/enable) require a caller token that carries theMicrosoft.SecHealthUIpackage SID, theWinDefendservice SID, or an equivalent intent-trusted signer. No combination ofSeDebugPrivilege,SeBackupPrivilege,SeRestorePrivilege,TrustedInstallerimpersonation, or token elevation that I could obtain in user mode satisfies the check.
This is, importantly, the correct outcome from Microsoft's perspective. It is exactly the trust boundary they document in their Tamper Protection design notes. The negative result is a confirmation of that boundary, not a discovery of a hole in it. From a research standpoint that is the result I wanted: I now know that the inject path of §11 is the only user-mode route, and that the byte-patch target of §10 is the only kernel-mode route. Everything between those two is solidly closed.
12.8 The deeper layer: caller-origin verification
The conclusion in §12.6 — that Tamper Protection demands the SecHealthUI package SID on the caller's token — was correct as far as it went. But the follow-up experiment told me that even with the package SID natively present in the primary token, the write still fails. The only way I could prove this was to run the exact same RPC sequence from inside the SecHealthUI.exe process itself.
I modified my injected payload so that, instead of walking the XAML visual tree and invoking ToggleSwitch::IsOn(false), it dropped straight into the Direct-RPC sequence: MpConfigInitialize, MpManagerOpen(0, &ctx), MpSetTPState(ctx, 1). Same DLL, same package, same loader image — only the code path inside the process changed.
Result (with Tamper Protection off at the time of the test, to isolate the RPC enforcement from the registry minifilter):
[payload] === Direct RPC variant start ===
[payload] PID=2936 process=SecHealthUI.exe
[payload] target='Tamper Protection' state=0
[payload] MpConfigInitialize hr=0x00000000
[payload] MpManagerOpen hr=0x00000000 ctx=0x000000FFD14546E0
[payload] Calling MpSetTPState(ctx, 1) ...
[payload] MpSetTPState hr=0x80070005 ← E_ACCESSDENIED
[payload] Server-side ACCESS_DENIED
This was a surprise. The token in my thread is the exact same primary token the process started with. There is no impersonation involved, no thread token override, no integrity downgrade — the package SID is present in the primary token as a natural consequence of the process being the legitimate UWP package. Yet MsMpEng refuses the write.
That observation can only mean one thing: the per-procedure ACL on proc 0x95 in MsMpEng's dispatcher does not stop at the caller's token. It performs a second check.
12.9 The stack-origin hypothesis
The most plausible candidate for the second check is a caller-origin verification: the dispatcher inspects the return address chain (the stack frames above the NdrServerCall3 entry on the server side, walked across the ALPC port boundary on Windows 11 26H1) and demands that the originating user-mode RIP lives inside an allowed module's text section. The allowed modules would be the hand-picked components of the SecHealthUI package and a few signed Microsoft client binaries (MpCmdRun.exe, MpCommandHandler.dll, Set-MpPreference cmdlet hosts).
If this hypothesis is right, the explanation for why the XAML-walk path of §11 works is structural:
- When my payload walks the XAML tree and calls
ToggleSwitch::IsOn(false), the call lands insideSecHealthUIDataModel.dll— the WinUI model that owns the setting's "click" handler. That DLL is signed by Microsoft and is on the allow-list. - From inside that handler, the model issues its own
MpSetTPStatecall. The originating RIP is insideSecHealthUIDataModel.dll's text section. The dispatcher's stack-walk verifier is satisfied, the RPC dispatches, the state changes.
When my payload calls MpSetTPState directly, on the other hand, the originating RIP is inside payload.dll. The verifier sees a non-allow-listed module on the stack at the user-mode caller frame, returns E_ACCESSDENIED, and never invokes the procedure.
This would also explain why no event 5013 is logged: 5013 is the registry minifilter's block event, and registry was never touched. The RPC dispatcher silently refuses with a clean HRESULT, exactly as documented behaviour for an ACL miss.
12.10 Implications
I cannot at this point disprove other hypotheses — there are at least two more plausible variants of the same general idea (integrity-level demand on the caller thread; interactive-desktop demand on the originating window station) — but the stack-walk explanation accounts for every observed behaviour: the success of the XAML widget path, the failure of the in-process direct-RPC path, the absence of any 5013 event, the symmetric success of read-side RPCs. It also matches the spirit of the design Microsoft documents for Tamper Protection ("only the in-product Security UI can change these settings"), and it would explain why ordinary admin impersonation, TrustedInstaller, and all the other token tricks from Phase 2 silently failed even though the token shape ought to have been good enough.
For my purposes — building a maintainable tool — this is the practical takeaway: the only user-mode delivery channel left is the XAML widget call from inside SecHealthUI, exactly the path the DefenderDirect tool already takes in §11. The Direct-RPC route was worth chasing; it is now conclusively a dead end for arbitrary admin-mode callers.
For Microsoft's purposes, this is — again — correct behaviour: the trust boundary is enforced not just by checking who the caller claims to be, but by checking which code is making the claim. That is exactly what a defence-in-depth design ought to do, and it is the reason I am writing this section in the appreciative register rather than the adversarial one.
12.11 A subtler observation about the per-procedure ACL
I am not going to claim the per-procedure ACL is unbreakable; what I can say is that it is consistent. Every read-side proc number I have tried (MpManagerStatusQuery = 0x94, MpGetTPStateInfo = 0x94 in the TP-specific interface, the various MpConfigQuery* calls) returns S_OK from an arbitrary admin-mode caller. Every write-side proc number (MpSetTPState = 0x95, MpManagerDisable, MpManagerEnable, MpConfigSetValue when targeting a Tamper-protected scope) returns E_ACCESSDENIED without a server-side log event. The ACL clearly distinguishes read from write at a fine granularity, and the distinction lives inside the daemon, not in any external gate I can modify in user mode.
The only way to spoof that check from outside the legitimate package would be to forge the caller's primary token to include the SecHealthUI package SID. Token forgery against an enforcing verifier requires either a kernel-mode primitive (a driver with PsImpersonateClient or a write to the token's groups array via MmCopyVirtualMemory) or an exploit of the verifier itself. Neither of those is a "bypass" in the trivial sense; both are full kernel-level compromises with much bigger implications than just Defender control.
13. Memory Layout Reference
For completeness, the section layout of MpSvc.dll (4.18.26040.7-0) as observed in the live process:
| Section | RVA | Virtual size | Flags |
|---|---|---|---|
.text |
0x001000 | 0x490E8C | 0x60000020 RX |
.rdata |
0x492000 | 0x149D02 | 0x40000040 R |
.data |
0x5DC000 | 0x02CDE0 | 0xC0000040 RW |
.pdata |
0x609000 | 0x024504 | 0x40000040 R |
.didat |
0x62E000 | 0x000330 | 0xC0000040 RW |
.fptable |
0x62F000 | 0x000100 | 0xC0000040 RW |
.rsrc |
0x630000 | 0x010390 | 0x40000040 R |
.reloc |
0x641000 | 0x006F10 | 0x42000040 R |
Confirmed primary patch target (single-byte boolean):
RTP_DISABLE_FLAG = MpSvc.dll!.data + (0x602F18 - 0x5DC000)
= MpSvc.dll!.data + 0x26F18
That .data-relative offset is the value any patcher should compute from the runtime module base, because the ASLR slide makes the absolute VA different on every boot.
14. Patch Vector
The combination of Phase 5–8 leaves three realistic delivery routes. The table below collects them so a later reader does not need to thumb backwards through the document; the prose underneath each entry expands on the trade-offs.
| # | Path | Where it lives | Trust required | Visible UI? |
|---|---|---|---|---|
| 1 | SecHealthUI inject + UIA toggle from inside the package | user mode | local administrator + SeDebugPrivilege |
yes — package frame flickers briefly; cannot be cloaked on 26H1 |
| 2 | Direct-byte patch of MpSvc.dll + 0x602F18 in MsMpEng's VAS |
kernel mode | PPL bypass (e.g. kvc dse off --safe + custom signed driver) or a kernel exploit |
no |
| 3 | Direct RPC MpSetTPState(ctx, 1) over MpManagerOpen context |
user mode | … none we can obtain | n/a — fails with E_ACCESSDENIED (§12) |
14.1 SecHealthUI inject (route 1)
Already shipped as WinDefCtl\. Build with the project's build.ps1; outputs an 8 KB injector and a ~245 KB payload DLL. The payload reaches RTP, TP and the four other 26H1 sliders through a single UIA-Name match. The only sharp edge is the inability to hide the host's flicker; that is covered by a full-screen Direct2D overlay in the same way WinDefCtl v2.0 covers slider automation.
14.2 Kernel-mode byte patch (route 2)
This is the elegant option if a signed driver is available. The driver needs three things:
- A way to enumerate
MsMpEng's loaded modules —PsGetProcessSectionBaseAddressor aKAPCwalking the PEB — to resolve the current ASLR base ofMpSvc.dll. - A way to write to PPL-protected memory.
MmCopyVirtualMemoryworks for kernel→PPL writes when the writer's signer level meets or exceeds the target's; for an unsigned driver loaded under DSE-off, the signer level is implicitlyWinSystemand the write succeeds. - The relative address
0x602F18fromMpSvc!.data, which is independent of ASLR.
Pseudocode:
NTSTATUS PatchRtpFlag(PEPROCESS msmpEng, UCHAR newValue) {
ULONG_PTR base = ResolveModuleBase(msmpEng, L"MpSvc.dll");
if (!base) return STATUS_NOT_FOUND;
PVOID target = (PVOID)(base + 0x602F18);
SIZE_T written;
return MmCopyVirtualMemory(
PsGetCurrentProcess(), &newValue,
msmpEng, target,
sizeof(newValue), KernelMode, &written);
}
newValue = 0 forces real-time monitoring on; 1 forces it off. Symmetric for the sibling flag 0x602F2C.
14.3 User-mode write via direct syscalls / trampolines
Writing to a PPL-Antimalware process from user mode normally fails: OpenProcess(PROCESS_VM_WRITE, …) returns ERROR_ACCESS_DENIED before the memory operation begins. However, the standard route is not the only route.
EDR products — and indeed Defender itself — instrument the user-mode functions in ntdll.dll by patching their prologues with a jmp to a callback. This means that the advertised NtWriteVirtualMemory is observed, but a process that calls the syscall directly, without going through the ntdll!Nt* entry-point, bypasses the hooks entirely.
I already use this approach in production: my asm-based trampoline machinery lives at AbiTramp.asm. The idea is to:
- Resolve the syscall number for
NtWriteVirtualMemoryfrom a clean copy ofntdll.dll(read from disk, not the in-process mapped image, so any hooks on the in-process copy are irrelevant) - Emit a tiny stub
mov r10, rcx; mov eax, <syscall#>; syscall; ret - Call the stub directly; the kernel never sees a trampolined entry-point
PPL still applies in the kernel, so direct syscalls alone do not let me write to MsMpEng. The combination I plan to use is:
The DSE bypass to grant PPL-Antimalware to a chosen caller is already a service that KVC provides via kvc dse off --safe and a custom signed driver. Once the caller process has been elevated, the direct syscall lands and writes the byte.
14.4 Kernel-mode driver via a custom .sys (alternative to direct syscalls)
If we prefer to keep the patch in kernel mode start-to-finish, the equivalent in a small .sys driver would be:
NTSTATUS PatchRtpFlag(PEPROCESS msmpEng, ULONG_PTR mpSvcBase, UCHAR value) {
SIZE_T written;
PVOID addr = (PVOID)(mpSvcBase + 0x602F18);
return MmCopyVirtualMemory(
PsGetCurrentProcess(), &value,
msmpEng, addr,
sizeof(value), KernelMode, &written);
}
…with mpSvcBase resolved by enumerating the loaded modules of msmpEng via PsGetProcessSectionBaseAddress or by walking the PEB. The driver itself must be either signed or loaded via the standard kvc dse off flow.
This is more work to build than the user-mode trampoline version because it needs a real .sys and the deploy/teardown infrastructure. The trampoline route is my preferred path for the initial proof of concept; the kernel-mode version remains an option for production.
15. Tooling Reference
| Tool | Source | Used for |
|---|---|---|
kvc.exe (Kernel Vulnerability Capabilities Framework) |
my own framework, in System32 |
kvc dump, kvc trusted, kvc dse off, kvc driver load |
cmdt.exe |
my own, in System32 |
Interactive TrustedInstaller shell |
| WinDefCtl v2.0 C++ + PowerShell | my own | Reference implementation of RTP/TP/kill/restore |
| IDA Pro 9.1 | Hex-Rays | Static analysis with PDB symbols |
| IDAPython | bundled with IDA Pro | Scriptable scans + xref enumeration |
cdb.exe |
Windows SDK Debugging Tools | Minidump inspection, .writemem |
| WinDbg | Windows SDK | Available as alternative to cdb |
inspect.exe |
Windows SDK | UI Automation tree visualisation |
| Accessibility Insights for Windows | Microsoft | Same purpose, prettier UI |
| Microsoft Public Symbol Server | https://msdl.microsoft.com/download/symbols |
PDB downloads for MpRtp, MpSvc, MpClient |
System.Windows.Automation |
.NET BCL | Programmatic UI tree dump from PowerShell |
Get-WinEvent on Microsoft-Windows-Windows Defender/Operational |
inbox | Correlate write attempts with 5007 / 5013 events |
C++/WinRT (cppwinrt headers) |
Windows SDK | XAML in-process probe inside the injected payload |
GetPackageFamilyName |
kernel32 |
Extracting Microsoft.SecHealthUI_8wekyb3d8bbwe PFN from a running handle |
VirtualAllocEx + WriteProcessMemory + CreateRemoteThread(LoadLibraryW) |
classic injection trio | Loading payload.dll into the package process |
MpClient.dll exports + PDB |
Defender platform | Direct-RPC probe (§12) |
NdrClientCall3 arguments |
Rpcrt4.dll |
Identifying RPC procedure numbers (0x94 = read, 0x95 = write) |
MpManagerOpen (ord 197) |
MpClient.dll |
Producing the correctly-shaped context for MpSetTPState |
16. Limitations & Future Work
- Build dependency. The RVA
0x602F18is specific toMpSvc.dll4.18.26040.7-0. Every Defender platform update reshuffles the layout. A robust patcher needs a pattern scan: locate the gate function by its prologue and the dual-flag comparison pattern, then derive the flag RVA from the immediate operand of the secondcmp. - Concurrency. The gate function is presumably called on every scan; I have not yet investigated whether there is a critical section around it. The
EnterCriticalSection(stru_1806071F8)pattern observed neardword_180607230suggests Defender keeps a reload lock; write atomicity for a single byte on x64 is fine, but I should still verify there is no double-buffer or shadow-copy elsewhere. - Telemetry. A memory write is invisible to the registry minifilter, but Defender may still notice its own state has changed via internal heartbeats and try to "self-heal" by re-reading from the WMI provider. The sibling
dword_180602F2Cflag and the counter atdword_180607230may be part of such a reconciliation pass — that needs another diff under load. - The MpFC dispatch table. I have not yet enumerated the full table that the
MpFC_*strings index into. Doing so would map every Defender feature to its.dataoffset and yield a complete "Defender state struct". mpengine.dll. I deliberately did not analyse the largempengine.dll(the actual signature engine), because the RTP toggle does not need its participation. But the engine itself may carry an independent enabled bit — worth checking.sub_1800793F8internals. I inferred its behaviour (a lock-and-fetch on a per-connection mutex held inside the client bookkeeping table) from the call site, the strict use ofWaitForMultipleObjectsandReleaseMutexon[ctx+8], and the wayMpManagerOpenpopulates the slot at[+0x18]of the inner structure whereMpOpen(0)leaves it null. A full read of the function would be welcome and would harden the per-procedure ACL description in §12.7.MpSetUacElevationDefaultWindowHandle(ord 236). The name suggests this is the export by which a UWP front-end can tellMsMpEng"if a UAC prompt is needed for this state change, parent it to this window handle". I have not yet probed it. There may be an interesting interplay between this hint and the per-procedure ACL — for example, supplying a window handle fromSecHealthUI's frame might be one of the factors the ACL evaluates. This is a thread worth pulling.- The flag at
0x180602F2C. The sibling boolean directly above the RTP flag in the gate function is almost certainly another feature bit (most likelyBehaviorMonitorbased on its proximity in theMpFC_*enum), and inspection would complete the map of the dual-gate pattern. I left it for the next session.
17. Ethics & Responsible Disclosure
This work is performed on my own machine, for my own systems administration purposes, and as part of a public project (WinDefCtl) intended for users who genuinely need the ability to disable Defender locally — for example to install forensic tools, to test other AV engines, or to perform incident response on endpoints where Defender's own scanning is interfering. WinDefCtl is shipped with full restore parity: every operation is reversible, and the "kill" operation already exists in the v2.0 release in a more invasive form (IFEO block + kernel kill of MsMpEng.exe), which has been publicly available and discussed on MyDigitalLife for many months without abuse.
The PoC patch described here is, in the threat model sense, less disruptive than the existing kill: it touches one byte in one process and leaves no artifacts on disk or in the registry. It does not bypass Tamper Protection in the sense of "make TP think it is on while RTP is off" — TP itself remains fully active and the user can re-enable RTP from the Windows Security UI at any time.
I will not publish the binary form of the PoC patcher in this repository. The methodology, the address, and the disassembly are documented here so that other reverse engineers can verify, but the deliverable in WinDefCtl.ps1 will remain the documented, well-behaved IFEO-block + kvckiller path that respects the existing Defender event-log contract.
If a reader from Microsoft would prefer that even the methodology be retracted, contact me at [email protected] and I will discuss.
18. Acknowledgements
- The MyDigitalLife forum community, in particular Opulent_Maelstrom and Carlos Detweiller, for early feedback on
WinDefCtlthat motivated the work. - Hex-Rays for IDA Pro 9.1 and the responsive symbol-handling pipeline.
- Microsoft for publishing PDBs for the Defender platform binaries on the public symbol server, without which this work would have been an order of magnitude harder.
- My wife, for keeping breakfast warm while I was running the second memory dump.
Appendix A — Full byte diff between the two MpSvc.data snapshots
$ diff -u mpsvc-on.hex mpsvc-off.hex | head -40
RVA ON OFF notes
------ ---- ---- -----
0x5DC680 0x34 0x1D single byte (heap pool LSB; 10 xrefs as lpMem)
0x602F18 0x00 0x01 single byte (RTP gate flag) ← primary target
0x607218 0xCE 0xCD single byte (no xref — padding / vftable slot?)
0x607230 0x04 0x03 single byte (lock-protected counter, 2 xrefs)
0x608CE8 0x28 0x7D 3-byte run (counter / timestamp,
0x608CE9 0xAA 0x49 3 xrefs in sub_18041C9DC
0x608CEA 0x4F 0x6C comparing with 0xEA60 = 60000 ms)
Total differing bytes: 7 / 183 776 (≈ 0.004%)
Appendix B — cdb commands used
Load dump, locate MpSvc, list sections:
cdb -z msmp-on.dmp -c ".symfix; lm m mpsvc; !dh -s mpsvc; q"
Inspect target bytes:
cdb -z msmp-on.dmp -c ".symfix; db mpsvc+602F18 L40; db mpsvc+602F2C L20; q"
Extract .data section to file (used by the bindiff):
cdb -z msmp-on.dmp -c ".writemem mpsvc-on.data mpsvc+5DC000 L2CDE0; q"
cdb -z msmp-off.dmp -c ".writemem mpsvc-off.data mpsvc+5DC000 L2CDE0; q"
Live inspection (if attaching to a running, non-PPL process):
cdb -p <PID> -c "ed mpsvc+602F18 1; gh"
Appendix C — IDAPython scripts
Three scripts were written for this investigation, all stored under generator/:
| Script | Purpose |
|---|---|
ida-defender-scan.py |
Bulk string + symbol scan with keyword filter |
ida-mpsvc-deepdive.py |
Per-target xref dump for the strings shortlist |
ida-disasm.py |
Disassembly of candidate loader functions |
ida-xref-target.py |
Final xref + symbol resolution for the seven diff targets |
Each is invoked with ida.exe -A -B -S<script> -L<log> <binary> and is fully self-contained. The headless mode does the auto-analysis, loads the PDB if available, runs the script, and exits without ever opening the GUI.
Appendix D — Defender platform inventory (4.18.26040.7-0)
The binaries scanned or considered, in C:\ProgramData\Microsoft\Windows Defender\Platform\4.18.26040.7-0\:
| Binary | Size | Note |
|---|---|---|
| MsMpEng.exe | 290 704 B | Service stub |
| MpSvc.dll | 6 559 232 B | Investigated — RTP gate located here |
| MpRtp.dll | 1 254 792 B | Investigated — only the filter machinery, not the state |
| MpClient.dll | 1 824 256 B | Investigated — WMI client surface |
| mpengine.dll | (separate package) | Not investigated (out of scope) |
| MpDetours.dll | 460 800 B | EDR detour shim |
| MpAzSubmit.dll | 1 670 656 B | Cloud submission |
| MpSenseComm.dll | 898 560 B | Sense / MDE communication |
| MpDefenderCoreService.exe | 2 097 152 B | Core service |
Total platform footprint: ~280 MB across roughly 40 files.
Appendix E — sub_180078724 and the binding-resolver micro-architecture
Full reconstruction of the 163-byte function that converts an MpManagerOpen/MpOpen-style handle into the live RPC binding pointer that NdrClientCall3 actually consumes.
//
// HRESULT sub_180078724(
// DWORD flag, // 0..3, selects which binding slot
// PVOID handle, // value found at ctx[+8] after MpManagerOpen
// PVOID *outBindingPtr // OUT — receives the resolved binding
// );
//
// Sister function sub_1800793F8 is a lock-and-fetch helper: it walks an
// internal per-connection table indexed by `handle`, takes the mutex
// stored alongside the entry, and hands back a pointer to the inner
// record. The mutex is released by sub_180078724 on the way out.
//
HRESULT sub_180078724(DWORD flag, PVOID handle, PVOID *outBindingPtr) {
PVOID innerCtx = NULL;
HRESULT hr = sub_1800793F8(handle, &innerCtx, /*?*/);
if (FAILED(hr)) return hr;
PVOID binding = NULL;
switch (flag) {
case 0: binding = *(PVOID*)((BYTE*)innerCtx + 0x18); break;
case 1: binding = *(PVOID*)((BYTE*)innerCtx + 0xC0); break;
case 2: binding = *(PVOID*)((BYTE*)innerCtx + 0xC8); break;
case 3: binding = *(PVOID*)((BYTE*)innerCtx + 0xD0); break;
default: hr = E_INVALIDARG;
goto release;
}
if (!binding) { hr = E_NOINTERFACE; goto release; }
*outBindingPtr = binding;
release:
if (innerCtx) {
HANDLE mtx = *(HANDLE*)((BYTE*)innerCtx + 8);
if (mtx) ReleaseMutex(mtx);
}
return hr;
}
The key insight is that the four binding_flagN slots in the inner record are populated by different openers. MpOpen(0..4) writes the slot at offset 0x18 only when its initialisation path matches the slot's intended consumer; for the Manager-class RPCs that MpSetTPState uses, MpOpen(0) does not — it leaves [+0x18] null. MpManagerOpen takes the longer initialisation path (it internally invokes the ProductFeature variant of MpOpen, the one that walks through sub_180078114(ctx, L"ProductFeature", ..., outFeatureCtx)), and that extra step is what populates the slot the setter reads from.
So the algorithm that drives a successful read or write at the direct-RPC layer is:
CoInitializeEx(NULL, COINIT_MULTITHREADED);
MpConfigInitialize();
MpManagerOpen(0, &ctx); // ← right opener: ctx[+8] points at a
// record whose slot [+0x18] is filled
MpManagerStatusQuery(ctx, &buf); // succeeds for any admin caller
MpSetTPState(ctx, newState); // succeeds only if caller carries a
// package SID on the ACL of proc 0x95
MpConfigUninitialize();
CoUninitialize();
The ACL check happens inside MsMpEng, after the RPC message has been demarshalled, but before the service method runs. That is why the failure mode is a clean E_ACCESSDENIED — no exception, no event 5013, no client-side parser fault.
Appendix F — End-to-end summary
For anyone landing on this document looking for the executive headline:
without UI, without UAC] --> Q{User-mode?} Q -->|registry| R1[blocked by ACL + 5 minifilter callbacks] Q -->|WMI Set-MpPreference| R2[blocked, event 5013] Q -->|GPO write| R3[ignored, event 5013] Q -->|hive save/load/restore| R4[ACL follows hive — write denied] Q -->|take ownership + ACL fix| R5[NtSetSecurityObject hooked] Q -->|TrustedInstaller via cmdt or kvc trusted| R6[NtRestoreKey hooked] Q -->|direct RPC via MpManagerOpen + MpSetTPState| R7[E_ACCESSDENIED — per-procedure ACL in MsMpEng] Q -->|inject DLL into SecHealthUI.exe| OK1[**works** — worker calls ToggleSwitch::IsOn under package SID] OK1 --> Note1[Phase 9: AO_PRELAUNCH removes the flicker; TP covered via XAML event-router masquerade] Q -->|kernel mode| K{driver loaded?} K -->|kvc dse off + custom .sys| OK2[**works** — MmCopyVirtualMemory on MpSvc+0x602F18] OK2 --> Note2[no flicker, no events, no registry trace]
The two green branches — SecHealthUI inject and kernel byte patch — are the only viable delivery channels. Every other route is sealed.
Post-Phase 9 update. The inject channel now covers all six sliders, including Tamper Protection, with no observable UI realisation and no engine-side revert (see § 9.11 for the architectural reason). The kernel-byte-patch channel remains a valid alternative for scenarios where even a transient prelaunched host process is unacceptable, but on 26H1 it is no longer a requirement to defeat Tamper Protection from user mode.
Phase 9 — From proof of concept to deterministic tool
Phase 7 closed by demonstrating that a DLL loaded inside SecHealthUI.exe can flip a ToggleSwitch because the call now executes under the package SID that MsMpEng's per-procedure ACL accepts. That settles the what. This phase documents the how — turning a brittle proof of concept into a single binary that succeeds on the first try, ten times in a row, with no manual cleanup, no UI flash, and no diagnostic ambiguity left behind.
The path was iterative and each iteration was forced by a specific observed failure mode rather than a speculative refactor. I think it is worth recording the dead ends, because each one explains why the final shape is what it is.
9.1 Iteration 1 — AO_NOERRORUI with a race cloak
The first implementation activated SecHealthUI with AO_NOERRORUI | AO_NOSPLASHSCREEN. The frame window becomes a real CoreWindow immediately; the only thing standing between the user and a flash of the Defender UI is whatever I can do before the compositor draws the first frame.
I tried two hiding primitives:
-
Teleport off-screen.
SetWindowPos(hwnd, NULL, -32000, -32000, …)as soon asFindWindowWreturned theApplicationFrameWindow. This works for ordinary Win32; it fails for UWP because the frame is composited through DirectComposition and a non-client move triggers one re-present at the original position before the move takes effect. Net result: a single white rectangle, then darkness. Sometimes invisible, sometimes a clearly visible blink. -
DWM cloak.
DwmSetWindowAttribute(hwnd, DWMWA_CLOAK, &one, sizeof(one)). Faster thanSetWindowPos, no off-screen geometry. But the cloak only suppresses future presents; the very first frame that the compositor commits before the cloak call still rasterises. The race window is on the order of 5–20 ms, dominated bydwm.exescheduling jitter and the cold/warm state of SecHealthUI.
Combining the two narrowed the window but never closed it. Empirically, on the 26H1 builds in scope, the flash was visible roughly one run in five. For a tool that has to be callable from scripts, that is a defect, not a feature.
9.2 Iteration 2 — AO_PRELAUNCH
IApplicationActivationManager::ActivateApplication accepts AO_PRELAUNCH (0x2000000). The documented contract is that the host is created in a dormant state: the manifest entry point runs, but the framework defers XAML root creation and never asks the windowing subsystem for a frame. There is nothing to hide because nothing was ever realised.
This eliminates the entire cloak race in one stroke and removes ~80 lines of fragile windowing code. I kept AO_NOERRORUI | AO_NOSPLASHSCREEN | AO_PRELAUNCH and added a single fallback to AO_NOERRORUI | AO_NOSPLASHSCREEN for the (theoretical) case where a future Windows build refuses the prelaunch flag.
Cosmetically this was the right answer. Functionally, it introduced a new failure mode — but a more interesting one.
9.3 The new failure mode — intermittent toggles
Once the cloak race was gone, I expected steady-state success. Instead, the toggle landed on the requested state roughly half the time. The other half, the injector reported success, the UWP host exited cleanly, and Defender's effective state simply hadn't changed.
Reproducing it from a script: WinDefCtl rtp off; …; WinDefCtl rtp on would produce arbitrary sequences of (good, good), (good, bad), (bad, good), (bad, bad) with no pattern over time of day, system load, or whether SecHealthUI had been touched manually beforehand. Same binary, same arguments, different outcome.
A few hypotheses up front:
- MsMpEng RPC commit latency. The worker calls
ToggleSwitch::IsOn(newState), which marshals throughSecurityHealthCore.dll → SecurityHealthService.exe → MsMpEng.exeover an LRPC port. If the worker'sCoreApplication::Exit()fires before the LRPC reply is observed, the write is lost. - Tamper Protection intent verification. TP has documented behaviour where a programmatic toggle without raw input from a foreground frame is reverted by MsMpEng after a short delay. This would explain
tpmisbehaving but notrtp,cdp, etc. - PLM lifecycle interference. A prelaunched UWP is by design a candidate for immediate freeze. If PLM intercepts the host before injection lands, the host is torn down before anything useful runs.
The third hypothesis fit the symptom most cleanly — a binary outcome with no recoverable error and no log entry on the failure side — but the cost of testing it was a real diagnostic channel, which I built first.
9.4 Iteration 3 — compile-time diagnostic logger
Random failures are the worst kind of failure to debug at a distance. I needed every interesting decision in both the injector and the worker to be recoverable from a log file, switchable off in release with zero residual cost.
Design:
DebugLog.hexposes a singleWINDEFCTL_DEBUGmacro. At0, everyDLOG(...)collapses to((void)0)and no string literal reaches the binary.- The format is
[HH:MM:SS.mmm] [TID nnnn] [TAG] [file:line] message.TAGisINJfor the elevated injector andPAYfor the in-process worker. The thread id distinguishes the worker's UI thread from its dispatcher delegate, which matters when reading the XAML walk traces. - The runtime side uses
wsprintfAandwvsprintfAfromuser32.lib, so the no-CRT injector links cleanly and the cppwinrt worker does not need to pull in its own formatter. - Both sides append to a single file under
%LOCALAPPDATA%\Packages\Microsoft.SecHealthUI_8wekyb3d8bbwe\LocalState\WinDefCtl.log.FILE_APPEND_DATAwithFILE_SHARE_READ | FILE_SHARE_WRITEgives atomic concurrent appends. Inter-process interleaving is theoretically possible but never observed, because the worker only logs once per XAML walk iteration.
9.5 Iteration 4 — DACL on the log file
The first run with the logger enabled produced injector lines through CreateRemoteThread, then nothing. No worker lines at all.
My initial reading was that the worker DLL had failed to load. Then it occurred to me that the log file itself was unreachable from the worker. SecHealthUI runs in an AppContainer; admin-created files inherit a DACL that, by default, has no ACE for ALL APPLICATION PACKAGES. The worker's CreateFileW on the log path would silently return ERROR_ACCESS_DENIED and the worker would proceed without logging.
Fix 1 — minimal SDDL:
"D:(A;OICI;FA;;;WD)(A;OICI;FA;;;AC)"
WD (Everyone) for legacy compatibility, AC (ALL APPLICATION PACKAGES) for the AppContainer. With PROTECTED_DACL_SECURITY_INFORMATION on SetSecurityInfo, the explicit DACL replaced whatever the inherited one was.
This was worse than the inheritance default. The next run still produced only INJ lines. Two realisations landed at once:
PROTECTED_DACLkills inheritance — includingLocalState's own inheritable ACEs for the package-specific SID — so the new DACL had only what I had written.ACis the token group for ordinary UWP apps. SecHealthUI on 26H1 is not an ordinary UWP. It is LPAC (Less Privileged AppContainer); its access check treatsS-1-15-2-2(ALL RESTRICTED APPLICATION PACKAGES) as the load-bearing SID, and ignoresACalone.
Fix 2 — LPAC-aware SDDL with inheritance preserved:
"D:"
"(A;OICI;FA;;;WD)"
"(A;OICI;FA;;;AC)"
"(A;OICI;FA;;;S-1-15-2-2)"
"(A;OICI;FA;;;BA)"
"(A;OICI;FA;;;SY)"
with UNPROTECTED_DACL_SECURITY_INFORMATION on SetSecurityInfo, so any inheritable ACE from LocalState survives as a safety net.
Worker logs appeared on the next run. The DACL story is also what later motivated the explicit DACL on the extracted DLL itself (§ 9.8) — same diagnosis, different file.
9.6 Iteration 5 — PLM, diagnosed by what is missing
The first run with both sides logging produced a clean success: the worker walked the XAML tree, found ToggleSwitch for Real-time protection, applied IsOn(0), and exited. Then, a few runs later, this:
[INJ] WriteProcessMemory ok (214 bytes)
[INJ] CreateRemoteThread ok (LoadLibraryW dispatched)
[INJ] === injector done ===
Zero PAY lines. CreateRemoteThread returned a valid handle. The injector exited normally. The remote thread had never been scheduled.
This is precisely the signature of a process the kernel knows about but the scheduler is not running. PLM (Process Lifecycle Manager) freezes prelaunched UWPs by setting the job-object freeze flag on the host job. The kernel:
- Accepts
CreateRemoteThreadand allocates theETHREAD. - Queues the thread in the ready list, but the ready list never advances because the host's threads are blocked by the job-level freeze.
- Returns the handle to user mode.
The injector, seeing a valid handle, considers its job done and exits. With no parent keeping the host alive, PLM concludes that the dormant prelaunch has timed out and tears the host down. LoadLibraryW is never invoked. DllMain never runs. There is no record of failure because, in the user-mode sense, nothing failed.
Fix:
- Wake the host.
NtResumeProcess(hProc)lifts the freeze. The function is undocumented but stable; resolve throughGetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtResumeProcess").PROCESS_ALL_ACCESS(or the narrowerPROCESS_SUSPEND_RESUME) suffices when the caller carries an admin token andSeDebugPrivilege— which we already enabled to OpenProcess into the LPAC target in the first place. - Block on the remote thread.
WaitForSingleObject(hThread, 5000). The thread's exit code is the low 32 bits of theHMODULEreturned byLoadLibraryW. Non-zero means the DLL mapped and ourDllMainran (which is what spawns the worker thread); zero means the load failed — typically access denied, which would point back at a DACL regress. - Diagnostic only.
NtQueryInformationThread(hThread, ThreadSuspendCount, …)immediately afterCreateRemoteThread. Caveat worth noting: PLM uses job-object freeze, not per-threadNtSuspendThread, so this value is usually zero even when the process is frozen. The non-informative outcome is itself informative — it confirms which suspend mechanism is in play and rules out the alternative.
The sequence becomes:
hThread = CreateRemoteThread(hProc, NULL, 0, pLoadLib, remoteMem, 0, NULL);
log SuspendCount // diagnostic, usually 0 under job freeze
NtResumeProcess(hProc); // lift PLM freeze
WaitForSingleObject(hThread, 5000);
GetExitCodeThread // non-zero HMODULE low32 == load OK
CloseHandle(hThread);
This single change eliminated the silent failure mode. The remaining failures had a different signature.
9.7 Iteration 6 — ERROR_SHARING_VIOLATION
With deterministic sync in place, running the tool twice in quick succession produced a new error on the second run:
[-] ExtractPayloadDll failed (GLE=32)
GLE=32 is ERROR_SHARING_VIOLATION. The cause is the previous SecHealthUI host, which is still alive after the first run exited. CoreApplication::Exit() returns asynchronously, and the host's UWP runtime does not immediately unload modules. It still has WinDefCtl.dll mapped through LoadLibraryW. DeleteFileW followed by CreateFileW(CREATE_ALWAYS) cannot move forward against a live mapping.
The honest fix is not "retry until it works" and not "use a unique filename per run" — both leak state. The correct fix is to ensure that any leftover SecHealthUI is gone before we touch the filesystem:
static void KillExistingSecHealthUI(void) {
DWORD pid = GetPidByName(L"SecHealthUI.exe");
if (!pid) return;
HANDLE h = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, pid);
if (!h) return;
TerminateProcess(h, 0);
WaitForSingleObject(h, 5000); // kernel signals on full teardown
CloseHandle(h);
}
The synchronisation is event-driven: WaitForSingleObject on a process handle blocks until the kernel signals the EPROCESS object's exit, at which point every handle the process held — including the DLL mapping — is closed by the kernel. No Sleep, no polling, no arbitrary timeout other than a defensive cap that is never reached in practice.
This call is unconditional at the start of every run. Always-respawn beats reuse-or-respawn: reuse re-enters whatever XAML state the previous session left behind, including a half-navigated ThreatSettingsPage, and there is no observable benefit. Cold start of SecHealthUI costs ~1.8 s; that is the price of correctness.
9.8 Iteration 7 — explicit SDDL on the extracted DLL
Belt and braces. The extracted WinDefCtl.dll lives in LocalState, which carries inheritable ACEs covering both AC and S-1-15-2-2. In every observed run, LoadLibraryW from the LPAC worker succeeded under default inheritance. But it costs three lines to harden the path explicitly, and the cost of a future inheritance regression in some servicing update of LocalState's DACL would be a silent failure exactly identical to the PLM one from § 9.6 — and the diagnostic work needed to disambiguate the two would be considerable.
ExtractPayloadDll now constructs the same LPAC-aware SDDL it uses on the log file, passes it to CreateFileW for the new-file path, and re-stamps it via SetSecurityInfo(SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION) for the replace path where CREATE_ALWAYS ignores the passed SA per MSDN. The DLL is therefore deterministically readable by the worker regardless of LocalState's state at the time of extraction.
9.9 Why the result is stable
Three kernel-level synchronisation points govern every successful run, and the tool waits on each of them explicitly:
- Prior host teardown.
WaitForSingleObject(hPriorProcess, …)— kernel signals onEPROCESSexit. Guarantees the DLL mapping is gone before extraction. LoadLibraryWcompletion in the new host.WaitForSingleObject(hRemoteThread, …)— kernel signals onETHREADexit. GuaranteesDllMainran and the worker thread is alive before the injector exits.- PLM freeze removal.
NtResumeProcessprecedes the wait. Without it the wait would never observe the signal, because the thread would never be scheduled.
No Sleep. No WaitForInputIdle (which does not apply to UWP). No retry loops other than the worker's bounded XAML walk that is unavoidable while waiting for the dispatcher to realise the navigated page.
Failure modes that could still occur, and how the tool degrades:
- Tamper Protection — empirically not a failure mode. The hypothesis from § 9.3 turns out to be wrong. Tamper Protection toggles through this channel are not reverted by MsMpEng; the slider state set by the injected
IsOn(...)call is persistent across the next status read and across a reboot. The architectural reason — XAML event-router masquerade — is recorded in § 9.11. I am keeping the bullet here as a pointer rather than removing it, because the hypothesis is a natural one to form on first encounter and the refutation is the load-bearing finding of the project. - Slider not present on older builds. The walker matches by UIA Name. On Windows 10 22H2 the
Dev Drive protectionrow does not exist; the worker iterates to the retry cap and exits without applying. The CLI returns control normally; the diagnostic log records the missing slider explicitly. - DLL extraction fails for non-sharing reasons. Disk full, AV quarantine of the dropped DLL, etc. The injector reports
ExtractPayloadDll failedwithGetLastError. Hard fail, no injection attempted.
9.10 Engineering principles, in hindsight
A few habits paid off and are worth recording for future projects of similar shape:
- Build a diagnostic channel before chasing intermittent bugs. The compile-time logger turned what would have been days of guesswork into a half-hour reading session. The DACL detour in § 9.5 was painful but necessary; without
PAYlines I could not have grounded the PLM diagnosis. - Trust kernel signals; distrust sleeps. Every fixed-duration
Sleepin a multi-process pipeline is a bug waiting for a slow machine.WaitForSingleObjecton the right handle is precise to the kernel scheduler. - Distinguish "looks like it worked" from "actually worked."
CreateRemoteThreadreturning a handle does not mean the thread ran.IsOn(newState)returning success does not mean MsMpEng committed the change. Each layer needs its own observable proof, and the observability needs to be in place before the next layer is trusted. - Lean on the lowest-privileged ACL the target requires. LPAC demands
S-1-15-2-2; cargo-cultingACis worse than no ACE at all if you also turn off inheritance. The fix is to know the target's privilege class, not to widen the DACL until it works by accident. - Make every irreversible action explicit.
KillExistingSecHealthUIis loud in the log. Hidden cleanups that only happen on some code paths are unmaintainable.
The tool now succeeds on the first try, deterministically, in steady-state usage — including for Tamper Protection, which earlier I had budgeted as a possible degradation point and which proved not to be (§ 9.11). The remaining open question is CFA / DDP availability on older Windows builds, which is testable surface rather than an architectural unknown.
9.11 On Tamper Protection — why this channel covers it
Section 9.3 listed three hypotheses for the random-failure phase, and "Tamper Protection intent verification" was one of them. After Phases 9.6 through 9.8 stabilised the channel, I expected tp to start misbehaving again as soon as PLM and DACL no longer hid the engine's own behaviour. It didn't. Repeated tp on / tp off cycles set and clear the slider, and a subsequent read through Get-MpComputerStatus confirms that IsTamperProtected follows the requested state across the next status query and across a reboot. No revert window, no engine-side retraction.
The reason comes down to what is on the stack at the moment ToggleSwitch::IsOn(bool) triggers the LRPC call to MsMpEng. Three properties of the call site combine into a single architectural masquerade:
-
Dispatcher-thread context. The XAML walker dispatches via
CoreWindow::Dispatcher().RunAsync(CoreDispatcherPriority::Normal, …). TheIsOn(bool)setter therefore runs on SecHealthUI's UI dispatcher thread — the same thread that would have processed a genuine pointer event. Any heuristic in MsMpEng keyed on "is this the UI thread of the SecHealthUI host" sees the expected answer. -
SecHealthUIDataModel.dllas the originating module. XAML does not treat a programmaticIsOnwrite differently from a click-driven one. The framework's internal state-change router raises the same dependency-property-changed event, which is forwarded to the bound view-model inSecHealthUIDataModel.dll. That module — Microsoft-signed, in the host's load-bearing module set — is the one that issues the LRPC call toMsMpEng.exe. When MsMpEng's RPC dispatcher walks the caller stack looking for the originating return address, it lands inside a module whose signature matches the allowlist the dispatcher applies to legitimate UI-driven writes. -
No hardware-input cross-check on the server side. MsMpEng's caller-origin verification is a stack-and-module check performed on the LRPC server thread. It does not reach back into the client's USER32 input message queue to confirm that an
INPUT_HARDWARErecord preceded the call. The "intent" is inferred from the trusted module on the stack, not from the input pedigree; once point (2) is satisfied, point (3) does not probe further.
The chain XAML setter → DP-changed event → SecHealthUIDataModel.dll → LRPC → MsMpEng is, from the engine's point of view, indistinguishable from a real click. The "intent verification" that public literature attributes to TP is real — it is enforced at the module boundary, not at the input-source boundary — and an injected DLL inside the host that drives the genuine XAML setter ends up calling through the very module that defines the trusted boundary. Synthesising SendInput on the prelaunched frame would have been the wrong fix: the channel already satisfies the check the engine actually performs.
This is the architecturally interesting finding of the project. On 26H1 the kernel-byte-patch channel (Phase 8) is no longer required to defeat Tamper Protection from user mode. It remains a valid alternative for scenarios where even a transient prelaunched host process is unacceptable, but the inject channel covers all six sliders, Tamper Protection included, with no observable engine-side revert.
Appendix G — failure-mode → fix matrix (Phase 9)
| # | Symptom | Root cause | Fix | Verified by |
|---|---|---|---|---|
| 1 | Visible UI flash on activation | First compositor frame lands before cloak/teleport takes effect | Switch to AO_PRELAUNCH; no frame is ever realised |
Repeated cold-launch observation; no compositor frame in dwm.exe ETW trace |
| 2 | Random toggle silently no-ops | PLM job-object freeze of prelaunched UWP; remote thread queued but never scheduled | NtResumeProcess before WaitForSingleObject(hThread, 5000) |
Worker log lines appear on every run; thread ExitCode is a non-zero HMODULE |
| 3 | Worker DLL loads but no log lines | Log file DACL admin-only; LPAC CreateFileW denied |
Explicit SDDL with WD/AC/S-1-15-2-2/BA/SY and UNPROTECTED_DACL_SECURITY_INFORMATION to preserve inherited ACEs |
[PAY] lines on every run after fix |
| 4 | ExtractPayloadDll failed (GLE=32) on rapid re-runs |
Previous SecHealthUI host still holds DLL mapping | KillExistingSecHealthUI with TerminateProcess + WaitForSingleObject on the process handle |
kill: pid=N terminate=1 wait=0 in log, followed by clean extract |
| 5 | (Theoretical) extracted DLL unreadable under future LocalState DACL regress |
Inheritance change in a servicing update could strip LPAC ACEs | Explicit LPAC-aware SDDL on the extracted DLL via CreateFileW + SetSecurityInfo(SE_FILE_OBJECT, UNPROTECTED) |
Defensive; no observed failure |
| 6 | C4101 'e' unreferenced local variable after flipping debug off |
DLOG macros collapse to no-op; catch (… const& e) body loses its sole reference to e |
catch ([[maybe_unused]] winrt::hresult_error const& e) |
Clean build with WINDEFCTL_DEBUG=0, no warnings |
| 7 | Tamper Protection toggle expected to be reverted by MsMpEng's intent verification | XAML IsOn(...) runs on the host's UI dispatcher thread; the DP-changed event routes through SecHealthUIDataModel.dll, which becomes the LRPC originator on the stack; MsMpEng's caller-origin check sees a module signature identical to a real click and does not cross-check the USER32 input queue |
No code fix required — the integration is structural to ToggleSwitch's XAML event router (see § 9.11) |
Repeated tp on / tp off cycles; Get-MpComputerStatus.IsTamperProtected follows the requested state across status reads and across a reboot |
Phase 10 — From deterministic prototype to production-grade tool (v1.1.2)
Phase 9 ended at a deterministic single-binary that succeeded on the first try on Win11 26H1 — invisible, idempotent, kernel-signal-driven. What remained was ensuring the same seamless experience across the entire Windows matrix, including builds where dormant activation is denied.
10.1 Invisible window suppression via SetWinEventHook
On Windows 11 25H2, the AO_PRELAUNCH flag is denied (ACCESS_DENIED). The host process starts and immediately attempts to realise its window. To prevent a visible flash, WinDefCtl implements a system-wide hook listening for EVENT_OBJECT_CREATE.
By intercepting the window handle allocation, the tool can suppress the frame before the first compositor present. The result is a completely silent execution — the host process executes, the signed DLL performs its work, and the process exits without the user ever seeing a window or a taskbar icon.
10.2 Universal Language Support
The tool handles locale-neutrality through a two-tier matching system. On modern Windows 11 builds, it matches sliders via technical XAML identifiers. For older builds (Windows 10 22H2), it falls back to a comprehensive database of localized names covering major European languages.
10.3 Authenticode & Security Architecture
WinDefCtl is fully signed by the WESMAR Authenticode publisher chain. The final executable embeds the signed worker DLL and automatically manages its deployment to a secure system location (%ProgramFiles%WinDefCtl`). This satisfies the OS requirement for theuiAccess="true"` manifest flag, allowing for the cross-integrity window suppression that makes the tool truly silent.
Conclusion
The final WinDefCtl.exe (v1.1.2) represents the culmination of this research: a single, self-contained binary that achieves programmatic, silent, and deterministic control over all Windows Defender features.
- Silent: Zero window flash and no taskbar presence across Windows 10 and 11.
- Privileged: Bypasses Tamper Protection by executing within the trusted
SecHealthUIpackage context. - Robust: Uses kernel-level synchronization and event hooks for 100% reliability.
Note on Source Code: Due to the potential for misuse by actors of uncertain reputation or criminal intent, I have decided not to release the full source code for the v1.1.x line publicly. The signed binary itself stands as sufficient empirical proof of the GUI-bypass techniques documented here.
Document version 1.5 — 31 May 2026. Major revision: Finalized for production. Added Phase 10 details on invisible window suppression, universal language database, and final shipping binary capabilities. Verified across Win10/Win11 test matrix. Updated with specific guidance on tool evolution and security considerations.
© Marek Wesołowski 2026. Distributed alongside the WinDefCtl project under the project's MIT license. Reproduction with attribution is welcome.