Skip to content

Repository files navigation

MIRAGE

Kernel-Mode Detection of Anti-Analysis Behavior on Windows


Overview

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.

What makes this different from existing tools

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

Architecture

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
Loading

Detected Techniques

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
Loading
# 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 PsGetProcessJobObOpenObjectByPointerZwQueryInformationJobObject 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 ⚠️ planned

Why ThreadHideFromDebugger was scoped out

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.


Source Structure

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

Two-Pass Sweep Design

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
Loading

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.


ObCallback Altitude Chain

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
Loading

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
Loading

Why PsGetThreadStartAddress isn't used

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.


Build & Deploy

Prerequisites

  • 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

Build

  1. Open Mirage.sln in Visual Studio
  2. Set target to x64 / Debug
  3. Build Solution (Ctrl+Shift+B)

Install & Run

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

Run Test Harnesses

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

Key Implementation Details

Manual API Declarations

Several 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

The ntifs.h vs ntddk.h Issue

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.

Pool Tag

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.

Fail-Open Policy

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.


Wire Format

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

Authors

Name Role
🔴 Omar Shehata Kernel driver, detection logic, test harnesses, research paper
🔴 Johannes Kiermeier User-mode controller, GUI, event aggregation, logging

References

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

About

Detects malware anti-analysis techniques at the Windows kernel layer.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages