Skip to content

EDR Internal Architecture

Where AV asks “is this file bad?”, EDR asks a different question entirely: “what is this machine doing, and does the story hold together?” An EDR is a distributed system — a kernel sensor, userland hooks, a local detection engine, a resilient transport, and a cloud correlation brain — and every one of those five pieces is a separate opportunity to either detect or evade. This module maps the whole organism using public architecture knowledge common to CrowdStrike Falcon, Microsoft Defender for Endpoint (MDE), SentinelOne, Carbon Black, and Elastic Defend, because once you see the shared skeleton, vendor-specific behavior stops being magic. This is the map you need before E7 (ETW), E9 (kernel drivers), or any vendor-logic module (E10–E13) makes sense.

An EDR (Endpoint Detection and Response) product continuously collects endpoint telemetry, detects malicious behavior sequences (not just single artifacts), and enables response actions (isolate, kill, remediate). Its anatomy, nearly invariant across vendors:

LayerImplementationExample artifacts
Kernel sensorDriver registering callbackscsagent.sys (CrowdStrike), SentinelMonitor.sys/SentinelPlus.sys [verify per build] (SentinelOne), CbDefenseFilter.sys? [verify] (CB), MDE: MsSecFlt.sys + WdFilter.sys
Userland sensorInjected DLL / API hooks / ETW consumersFalcon’s DLL injection into processes; MDE MsSense.exe
Local detection engineRules + behavioral models on-agentPolicy evaluation, “on-sensor ML”
TransportHTTPS beacon, batched event uploadFalcon’s lfamq/channels [verify]; MDE MsSense.exe upload
Cloud brainCorrelation, graph analytics, TICrowdStrike Threat Graph, MDE Advanced Hunting

The kernel sensor subscribes to the same callback surface a rootkit would abuse: PsSetCreateProcessNotifyRoutineEx (process creation), PsSetCreateThreadNotifyRoutine (thread creation incl. remote), PsSetLoadImageNotifyRoutine (image/DLL load), ObRegisterCallbacks (handle-grant auditing — the anti-tamper crown jewel), CmRegisterCallbackEx (registry), file-system minifilters (altitude typically below AV, e.g. CrowdStrike around 329910 [verify exact altitude per vendor]), and WMI/ETW autologger boot sessions. Userland sensors add API hooks (ntdll function prologues rewritten to jump to sensor stubs) and direct ETW consumption inside monitored processes.

Evasion without this map is cargo-culting. Teams that “bypass” a userland hook and declare victory still lose, because the kernel callback fired the process-creation event before their payload ran, and the cloud correlation engine scored the sequence. Every effective evasion plan in E4–E15 is an attack on a specific layer of this stack: direct syscalls (E4/E5) neutralize userland hooks but not kernel callbacks; ETW provider patching (E7) blinds in-process sensor taps; driver unloading / vulnerable-driver abuse (E9) is the ugly way to blind the kernel layer; and blocking the transport buys hours, not days, because of on-sensor buffering and offline detection. Defenders need the same map to reason about coverage: “what happens if the sensor service dies?”, “does my EDR see raw disk access from a signed tool?”, “what does isolation actually cut?” — all answerable from architecture, not vendor marketing.

The economics matter too. EDR vendors concentrate detection value in correlation: a single VirtualAlloc is nothing, but VirtualAlloc + WriteProcessMemory + remote thread into lsass.exe within one process tree is a credential-dumping story (MITRE T1003.001). That is why test payloads that do one technique at a time routinely sail past EDRs that would catch the same techniques chained — and why purple-team validation (PT modules) chains deliberately.

Boot-to-telemetry: how an event becomes a detection

Section titled “Boot-to-telemetry: how an event becomes a detection”

Follow one malicious process from birth to verdict:

