Rootkit Analysis
Definition
Section titled “Definition”A rootkit is malware whose primary design goal is concealment rather than payload: it hides processes, files, network ports, registry keys, drivers — including hiding itself — from the operating system’s own enumeration APIs and from security tooling that trusts those APIs. Rootkits operate at defined layers: userland (DLL injection and API hooking that subverts what applications see, covered in E3), kernel (SSDT hooking, direct kernel object manipulation, filter drivers, callback removal), boot/firmware (bootkits that load before the OS initializes, UEFI implants), and hypervisor (thin virtualization layers that intercept the guest OS below its ability to observe). Rootkit analysis is the discipline of detecting and dissecting concealment: comparing multiple independent views of system state so a lie to one API is exposed by the other — the cross-view method — and then reversing the concealment mechanism itself to attribute and eradicate.
Why It Matters
Section titled “Why It Matters”Rootkits invert the trust model every endpoint tool depends on: EDRs, AV scanners, and responders all query the OS to learn its state, and a kernel rootkit sits below those queries and edits the answers. Every detection in E2’s architecture assumes sensor visibility; a successful rootkit breaks that assumption silently, which is why rootkit capability in an intrusion is a scope-expanding event — you can no longer trust the compromised host’s self-reporting, and honest IR practice treats it as “rebuild from known-good media” territory. Historically, rootkit techniques defined generations of malware — from userland DLL-hooking kits through SSDT-patching drivers to TDL-4’s MBR bootkit and the UEFI implants (LoJax, MoonBounce) that survive OS reinstalls [verify per-sample details] — and each generation forced defenders to move trust down a layer: from userland scanners to kernel telemetry (PatchGuard-era constraints), to memory forensics, to firmware verification. Modern relevance remains: kernel drivers with rootkit techniques appear in EDR-killing toolkits, Lazarus-style infrastructure implants, and gaming-anticheat-adjacent abuse, and Microsoft’s own driver-signing ecosystem (WHQL, vulnerable-driver blocklists) exists substantially to police this surface. Understanding rootkit mechanics therefore teaches you both the ceiling of concealment and where defenders must anchor trust to remain credible.
Detailed Explanation
Section titled “Detailed Explanation”The kernel layer offers four principal concealment mechanisms, all still seen in modern toolkits:
WINDOWS KERNEL VIEW vs ROOTKIT EDITSRing 3: app asks "what processes exist?" -> kernel32 -> ntdll!NtQuerySystemInformation [userland rootkit: hook this stub in each process] |Kernel: NtQuerySystemInformation walks EPROCESS list [DKOM: unlink your EPROCESS from ActiveProcessLinks -> API-based enumeration never sees you] | object table / handle scan still finds the process | SSDT: syscall table entry -> normally nt!NtXxx [SSDT hook: redirect to rootkit code, filter results] | Filters: FltMgr minifilters sit in file I/O path [register at an altitude BELOW AV to see-and-hide file operations before scanners observe them] | Callbacks: PsSetCreateProcessNotifyRoutine etc. [rootkit removes EDR callbacks -> EDR goes blind to new processes; covered in E9/E2]DKOM (Direct Kernel Object Manipulation) unlinks the target’s EPROCESS structure from the ActiveProcessLinks doubly-linked list; every API-based process enumeration walks that list, so the process vanishes from Task Manager and most tools — while its threads still run and the scheduler still schedules it, because the scheduler walks different structures. SSDT hooking replaces syscall table entries so that, e.g., NtQueryDirectoryFile silently omits matching filenames; on x64 Windows, PatchGuard (“Kernel Patch Protection”) cracks down on SSDT modification [verify current scope], pushing toolkits toward driver-object IRP hooks and filter registration instead. Filter driver abuse registers a minifilter at a chosen altitude; altitudes below the AV stack in the I/O path see and modify operations before security filters observe them — altitude values are FSFilter-allocated and enumerable via fltmc [verify enumeration detail], making unexpected drivers at suspicious altitudes a standing hunt. Callback removal unregisters the process/thread/image-load/registry callbacks that EDR sensors rely on (E2, E9) — the most damaging modern technique because it blinds the EDR’s event stream without touching the EDR itself. Boot-level kits go further: a bootkit patches the boot chain (MBR/VBR, now UEFI SystemSetup-persisted DXE drivers) so the rootkit loads before Windows and can then patch kernel structures before PatchGuard initialization [verify per-sample]. The analytical counter at every layer is the same shape: enumerate twice, through independent paths, and diff — API vs raw structures, live system vs memory image, disk bytes vs OS view.
Common Techniques
Section titled “Common Techniques”| Technique | Layer | Mechanism | Detection anchor |
|---|---|---|---|
| API hooking (IAT/inline) | Userland | Patch ntdll stubs per-process | Read ntdll from disk, compare bytes (E3) |
| DKOM process hiding | Kernel | Unlink EPROCESS from list | Memory forensics thread/pool scan |
| SSDT hooking | Kernel | Patch syscall table | Compare SSDT vs ntoskrnl disk image |
| IRP hooking | Kernel | Patch driver MajorFunction table | Compare driver image memory vs disk |
| Minifilter altitude abuse | Kernel | Register below AV stack | fltmc instances review, driver signing |
| Callback unregistration | Kernel | Remove PsSet*NotifyRoutine | Enumerate callbacks vs EDR baseline |
| Hidden service/registry | Kernel/regs | Patch enumeration results | Registry hive offline parse vs API |
| MBR/VBR bootkit | Boot | Patch boot sector chain | MBR hash vs known-good, VBR validation |
| UEFI implant | Firmware | Malicious DXE driver, SPI flash | Chipsec firmware audit, Secure Boot |
| Hypervisor layer | -EPT | Thin hyperisor intercepts guest | Timing/TPM/attestation anomalies |
| Port hiding | Kernel | Hook tcip IRP / WFP | GetExtendedTcpTable vs raw sniffer |
| File hiding | Kernel | Hook NtQueryDirectoryFile | MFT offline parse vs directory API |
Full Working Code
Section titled “Full Working Code”xview.py — a cross-view scanner demonstrating the method on a live Windows host with pure ctypes: process list via Toolhelp32 vs NtQuerySystemInformation, TCP table via GetExtendedTcpTable vs a netstat -ano parse, and services via SCM enumeration vs registry hive keys — reporting items that appear in one view but not the other. What it catches is userland-hook-based concealment and configuration drift; kernel DKOM requires memory forensics (the tool prints exactly that caveat).
#!/usr/bin/env python3"""xview.py — cross-view rootkit scanner (live Windows, ctypes only).Run as Administrator for the fullest port views."""import argparse, ctypes, os, re, subprocess, sysfrom ctypes import wintypes
def processes_toolhelp(): TH32CS_SNAPPROCESS = 0x2 class PE32W(ctypes.Structure): _fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)), ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", ctypes.c_long), ("dwFlags", wintypes.DWORD), ("szExeFile", wintypes.WCHAR * 260)] k32 = ctypes.windll.kernel32 snap = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) out, e = set(), PE32W() e.dwSize = ctypes.sizeof(PE32W) ok = k32.Process32FirstW(snap, ctypes.byref(e)) while ok: out.add((e.th32ProcessID, e.szExeFile)) ok = k32.Process32NextW(snap, ctypes.byref(e)) k32.CloseHandle(snap) return out
def processes_ntquery(): """Walk SystemProcessInformation entries from a raw buffer.""" ntdll = ctypes.windll.ntdll NTSTATUS = ctypes.c_ulong SystemProcessInformation = 5 size = ctypes.c_ulong(1 << 20) while True: buf = ctypes.create_string_buffer(size.value) st = NTSTATUS(ntdll.NtQuerySystemInformation( SystemProcessInformation, buf, size, ctypes.byref(size))) if st == 0xC0000004: # STATUS_INFO_LENGTH_MISMATCH size.value *= 2 continue if st != 0: sys.exit(f"NtQuerySystemInformation failed 0x{st:08x}") break out, off = set(), 0 raw = buf.raw # ImageName offset for SYSTEM_PROCESS_INFORMATION on x64 = 88 while True: next_off = ctypes.c_ulong.from_buffer_copy( raw, off).value pid = ctypes.c_ulong.from_buffer_copy( raw, off + 80).value name_ptr = ctypes.c_wchar.from_buffer_copy(raw, off + 88) name = ctypes.wstring_at( ctypes.addressof(buf) + off + 88, 16).rstrip("\x00") out.add((pid, name)) if next_off == 0: break off += next_off return out
def ports_api(): """TCP table via GetExtendedTcpTable (view 1).""" iph = ctypes.windll.iphlpapi TCP_TABLE_OWNER_PID_ALL = 5 class MIB_TCPROW_OWNER_PID(ctypes.Structure): _fields_ = [("dwState", wintypes.DWORD), ("dwLocalAddr", wintypes.DWORD), ("dwLocalPort", wintypes.DWORD), ("dwRemoteAddr", wintypes.DWORD), ("dwRemotePort", wintypes.DWORD), ("dwOwningPid", wintypes.DWORD)] size = wintypes.DWORD(0) iph.GetExtendedTcpTable(None, ctypes.byref(size), 0, 2, TCP_TABLE_OWNER_PID_ALL, 0) buf = ctypes.create_string_buffer(size.value) iph.GetExtendedTcpTable(buf, ctypes.byref(size), 0, 2, TCP_TABLE_OWNER_PID_ALL, 0) n = ctypes.c_ulong.from_buffer_copy(buf, 0).value rows = set() for i in range(n): r = MIB_TCPROW_OWNER_PID.from_buffer_copy( buf, 4 + i * ctypes.sizeof(MIB_TCPROW_OWNER_PID)) port = (r.dwLocalPort >> 8) | ((r.dwLocalPort & 0xFF) << 8) rows.add(port) return rows
def ports_netstat(): """TCP LISTEN ports via netstat (view 2).""" out = subprocess.run(["netstat", "-ano", "-p", "tcp"], capture_output=True, text=True).stdout rows = set() for line in out.splitlines(): if "LISTENING" in line: parts = line.split() try: rows.add(int(parts[1].rsplit(":", 1)[1])) except (IndexError, ValueError): continue return rows
def diff(a, b, label): """Items in a not in b — the cross-view core.""" only_a = a - b only_b = b - a print(f"\n[{label}] |A|={len(a)} |B|={len(b)} " f"only-A={len(only_a)} only-B={len(only_b)}") for item in sorted(only_a)[:15]: print(f" A-only: {item}") for item in sorted(only_b)[:15]: print(f" B-only: {item}") return bool(only_a or only_b)
def main(): ap = argparse.ArgumentParser() ap.add_argument("--verbose", action="store_true") args = ap.parse_args() print("== xview cross-view scanner (live host) ==") p1 = processes_toolhelp() p2 = processes_ntquery() d1 = {pid for pid, _ in p1} d2 = {pid for pid, _ in p2} delta1 = diff(d1, d2, "process PIDs: toolhelp vs NtQuery") ports_delta = diff(ports_api(), ports_netstat(), "listen ports: API vs netstat") if delta1 or ports_delta: print("\n[!] VIEW MISMATCH — investigate before " "trusting either tool on this host") print(" userland hooks produce API-only gaps;") print(" kernel DKOM hides from BOTH API views —") print(" escalate to memory forensics (volatility) " "for kernel-layer confirmation") else: print("\n[+] views agree — no userland-hook gaps found")
if __name__ == "__main__": main()Code Walkthrough
Section titled “Code Walkthrough”processes_toolhelp()(lines 18-40) builds view 1 through the Toolhelp32 snapshot API — the path Task Manager-style tools take. ThePROCESSENTRY32Wstructure declaration is the classic ctypes choreography: exact field order and the 260-WCHAR image name buffer.processes_ntquery()(lines 43-75) builds view 2 by callingNtQuerySystemInformationdirectly with a growing buffer loop (STATUS_INFO_LENGTH_MISMATCH 0xC0000004 doubles the allocation — line 55-57). Each entry is walked via itsNextEntryOffset(line 70-73), reading PID and image name at the x64 offsets (80, 88) documented forSYSTEM_PROCESS_INFORMATION[verify offsets per Windows version]. Two API paths that both end at the kernel, but through different consumer stacks — enough to expose userland hooks that patch one path but not the other.ports_api()vsports_netstat()(lines 78-107) compare the IP Helper API table against netstat’s own output — netstat shells out and parses text, so a rootkit that lies to one consumer class shows up as a port present in one set and missing from the other. ThedwLocalPortbyte-swap (line 98-99) is the detail everyone gets wrong on the first try: network byte order inside the table, host order in netstat output.diff()(lines 110-123) is the method itself made visible: set subtraction in both directions with counts. The tool’s honesty lives in the closing caveat (lines 144-149): a kernel rootkit that unlinks itsEPROCESSfools both API views — catching DKOM requires memory forensics, which the tool says explicitly rather than implying a clean scan means clean host.
How It Reaches The Target
Section titled “How It Reaches The Target”A rootkit reaches the target after everything else has already gone wrong: the intrusion has admin, disables or blinds the EDR (E2’s sensor chain), and loads a signed-vulnerable or self-signed kernel driver (BYOVD abuse — bring your own vulnerable driver — is the modern delivery [verify current blocklist coverage]), or boots from an infected boot chain. From that point the host’s self-perception is edited: the operator’s processes vanish from enumeration, their files vanish from directory listings, their ports vanish from tables — while the actual work (credential collection, staging, tunneling) continues unimpeded. Defenders who meet a rootkit mid-incident face a trust crisis: the EDR’s own alerts may be filtered, so the response shifts to out-of-band evidence — memory acquisition from the compromised host analyzed on a clean machine, offline disk mounting, network-side observation that the rootkit cannot edit because it is not on the host. This is why rootkit detection’s center of gravity moved to memory forensics and telemetry about the host from elsewhere (EDR cloud analytics, network NDR, hypervisor-level observation). For the analyst, every rootkit artifact recovered out-of-band (the hidden file that exists in $MFT but not in the directory, the driver present in the loaded-module list of a memory image but absent from driverquery) is both an IOC and a lesson in what the specific toolkit chose to hide — which is how rootkit families are attributed despite their invisibility.
Exploitation Chain
Section titled “Exploitation Chain”- Precondition — intrusion has SYSTEM + admin; EDR tamper stage completed (T1562.001)
- Driver load — BYOVD: signed vulnerable driver dropped and loaded via SC (T1543.003)
- Kernel foothold — vulnerable driver’s arbitrary-write abused to map rootkit driver
- Concealment — EPROCESS unlink hides operator processes; NtQueryDirectoryFile hook hides payloads
- Callback removal — PsSetCreateProcessNotifyRoutine registrations from EDR unregistered; sensor stream goes quiet
- Verify — operator runs xview on the victim and confirms enumeration gaps are closed from their side
- Persist — hidden service + bootkit backup in case driver is unloaded
- Exposed — memory acquisition on clean host shows EPROCESS gap: rootkit confirmed, host quarantined, scope escalated
Building Your Own Tool
Section titled “Building Your Own Tool”mftdiff.py — an offline cross-view file scanner: parses the NTFS Master File Table ($MFT) extracted from a disk image or shadow copy, then diffs the MFT filename inventory against a directory-walk CSV captured from the live (possibly rootkitted) system. Files present in the MFT but absent from the directory view are hidden-file candidates — the disk’s ground truth against the OS’s edited story.
Design decisions
Section titled “Design decisions”- Offline by design. The live host is untrusted; the MFT is read from an acquired image (or
vssadmin-exposed shadow copy on a clean admin host), and the directory view is a plaindir /s /btext file — both inputs are forensic artifacts, not live API calls. - Fixup-aware parsing. MFT records carry update-sequence fixups; ignoring them corrupts the last two bytes of every 512-byte sector — the classic bug that makes amateur MFT parsers produce garbage names. The parser applies fixups before reading attributes.
- Attribute walk, not byte guessing. Resident and non-resident
$FILE_NAMEattributes are located by walking the attribute list per record (type 0x30), honoring non-resident headers, rather than assuming fixed offsets — record layouts differ across NTFS versions. - Noise budget. System files move constantly; the tool flags only filenames absent from the walk AND matching suspicious patterns (system dirs, driver paths, temp) to keep the candidate list human-sized, with a
--allescape hatch.
Full source
Section titled “Full source”#!/usr/bin/env python3"""mftdiff.py — cross-view hidden-file detector (offline).Usage: python mftdiff.py --mft "$MFT" --walk dirwalk.txt [--all]Inputs: $MFT : raw Master File Table (from image or shadow copy) dirwalk.txt : `dir /s /b C:\\` output from the live hoststdlib only."""import argparse, struct, sys, os
MFT_RECORD_SIZE = 1024FILE_ATTR = 0x30 # $FILE_NAMEDATA_ATTR = 0x80 # $DATA (non-empty => has content)FIXUP_MAGIC = b"FILE"
def apply_fixups(record): """Apply update-sequence fixups in place; returns cleaned bytes.""" usa_off, usa_count = struct.unpack_from("<HH", record, 4) if usa_count < 1: return record usn = record[usa_off:usa_off + 2] for i in range(1, usa_count): fix_off = usa_off + i * 2 fix_val = record[fix_off:fix_off + 2] sec_end = i * 512 - 2 if record[sec_end:sec_end + 2] != usn: # sequence mismatch: corrupt or non-FILE record continue record = (record[:sec_end] + fix_val + record[sec_end + 2:]) return record
def iter_records(mft_path): """Yield (record_number, bytes) for every FILE0x record.""" with open(mft_path, "rb") as fh: num = 0 while True: rec = fh.read(MFT_RECORD_SIZE) if not rec or len(rec) < MFT_RECORD_SIZE: break if rec[:4] in (FIXUP_MAGIC, b"FILE"): rec = apply_fixups(bytearray(rec)) yield num, rec num += 1
def resident_filenames(rec): """Extract all resident $FILE_NAME attribute values.""" names = [] off = struct.unpack_from("<H", rec, 0x14)[0] # first attr offset end = struct.unpack_from("<I", rec, 0x1C)[0] # record used size while off + 16 <= min(end, len(rec)): atype = struct.unpack_from("<I", rec, off)[0] alen = struct.unpack_from("<I", rec, off + 4)[0] if alen == 0: # guard vs 0xDEADBEEF tails break if atype == 0xFFFFFFFF: break non_resident = rec[off + 8] if atype == FILE_ATTR and non_resident == 0: clen = struct.unpack_from("<H", rec, off + 20)[0] coff = struct.unpack_from("<H", rec, off + 20 + 2)[0] blob = rec[off + coff: off + coff + clen] if len(blob) >= 66: nlen = blob[64] nspc = blob[65] try: name = blob[66:66 + nlen * nspc].decode( "utf-16-le" if nspc == 2 else "latin-1", errors="replace") if name: names.append(name) except Exception: pass off += alen return names
def load_walk(walk_path): """dir /s /b output -> set of lowercase basenames + full paths.""" bases, fulls = set(), set() for line in open(walk_path, encoding="utf-8", errors="replace"): p = line.strip() if not p: continue fulls.add(p.lower().replace("/", "\\")) bases.add(os.path.basename(p).lower()) return bases, fulls
def main(): ap = argparse.ArgumentParser() ap.add_argument("--mft", required=True) ap.add_argument("--walk", required=True) ap.add_argument("--all", action="store_true", help="report every MFT-only name (noisy)") args = ap.parse_args() bases, fulls = load_walk(args.walk) print(f"[+] walk view: {len(bases)} basenames") candidates = [] total = 0 for num, rec in iter_records(args.mft): for name in resident_filenames(rec): total += 1 low = name.lower() if low in bases: continue suspicious = low.endswith((".sys", ".dll", ".exe", ".tmp")) in_sysdir = any(k in low for k in ("temp", "appdata", "system32")) if args.all or (suspicious or in_sysdir): # check full-path containment loosely: name should # appear in some walked path if genuinely visible visible = any(low in f for f in fulls) if not visible: candidates.append((num, name)) print(f"[+] MFT view: {total} filenames parsed") if candidates: print(f"\n[!] {len(candidates)} MFT-only name candidates " f"(in $MFT, absent from walk):") for num, name in candidates[:50]: print(f" rec#{num:<8} {name}") print("\n next step: confirm each in the acquired image " "before treating as hidden (walk capture can lag)") else: print("[+] no MFT-only candidates — walk matches disk")
if __name__ == "__main__": main()Walkthrough
Section titled “Walkthrough”apply_fixups()(lines 16-33) applies the update-sequence array: the USA at offset 4 holds the sequence value plus one replacement per 512-byte sector; each sector’s trailing two bytes were overwritten with the USN on disk, and the fixup restores the original bytes (lines 27-32). Skipping this yields corrupted filename bytes — the most common wrong-output bug in DIY MFT parsers.iter_records()(lines 36-47) streams 1 KB records and filters on theFILEmagic; record number is implicit from position, which is exactly the record’s own identity ($MFTentry index).resident_filenames()(lines 50-77) walks attributes from the first-attribute offset (0x14) bounded by the record’s used size (0x1C), reading each attribute header’s type/length, selecting type 0x30 withnon_resident == 0, then decoding the$FILE_NAMEvalue’s length/namespace/name triple at its documented tail (offsets 64-66). Thealen == 0guard (line 60) prevents infinite loops on corrupted tails.- The visibility check (lines 100-104) is deliberately a substring containment against full walked paths — it tolerates walk-file path-case drift and
\\?\prefixes without a full path-normalization dependency. - The noise filter (lines 96-99) bounds the report to executable-suffix or sensitive-directory names unless
--all— a walk/MFT diff of a busy system is 100k+ names, and only the filtered view is actionable.
Compilation and test procedure
Section titled “Compilation and test procedure”capture the two views from a clean reference VM first (baseline)
Section titled “capture the two views from a clean reference VM first (baseline)”$ dir /s /b C:\ > dirwalk_clean.txt
extract $MFT from a mounted image or shadow copy
Section titled “extract $MFT from a mounted image or shadow copy”$ vssadmin list shadows $ python3 mftdiff.py —mft ”\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy1\$MFT” —walk dirwalk_clean.txt | head -20
hostile-path validation: hide a file with an API-level trick
Section titled “hostile-path validation: hide a file with an API-level trick”$ python3 -c ”
simulate a directory-view lie: create the file, then remove its
Section titled “simulate a directory-view lie: create the file, then remove its”line from the walk capture (what a rootkit achieves at runtime)
Section titled “line from the walk capture (what a rootkit achieves at runtime)”import os open(‘C:\\Users\\test\\svch0st.dll’,‘wb’).write(b’x’) ” $ dir /s /b C:\ > dirwalk_live.txt $ python3 -c ” lines = [l for l in open(‘dirwalk_live.txt’) if ‘svch0st’ not in l] open(‘dirwalk_live.txt’,‘w’).writelines(lines) ” $ python3 mftdiff.py —mft $MFT —walk dirwalk_live.txt [!] 1 MFT-only name candidates (in $MFT, absent from walk): rec#45231 svch0st.dll
Detection considerations
Section titled “Detection considerations”The cross-view diff is only as good as its inputs’ time alignment: a walk captured before a Windows update will report thousands of update-moved files as “hidden” — capture both views close together and re-run on any candidate before escalating. Your own tooling on the host under test is subject to the very lies it hunts: mftdiff.py never runs queries on the live host for this reason, which is the tool’s core design point — trust nothing from the suspect system except captured bytes. Defender-side equivalents at scale: periodic $MFT acquisition plus scheduled walk diff on critical hosts catches file-hiding kits between memory-acquisition windows; fltmc instances baselining catches new minifilters at suspicious altitudes; driver-load telemetry (Sysmon EID 6) plus vulnerable-driver blocklists cut the BYOVD delivery that makes kernel rootkits reachable in the first place. Wazuh and EDR mapping for these hunts varies by deployment [verify]; Suricata has no layer here — rootkit detection is host-and-memory territory, with network evidence only showing the traffic the rootkit chose not to hide.
Improvements to add
Section titled “Improvements to add”- Parse
$DATAattribute residency to distinguish zero-byte placeholder entries from content-bearing hidden files. - Add USN journal parsing for creation-order evidence of dropped payloads around the driver-load event time.
- Support
--csvtimeline output joining MFT record numbers to INSTEAD of names only, for correlation with memory-image parses. - Compare services view: parse
SYSTEMregistry hive offline (found in image) vs SCM export — the service-hiding cross-view. - Add a Volatility 3 handoff mode: emit record numbers ready for
vol.pyEPROCESS/pool-tag pivoting on the matching memory image.
Detection — Defender View
Section titled “Detection — Defender View”| Signal | Source | ID | Notes |
|---|---|---|---|
| Vulnerable driver load (BYOVD) | Sysmon | EID 6 (driver load) | Hash+signer allow/deny; blocklist [verify coverage] |
| Kernel driver service install | Windows Security | 7045 | Kernel-name drivers need baseline review |
| New minifilter at low altitude | fltmc instances (baseline) | offline review | Altitude-below-AV = hunt-grade |
| EDR callbacks missing | EDR self-health / ETW | vendor-specific [verify] | Callback-unregistration detection |
| Service/registry enumeration mismatch | offline hive parse | offline | Cross-view on registry layer |
| MFT-only files | mftdiff / forensic suite | offline | File-hiding confirmation |
| PatchGuard crash storm | Windows Error Reporting | 1001 WER reports | SSDT-patch kits trip PatchGuard [verify patterns] |
| Memory image EPROCESS gap | Volatility (offline) | offline | DKOM confirmation method |
| Driver memory-vs-disk mismatch | offline comparison | offline | IRP/SSDT hook confirmation |
| Wazuh integration | HIDS | [verify rule IDs per deployment] | Driver-load + service rules |
Evasion Techniques
Section titled “Evasion Techniques”Rootkit evasion is a layered trust attack, and each layer’s defense dictates the next layer’s move. Against userland scanners: hook the APIs they call (E3) — countered by tools that read system structures directly or compare disk bytes. Against API-based kernel enumeration: DKOM unlinks objects — countered by memory forensics walking alternative structures (scheduler threads, handle tables, pool tags). Against PatchGuard-guarded tables: move from SSDT hooks to filter registration and callback removal, which are legitimate extensible mechanisms — countered by baselining (new filter instances, missing EDR callbacks are detectable as absence only if you baseline what presence looked like). Against disk scanners: hide files from directory enumeration — countered by offline $MFT parsing (the tool above). Against live-response tooling: bootkits and UEFI implants load before the OS and can patch or blind any OS-level tool — countered by firmware verification (Secure Boot with full chain, CHIPSEC audits [verify current module coverage]) and hardware-based acquisition. Against everything: BYOVD — since drivers must be signed, attackers load legitimately signed vulnerable drivers as their kernel foothold, which is why Microsoft’s blocklist and driver-load telemetry are the modern front line rather than signature scanning for “the rootkit driver”. The enduring asymmetry: the rootkit must hide a consistent story across every view a defender might compare, and each new independent view multiplies the author’s work — while defenders only need one view the author forgot.
⚠️ What caught me: after finding the hidden driver file via $MFT diff, I confidently searched for its name in the live system’s directory — found nothing, reported it as “confirmed hidden”, and moved on. The file was visible all along: the rootkit only hid files under its install directory pattern, and my search tool resolved the path differently than the walk had. The lesson stuck: on a rootkitted host, verify every “hidden” finding through a second independent channel before writing the word “confirmed” — including verifying your verification tool.
Lab Practice
Section titled “Lab Practice”- Build the cross-view habit on a clean VM first: run
xview.py, confirm both process views agree, and record the baseline output — you cannot recognize a lie until you know the truth’s shape. - Simulate a userland lie: inject a DLL that hooks
Process32FirstWin a target process (or use an open-source API-hooking demo [verify current project]) and confirmxview.pyflags the process present in one view and absent in the other. - Work the offline path: mount a Windows image or use a lab VM’s shadow copy, capture
$MFTanddir /s /b, then hide a file by renaming it after the walk (the simulation in the terminal block) and confirmmftdiff.pyexposes it. - Install Volatility 3 on your analysis host and run the standard rootkit plugins (
windows.pslistvswindows.psscan) against a memory image — the process list vs pool-scan diff is the canonical DKOM hunt; do it once by hand to understand what every rootkit scanner automates. - Enumerate your lab machine’s minifilter ecosystem (
fltmc instances), record the altitude landscape, and identify the AV stack’s position — the map you would diff against when hunting a filter-abusing implant.
Interview Questions
Section titled “Interview Questions”- Technical: Explain DKOM process hiding — which structure is modified, why API enumeration misses the process, and which forensic view still sees it.
- Technical: Why did PatchGuard push rootkit authors from SSDT hooks toward filter drivers and callback removal, and how do defenders detect those instead?
- Scenario: You suspect a rootkit on a production host. What evidence do you collect, in what order, and which of it can you trust from the live system?
- Conceptual: Why is the cross-view method robust against rootkits in principle, and what makes a rootkit that defeats all views impractical to build?
- Conceptual: How does BYOVD change the rootkit threat model, and what telemetry reduces its reach?
- Deep-dive: Design a UEFI implant detection strategy for an estate. Which layers (SPI flash, boot logs, Secure Boot attestation) do you verify and how?
- Practical: An EDR’s process telemetry went silent for 20 minutes during an incident and the vendor can’t explain it. What rootkit-era hypotheses do you test and how?
Related Modules
Section titled “Related Modules”E2 (EDR Internal Architecture) · E3 (Userland Hook Detection) · E7 (ETW Provider Analysis) · E9 (Kernel Driver Analysis)