Detecting FakeVEH: Hardware Breakpoints on the VEH Handler List

This post covers how Peregrine detects FakeVEH, a cheat injection technique that abuses Windows Vectored Exception Handling. The detection sets a CPU hardware breakpoint (DR0) on the internal VEH handler list head, catches modifications in real time through a high-priority VEH handler, and uses a watchdog thread plus an NtSetContextThread hook to prevent attackers from silently disabling the trap. For a high-level view of where this fits in the overall system, see the architecture overview.

What FakeVEH Is and Why It Matters

Vectored Exception Handling is a Windows mechanism that lets a process register callback functions that run before any frame-based __except handler [1]. The VEH handler list is a userland data structure inside ntdll.dll, managed by ntdll’s runtime library functions (RtlAddVectoredExceptionHandler, RtlRemoveVectoredExceptionHandler, and friends). The internal list head is an RTL_VECTORED_HANDLER_LIST structure containing an SRWLOCK and a circular LIST_ENTRY. The symbol name varies by Windows version (LdrpVectorHandlerList, RtlpVectoredExceptionList, or similar) [2]. It lives in ntdll’s .mrdata (mutable read-only data) section, which is normally read-only at runtime.

Each registered handler is stored as an RTL_VECTORED_EXCEPTION_ENTRY: a LIST_ENTRY node, an active/reference flag, and an encoded function pointer. The encoding uses the per-process cookie (obtained via NtQueryInformationProcess with info class ProcessCookie): the pointer is XORed with the cookie, then rotated right by cookie & 0x3F bits on x64 [6]. The exception dispatcher decodes this pointer at dispatch time via RtlDecodePointer before calling the handler.

FakeVEH is a cheat injection technique, popularized by the Guided Hacking Injector [7], that exploits this machinery. The critical property of FakeVEH is that it never calls AddVectoredExceptionHandler. Instead, the injector operates entirely from an external process and performs the following steps:

  1. Resolve the list head address from ntdll’s PDB symbols (or by other means).
  2. Allocate memory in the target process for a fake RTL_VECTORED_EXCEPTION_ENTRY and for the shellcode handler.
  3. Obtain the target’s process cookie via NtQueryInformationProcess and manually encode the handler pointer (XOR + rotate), replicating what RtlEncodePointer does internally.
  4. Construct the fake list entry with the encoded pointer and valid Flink/Blink values that splice it into the head of the circular list.
  5. Change the .mrdata section protection to writable (via VirtualProtectEx or by calling LdrProtectMrdata in the target) and patch the list head’s Flink plus the old first entry’s Blink to insert the fake node. This is raw linked-list surgery via WriteProcessMemory, with no API call inside the target process.
  6. Set the PEB’s ProcessUsingVEH flag (bit 2 of CrossProcessFlags) if it was not already set, so the exception dispatcher actually walks the VEH list.
  7. Trigger execution by setting a PAGE_GUARD flag on a frequently called ntdll function like NtDelayExecution. When any thread in the target next calls Sleep or similar, it triggers a STATUS_GUARD_PAGE_VIOLATION. The exception dispatcher walks the VEH list, finds the fake entry, decodes the pointer, and calls the attacker’s shellcode handler.
  8. Self-cleanup: the shellcode handler unlinks itself from the VEH list, optionally restores the ProcessUsingVEH flag, executes the actual payload (typically LoadLibrary), and returns EXCEPTION_CONTINUE_EXECUTION.

Because FakeVEH never calls AddVectoredExceptionHandler or RtlAddVectoredExceptionHandler, hooking those APIs catches nothing. Because it never creates a remote thread, CreateRemoteThread hooks and the ObCallbacks layer are also bypassed. The only observable artifact is the write to the list head’s Flink pointer, and the attacker’s shellcode cleans that up within milliseconds of execution. The technique also skips acquiring ntdll’s LdrpVehLock SRWLOCK, which creates a small race condition window but avoids any lock-related API calls that could be monitored.

This is what makes hardware breakpoint monitoring valuable: the write to the list head is the one operation FakeVEH cannot avoid, and a CPU debug register catches it regardless of whether the write comes from an API call, injected shellcode, or an external WriteProcessMemory.

Resolving the VEH List Head Without Hardcoded Offsets

The first problem is finding where the VEH list head lives in memory. This is an unexported ntdll symbol whose address varies across Windows versions. Hardcoding the offset would be fragile. Peregrine uses a self-discovery technique instead: it registers a temporary handler as the first entry, reads the Blink pointer (which points back to the list head), then removes the temporary handler. In hwbp.c:

