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.
Definition
Section titled “Definition”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:
| Layer | Implementation | Example artifacts |
|---|---|---|
| Kernel sensor | Driver registering callbacks | csagent.sys (CrowdStrike), SentinelMonitor.sys/SentinelPlus.sys [verify per build] (SentinelOne), CbDefenseFilter.sys? [verify] (CB), MDE: MsSecFlt.sys + WdFilter.sys |
| Userland sensor | Injected DLL / API hooks / ETW consumers | Falcon’s DLL injection into processes; MDE MsSense.exe |
| Local detection engine | Rules + behavioral models on-agent | Policy evaluation, “on-sensor ML” |
| Transport | HTTPS beacon, batched event upload | Falcon’s lfamq/channels [verify]; MDE MsSense.exe upload |
| Cloud brain | Correlation, graph analytics, TI | CrowdStrike 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.
Why It Matters
Section titled “Why It Matters”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.
Detailed Explanation
Section titled “Detailed Explanation”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 shellThe 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.
Data flow and the correlation graph
Section titled “Data flow and the correlation graph”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.
Common Techniques
Section titled “Common Techniques”| EDR component | What it observes | Attacker technique against it | Defender counter |
|---|---|---|---|
| Process callbacks (kernel) | Every process create, full cmdline | LOLBin parenting, parent spoofing is not invisible [verify claims] | Correlate with image-load + network |
| Handle callbacks (kernel) | OpenProcess/OpenThread grants | BYOVD, trusted-signer abuse, AMSI-style indirect | Monitor driver loads (Sysmon 6) |
| Image-load callbacks | Every DLL/driver load | Side-loading signed DLLs | Signer + prevalence baselining |
| Userland hooks (ntdll) | Injection-flavored API use | Direct/indirect syscalls (E4/E5) | Hook-integrity self-checks; treat unhooking as signal |
| ETW providers | Script blocks, .NET loads, WMI | Provider patching/timeout abuse (E7) | TI provider is PPL-protected [verify scope] |
| Minifilter | File writes/opens | In-memory-only execution (M10) | Memory scanning + ETW .NET |
| Transport beacon | HTTPS to vendor infra | Block egress (buys time only) | Offline detection policies; on-sensor ML |
| Cloud correlation | Sequences across fleet | Infrastructure reuse, hash re-use | Prevalence + graph joins |
Full Working Code
Section titled “Full Working Code”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 jsonimport timefrom dataclasses import dataclass, fieldfrom pathlib import Path
# ---------------------------------------------------------------- events@dataclassclass 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 treeclass 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))
# ---------------------------------------------------------------- rulesRULES = [ {"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
# ---------------------------------------------------------------- engineclass 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())Code Walkthrough
Section titled “Code Walkthrough”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.lineagewalks parents to the root;tree_hasanswers “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_grantto 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 accumulatingalloc(VM_OPERATION),write(VM_WRITE), and aremotethread-creation event set promotes to aninject_chainevent, 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, writingedrsim_report.json. Read the output as an attacker: each ⚡ line is a layer you must have made boring before the next one ran.
How It Reaches The Target
Section titled “How It Reaches The Target”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.
Exploitation Chain
Section titled “Exploitation Chain”- Recon of sensor state — query running services/drivers for vendor artifacts (tasklist, fltmc, driverquery) to identify the deployed EDR
- Layer assessment — userland hooks present? ETW autologgers? which kernel driver altitudes? (E3, E7, E9 tooling)
- Stage the path of least telemetry — in-memory execution, LOLBin parenting, no suspicious file drops (M10)
- 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
- Execute the objective — but every kernel callback still fires: sequence must stay boring (allocation+write+thread must not chain)
- Expect correlation — infrastructure, hashes, and timing are the cloud’s evidence; rotate and separate everything
- Defender counterchain — sensor health alerts, on-sensor policies, graph joins: see BT9/BT10 for the blue-team view of this exact loop
Building Your Own Tool
Section titled “Building Your Own Tool”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 argparseimport ctypesimport ctypes.wintypes as wtimport jsonimport osimport subprocessimport sys
# vendor knowledge base — artifact substrings -> product nameKB = { "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())Walkthrough
Section titled “Walkthrough”KBdict (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) — parsesfltmc 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 altitude328010reference point).enumerate_autologgers(lines 71–82) — ETW autologger sessions underHKLM\...\WMI\Autologgerare 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 throughmap_vendorsinto 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.
Compilation and test procedure
Section titled “Compilation and test procedure”$ 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 - SysmonLab 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.
Detection considerations
Section titled “Detection considerations”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).
Improvements to add
Section titled “Improvements to add”- Parse
driverquery /vproperly per-locale and expose file paths + signing state (signtool verifyorGet-AuthenticodeSignatureequivalent via ctypes). - Query
NtQuerySystemInformation(SystemProcessInformation)for the process protection flag to report true PPL status [verify struct offsets per build]. - Add a diff mode: snapshot → later run → report new drivers/services/autologgers (deployment drift and BYOVD watch).
- Map altitudes to the FSFilter definition ranges (0x3xxxxx bands) to classify a driver as AV/EDR/activity-monitor by position, not by name.
Detection — Defender View
Section titled “Detection — Defender View”How the defender sees sensor health and this module’s own tooling:
| Source | ID | What fires | Notes |
|---|---|---|---|
| Sysmon | 1 | driverquery/fltmc/sc query bursts in one parent | Sensor-mapping recon (T1012) |
| Sysmon | 6 | DriverLoad — new kernel driver on endpoint | BYOVD watch; join signer + prevalence |
| Sysmon | 4/16 | Sysmon state change / config change | Tamper-with-telemetry indicator |
| Windows Security | 7045 | New service installed (System log) | EDR-adjacent service implants |
| MDE / vendor console | — | “Sensor health” / “agent offline” alerts | Any of the seven layers dying pages someone |
| Wazuh | 5710-group sshd, 87104-group Windows AV rules [verify IDs vs your ruleset] | Defender/EDR state events forwarded | Map 5001/5007 to high severity |
| ETW | Kernel-Process provider | Process/thread/image events — the callback twin in userland | Powers TI provider analytics [verify access] |
Evasion Techniques
Section titled “Evasion Techniques”- 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.
Lab Practice
Section titled “Lab Practice”- Build
edrmap.pyoutput on clean/Sysmon/EDR VMs; diff the artifacts; snapshot each baseline. - Run
edrsim.pyand read the report: identify which of the four rules would still fire if you (a) renamedschtasks.exeusage to PowerShell, (b) split the LSASS handle grant from the thread creation across processes — then explain which layer defeats each split (hint: graph joins). - 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. - Read your vendor’s (or MDE’s) on-sensor policy docs and map each policy to a layer from the architecture table above.
Interview Questions
Section titled “Interview Questions”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.
Related Modules
Section titled “Related Modules”E1 (AV Engine Internals) · E7 (ETW Provider Analysis) · E9 (Kernel Driver Analysis) · BT9 (EDR Internals)