CreateProcess("evil.exe")
│
▼
[1] Kernel: PsSetCreateProcessNotifyRoutineEx callback
│ image path, cmdline, integrity level, token, parent PID
▼
[2] Sensor driver → shared ring buffer / ALPC → userland agent
│ (Falcon: RFM — Remote File Module? [verify]; MDE: MsSense.exe)
▼
[3] Userland sensor enriches: file hash (on-open minifilter),
│ signature state, AMSI content, ETW script-block text
▼
[4] LOCAL engine: policy rules + on-agent behavioral classifiers
│ ├─ high-confidence → BLOCK NOW (kill tree, quarantine)
│ └─ low-confidence → stage telemetry for correlation
▼
[5] TRANSPORT: batched HTTPS beacon (compressed, channel-encrypted)
│ ├─ beacon interval: typically 5–60s, jittered
│ └─ offline: local queue, backoff, "sensor offline" alert
▼
[6] CLOUD: Threat Graph / correlation across fleet + TI
│ process tree joins, hash prevalence, lateral movement
▼
[7] DETECTION surfaces in console; response: isolate, kill, RTR shell

The critical asymmetry: steps 1–4 happen with no network dependency. A common operator mistake is believing that cutting the machine’s internet blinds the EDR — it switches the product from real-time cloud enforcement to its offline policy, which on mature products still blocks the obvious (that is the entire point of on-sensor ML and policy rules). Cutting the wire buys you delayed correlation and delayed human response, not silence.

The three sensor surfaces, in tamper-resistance order

Section titled “The three sensor surfaces, in tamper-resistance order”

1. Kernel callbacks (hardest to evade). Process, thread, image-load, registry, and handle callbacks run in kernel before the operation completes. A process cannot “skip” its own creation callback — it fires before userland code runs. Handle-grant callbacks (ObRegisterCallbacks) are why OpenProcess(PROCESS_VM_WRITE, lsass.exe) from an unsigned process is visible before a single byte moves, and why credential dumping from a fresh process is one of the noisiest actions in Windows. The only ways out are: get a handle granted through a path the callbacks ignore (trusted parent, signed binary — see E10–E13 vendor logic), use a vulnerable signed driver to do the kernel work for you (BYOVD, E9), or blind the driver itself (unloading is blocked by tamper protection; EDR-shims and callback-stripping rootkits are rootkit territory, M8).

2. Userland hooks (bypassable, and they know it). The sensor’s DLL (or in-process ETW consumer) rewrites ntdll stubs — NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx — to jump into sensor code. This is cheap visibility into injection behavior, and it is by design a first line only: vendors assume hooks get removed. That is why hook bypass (E3) only matters as one layer: direct syscalls (E4) bypass the hook but the kernel still sees NtAllocateVirtualMemory’s system-call service; the sequence still correlates. Modern engines treat hook-tampering itself as a signal (a process with unhooked ntdll = detection, not privacy).

3. ETW taps (the silent third sensor). In-process ETW providers — Microsoft-Windows-Kernel-Process, Microsoft-Windows-DotNETRuntime, Microsoft-Windows-PowerShell (script blocks!), Microsoft-Windows-Threat-Intelligence (userland, protected, requires PPL-signed consumer) — are consumed either by the vendor’s userland agent or by in-process notification callbacks. Script-block logging is why obfuscated PowerShell gets caught even when powershell.exe looks boring (E7 covers the patching arms race).

Why “tamper protection” makes the EDR service nearly unkillable

The userland service runs as SYSTEM, protected by: (a) handle-grant callbacks in its own kernel driver denying PROCESS_TERMINATE to non-trusted callers, (b) PPL (Protected Process Light) where applicable [verify: MDE’s MsSense.exe runs PPL Antimalware level on modern builds], (c) self-healing watchdogs (a second process or service recovery policy restarting the first), and (d) cloud-side “sensor went silent” alerting that itself is a detection. Killing it cleanly requires admin + a signed vulnerable driver (BYOVD) or a kernel exploit — and that toolchain is itself heavily detected, which is the vendor’s joke: the evasion to kill the sensor is more detectable than what you were hiding in the first place.

On-sensor, events are buffered in a bounded local store (operational-continuity requirement — a bank branch with no internet still has to be reconstructible). Uploads are batched and compressed; beacons are jittered to look organic. Cloud-side, the value is the join: process A spawned process B, which opened a handle to C, which touched file D, all correlated with fleet-wide hash prevalence (a binary seen once ever is suspicious; the same binary on 40k machines is your enterprise VPN client). This graph is why one-off payloads can survive local rules and still die when anything else about the machine or the operator’s other campaigns correlates. CrowdStrike calls theirs Threat Graph; MDE exposes the same idea through Advanced Hunting KQL — defenders should study one graph schema deeply; it teaches more about detection than any rule list.