// From: PeregrineDLL/hwbp.c

typedef struct _VEH_LIST_ENTRY {
    LIST_ENTRY List;
} VEH_LIST_ENTRY;

static LONG NTAPI DummyVeh(PEXCEPTION_POINTERS ep) {
    (void)ep;
    return EXCEPTION_CONTINUE_SEARCH;
}

static ULONG_PTR find_veh_list_head(void)
{
    /* Register ourselves as FIRST handler so entry->List.Blink == list head */
    PVOID h = AddVectoredExceptionHandler(1, DummyVeh);
    if (!h) return 0;

    VEH_LIST_ENTRY* entry = (VEH_LIST_ENTRY*)h;
    ULONG_PTR head = (ULONG_PTR)entry->List.Blink;

    RemoveVectoredExceptionHandler(h);
    return head;
}

AddVectoredExceptionHandler returns a pointer to the internal list entry, not an opaque handle [2]. When registered with priority 1 (first position), the entry’s List.Blink points to the list head. This works across Windows versions because it relies on the documented LIST_ENTRY semantics of a doubly-linked list rather than on any particular ntdll layout [3].

DR0 as a Write-Watchpoint on the List Head

Once the list head address is known, Peregrine sets CPU debug register DR0 on every thread in the process to watch for writes to that address. The DR7 control register is configured for a write-only breakpoint with 8-byte length:

// From: PeregrineDLL/hwbp.c

/* DR7: local-enable DR0 (bit 0), write-only condition (bits 16-17 = 01),
   8-byte length (bits 18-19 = 10 on x64).  Result: 0x00090001 */
#define DR7_WATCH_DR0  0x00090001ULL

The DR7 length encoding is non-obvious: 10 in binary (bits 18-19) means 8 bytes, but only in 64-bit long mode. In 32-bit mode, the same encoding means “undefined” or 4 bytes depending on the processor [4]. Since Peregrine targets x64 processes exclusively, this is fine, but the code would not port to a WoW64 (32-bit) context without changing the DR7 value.

The arming function suspends each thread, writes DR0 and DR7 via SetThreadContext, then resumes it:

// From: PeregrineDLL/hwbp.c

static int set_dr0_on_thread(DWORD tid)
{
    if (tid == GetCurrentThreadId() || tid == g_watchdog_tid)
        return 0;

    HANDLE th = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT |
                           THREAD_SET_CONTEXT, FALSE, tid);
    if (!th) return 0;

    SuspendThread(th);

    CONTEXT ctx;
    memset(&ctx, 0, sizeof(ctx));
    ctx.ContextFlags = CONTEXT_DBG_REGS;

    int armed = 0;
    if (GetThreadContext(th, &ctx)) {
        ctx.Dr0 = (DWORD64)g_veh_list_addr;
        ctx.Dr7 = (ctx.Dr7 & ~0x000F0003ULL) | DR7_WATCH_DR0;
        ctx.Dr6 = 0;
        ctx.ContextFlags = CONTEXT_DBG_REGS;

        InterlockedExchange(&g_hwbp_arming, 1);
        armed = SetThreadContext(th, &ctx);
        InterlockedExchange(&g_hwbp_arming, 0);
    }

    ResumeThread(th);
    CloseHandle(th);
    return armed;
}

When any thread writes to the watched address, the CPU raises an EXCEPTION_SINGLE_STEP exception [4]. In the FakeVEH scenario, the write comes from WriteProcessMemory called by the external injector, which patches the list head’s Flink to point to the fake entry. Because the breakpoint operates at the CPU level, it fires regardless of the source: API calls, injected shellcode, or cross-process memory writes all trigger it equally. The g_hwbp_arming atomic flag prevents the NtSetContextThread hook (discussed below) from reporting Peregrine’s own context modifications as suspicious.

Catching the Breakpoint: The VEH Handler

Peregrine registers its own VEH handler with the highest priority (first position) to intercept the single-step exception before anything else sees it:

// From: PeregrineDLL/hwbp.c

static LONG NTAPI HwbpVehHandler(PEXCEPTION_POINTERS ep)
{
    if (ep->ExceptionRecord->ExceptionCode != STATUS_SINGLE_STEP)
        return EXCEPTION_CONTINUE_SEARCH;

    /* Check DR6 bit 0 (B0) — our DR0 triggered */
    DWORD64 dr6 = ep->ContextRecord->Dr6;
    if (!(dr6 & 1))
        return EXCEPTION_CONTINUE_SEARCH;

    DWORD tid = GetCurrentThreadId();
    ipc_log_event("VehTableTamper",
        "\"callerPID\":%lu,\"threadId\":%lu,\"dr6\":\"0x%llX\"",
        g_my_pid, (unsigned long)tid, (unsigned long long)dr6);

    /* Clear B0 and re-arm DR0 */
    ep->ContextRecord->Dr6 &= ~(DWORD64)1;
    ep->ContextRecord->Dr0 = (DWORD64)g_veh_list_addr;
    ep->ContextRecord->Dr7 = (ep->ContextRecord->Dr7 & ~0x000F0003ULL) | DR7_WATCH_DR0;

    return EXCEPTION_CONTINUE_EXECUTION;
}

