Kernel-Mode Detection of Anti-Analysis Behavior on Windows
Mirage is a Windows Driver Model (WDM) kernel driver that detects anti-analysis techniques used by malware to obstruct forensic inspection. It observes process creation, thread creation, and Object Manager handle events at the kernel layer, periodically sweeps recently-created processes for suspicious state, and emits per-technique behavioral events to a user-mode controller.
Each detected technique contributes a weighted score to a per-process running total. A process exceeding the configurable threshold (default 7.0 / 13.0) is flagged as exhibiting anti-analysis behavior.
| Tool | What it does | What it doesn't do |
|---|---|---|
| Sysmon | Logs process/network/registry telemetry | Doesn't surface anti-analysis as a signal category |
| Commercial EDR | Uses ETW-TI internally for self-protection | Vendor-specific, opaque classifiers |
| ScyllaHide / TitanHide | Hides the analyst's debugger from anti-debug | Analyst-deployed, not a defensive monitor |
| Mirage | Detects anti-analysis behavior and surfaces it as events | Doesn't prevent — strictly observes |
graph TB
subgraph USER["USER MODE"]
GUI["GUI Controller<br/><i>Johannes</i>"]
AGG["Event Aggregator<br/>+ Policy Engine"]
LOG["JSONL Logger"]
GUI --> AGG --> LOG
end
subgraph IOCTL["DeviceIoControl · poll ~200ms"]
IRP["IRP_MJ_DEVICE_CONTROL<br/>→ MirageRingBufferDrain()<br/>→ memcpy to SystemBuffer<br/>→ IoCompleteRequest"]
end
subgraph KERNEL["KERNEL MODE · PASSIVE_LEVEL"]
RING["Ring Buffer<br/>128 × MIRAGE_EVENT (544 B)<br/>KSPIN_LOCK · ~68 KiB pool"]
subgraph PRODUCERS["Detection Producers"]
SWEEP["Sweep Thread<br/>500ms interval<br/>TTL 20 sweeps"]
OBCB["ObCallback Pre-Op<br/>altitude 385000<br/>PsProcessType"]
TNOTIFY["Thread Notify<br/>PID == 4 filter<br/>AuxKlib module walk"]
end
TABLE["Tracked Process Table<br/>LIST_ENTRY + FAST_MUTEX<br/>per-PID score + bitmask"]
PNOTIFY["PsSetCreateProcessNotifyRoutineEx<br/>add on create · remove on exit"]
end
GUI -.->|"IOCTL_MIRAGE_GET_EVENTS"| IRP
IRP -->|drain| RING
SWEEP -->|emit| RING
OBCB -->|emit| RING
TNOTIFY -->|emit| RING
SWEEP -->|"snapshot / probe"| TABLE
PNOTIFY -->|"add / remove"| TABLE
style USER fill:#1a1a2e,stroke:#3d3d4f,color:#fafafa
style KERNEL fill:#0e0e14,stroke:#991b1b,color:#fafafa
style IOCTL fill:#1a1a24,stroke:#f59e0b,color:#f59e0b
style RING fill:#070710,stroke:#f59e0b,color:#f59e0b
style TABLE fill:#070710,stroke:#10b981,color:#10b981
style PRODUCERS fill:#1a1a24,stroke:#dc2626,color:#fafafa
graph LR
subgraph SCORING["Per-Process Scoring · threshold 7.0 / 13.0"]
T1["Self-Debug Attach<br/><b>+1.0</b><br/>ProcessDebugPort"]
T2["Big Memory<br/><b>+2.0</b><br/>ProcessVmCounters"]
T3["Hidden Sys Thread<br/><b>+2.5</b><br/>AuxKlib module walk"]
T4["Job Kill-on-Close<br/><b>+2.5</b><br/>ZwQueryInfoJobObject"]
T5["ObCallback Strip<br/><b>+2.0</b><br/>altitude comparison"]
T6["ThreadHideFromDbg<br/><b>+3.0</b><br/>⚠️ planned"]
end
T1 --> SUM["Running<br/>Total"]
T2 --> SUM
T3 --> SUM
T4 --> SUM
T5 --> SUM
T6 -.->|"not implemented"| SUM
SUM -->|"≥ 7.0"| FLAG["🚩 FLAGGED"]
SUM -->|"< 7.0"| OK["✓ clean"]
style T6 fill:#2a1f08,stroke:#f59e0b,color:#f59e0b
style FLAG fill:#991b1b,stroke:#dc2626,color:#fafafa
style OK fill:#0f2a1c,stroke:#10b981,color:#10b981
| # | Technique | Weight | Kernel API Path | Detection Type | Status |
|---|---|---|---|---|---|
| 01 | Self-debug attach | 1.0 | ZwQueryInformationProcess(ProcessDebugPort) |
sweep | ✅ shipped |
| 02 | Big-memory anti-inspect | 2.0 | ZwQueryInformationProcess(ProcessVmCounters) |
sweep | ✅ shipped |
| 03 | Hidden System thread | 2.5 | PsSetCreateThreadNotifyRoutine + AuxKlibQueryModuleInformation |
event-driven | ✅ shipped |
| 04 | Job kill-on-close | 2.5 | PsGetProcessJob → ObOpenObjectByPointer → ZwQueryInformationJobObject |
sweep | ✅ shipped |
| 05 | ObCallback handle strip | 2.0 | ObRegisterCallbacks pre-op at altitude 385000 |
event-driven | ✅ shipped |
| 06 | ThreadHideFromDebugger | 3.0 | ETW-TI or direct ETHREAD offset read |
— |
Two detection paths exist. ETW-TI is clean and build-stable but requires a PPL-protected driver (restricted to Microsoft-signed binaries). Direct ETHREAD read works but uses hardcoded struct offsets that change every Windows build. Five stable detections across builds > six where one breaks every Patch Tuesday.
Mirage/
├── Main.c # DriverEntry, DriverUnload, IRP dispatch
├── Mirage.h # Manual type decls, function prototypes, defensive #ifndef guards
├── MirageSweep.c # Tracked-process table, sweep thread, two-pass architecture
├── MirageTechniquesCovered.c # Self-debug, big-memory, job kill-on-close, AuxKlib helper
├── MirageObCallback.c # ObRegisterCallbacks registration + pre-op handler
├── MirageShared.h # Wire-format MIRAGE_EVENT struct, IOCTL codes (shared with user mode)
├── MirageRingBuffer.c # Ring buffer + emit/drain + stats
│
├── Tests/
│ ├── TestSelfDebug.c # user-mode · DebugActiveProcess on a child
│ ├── TestBigMemory.c # user-mode · VirtualAlloc 1.5 GB
│ ├── TestHiddenSystemThread.c # kernel · shellcode in pool + PsCreateSystemThread
│ ├── TestKillOnClose.c # user-mode · job with KILL_ON_JOB_CLOSE
│ ├── TestObStripDriver.c # kernel · ObCallback at altitude 1000 that strips PROCESS_TERMINATE
│ └── TestObStrip.c # user-mode · OpenProcess(TERMINATE) on a target through the test driver
│
└── Paper/
├── main.tex # ACM SIGCONF research paper
└── references.bib # 16 references
The sweep thread runs every 500ms and probes each tracked process. The architecture splits each sweep into two phases to bound lock-hold time:
sequenceDiagram
participant ST as Sweep Thread
participant TBL as Tracked Process Table<br/>(FAST_MUTEX)
participant KERN as Kernel APIs<br/>(Zw*, Ps*, Ob*)
participant RING as Ring Buffer<br/>(KSPIN_LOCK)
Note over ST: Phase 1 — snapshot (lock held)
ST->>TBL: acquire FAST_MUTEX
ST->>TBL: walk LIST_ENTRY, copy PIDs to stack buffer
ST->>TBL: increment sweep counter per entry
ST->>TBL: release FAST_MUTEX
Note over ST: Phase 2 — probe (no lock)
loop for each PID in snapshot
ST->>KERN: MirageCheckSelfDebug(PID)
ST->>KERN: MirageCheckBigMemory(PID)
ST->>KERN: MirageCheckJobKillOnClose(PID)
alt detection positive
ST->>TBL: re-acquire mutex
ST->>TBL: re-locate entry by PID (may be gone)
ST->>TBL: update score + bitmask
ST->>TBL: release mutex
ST->>RING: MirageRingBufferEmit(event)
end
end
Why two passes? The probe functions call ZwOpenProcess, ZwQueryInformationProcess, ObOpenObjectByPointer, ZwQueryInformationJobObject — these can take milliseconds and would unacceptably extend lock-hold time if the mutex were retained. The re-locate-by-PID on positive detection handles the race where a process exits between snapshot and probe.
graph LR
A["Caller<br/>OpenProcess(<br/>PROCESS_TERMINATE)"] --> B
subgraph CHAIN["Object Manager Callback Chain"]
direction LR
B["altitude 1000<br/><b>Malware / Test Driver</b><br/>strips PROCESS_TERMINATE"] --> C["altitude 95000<br/><b>wdfilter.sys</b><br/>(Defender)"] --> D["altitude 385000<br/><b>Mirage</b><br/>compares orig vs current"]
end
D --> E["Handle created<br/>without TERMINATE"]
style B fill:#991b1b,stroke:#dc2626,color:#fafafa
style C fill:#1a1a24,stroke:#3d3d4f,color:#94a3b8
style D fill:#0f2a1c,stroke:#10b981,color:#10b981
Mirage registers at a high altitude so it runs last in the chain. By the time our pre-op fires, every other callback has had a chance to modify DesiredAccess. We diff:
ACCESS_MASK stripped = OriginalDesiredAccess & ~DesiredAccess;
if (stripped & PROCESS_TERMINATE) {
// someone upstream stripped it → emit detection
}Hidden System Thread Detection Flow
flowchart TD
A["Thread created anywhere<br/>in the system"] --> B{PID == 4?}
B -->|No| Z["return immediately<br/><i>99%+ of callbacks</i>"]
B -->|Yes| C["Query thread start address<br/><code>ZwQueryInformationThread</code><br/>class 9"]
C --> D{"addr ≥<br/>MM_SYSTEM_RANGE_START?"}
D -->|No| Z2["skip<br/><i>user-mode addr in PID 4<br/>is a boot artifact</i>"]
D -->|Yes| E["Walk loaded modules<br/><code>AuxKlibQueryModuleInformation</code>"]
E --> F{"addr inside any<br/>[base, base+size)?"}
F -->|Yes| Z3["legitimate<br/><i>known driver code</i>"]
F -->|No| G["🚩 DETECTION<br/>Hidden System thread<br/>at unassociated address"]
style G fill:#991b1b,stroke:#dc2626,color:#fafafa
style Z fill:#1a1a24,stroke:#3d3d4f,color:#71717a
style Z2 fill:#1a1a24,stroke:#3d3d4f,color:#71717a
style Z3 fill:#0f2a1c,stroke:#10b981,color:#10b981
The function exists in ntoskrnl.exe but is not exported on the test environment's Windows build (verified via x nt!PsGet* in WinDbg — the symbol is absent from the export table). MmGetSystemRoutineAddress returns NULL. We fall back to ZwQueryInformationThread with info class ThreadQuerySetWin32StartAddress (value 9) — the same path used by Process Hacker / System Informer.
- Windows 11 VM with test signing enabled (
bcdedit /set testsigning on) - Visual Studio 2022 with the Windows Driver Kit (WDK) installed
- WinDbg or DbgView for observing kernel output
- Open
Mirage.slnin Visual Studio - Set target to x64 / Debug
- Build Solution (Ctrl+Shift+B)
:: Copy Mirage.sys to the test VM
sc create Mirage type= kernel binPath= "C:\Drivers\Mirage.sys"
sc start Mirage
:: Observe output
:: Open DbgView as Administrator → Capture → Capture Kernel:: 1. Self-debug detection
TestSelfDebug.exe
:: → Mirage logs: ProcessDebugPort non-NULL on child PID
:: 2. Big memory detection
TestBigMemory.exe
:: → Mirage logs: PrivateUsage >= 1 GB
:: 3. Hidden System thread detection (kernel test driver)
sc create TestHidden type= kernel binPath= "C:\Drivers\TestHiddenSystemThread.sys"
sc start TestHidden
:: → Mirage logs: thread in PID 4 at unassociated address
sc stop TestHidden && sc delete TestHidden
:: 4. Job kill-on-close detection
TestKillOnClose.exe
:: → Mirage logs: KILL_ON_JOB_CLOSE flag set
:: 5. ObCallback handle-strip detection (two-part)
sc create TestStripDrv type= kernel binPath= "C:\Drivers\TestObStripDriver.sys"
sc start TestStripDrv
TestObStrip.exe
:: → Mirage logs: PROCESS_TERMINATE stripped on target PID
sc stop TestStripDrv && sc delete TestStripDrvSeveral kernel APIs used by Mirage are present in ntoskrnl.exe but not consistently declared in WDK headers across versions:
| API / Type | Issue | Resolution |
|---|---|---|
ZwQueryInformationJobObject |
Not declared | Manual NTSYSAPI prototype in Mirage.h |
ZwOpenThread |
Not declared | Manual NTSYSAPI prototype |
PsGetProcessJob |
Gated behind NTDDI_VERSION |
Manual NTKERNELAPI prototype |
PsGetThreadStartAddress |
Not exported on test build | Fallback to ZwQueryInformationThread class 9 |
VM_COUNTERS_EX |
Not in kernel headers | Manual struct typedef |
JOBOBJECT_EXTENDED_LIMIT_INFORMATION |
Not in kernel headers | Manual struct typedef |
PROCESS_TERMINATE |
Not always defined | #ifndef guard (0x0001) |
ProcessVmCounters |
Info class not in headers | #define ProcessVmCounters 3 |
MirageTechniquesCovered.c requires #include <ntifs.h> instead of <ntddk.h> because PsLookupProcessByProcessId, PsGetProcessJob, and ObOpenObjectByPointer are only exposed through the filesystem-filter header chain. ntifs.h is a superset of ntddk.h — everything ntddk.h exposes is also in ntifs.h.
MIRAGE_POOL_TAG = 'rgiM' (0x4D696772)
"Mirg" stored backwards — Windows pool dumps display tags in reverse byte order. Search for rgiM in !poolfind output to find Mirage's allocations.
All detection probes return "clean" on transient errors (process exited mid-query, AuxKlib allocation failure, ZwOpenProcess returns STATUS_INVALID_CID). This is a deliberate design choice: a transient kernel-query failure should not produce a false positive. The cost is a potential false negative on a process that exits within the probe window — acceptable because such a process performed anti-analysis for less than 500ms, which is too brief to obstruct an analyst in practice.
#pragma pack(push, 8)
typedef struct _MIRAGE_EVENT {
ULONG ProcessId; // PID
ULONG TechniqueId; // MIRAGE_TECHNIQUE_ID enum value
float Score; // weight of this detection
float RunningTotal; // cumulative score for the PID
LONGLONG TimestampTicks; // KeQuerySystemTimePrecise
WCHAR ImagePath[260]; // NT image path
} MIRAGE_EVENT; // sizeof = 544 bytes
#pragma pack(pop)| Name | Role | |
|---|---|---|
| 🔴 | Omar Shehata | Kernel driver, detection logic, test harnesses, research paper |
| 🔴 | Johannes Kiermeier | User-mode controller, GUI, event aggregation, logging |
- Branco et al., Scientific but Not Academical Overview of Malware Anti-Debugging, Anti-Disassembly and Anti-VM Technologies, BlackHat USA 2012
- Russinovich et al., Windows Internals, 7th Edition, Microsoft Press 2017
- Yosifovich, Windows Kernel Programming, 2nd Edition, 2022
- Microsoft, ObRegisterCallbacks
- Microsoft, Sysmon
- al-khaser — public malware anti-analysis technique aggregator
- Pafish — sandbox detection reference implementation
- System Informer (Process Hacker)
Built with kernel-level determination and an unreasonable amount of DbgPrint output.