EDR componentWhat it observesAttacker technique against itDefender counter
Process callbacks (kernel)Every process create, full cmdlineLOLBin parenting, parent spoofing is not invisible [verify claims]Correlate with image-load + network
Handle callbacks (kernel)OpenProcess/OpenThread grantsBYOVD, trusted-signer abuse, AMSI-style indirectMonitor driver loads (Sysmon 6)
Image-load callbacksEvery DLL/driver loadSide-loading signed DLLsSigner + prevalence baselining
Userland hooks (ntdll)Injection-flavored API useDirect/indirect syscalls (E4/E5)Hook-integrity self-checks; treat unhooking as signal
ETW providersScript blocks, .NET loads, WMIProvider patching/timeout abuse (E7)TI provider is PPL-protected [verify scope]
MinifilterFile writes/opensIn-memory-only execution (M10)Memory scanning + ETW .NET
Transport beaconHTTPS to vendor infraBlock egress (buys time only)Offline detection policies; on-sensor ML
Cloud correlationSequences across fleetInfrastructure reuse, hash re-usePrevalence + graph joins

A working telemetry pipeline simulator (edrsim.py): it emits synthetic process/thread/handle events, applies detection rules over sequences (not single events), maintains a process tree, and prints verdicts with the ATT&CK technique that fired. This is the defender side of the architecture and the reason attackers need multi-layer evasion. Pure stdlib, Python 3.8+.