The handler checks two things: that the exception is STATUS_SINGLE_STEP (not some other exception), and that the B0 bit in DR6 confirms it was DR0 that fired [4]. If both conditions hold, it logs a VehTableTamper event with the PID, thread ID, and DR6 value, then clears the B0 bit and re-arms DR0 so the breakpoint stays active for future modifications. The handler returns EXCEPTION_CONTINUE_EXECUTION, so the thread that modified the VEH list resumes normally without crashing.

Detecting Debug Register Clearing via NtSetContextThread

An attacker who discovers the hardware breakpoint can attempt to clear DR0 by calling NtSetContextThread to zero out the debug registers. Peregrine hooks this function to detect exactly that pattern. In dllmain.cpp:

// From: PeregrineDLL/dllmain.cpp

static LONG NTAPI HookNtSetContextThread(HANDLE hThread, PCONTEXT ctx) {
    if (!g_hwbp_arming && ctx && (ctx->ContextFlags & CONTEXT_DBG_REGS_FLAG) == CONTEXT_DBG_REGS_FLAG) {
        if (ctx->Dr0 == 0 && ctx->Dr1 == 0 && ctx->Dr2 == 0 && ctx->Dr3 == 0 && ctx->Dr7 == 0) {
            callstack_check("NtSetContextThread");
            DWORD tid = GetThreadId(hThread);
            ipc_log_event("DebugRegisterClearing",
                "\"callerPID\":%lu,\"threadId\":%lu",
                PID, (unsigned long)tid);
        }
    }
    return oNtSetContextThread(hThread, ctx);
}

The hook only fires when the context flags include debug registers and all four breakpoint registers plus DR7 are zero. This signature (all DRs cleared at once) is highly suspicious: legitimate Windows code almost never zeroes all debug registers simultaneously. The g_hwbp_arming flag is checked first so that Peregrine’s own arming operations do not trigger a false positive. If the clearing is detected, the hook still allows the original call to proceed (detection-only), but logs a DebugRegisterClearing event. The callstack validation post covers how the callstack_check call adds stack frame analysis to the detection event.

The Watchdog Thread: Persistent Re-Arming

Even if the NtSetContextThread hook is bypassed (for example, through direct syscalls), Peregrine has a fallback. A dedicated watchdog thread wakes every five seconds, enumerates all threads in the process, and checks whether DR0 still holds the expected address:

// From: PeregrineDLL/hwbp.c

#define WATCHDOG_INTERVAL_MS 5000

static DWORD WINAPI WatchdogThread(LPVOID param)
{
    (void)param;
    g_watchdog_tid = GetCurrentThreadId();

    for (;;) {
        Sleep(WATCHDOG_INTERVAL_MS);

        HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
        if (snap == INVALID_HANDLE_VALUE) continue;

        THREADENTRY32 te;
        te.dwSize = sizeof(te);

        if (Thread32First(snap, &te)) {
            do {
                if (te.th32OwnerProcessID != g_my_pid) continue;

                int cleared = 0;
                if (check_dr0_on_thread(te.th32ThreadID, &cleared) && cleared) {
                    ipc_log_event("DebugRegisterTamper",
                        "\"callerPID\":%lu,\"threadId\":%lu,\"action\":\"re-armed\"",
                        g_my_pid, (unsigned long)te.th32ThreadID);
                }
            } while (Thread32Next(snap, &te));
        }
        CloseHandle(snap);
    }
    return 0;
}

If any thread’s DR0 has been cleared or changed, the watchdog re-arms it and logs a DebugRegisterTamper event. This creates a layered defense: an attacker must not only clear the breakpoint but also prevent the watchdog from re-arming it every five seconds and prevent the NtSetContextThread hook from logging the clearing attempt.

Three Detection Events in the Tauri GUI

The Tauri frontend displays each of the three events with distinct severity levels. From +page.svelte:

// From: peregrine-tauri/src/routes/+page.svelte

