Author: Marek Wesołowski (WESMAR)
Date: 1 June 2026
Target: Windows 11 Pro 26H1, build 28000, fully patched
Defender platform: 4.18.26040.7-0 (May 2026)
This video 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.
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 eleven 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, a direct-RPC probe through the MpClient.dll client surface, and finally a hardening pass using WMI self-exclusion — I established four results that together answer the original question:
MpSvc.dll + 0x602F18, read by exactly one function (sub_180252504) which acts as the canonical scan gate.Microsoft.SecHealthUI_8wekyb3d8bbwe UWP 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 XAML ToggleSwitch::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.MpManagerOpen + MpSetTPState from an arbitrary admin process yields a clean E_ACCESSDENIED on the write side while the read side (MpManagerStatusQuery) succeeds. The check is inside MsMpEng's RPC dispatcher, per procedure, and it gates 0x95 (write) against the SecHealthUI package SID — exactly as Microsoft intend.In total the document covers seven 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.
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:
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.
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.
I went into the investigation with four questions:
%ProgramData%\Microsoft\Windows Defender\, or only to volatile memory?| 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 |
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 |
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.
SecHealthUI.exeThe 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.
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.
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.
Real-Time Protection keySet-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.
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.
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".
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.
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.
cmdt / kvc trustedBoth 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.
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.
MpSvc.dllThe 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.
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 |
MpFC_* feature code enumThe 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.
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.
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.
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.
The same PID survived the toggle, which confirmed:
MsMpEng.exe does not restart on RTP off — the flip is in-placeMpSvc at 0x7ffa0acc0000 in both)MpSvc.dll's .data section must reflect runtime state mutation and nothing elseI 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).
$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.
0x180602F18A 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.
Given that:
.data of MpSvc.dll, image-loaded into MsMpEng.exe at a known ASLR baseNt*Key* paths and do not hook process memory writesNtWriteVirtualMemory on 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.
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.
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.
I built a two-binary tool, WinDefCtl\:
injector.exe — 8 KB C executable, no CRT, /NODEFAULTLIB. Steps:
SecHealthUI.exe. If missing, fireShellExecuteW(L"open", L"windowsdefender://threatsettings", SW_HIDE)OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid) (SE_DEBUG_NAME enabled).GetPackageFamilyName(handle, …) → returns the PFN.payload.dll to%LOCALAPPDATA%\Packages\<PFN>\LocalState\payload.dll — this is thetarget.txt next to it carrying the UIA label andReal-time protection\noff).VirtualAllocEx + WriteProcessMemory + CreateRemoteThread(LoadLibraryW)payload.dll — C++/WinRT DLL. On DLL_PROCESS_ATTACH it spawns a worker thread that:
target.txt from ApplicationData::Current().LocalFolder().CoreApplication::MainView().Dispatcher() and posts a closureWindow::Current().Content() viaVisualTreeHelper::GetChildrenCount/GetChild.ToggleSwitch encountered, readsAutomationProperties::GetName(element) — the UIA Name —ToggleSwitch::IsOn(bool). The call returnsCoreApplication::Exit() so the host shuts down cleanly with noThe 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.
FrameworkElement.Name ≠ AutomationProperties.GetNameMy 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.
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.
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:
WS_EX_LAYERED | WS_EX_TOPMOST window 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.End-to-end with no UAC, no registry write, no event 5013. The injected DLL inherits the trust boundary that Tamper Protection respects.
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.
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".
MpOpenThe 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-nullI 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.
sub_180078724The 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.
MpManagerOpenThe 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.
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.
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.
The first six sub-sections of Phase 8 established that a correctly-initialised MpManagerOpen context allows any elevated admin-mode caller to read Tamper Protection state (MpManagerStatusQuery, MpGetTPStateInfo), but that writing state (MpSetTPState, proc 0x95) returns a clean E_ACCESSDENIED with no registry event. The check lives inside MsMpEng's RPC dispatcher and is per-procedure. The following sub-sections probe whether the SID check can be satisfied from inside the SecHealthUI package process itself.
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.
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:
ToggleSwitch::IsOn(false), the call lands inside SecHealthUIDataModel.dll — the WinUI model that owns the setting's "click" handler. That DLL is signed by Microsoft and is on the allow-list.MpSetTPState call. The originating RIP is inside SecHealthUIDataModel.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.
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.
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.
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.
AO_NOERRORUI with a race cloakThe 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 as FindWindowW returned the ApplicationFrameWindow. 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 than SetWindowPos, 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 by dwm.exe scheduling 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.
AO_PRELAUNCHIApplicationActivationManager::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.
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:
ToggleSwitch::IsOn(newState), which marshals through SecurityHealthCore.dll → SecurityHealthService.exe → MsMpEng.exe over an LRPC port. If the worker's CoreApplication::Exit() fires before the LRPC reply is observed, the write is lost.tp misbehaving but not rtp, cdp, etc.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.
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.h exposes a single WINDEFCTL_DEBUG macro. At 0, every DLOG(...) collapses to ((void)0) and no string literal reaches the binary.[HH:MM:SS.mmm] [TID nnnn] [TAG] [file:line] message. TAG is INJ for the elevated injector and PAY for 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.wsprintfA and wvsprintfA from user32.lib, so the no-CRT injector links cleanly and the cppwinrt worker does not need to pull in its own formatter.%LOCALAPPDATA%\Packages\Microsoft.SecHealthUI_8wekyb3d8bbwe\LocalState\WinDefCtl.log. FILE_APPEND_DATA with FILE_SHARE_READ | FILE_SHARE_WRITE gives atomic concurrent appends. Inter-process interleaving is theoretically possible but never observed, because the worker only logs once per XAML walk iteration.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_DACL kills inheritance — including LocalState's own inheritable ACEs for the package-specific SID — so the new DACL had only what I had written.AC is the token group for ordinary UWP apps. SecHealthUI on 26H1 is not an ordinary UWP. It is LPAC (Less Privileged AppContainer); its access check treats S-1-15-2-2 (ALL RESTRICTED APPLICATION PACKAGES) as the load-bearing SID, and ignores AC alone.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 (§ 13.8) — same diagnosis, different file.
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:
CreateRemoteThread and allocates the ETHREAD.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:
NtResumeProcess(hProc) lifts the freeze. The function is undocumented but stable; resolve through GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtResumeProcess"). PROCESS_ALL_ACCESS (or the narrower PROCESS_SUSPEND_RESUME) suffices when the caller carries an admin token and SeDebugPrivilege — which we already enabled to OpenProcess into the LPAC target in the first place.WaitForSingleObject(hThread, 5000). The thread's exit code is the low 32 bits of the HMODULE returned by LoadLibraryW. Non-zero means the DLL mapped and our DllMain ran (which is what spawns the worker thread); zero means the load failed — typically access denied, which would point back at a DACL regress.NtQueryInformationThread(hThread, ThreadSuspendCount, …) immediately after CreateRemoteThread. Caveat worth noting: PLM uses job-object freeze, not per-thread NtSuspendThread, 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.
ERROR_SHARING_VIOLATIONWith 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.
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 § 13.6 — and the diagnostic work needed to disambiguate the two would be considerable.
ExtractPayloadDll applies the following DACL to WinDefCtl.dll immediately after extraction via ConvertStringSecurityDescriptorToSecurityDescriptorW + SetFileSecurityW(path, DACL_SECURITY_INFORMATION, sd):
"D:"
"(A;;GRGX;;;WD)" /* Everyone: read+execute only — no write, no replace */
"(A;;FA;;;AC)" /* All Application Packages */
"(A;;FA;;;S-1-15-2-2)" /* LPAC */
"(A;;FA;;;BA)" /* Builtin Admins */
"(A;;FA;;;SY)" /* System */
The WD entry deliberately grants only GENERIC_READ | GENERIC_EXECUTE rather than FA. LoadLibraryW inside the LPAC worker requires read and execute access; full access is unnecessary. Granting FA to Everyone would open a TOCTOU window between SetFileDacl and CreateRemoteThread in which a concurrent process could replace WinDefCtl.dll with a malicious payload that then loads under the package's AppContainer token. GRGX closes that window without affecting the worker's ability to map the DLL. The DLL is therefore deterministically readable by the worker regardless of LocalState's state at the time of extraction.
Three kernel-level synchronisation points govern every successful run, and the tool waits on each of them explicitly:
WaitForSingleObject(hPriorProcess, …) — kernel signals on EPROCESS exit. Guarantees the DLL mapping is gone before extraction.LoadLibraryW completion in the new host. WaitForSingleObject(hRemoteThread, …) — kernel signals on ETHREAD exit. Guarantees DllMain ran and the worker thread is alive before the injector exits.NtResumeProcess precedes 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:
IsOn(...) call is persistent across the next status read and across a reboot. The architectural reason — XAML event-router masquerade — is recorded in § 13.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.Dev Drive protection row 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.ExtractPayloadDll failed with GetLastError. Hard fail, no injection attempted.A few habits paid off and are worth recording for future projects of similar shape:
PAY lines I could not have grounded the PLM diagnosis.Sleep in a multi-process pipeline is a bug waiting for a slow machine. WaitForSingleObject on the right handle is precise to the kernel scheduler.CreateRemoteThread returning 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.S-1-15-2-2; cargo-culting AC is 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.KillExistingSecHealthUI is 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 (§ 13.11). The remaining open question is CFA / DDP availability on older Windows builds, which is testable surface rather than an architectural unknown.
§ 13.3 listed three hypotheses for the random-failure phase, and "Tamper Protection intent verification" was one of them. After §§ 13.6 through 13.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, …). The IsOn(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.dll as the originating module. XAML does not treat a programmatic IsOn write 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 in SecHealthUIDataModel.dll. That module — Microsoft-signed, in the host's load-bearing module set — is the one that issues the LRPC call to MsMpEng.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_HARDWARE record 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.
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 (PRELAUNCH) is denied.
On Windows 11 25H2, the AO_PRELAUNCH flag is denied for third-party callers. To maintain the "zero-UI" requirement, 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 signed DLL payload performs its work entirely in the background, ensuring that the process executes and exits without the user ever seeing a window or a taskbar icon. This effectively simulates dormant activation even on restrictive builds.
To eliminate the need for localized name databases, the tool implements a 3-tier matching system in the XAML walker:
x:Name (e.g., realTimeProtectionSettings).ToggleSwitch encountered on the page to Real-Time Protection and the last one to Tamper Protection. This approach provides 100% reliability on any system language (including Chinese or Arabic) without needing hardcoded strings.WinDefCtl is fully signed by the WESMAR Authenticode publisher chain. The final executable manages the deployment of the signed DLL to a secure system location (%ProgramFiles%\WinDefCtl\) to satisfy the OS requirement for the uiAccess="true" manifest flag. This privileged state is what allows the signed library to perform its work and keep the operation truly silent.
The final evolution of WinDefCtl addresses the two remaining "trace" problems: potential interference from Defender during deployment and the lingering LocalState artifacts.
To ensure that the injection and UAC modification proceed without a "Self-Heal" race or file-lock interference, I implemented a pre-flight exclusion step. Instead of using the loud Add-MpPreference PowerShell cmdlet, WinDefCtl now talks directly to the WMI COM interface (ROOT\Microsoft\Windows\Defender :: MSFT_MpPreference).
The tool whitelists:
%ProgramFiles% target.WinDefCtl.exe (as an ExclusionProcess).LocalState AppContainer folder.This "Phase 0" runs before any files are extracted or registry keys touched, creating a "clean corridor" for the rest of the pipeline.
The worker DLL no longer relies on graceful CoreApplication::Exit() delays. It now signals a named system event (Global\WinDefCtl_Done_<PID>) the millisecond the XAML toggle succeeds. The injector waits on this event, allowing it to move to cleanup in under 50 ms. The event is created by the injector with the following DACL:
"D:"
"(A;;GA;;;WD)" /* World: Generic All — required for LPAC Phase 1 */
"(A;;GA;;;AC)" /* All Application Packages */
"(A;;GA;;;S-1-15-2-2)" /* LPAC */
"(A;;GA;;;BA)" /* Builtin Admins */
"(A;;GA;;;SY)" /* System */
The WD (Everyone) ACE is not optional. Windows AppContainer access checks operate in two sequential phases: Phase 1 evaluates the caller's user SID against the DACL and must grant EVENT_MODIFY_STATE; only if Phase 1 succeeds does the kernel proceed to Phase 2, which evaluates the AppContainer SID (S-1-15-2-2 for LPAC). If WD is absent, Phase 1 fails unconditionally for any AppContainer caller and the kernel never evaluates the LPAC ACE. The practical consequence is that OpenEvent inside the SecHealthUI process returns ERROR_ACCESS_DENIED despite the explicit S-1-15-2-2 grant, and the synchronisation signal is never delivered. The event name already embeds the injector PID (WinDefCtl_Done_<PID>), making blind-signal attacks from unrelated processes impractical without process handle enumeration.
Once the signal is received, the injector performs a multi-instance termination of all SecHealthUI.exe processes. This ensures all file handles to WinDefCtl.dll and settings.dat are dropped by the kernel. The tool then deletes all traces from LocalState, leaving the machine in the exact same state as before execution.
The final WinDefCtl.exe (v1.2.0) is the definitive result of this research: a single, self-contained binary that achieves programmatic, silent, and deterministic control over all Windows Defender features across Windows 10 and 11.
SecHealthUI package context using a signed library.A 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 production line publicly. The signed binary itself stands as sufficient empirical proof of the techniques documented here.
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.
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 |
no — AO_PRELAUNCH on 26H1 prevents frame realisation; SetWinEventHook(EVENT_OBJECT_CREATE) pre-paint cloak on 25H2 |
| 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) |
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.
On Windows 11 26H1, IApplicationActivationManager::ActivateApplication is called with AO_PRELAUNCH. The host process is created in a dormant state: the manifest entry point runs, but the XAML framework defers root creation and never requests a frame from the windowing subsystem. There is nothing to suppress because nothing is ever realised.
On Windows 11 25H2, AO_PRELAUNCH is denied to third-party callers. A SetWinEventHook(EVENT_OBJECT_CREATE, …) callback installed before activation intercepts the ApplicationFrameWindow handle allocation and cloaks the window via DwmSetWindowAttribute(DWMWA_CLOAK) before the compositor commits the first present. The hook runs on a dedicated thread under uiAccess="true" + Program Files installation, which satisfies the cross-process DWM cloak requirement that was the original constraint in §11.5.
This is the elegant option if a signed driver is available. The driver needs three things:
MsMpEng's loaded modules — PsGetProcessSectionBaseAddress or a KAPC walking the PEB — to resolve the current ASLR base of MpSvc.dll.MmCopyVirtualMemory works 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 implicitly WinSystem and the write succeeds.0x602F18 from MpSvc!.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.
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:
NtWriteVirtualMemory from a clean copy of ntdll.dll (read from disk, not the in-process mapped image, so any hooks on the in-process copy are irrelevant)mov r10, rcx; mov eax, <syscall#>; syscall; retPPL 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.
.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.
| 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 |
0x602F18 is specific to MpSvc.dll 4.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 second cmp.EnterCriticalSection(stru_1806071F8) pattern observed near dword_180607230 suggests 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.dword_180602F2C flag and the counter at dword_180607230 may be part of such a reconciliation pass — that needs another diff under load.MpFC_* strings index into. Doing so would map every Defender feature to its .data offset and yield a complete "Defender state struct".mpengine.dll. I deliberately did not analyse the large mpengine.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_1800793F8 internals. 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 of WaitForMultipleObjects and ReleaseMutex on [ctx+8], and the way MpManagerOpen populates the slot at [+0x18] of the inner structure where MpOpen(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 tell MsMpEng "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 from SecHealthUI's frame might be one of the factors the ACL evaluates. This is a thread worth pulling.0x180602F2C. The sibling boolean directly above the RTP flag in the gate function is almost certainly another feature bit (most likely BehaviorMonitor based on its proximity in the MpFC_* enum), and inspection would complete the map of the dual-gate pattern. I left it for the next session.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 production patch described here is, in the threat model sense, highly precise: it operates directly in memory, leaving no persistent artifacts on disk or in the registry. Instead of implicitly bypassing Tamper Protection (TP), the provided binary grants the user explicit, independent control over individual security features. Upon explicit request, it can turn off both Real-Time Protection (RTP) and Tamper Protection (TP), effectively allowing any toggle within windowsdefender://threatsettings to be disabled independently.
I will not publish the source code of the tool for modifying the production version in this repository. The methodology, address, and disassembly are documented here so that other reverse engineers can verify the findings. Based on this description, they can implement their own solution or use my public repository at https://github.com/wesmar/WinDefCtl, where I did not implement these injection techniques.
If a reader from Microsoft would prefer that even the methodology be retracted, contact me at marek@wesolowski.eu.org and I will discuss.
WinDefCtl that motivated the work.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%)
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"
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.
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.
sub_180078724 and the binding-resolver micro-architectureFull 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.
For anyone landing on this document looking for the executive headline:
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 § 13.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.
| # | 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 § 13.11) |
Repeated tp on / tp off cycles; Get-MpComputerStatus.IsTamperProtected follows the requested state across status reads and across a reboot |