---
title: "Detecting FakeVEH: Hardware Breakpoints on the VEH Handler List"
description: "How Peregrine uses a CPU debug register watchpoint on ntdll's VEH handler list to detect VEH-based cheat injection in real time, with a watchdog thread and NtSetContextThread hook to resist tampering."
date: 2026-05-27
category: "Anti-Cheat"
tags: ["windows", "veh", "hardware-breakpoints", "debug-registers", "injection-detection"]
---

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](/blog/peregrine-anatomy-of-an-anticheat).

## 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](/blog/peregrine-obcallbacks) 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`:

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

```c
// 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:

```c
// 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:

```c
// 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`:

```c
// 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](/blog/peregrine-callstack-validation) 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:

```c
// 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`:

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

```c
// 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](/blog/peregrine-minhook-api-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](https://github.com/PatchRequest/PeregrineAntiCheat) source code, reviewed and verified against the actual implementation.*

## References

[1] Microsoft, "Vectored Exception Handling" - [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/debug/vectored-exception-handling)

[2] Microsoft, "AddVectoredExceptionHandler function" - [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-addvectoredexceptionhandler)

[3] Microsoft, "LIST_ENTRY structure" - [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/api/ntdef/ns-ntdef-list_entry)

[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](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html)

[5] Microsoft, "CreateToolhelp32Snapshot function" - [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/api/tlhelp32/nf-tlhelp32-createtoolhelp32snapshot)

[6] Microsoft, "RtlDecodePointer function" - [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/api/winnt/nf-winnt-rtldecodepointer)

[7] Guided Hacking, "GH Injector" - [github.com](https://github.com/guidedhacking/GuidedHacking-Injector)
