---
title: 'Analyze Defender — Reverse Engineering Windows Defender RTP State'
url: 'https://kvc.pl/research/analyze-defender'
markdown: 'https://kvc.pl/research/analyze-defender.md'
date: '2026-08-29'
description: 'Reverse engineering investigation into where Windows Defender stores the effective RTP (Real-Time Protection) state. Static analysis of MpSvc.dll, live memory differential analysis under cdb, identification of the 0x180602F18 flag, deterministic patching tool, and a production-grade locale-neutral CLI (WinDefCtl 1.2.0) covering Windows 10 22H2, Windows 11 25H2 and 26H1 — signed Authenticode binary with auto-installing publisher chain, pre-paint frame suppression, no visible UI on any supported build.'
---

[ Download WinDefCtl.zip](https://kvc.pl/research/analyze-defender/WinDefCtl.zip)

**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.***

---

## 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](https://forums.mydigitallife.net/threads/command-line-utility-to-turn-on-off-windows-defender-and-tamper-protection.89900/) 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:

1. **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.
2. **The privileged front-end.** The `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.
3. **The architectural confirmation.** Direct RPC through `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.
4. **The hardening layer (v1.2.0).** Direct WMI/COM self-exclusion added at "Phase 0" prevents interference during self-deployment, while a named-event synchronization mechanism enables sub-millisecond response and atomic cleanup of all injection artifacts.

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.

---

## 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:

1. It depends on a sleep-based wait loop because the Windows Security UI does not expose a deterministic "ready" event.
2. It requires injecting a UAC-prompt suppression into the registry and restoring it afterwards.
3. 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.

flowchart TD A[user-mode caller
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:

1. Does the RTP slider write to a known registry value, a file under `%ProgramData%\Microsoft\Windows Defender\`, or only to volatile memory?
2. If only to memory, can the state be located precisely enough to be patched?
3. Which user-mode and kernel-mode channels does Tamper Protection block, and does any of them have an exploitable gap?
4. 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](https://forums.mydigitallife.net/threads/command-line-utility-to-turn-on-off-windows-defender-and-tamper-protection.89900/page-2) 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

```powershell
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

```powershell
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

```powershell
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.

```cmd
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.

```powershell
[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.

```cmd
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:

flowchart LR subgraph "User-mode write attempts" A1[Set-ItemProperty live] --> X1[ACL deny] A2[Set-MpPreference] --> X2[5013 Blocked] A3[GPO policies write] --> X3[5013 Ignored] A4[reg save/load/write/unload/restore] --> X4[ACL deny on temp hive] A5[Take ownership + ACL change] --> X5[NtSetSecurityObject hook] A6[TrustedInstaller token] --> X6[NtRestoreKey hook] end X1 --> R[Effective state unchanged] X2 --> R X3 --> R X4 --> R X5 --> R X6 --> R R --> M[State lives only in MsMpEng.exe memory] 

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:

```powershell
$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:

```asm
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

sequenceDiagram participant Me as Me (powershell) participant KVC as kvc.exe (PPL) participant MsMp as MsMpEng.exe (PPL) participant UI as SecHealthUI.exe (UWP) Me->>+KVC: kvc dump MsMpEng KVC->>KVC: elevate self to PPL-Antimalware KVC->>MsMp: MiniDumpWriteDump MsMp-->>KVC: process memory KVC-->>-Me: C:\Users\Administrator\Downloads\MsMpEng.exe_21724.dmp (760 MB) Me->>Me: mv dmp -> msmp-on.dmp Me->>UI: open windowsdefender://threatsettings Me->>UI: click RTP toggle, confirm UAC UI->>MsMp: RPC SetRtpEnabled(false) MsMp->>MsMp: flip in-memory flag Me->>+KVC: kvc dump MsMpEng (same PID) KVC->>MsMp: MiniDumpWriteDump KVC-->>-Me: dmp (714 MB) Me->>Me: mv dmp -> msmp-off.dmp Me->>Me: cdb -z msmp-on.dmp -c .writemem ... Me->>Me: cdb -z msmp-off.dmp -c .writemem ... Me->>Me: byte-diff .data sections 

The same PID survived the toggle, which confirmed:

- `MsMpEng.exe` does **not** restart on RTP off — the flip is in-place
- The module bases are identical between the two dumps (`MpSvc` at `0x7ffa0acc0000` in both)
- Any byte differences in `MpSvc.dll`'s `.data` section 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

```powershell
$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`:

```asm
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:

1. The state byte lives in writable `.data` of `MpSvc.dll`, image-loaded into `MsMpEng.exe` at a known ASLR base
2. Tamper Protection's registry callbacks operate on `Nt*Key*` paths and do **not** hook process memory writes
3. PPL only restricts who may call `NtWriteVirtualMemory` 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.

---

## 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:

    1. Enumerate processes; look for `SecHealthUI.exe`. If missing, fire
        `ShellExecuteW(L"open", L"windowsdefender://threatsettings", SW_HIDE)`
        and poll until it appears.
    2. `OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid)` (`SE_DEBUG_NAME` enabled).
    3. `GetPackageFamilyName(handle, …)` → returns the PFN.
    4. Copy `payload.dll` to
        `%LOCALAPPDATA%\Packages\<PFN>\LocalState\payload.dll` — this is the
        one directory in the file system that the package can read freely.
    5. Write a one-line `target.txt` next to it carrying the UIA label and
        the desired state (e.g. `Real-time protection\noff`).
    6. `VirtualAllocEx + WriteProcessMemory + CreateRemoteThread(LoadLibraryW)`
        on the package process. The remote thread loads the DLL by path.
- **`payload.dll`** — C++/WinRT DLL. On `DLL_PROCESS_ATTACH` it spawns a worker thread that:

    1. Reads `target.txt` from `ApplicationData::Current().LocalFolder()`.
    2. Polls `CoreApplication::MainView().Dispatcher()` and posts a closure
        that walks the visual tree from `Window::Current().Content()` via
        `VisualTreeHelper::GetChildrenCount/GetChild`.
    3. For each `ToggleSwitch` encountered, reads
        **`AutomationProperties::GetName(element)`** — the UIA Name —
        and compares it against the requested target.
    4. On match, calls `ToggleSwitch::IsOn(bool)`. The call returns
        synchronously; the RPC reaches MsMpEng under the package's identity
        and the state changes immediately.
    5. After the toggle succeeds, the payload calls
        `CoreApplication::Exit()` so the host shuts down cleanly with no
        leftover taskbar entry.

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:

```c
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:

1. **Cover the flicker.** Pop a fullscreen `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.
2. **Skip SecHealthUI entirely** and reach the state byte from the kernel side. See §12.2.

### 11.6 The inject path summarised

sequenceDiagram participant Me as Me (admin shell) participant Inj as injector.exe participant Pkg as SecHealthUI.exe (UWP) participant DLL as payload.dll participant MsMp as MsMpEng.exe Me->>Inj: injector.exe Tamper Protection off Inj->>Inj: GetPackageFamilyName, build LocalState path Inj->>Pkg: ShellExecute windowsdefender://threatsettings (if not running) Inj->>Pkg: VirtualAllocEx + WriteProcessMemory Inj->>Pkg: CreateRemoteThread(LoadLibraryW) Pkg->>DLL: load DLL->>DLL: read target.txt DLL->>DLL: CoreApplication.MainView().Dispatcher() DLL->>DLL: VisualTreeHelper walk - find ToggleSwitch by UIA Name DLL->>Pkg: ToggleSwitch.IsOn(false) Pkg->>MsMp: RPC SetTPState(false) - caller has SecHealthUI package SID MsMp-->>Pkg: ok, state changed Pkg-->>DLL: ok DLL->>Pkg: CoreApplication.Exit() Pkg-->>Inj: exit Inj-->>Me: done 

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`:

```asm
; 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:

```asm
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 that`sub\_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:

```c
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:

```c
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.

```cpp
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:

```cpp
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:

```cpp
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 `WdFilter` registry 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 the `Microsoft.SecHealthUI` package SID, the `WinDefend` service SID, or an equivalent intent-trusted signer. No combination of `SeDebugPrivilege`, `SeBackupPrivilege`, `SeRestorePrivilege`, `TrustedInstaller` impersonation, 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.7 Summary of Phase 8 up to this point

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.

---

### 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 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.
- From inside that handler, the model issues its own `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.

### 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. 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.

### 13.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:

1. **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.
2. **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.

### 13.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.

### 13.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 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.
- **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 `tp` misbehaving but not `rtp`, `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.

### 13.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.h` exposes a single `WINDEFCTL_DEBUG` macro. At `0`, every `DLOG(...)` collapses to `((void)0)` and no string literal reaches the binary.
- The format is `[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.
- The runtime side uses `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.
- Both sides append to a single file under `%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.

### 13.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:

```c
"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:

```c
"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.

### 13.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 `CreateRemoteThread` and allocates the `ETHREAD`.
- 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:

1. **Wake the host.** `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.
2. **Block on the remote thread.** `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.
3. **Diagnostic only.** `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:

```c
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.

### 13.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:

```c
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.

### 13.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 § 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)`:

```c
"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.

### 13.9 Why the result is stable

Three kernel-level synchronisation points govern every successful run, and the tool waits on each of them explicitly:

1. **Prior host teardown.** `WaitForSingleObject(hPriorProcess, …)` — kernel signals on `EPROCESS` exit. Guarantees the DLL mapping is gone before extraction.
2. **`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.
3. **PLM freeze removal.** `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:

- **Tamper Protection — empirically not a failure mode.** The hypothesis from § 13.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 § 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.
- **Slider not present on older builds.** The walker matches by UIA Name. On Windows 10 22H2 the `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.
- **DLL extraction fails for non-sharing reasons.** Disk full, AV malware quarantine of the dropped DLL, etc. The injector reports `ExtractPayloadDll failed` with `GetLastError`. Hard fail, no injection attempted.

### 13.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 § 13.5 was painful but necessary; without `PAY` lines I could not have grounded the PLM diagnosis.
- **Trust kernel signals; distrust sleeps.** Every fixed-duration `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.
- **Distinguish "looks like it worked" from "actually worked."** `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.
- **Lean on the lowest-privileged ACL the target requires.** LPAC demands `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.
- **Make every irreversible action explicit.** `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.11 On Tamper Protection — why this channel covers it

§ 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:

1. **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.
2. **`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.
3. **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.

---

---

## 14. 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 (PRELAUNCH) is denied.

### 14.1 Silent execution via signed DLL and system hooks

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.

### 14.2 Universal Language Independence

To eliminate the need for localized name databases, the tool implements a **3-tier matching system** in the XAML walker:

1. **Tier 1 (Technical Names):** On modern Windows 11, it identifies sliders by their technical `x:Name` (e.g., `realTimeProtectionSettings`).
2. **Tier 2 (Visual Tree Indexing):** As a universal fallback, it utilizes the visual tree structure. It maps the first `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.
3. **Tier 3 (Localized Fallback):** A small database of common strings remains for legacy coverage.

### 14.3 Authenticode & Trusted Deployment

`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.

---

---

## 15. Phase 11 — Hardening: WMI Self-Exclusion and Atomic Cleanup (v1.2.0)

The final evolution of `WinDefCtl` addresses the two remaining "trace" problems: potential interference from Defender during deployment and the lingering `LocalState` artifacts.

### 15.1 WMI-Based Self-Exclusion (Phase 0)

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:

1. **EXE Path:** Both the current execution path and the `%ProgramFiles%` target.
2. **Process Name:** `WinDefCtl.exe` (as an `ExclusionProcess`).
3. **Working Directory:** The entire `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.

### 15.2 Named Event Synchronization

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:

```c
"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.

### 15.3 Guaranteed Environment Purge

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.

---

## Conclusion

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.

- **Silent:** Zero window flash and no taskbar presence on any supported build.
- **Privileged:** Bypasses Tamper Protection by executing within the trusted `SecHealthUI` package context using a signed library.
- **Robust:** Replaces brittle sleeps with kernel-level event synchronization.
- **Global:** Works on any system language without code changes.

**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.

---

---

## 16. 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.

---

## 17. 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` | 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)** |

### 17.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.

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.

### 17.2 Kernel-mode byte patch (route 2)

This is the elegant option if a signed driver is available. The driver needs three things:

1. A way to enumerate `MsMpEng`'s loaded modules — `PsGetProcessSectionBaseAddress` or a `KAPC` walking the PEB — to resolve the current ASLR base of `MpSvc.dll`.
2. A way to write to PPL-protected memory. `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.
3. The relative address `0x602F18` from `MpSvc!.data`, which is independent of ASLR.

Pseudocode:

```c
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`.

### 17.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:

1. Resolve the syscall number for `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)
2. Emit a tiny stub `mov r10, rcx; mov eax, <syscall#>; syscall; ret`
3. 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:

flowchart TD A[my user process] -->|direct syscall stub| B[kernel NtWriteVirtualMemory] B --> C{PPL check} C -->|fail| D[ACCESS_DENIED] C -->|pass after kvc dse off + DSE bypass| E[write succeeds] E --> F[MpSvc + 0x602F18 ← 0/1] 

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.

### 17.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:

```c
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.

---

## 18. 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` |

---

## 19. Limitations & Future Work

1. **Build dependency.** The RVA `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`.
2. **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 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.
3. **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_180602F2C` flag and the counter at `dword_180607230` may be part of such a reconciliation pass — that needs another diff under load.
4. **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 `.data` offset and yield a complete "Defender state struct".
5. **`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.
6. **`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.
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.
8. **The flag at `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.

---

## 20. 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 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.

---

## 21. Acknowledgements

- The **MyDigitalLife** forum community, in particular **Opulent\_Maelstrom** and **Carlos Detweiller**, for early feedback on `WinDefCtl` that 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.

```c
//
// 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:

flowchart TD A[Goal: program a Defender state change
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 § 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.

---

---

## Appendix G — failure-mode → fix matrix (§13 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 § 13.11) | Repeated `tp on / tp off` cycles; `Get-MpComputerStatus.IsTamperProtected` follows the requested state across status reads and across a reboot |

---

### Add a comment

---

## Navigation

- Parent: [Research](https://kvc.pl/research.md)
- Next: [ForceIO — Reverse Engineering IObitUnlocker.sys for Minifilter-Bypass File Operations](https://kvc.pl/research/forceio-minifilter-bypass.md)