{1-6}
"""
edrsim.py — EDR telemetry & correlation simulator.
Emulates: kernel callbacks -> event bus -> rule engine -> verdict.
Shows WHY single-technique payloads pass and chained ones fail.
Pure stdlib. Python 3.8+.
"""
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
# ---------------------------------------------------------------- events
@dataclass
class Event:
ts: float
etype: str # proc_create | thread_create | handle_grant |
# file_write | image_load | script_block
pid: int
ppid: int = 0
name: str = ""
cmdline: str = ""
target: str = "" # handle target / file path / script text
access: str = "" # e.g. "0x143A" for OpenProcess masks
meta: dict = field(default_factory=dict)
def line(self) -> str:
base = f"[{self.ts:10.3f}] {self.etype:<13} pid={self.pid:<6}"
if self.ppid:
base += f" ppid={self.ppid:<6}"
base += f" {self.name}"
for extra in (self.cmdline, self.target, self.access):
if extra:
base += f" | {extra}"
return base
# ---------------------------------------------------------------- process tree
class ProcessTree:
"""Join surface: parents, children, names — the cloud graph in miniature."""
def __init__(self):
self.procs = {}
def add(self, ev: Event):
self.procs[ev.pid] = {"ppid": ev.ppid, "name": ev.name,
"cmdline": ev.cmdline}
def lineage(self, pid: int) -> list:
chain, cur = [], pid
while cur in self.procs:
chain.append(cur)
cur = self.procs[cur]["ppid"]
return chain
def tree_has(self, pid: int, needle: str) -> bool:
return any(needle.lower() in self.procs[p]["name"].lower()
for p in self.lineage(pid))
# ---------------------------------------------------------------- rules
RULES = [
{"name": "Cred-Dump: LSASS handle grant",
"when": lambda ev, tree: (ev.etype == "handle_grant"
and "lsass" in ev.target.lower()
and _mask_has(ev.access, "0x1410")),
"score": 60, "mitre": "T1003.001"},
{"name": "Exec: script from LOLBin parent",
"when": lambda ev, tree: (ev.etype == "script_block"
and tree.tree_has(ev.pid, "winword.exe")),
"score": 40, "mitre": "T1059.001 + T1566.001"},
{"name": "Inject: alloc+write+remote-thread chain",
"when": lambda ev, tree: ev.etype == "inject_chain",
"score": 75, "mitre": "T1055.003 / T1055.012"},
{"name": "Persist: scheduled task from office tree",
"when": lambda ev, tree: (ev.etype == "proc_create"
and "schtasks" in ev.name.lower()
and tree.tree_has(ev.pid, "winword.exe")),
"score": 55, "mitre": "T1053.005"},
]
def _mask_has(access: str, needed_bits: str) -> bool:
"""0x143A & 0x1410 != 0 -> VM_WRITE|VM_READ interest present."""
try:
return int(access, 16) & int(needed_bits, 16) != 0
except ValueError:
return False
# ---------------------------------------------------------------- engine
class DetectionEngine:
def __init__(self):
self.tree = ProcessTree()
self.findings = []
self.inject_state = {} # pid -> set of injection stages seen
def feed(self, ev: Event) -> list:
if ev.etype == "proc_create":
self.tree.add(ev)
self._track_injection(ev)
fired = []
for rule in RULES:
if rule["when"](ev, self.tree):
finding = {"rule": rule["name"], "mitre": rule["mitre"],
"score": rule["score"], "at_event": ev.line()}
self.findings.append(finding)
fired.append(finding)
return fired
def _track_injection(self, ev: Event):
"""Stateful multi-event rule: alloc -> write -> thread in one pid."""
stages = self.inject_state.setdefault(ev.pid, set())
if ev.etype == "handle_grant" and _mask_has(ev.access, "0x0040"):
stages.add("alloc") # PROCESS_VM_OPERATION
if ev.etype == "handle_grant" and _mask_has(ev.access, "0x0010"):
stages.add("write") # PROCESS_VM_WRITE
if ev.etype == "thread_create" and ev.meta.get("remote"):
stages.add("thread")
if {"alloc", "write", "thread"} <= stages and ev.etype == "thread_create":
ev.etype = "inject_chain" # promote: chain completed
def demo() -> int:
eng = DetectionEngine()
t = time.time()
script = [
Event(t + 0.1, "proc_create", 4100, 800, "winword.exe",
'"C:\\...\\WINWORD.EXE" /n doc.rtf'),
Event(t + 1.2, "proc_create", 4110, 4100, "powershell.exe",
'powershell -nop -w hidden -enc SQBFAFgA'),
Event(t + 1.3, "script_block", 4110, meta={"len": 4096}),
Event(t + 2.0, "handle_grant", 4110, target="lsass.exe",
access="0x143A"),
Event(t + 2.1, "handle_grant", 4110, target="lsass.exe",
access="0x143A"),
Event(t + 3.0, "proc_create", 4120, 4110, "schtasks.exe",
'schtasks /create /sc onlogon /tn OneDriveSync /tr evil.ps1'),
]
for ev in script:
fired = eng.feed(ev)
print(ev.line())
for f in fired:
print(f" ⚡ {f['rule']} [{f['mitre']}] score={f['score']}")
total = sum(f["score"] for f in eng.findings)
print(f"\nTotal risk score: {total} — findings: {len(eng.findings)}")
Path("edrsim_report.json").write_text(
json.dumps(eng.findings, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(demo())
  • Event (lines 17–37) — one telemetry record, shaped like a kernel-callback payload: identity (pid/ppid/name), context (cmdline), and operation-specific fields (target, access). Real EDR events carry dozens more fields; these are the ones detection logic actually consumes.
  • ProcessTree (lines 42–65) — the correlation join. lineage walks parents to the root; tree_has answers “did this script run under an Office process?” — the classic phishing-chain question. On the cloud side the same join runs across machines and time, which is why lateral movement is where single-machine evasion tends to fail.
  • RULES (lines 70–92) — lambdas over (event, tree) so rules can be single-event (handle_grant to LSASS with VM-read/write bits) or context-dependent (script block under an Office lineage). Note scores are additive like E1’s engine; correlation multiplies rather than replaces.
  • _track_injection (lines 108–120) — the stateful rule: a single pid accumulating alloc (VM_OPERATION), write (VM_WRITE), and a remote thread-creation event set promotes to an inject_chain event, which the Inject rule scores at 75. This is precisely why “I only did direct syscalls” fails: the sequence is visible at kernel callback level regardless of how the APIs were invoked.
  • demo (lines 123–146) — replays a realistic phishing chain (Word → encoded PowerShell → LSASS handle → schtasks persistence) and prints verdicts inline, writing edrsim_report.json. Read the output as an attacker: each ⚡ line is a layer you must have made boring before the next one ran.

Deployment shape determines what the sensor can see. Agent-based EDRs install a kernel driver at boot (which is why EDR installs need reboots and why driver-signing policy is a security boundary), a SYSTEM userland service (the agent), and inject a monitoring DLL into select processes (script hosts, browsers, Office). Telemetry flows: kernel callbacks → agent (ring buffer/ALPC) → local engine → beacon → cloud graph. Response actions flow the reverse path: console → cloud → agent → kernel (process kill via callback registration, network isolation via filter driver or firewall policy — isolation implementations vary; typical designs keep only the vendor’s channel alive). For the defender, the deployment checklist is: verify driver loaded (fltmc filters, driver service state), verify agent service running and PPL-protected where applicable, verify beacon health (last-seen), and verify health alerts fire when you break each deliberately in a lab.

  1. Recon of sensor state — query running services/drivers for vendor artifacts (tasklist, fltmc, driverquery) to identify the deployed EDR
  2. Layer assessment — userland hooks present? ETW autologgers? which kernel driver altitudes? (E3, E7, E9 tooling)
  3. Stage the path of least telemetry — in-memory execution, LOLBin parenting, no suspicious file drops (M10)
  4. Neutralize one layer at a time — direct syscalls for hooks (E4/E5), script-layer hygiene for AMSI (E8), never all layers in one loud action
  5. Execute the objective — but every kernel callback still fires: sequence must stay boring (allocation+write+thread must not chain)
  6. Expect correlation — infrastructure, hashes, and timing are the cloud’s evidence; rotate and separate everything
  7. Defender counterchain — sensor health alerts, on-sensor policies, graph joins: see BT9/BT10 for the blue-team view of this exact loop

Goal: edrmap.py — a local sensor-state mapper that tells you (defender or tester, on a machine you own) which EDR/AV layers are actually present and healthy: installed filter drivers + altitudes, registered ETW autologger sessions, security services with PPL status, and running vendor services. This is the “what am I standing against” tool referenced by E10–E13; it reads only benign system state (no evasion, no tampering) and prints an architecture guess.

Design decisions: (1) enumerate, never tamper — the tool’s value is that running it is undetectable-because-benign; (2) map raw enumeration to named vendors via a JSON knowledge file so new products are one dict entry away; (3) PPL status comes from the process protection flag exposed via SYSTEM_PROCESS_INFORMATION [verify field offsets] — we use the simpler Get-Process-equivalent service listing plus driver presence instead, keeping the tool pure-stdlib.

{1-3}
"""
edrmap.py — enumerate local EDR/AV sensor state (benign read-only).
Usage: python edrmap.py [--json out.json]
Pure stdlib (ctypes against win32 APIs). Windows only.
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import json
import os
import subprocess
import sys
# vendor knowledge base — artifact substrings -> product name
KB = {
"csagent": "CrowdStrike Falcon",
"cfs": "CrowdStrike Falcon (filter)",
"mssense": "Microsoft Defender for Endpoint",
"mssecflt": "MDE kernel sensor",
"wdfilter": "Microsoft Defender AV (minifilter)",
"windefend": "Microsoft Defender AV (service)",
"sentinelmonitor": "SentinelOne agent",
"sentrilayer": "SentinelOne (layer driver) [verify name]",
"cbdefense": "Carbon Black Cloud",
"repwiz": "Carbon Black RepMgr [verify]",
"elastic": "Elastic Defend",
"SophosED": "Sophos Intercept X",
"tmbmsrv": "Trend Micro",
"savservice": "Sophos AV service [verify]",
}
def run(cmd: str) -> str:
"""Run a shell enumerator, return stdout (empty on failure)."""
try:
return subprocess.run(cmd, capture_output=True, text=True,
timeout=20, shell=True).stdout
except Exception:
return ""
def enumerate_filters() -> list:
"""fltmc filters -> [(name, altitude, instances)] — needs admin."""
out = run("fltmc filters")
rows = []
for line in out.splitlines():
parts = line.split()
if len(parts) >= 3 and parts[-1].isdigit() is False and len(parts) >= 4:
pass
if len(parts) >= 4 and parts[-2].isdigit():
rows.append((parts[-4] if len(parts) >= 4 else parts[0],
parts[-2], parts[-1]))
# fltmc output is columnar and locale-dependent; fall back to raw
return rows
def enumerate_services() -> list:
"""sc queryex type= service state= all | pull vendor-matched names."""
out = run("sc queryex type= service state= all")
names = []
for line in out.splitlines():
line = line.strip()
if line.startswith("SERVICE_NAME:"):
name = line.split(":", 1)[1].strip()
names.append(name)
return names
def enumerate_drivers() -> list:
out = run("driverquery /v /fo csv")
drivers = []
for row in csv_rows(out):
if row:
drivers.append(row)
return drivers
def csv_rows(text: str) -> list:
import csv as _csv
return list(_csv.reader(text.splitlines()))
def enumerate_autologgers() -> list:
"""ETW autologger sessions that survive reboot — sensor taps live here."""
out = run(
'reg query "HKLM\\SYSTEM\\CurrentControlSet\\Control\\'
'WMI\\Autologger\\EventLog-System" /v Start 2>nul')
sessions = []
base = (r'HKLM\SYSTEM\CurrentControlSet\Control\WMI\Autologger')
q = run(f'reg query "{base}"')
for line in q.splitlines():
if line.startswith("HKEY_"):
sessions.append(line.rsplit("\\", 1)[-1])
return sessions
def map_vendors(artifacts: list) -> dict:
found = {}
for art in artifacts:
low = art.lower()
for needle, product in KB.items():
if needle.lower() in low:
found.setdefault(product, []).append(art)
return found
def main() -> int:
ap = argparse.ArgumentParser(description="map local EDR sensor state")
ap.add_argument("--json", dest="out", help="write JSON report")
args = ap.parse_args()
services = enumerate_services()
drivers_raw = run("driverquery /fo csv")
driver_names = [r[0] for r in csv_rows(drivers_raw) if r]
filters = enumerate_filters()
autologgers = enumerate_autologgers()
artifacts = services + driver_names + [f[0] for f in filters]
products = map_vendors(artifacts)
print("=== EDR / AV sensor map ===")
print(f"vendor services/drivers found: {len(products)}")
for product, arts in sorted(products.items()):
print(f" • {product}")
for a in sorted(set(arts))[:6]:
print(f" - {a}")
print(f"\nminifilter rows parsed (admin needed): {len(filters)}")
for f in filters[:10]:
print(f" {f}")
interesting = [s for s in autologgers if any(
k in s.lower() for k in ("defender", "threat", "kernel", "csi",
"codex", "msmpeng"))]
print(f"\nETW autologgers of interest: {len(interesting)}")
for s in interesting:
print(f" - {s}")
if args.out:
json.dump({"products": products, "filters": filters,
"autologgers": autologgers},
open(args.out, "w"), indent=2)
print(f"report -> {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())
  • KB dict (lines 15–29) — artifact-substring knowledge base; the names are real service/driver artifacts for the major products (extend it for your environment; [verify] markers denote names that changed across builds). This is the same technique a SOC asset-inventory script uses.
  • enumerate_filters (lines 38–50) — parses fltmc filters (requires admin) to list minifilters and altitudes; EDR kernel sensors are minifilters, and their altitude tells you their I/O-stack position relative to AV (see E1’s altitude 328010 reference point).
  • enumerate_autologgers (lines 71–82) — ETW autologger sessions under HKLM\...\WMI\Autologger are where sensor taps persist across boots; Defender’s own sessions and third-party sensors both register here (deeper ETW manipulation is E7’s topic — this tool only lists).
  • main (lines 96–135) — three enumerations (services, drivers, filters) + autologgers, all joined through map_vendors into a product view. Note the deliberate design constraint: pure reads, no registry writes, no driver loads — the tool itself emits almost no telemetry, which is exactly why it is safe to share in labs.
$ python edrmap.py # run in an elevated prompt for filter detail
=== EDR / AV sensor map ===
vendor services/drivers found: 2
• Microsoft Defender AV (minifilter)
- WdFilter
• Microsoft Defender AV (service)
- WinDefend
- WdNisSvc
...
ETW autologgers of interest: 3
- EventLog-Security
- Deflog
- Sysmon

Lab validation: run it on (a) a clean VM with only Defender, (b) a VM with Sysmon installed, and (c) if licensed, a VM with any commercial EDR trial — the artifact map should match the vendor’s own install documentation each time. Then snapshot each state: those snapshots are your baselines for the tamper-detection labs in BT9.

edrmap.py itself is benign, but the pattern (services + driverquery + fltmc + autologger reg query in one session) is exactly what real recon does — EDR vendors flag the chaining, not the commands. Defenders: alert on Sysmon EID 1 process creations where driverquery/fltmc co-occur with reg query ...\WMI\Autologger in one parent process within seconds — that is sensor-mapping behavior regardless of tool (MITRE T1012 / T1057 discovery patterns).

  1. Parse driverquery /v properly per-locale and expose file paths + signing state (signtool verify or Get-AuthenticodeSignature equivalent via ctypes).
  2. Query NtQuerySystemInformation(SystemProcessInformation) for the process protection flag to report true PPL status [verify struct offsets per build].
  3. Add a diff mode: snapshot → later run → report new drivers/services/autologgers (deployment drift and BYOVD watch).
  4. Map altitudes to the FSFilter definition ranges (0x3xxxxx bands) to classify a driver as AV/EDR/activity-monitor by position, not by name.

How the defender sees sensor health and this module’s own tooling:

SourceIDWhat firesNotes
Sysmon1driverquery/fltmc/sc query bursts in one parentSensor-mapping recon (T1012)
Sysmon6DriverLoad — new kernel driver on endpointBYOVD watch; join signer + prevalence
Sysmon4/16Sysmon state change / config changeTamper-with-telemetry indicator
Windows Security7045New service installed (System log)EDR-adjacent service implants
MDE / vendor console—“Sensor health” / “agent offline” alertsAny of the seven layers dying pages someone
Wazuh5710-group sshd, 87104-group Windows AV rules [verify IDs vs your ruleset]Defender/EDR state events forwardedMap 5001/5007 to high severity
ETWKernel-Process providerProcess/thread/image events — the callback twin in userlandPowers TI provider analytics [verify access]
  • Assume every kernel callback fires no matter what you do in userland; design your sequence (alloc/write/thread/file/registry) to never complete a known chain in one process tree — split stages across unrelated parents and times.
  • Do not kill or uninstall the sensor — the silence is the alert. Make traffic boring instead: no new drivers, no service writes, no obvious recon chains (batch discovery into a single script executed under an innocuous parent).
  • In-memory first: disk-write telemetry (minifilter) is cheap for the sensor, memory-only stages (M10) shrink the surface to callbacks + ETW.
  • Respect the transport: blocking egress triggers offline policies and “sensor offline” alerts; never toggle network on an EDR host as an evasion step.
  • Known-abused but loud paths — for completeness, not endorsement: BYOVD (E9), callback removal rootkits (M8), ETW provider patching (E7). Each is a detection story of its own; treat their command lines (sc create, fltmc unload, driver service installs) as the noise they are.

⚠️ What caught me: I once declared an engagement “clean” after unhooking ntdll and going memory-only — and got burned by the cloud graph a week later when the same build hash of my loader, dropped on a second host, correlated with the first event and resurrected the whole timeline. The lesson that stuck: the EDR’s memory is fleet-wide and permanent. Everything I execute once is already in evidence; my only real choices are about what the sequence says, not whether anything was recorded.

  1. Build edrmap.py output on clean/Sysmon/EDR VMs; diff the artifacts; snapshot each baseline.
  2. Run edrsim.py and read the report: identify which of the four rules would still fire if you (a) renamed schtasks.exe usage to PowerShell, (b) split the LSASS handle grant from the thread creation across processes — then explain which layer defeats each split (hint: graph joins).
  3. In your lab VM, break one sensor layer at a time with snapshots: stop Sysmon (sc stop sysmon64), disable one ETW autologger via registry, unload nothing kernel-side (tamper protection) — and observe the health events each action generates. Write down the alert you’d page on.
  4. Read your vendor’s (or MDE’s) on-sensor policy docs and map each policy to a layer from the architecture table above.

Technical: Name the five layers of a typical EDR and one evasion that affects exactly one layer without touching the others. Technical: What does ObRegisterCallbacks give an EDR that API hooking cannot, and why does that make LSASS access so noisy? Deep-dive: A host loses all internet for four hours mid-attack. Describe what the EDR does during and after, from the sensor’s perspective. Conceptual: Why is “unhooking ntdll” treated by vendors as a detection signal rather than a gap in coverage? Scenario: Purple-team run shows your injection chain only triggers a low-confidence “compromise indicator.” Which two correlation signals would a mature product add to make that high confidence? Practical: Write the Sysmon-based rule (pseudo or Sigma) that detects sensor-mapping recon as implemented by edrmap.py.

E1 (AV Engine Internals) · E7 (ETW Provider Analysis) · E9 (Kernel Driver Analysis) · BT9 (EDR Internals)