if (event === "VehTableTamper") {
  addLog(`[VEH TAMPER] VEH handler list modified! PID=${d.callerPID} TID=${d.threadId} DR6=${d.dr6}`, "tamper");
}
if (event === "DebugRegisterTamper") {
  addLog(`[DR TAMPER] Debug registers cleared on TID ${d.threadId} — re-armed by watchdog`, "suspicious");
}
if (event === "DebugRegisterClearing") {
  addLog(`[DR CLEARING] NtSetContextThread zeroing DR regs in PID ${d.callerPID} TID ${d.threadId}`, "suspicious");
}

VehTableTamper is the primary detection: someone modified the VEH handler list. DebugRegisterClearing and DebugRegisterTamper are meta-detections: someone is actively trying to disable the FakeVEH monitoring itself.

Initialization Sequence

The entire system initializes from a single hwbp_init call during DLL load:

// From: PeregrineDLL/hwbp.c

void hwbp_init(void)
{
    g_my_pid = GetCurrentProcessId();

    g_veh_list_addr = find_veh_list_head();
    if (!g_veh_list_addr) {
        ipc_log_event("HwbpInit", "\"status\":\"failed\",\"reason\":\"cannot resolve LdrpVectorHandlerList\"");
        return;
    }

    /* Register our VEH handler FIRST so we see single-step before anyone else */
    g_veh_handle = AddVectoredExceptionHandler(1, HwbpVehHandler);

    int armed = arm_all_threads();

    ipc_log_event("HwbpInit",
        "\"status\":\"ok\",\"vehListAddr\":\"0x%llX\",\"threadsArmed\":%d",
        (unsigned long long)g_veh_list_addr, armed);

    /* Start watchdog */
    HANDLE wt = CreateThread(NULL, 0, WatchdogThread, NULL, 0, NULL);
    if (wt) CloseHandle(wt);
}

The order matters: find the list head, register the VEH handler (so the breakpoint exception has somewhere to go), arm all threads, then start the watchdog. The arm_all_threads function iterates every thread in the process via CreateToolhelp32Snapshot and sets DR0 on each one, skipping itself and the watchdog thread [5].

Limitations

  • Consumes DR0. x86-64 provides only four hardware breakpoint registers (DR0 through DR3). Peregrine uses DR0 for VEH list monitoring, leaving three available for other purposes. If other anti-cheat components or debugging tools need all four, there will be a conflict.
  • Five-second watchdog window. Between watchdog cycles, an attacker who bypasses the NtSetContextThread hook has up to five seconds where the breakpoint is cleared. Reducing the interval trades latency for CPU overhead on the thread enumeration.
  • Threads created after arming. New threads spawned after hwbp_init will not have DR0 set until the next watchdog cycle picks them up. An attacker could race to use a newly created thread before the watchdog arms it.
  • No enforcement. Like other Peregrine detections, this is detection-only. The VEH list modification is allowed to proceed; Peregrine logs the event and relies on the userland component to act on it.
  • Direct syscall evasion of the NtSetContextThread hook. The MinHook-based interception operates at the API level. An attacker using direct syscalls to call the native NtSetContextThread without going through ntdll’s export will bypass the hook. The watchdog partially compensates for this, but the clearing event itself will not be logged.
  • FakeVEH’s self-cleanup races the detection. A real FakeVEH injector’s shellcode unlinks itself from the VEH list within milliseconds of execution. The DR0 breakpoint fires on the initial list head write, and Peregrine logs the VehTableTamper event, but by the time a human or automated response acts on that event, the fake entry may already be gone. The detection proves the list was tampered with; it does not prevent the payload from running.

The core insight is using the CPU’s debug facilities against the attacker. Hardware breakpoints cannot be intercepted or redirected by userland code; they are enforced by the processor itself [4]. Combining this with a VEH handler, an API hook, and a watchdog thread creates a defense-in-depth strategy where disabling any single layer still leaves the others operational.

Drafted with LLM assistance from the Peregrine Anti-Cheat source code, reviewed and verified against the actual implementation.

References

[1] Microsoft, “Vectored Exception Handling” - learn.microsoft.com

[2] Microsoft, “AddVectoredExceptionHandler function” - learn.microsoft.com

[3] Microsoft, “LIST_ENTRY structure” - learn.microsoft.com

[4] Intel, “Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3B: System Programming Guide, Chapter 17: Debug, Branch Profile, TSC, and Intel Resource Director Technology Features” - intel.com

[5] Microsoft, “CreateToolhelp32Snapshot function” - learn.microsoft.com

[6] Microsoft, “RtlDecodePointer function” - learn.microsoft.com

[7] Guided Hacking, “GH Injector” - github.com