PPL Bootstrap and ETW Threat Intelligence
This post covers how Peregrine elevates its usermode component to Protected Process Light (PPL) through a kernel driver patch of the EPROCESS structure, then uses that elevated status to subscribe to the ETW Threat Intelligence provider. ETW-TI delivers telemetry for every cross-process memory operation on the system, including NtAllocateVirtualMemory, NtWriteVirtualMemory, and NtSetContextThread, without hooking any APIs.
The PS_PROTECTION Byte in EPROCESS
Every process on Windows has a PS_PROTECTION field embedded in its EPROCESS kernel structure [1]. This single byte encodes the protection type and signer level:
// From: PeregrineKernelComponent/Protection.c
typedef struct _PS_PROTECTION {
union {
UCHAR Level;
struct {
UCHAR Type : 3;
UCHAR Audit : 1;
UCHAR Signer : 4;
} Flags;
} u;
} PS_PROTECTION, *PPS_PROTECTION;
The Type field distinguishes unprotected (0), Protected Process Light (1), and full Protected Process (2). The Signer field determines the trust hierarchy. PsProtectedSignerAntimalware (value 6) grants access to ETW-TI without requiring the process to be signed as a Windows component through Microsoft’s official signing program [2]. Since Peregrine operates from kernel mode, the signing requirement is moot; the driver sets the byte directly.
Hardcoded EPROCESS Offset: Fragile but Functional
The offset of PS_PROTECTION within EPROCESS is not stable across Windows versions. Microsoft does not export it and changes it between releases [1]:
// From: PeregrineKernelComponent/Protection.c
// Common offsets for different Windows versions (x64)
// Windows 10 1809-1903: 0x6CA
// Windows 10 1909-2004: 0x87A
// Windows 10 21H1+: 0x87A
// Windows 11: 0x87A
// For now, use a common offset (Windows 10 2004+)
// In production, you'd scan for this or use version detection
g_ProtectionOffset = 0x87A;
The current implementation uses a hardcoded offset of 0x87A, covering Windows 10 2004 through current Windows 11 builds. This is fragile. A Windows update that reorganizes EPROCESS will break it silently. A production implementation would resolve the offset dynamically by scanning known patterns in the structure or querying debug symbols at driver load time.
The Kernel-Side PPL Elevation
The driver function that performs the elevation looks up the target process by PID via PsLookupProcessByProcessId, calculates the pointer to the PS_PROTECTION field, and writes the new protection level:
// From: PeregrineKernelComponent/Protection.c
#define PsProtectedTypeProtectedLight 1
#define PsProtectedSignerAntimalware 6
static ULONG g_ProtectionOffset = 0;
NTSTATUS ProtectionSetProcessPPL(_In_ HANDLE ProcessId) {
NTSTATUS status;
PEPROCESS process = NULL;
// Find offset if not initialized
if (g_ProtectionOffset == 0) {
status = FindProtectionOffset();
if (!NT_SUCCESS(status)) {
return status;
}
}
// Get EPROCESS structure for the target process
status = PsLookupProcessByProcessId(ProcessId, &process);
if (!NT_SUCCESS(status)) {
return status;
}
// Calculate pointer to PS_PROTECTION field
PPS_PROTECTION protection = (PPS_PROTECTION)((PUCHAR)process + g_ProtectionOffset);
// Set protection level to PPL with Antimalware signer
PS_PROTECTION newProtection = { 0 };
newProtection.u.Flags.Type = PsProtectedTypeProtectedLight;
newProtection.u.Flags.Signer = PsProtectedSignerAntimalware;
newProtection.u.Flags.Audit = 0;
// Apply protection
__try {
protection->u.Level = newProtection.u.Level;
status = STATUS_SUCCESS;
}
__except (EXCEPTION_EXECUTE_HANDLER) {
status = STATUS_ACCESS_VIOLATION;
}
ObDereferenceObject(process);
return status;
}
The __try/__except guard handles the case where the hardcoded offset is wrong and points into an unmapped page or a critical field. Rather than bugchecking the machine, the driver catches the fault and returns STATUS_ACCESS_VIOLATION. ObDereferenceObject balances the reference taken by PsLookupProcessByProcessId [3].
The resulting protection byte is 0x61: the bit layout is Signer[7:4] | Audit[3] | Type[2:0], so (6 << 4) | (0 << 3) | 1 = 0x61. With PPL applied, a normal debugger cannot attach to the process, and any tool calling OpenProcess against it receives STATUS_ACCESS_DENIED unless it is itself running at an equal or higher protection level [2].
ETW Threat Intelligence: The Provider Behind PPL
The ETW-TI provider is identified by GUID {F4E1897C-BB5D-5668-F1D8-040F4D8DD344}. Unlike normal ETW providers that any administrator can consume, the kernel explicitly checks the protection level of the process calling EnableTraceEx2 [4]. If the caller is not PPL or higher, the call fails with ERROR_ACCESS_DENIED.
// From: peregrine-tauri/src-tauri/src/etw_ti.rs
const ETW_TI_GUID: GUID = GUID::from_u128(0xf4e1897c_bb5d_5668_f1d8_040f4d8dd344);
const SESSION_NAME: &str = "PeregrineETWThreatIntel";
Seven Keywords for Seven Cross-Process Primitives
ETW-TI organizes events by keyword bitmask. Each keyword corresponds to a specific cross-process operation:
// From: peregrine-tauri/src-tauri/src/etw_ti.rs
const KW_ALLOCVM_REMOTE: u64 = 0x4;
const KW_PROTECTVM_REMOTE: u64 = 0x40;
const KW_MAPVIEW_REMOTE: u64 = 0x400;
const KW_QUEUEAPC_REMOTE: u64 = 0x1000;
const KW_SETCTX_REMOTE: u64 = 0x4000;
const KW_READVM_REMOTE: u64 = 0x20000;
const KW_WRITEVM_REMOTE: u64 = 0x80000;
These seven keywords cover the full lifecycle of a process injection attack. ALLOCVM_REMOTE fires when one process allocates memory in another via NtAllocateVirtualMemory [5]. PROTECTVM_REMOTE catches permission changes, typically to PAGE_EXECUTE_READWRITE. WRITEVM_REMOTE logs the shellcode write. SETCTX_REMOTE and QUEUEAPC_REMOTE catch the two main execution-triggering techniques (thread context manipulation and APC queuing). MAPVIEW_REMOTE covers section-based injection. READVM_REMOTE catches external memory reading, the bread and butter of game cheats.
Together, these keywords instrument every NT-level cross-process memory operation relevant to injection detection. The telemetry arrives asynchronously without hooking anything: no SSDT hooks, no inline patches, no minifilter callbacks for these specific operations.
Standing Up the Trace Session
Starting an ETW real-time trace session requires allocating an EVENT_TRACE_PROPERTIES buffer, calling StartTraceW, enabling the provider with EnableTraceEx2, and opening the trace for consumption [4]. The setup in etw_ti.rs:
// From: peregrine-tauri/src-tauri/src/etw_ti.rs
pub fn start_etw_session(stop: Arc<AtomicBool>) -> Result<TiReceiver, String> {
let (tx, rx) = mpsc::channel();
unsafe { TX = Some(tx); }
let wide: Vec<u16> = SESSION_NAME.encode_utf16().chain(std::iter::once(0)).collect();
let props_size = size_of::<EVENT_TRACE_PROPERTIES>();
let total = props_size + wide.len() * 2;
let mut buf = vec![0u8; total];
let props = buf.as_mut_ptr() as *mut EVENT_TRACE_PROPERTIES;
unsafe {
(*props).Wnode.BufferSize = total as u32;
(*props).Wnode.Flags = WNODE_FLAG_TRACED_GUID;
(*props).LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
(*props).LoggerNameOffset = props_size as u32;
copy_nonoverlapping(
wide.as_ptr(),
buf.as_mut_ptr().add(props_size) as *mut u16,
wide.len(),
);
}
// Stop leftover session from a previous crash
let mut stop_buf = buf.clone();
unsafe {
ControlTraceW(
CONTROLTRACE_HANDLE::default(),
PCWSTR(wide.as_ptr()),
stop_buf.as_mut_ptr() as *mut EVENT_TRACE_PROPERTIES,
EVENT_TRACE_CONTROL_STOP,
);
}
// Start trace
let mut handle = CONTROLTRACE_HANDLE::default();
let err = unsafe { StartTraceW(&mut handle, PCWSTR(wide.as_ptr()), props) };
if err != WIN32_ERROR(0) {
return Err(format!("StartTraceW failed: {:?}", err));
}
// Enable TI provider with all remote-operation keywords
let enable_kw = KW_ALLOCVM_REMOTE | KW_PROTECTVM_REMOTE | KW_MAPVIEW_REMOTE
| KW_QUEUEAPC_REMOTE | KW_SETCTX_REMOTE | KW_READVM_REMOTE
| KW_WRITEVM_REMOTE;
let err = unsafe {
EnableTraceEx2(
handle, &ETW_TI_GUID,
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
5, // TRACE_LEVEL_VERBOSE
enable_kw, 0, 0, None,
)
};
if err != WIN32_ERROR(0) {
let mut sb = buf.clone();
unsafe {
ControlTraceW(
handle, PCWSTR::null(),
sb.as_mut_ptr() as *mut _,
EVENT_TRACE_CONTROL_STOP,
);
}
return Err(format!("EnableTraceEx2 failed (need PPL?): {:?}", err));
}
// ...
The defensive ControlTraceW call before StartTraceW handles a common edge case. ETW session names are system-wide singletons [4]. If a previous instance of the application crashed without cleanup, the old session lingers and StartTraceW fails with ERROR_ALREADY_EXISTS. Stopping it first is standard practice.
The EnableTraceEx2 call is where PPL status matters. If the process has not been elevated, this call returns ERROR_ACCESS_DENIED. The error message in the code hints at the cause: "EnableTraceEx2 failed (need PPL?)".
ProcessTrace on a Dedicated Thread
ProcessTrace is a blocking call that sits in a loop reading ETW buffers and invoking a callback for each event [4]. The implementation spawns it on a dedicated thread, with a second thread watching the stop flag for clean teardown:
// From: peregrine-tauri/src-tauri/src/etw_ti.rs
let mut logfile: EVENT_TRACE_LOGFILEW = unsafe { std::mem::zeroed() };
logfile.LoggerName = PWSTR(wide_mut.as_mut_ptr());
logfile.Anonymous1.ProcessTraceMode =
PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
logfile.Anonymous2.EventRecordCallback = Some(trace_callback);
let trace_handle = unsafe { OpenTraceW(&mut logfile) };
if trace_handle.Value == u64::MAX {
return Err("OpenTraceW failed".into());
}
// ProcessTrace blocks, so it runs on a dedicated thread
std::thread::spawn(move || {
unsafe { ProcessTrace(&[trace_handle], None, None); }
});
// Cleanup on stop
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(200));
}
unsafe {
CloseTrace(trace_handle);
ControlTraceW(
handle, PCWSTR(wide2.as_ptr()),
buf2.as_ptr() as *mut _,
EVENT_TRACE_CONTROL_STOP,
);
}
});
Ok(rx)
}
The function returns an mpsc::Receiver<TiEvent> that the caller can poll for incoming threat intelligence events.
Parsing Event Payloads from Raw UserData
The callback receives raw EVENT_RECORD pointers. Each event carries a keyword bitmask identifying the operation type and a UserData blob containing caller and target process information. The field layout is not formally documented by Microsoft; it was reverse-engineered by the security research community [6]:
// From: peregrine-tauri/src-tauri/src/etw_ti.rs
unsafe extern "system" fn trace_callback(record: *mut EVENT_RECORD) {
if record.is_null() {
return;
}
let ev = unsafe { &*record };
let kw = ev.EventHeader.EventDescriptor.Keyword;
let data = ev.UserData as *const u8;
let data_len = ev.UserDataLength as usize;
if data.is_null() || data_len < 40 {
return;
}
let event_type = if kw & KW_ALLOCVM_REMOTE != 0 {
"ALLOCVM_REMOTE"
} else if kw & KW_PROTECTVM_REMOTE != 0 {
"PROTECTVM_REMOTE"
} else if kw & KW_MAPVIEW_REMOTE != 0 {
"MAPVIEW_REMOTE"
} else if kw & KW_QUEUEAPC_REMOTE != 0 {
"QUEUEAPC_REMOTE"
} else if kw & KW_SETCTX_REMOTE != 0 {
"SETTHREADCONTEXT_REMOTE"
} else if kw & KW_READVM_REMOTE != 0 {
"READVM_REMOTE"
} else if kw & KW_WRITEVM_REMOTE != 0 {
"WRITEVM_REMOTE"
} else {
return;
};
// ...
The data layout varies by event type. For READVM_REMOTE, WRITEVM_REMOTE, QUEUEAPC_REMOTE, and SETTHREADCONTEXT_REMOTE, a 4-byte OperationStatus field is prepended before the common structure. The others start at offset 0:
// From: peregrine-tauri/src-tauri/src/etw_ti.rs
// ALLOCVM/PROTECTVM/MAPVIEW: fields start at offset 0
// READVM/WRITEVM/QUEUEAPC/SETTHREADCONTEXT: OperationStatus (u32) prepended, +4
let base_off: usize = match kw {
k if k & (KW_READVM_REMOTE | KW_WRITEVM_REMOTE
| KW_QUEUEAPC_REMOTE | KW_SETCTX_REMOTE) != 0 => 4,
_ => 0,
};
After adjusting for the base offset, the common layout provides all fields needed for detection:
+0: CallingProcessId (u32)
+4: CallingProcessCreateTime (i64)
+12: CallingProcessStartKey (u64)
+20: CallingProcessSignatureLevel (u8)
+21: CallingProcessSectionSignatureLevel (u8)
+22: CallingProcessProtection (u8)
+23: CallingThreadId (u32)
+27: CallingThreadCreateTime (i64)
+35: TargetProcessId (u32)
+39: TargetProcessCreateTime (i64)
+47: TargetProcessStartKey (u64)
+55: TargetProcessSignatureLevel (u8)
+56: TargetProcessSectionSignatureLevel (u8)
+57: TargetProcessProtection (u8)
+58: BaseAddress (u64)
+66: RegionSize (u64)
+74: AllocationType (u32)
+78: ProtectionMask (u32)
The fields are read with unaligned pointer reads because ETW event payloads are packed, not padded to natural alignment:
// From: peregrine-tauri/src-tauri/src/etw_ti.rs
let r32 = |off: usize| -> u32 {
let o = base_off + off;
if o + 4 > data_len { 0 } else {
unsafe { std::ptr::read_unaligned(data.add(o) as *const u32) }
}
};
let r64 = |off: usize| -> u64 {
let o = base_off + off;
if o + 8 > data_len { 0 } else {
unsafe { std::ptr::read_unaligned(data.add(o) as *const u64) }
}
};
let ti = TiEvent {
event_type: event_type.to_string(),
caller_pid: r32(0),
target_pid: r32(35),
base_address: r64(58),
region_size: r64(66),
protection: if base_off + 82 <= data_len { r32(78) } else { 0 },
};
The TiEvent Structure
Each parsed event is sent through an mpsc channel to the main application:
// From: peregrine-tauri/src-tauri/src/etw_ti.rs
#[derive(Debug, Clone, serde::Serialize)]
pub struct TiEvent {
pub event_type: String,
pub caller_pid: u32,
pub target_pid: u32,
pub base_address: u64,
pub region_size: u64,
pub protection: u32,
}
The protection field carries the memory protection constant (for example, 0x40 for PAGE_EXECUTE_READWRITE [5]), which, combined with the operation type and the caller/target PID pair, provides a complete picture of each injection attempt.
Tradeoffs: Fragile Offset and Driver Requirement
The combination of PPL elevation and ETW-TI subscription allows Peregrine to observe every cross-process memory operation on the system without hooking a single API. The kernel delivers telemetry directly to the trace session, and because events are consumed rather than intercepted, no latency is added to the operations themselves.
The tradeoffs are real. The hardcoded EPROCESS offset is fragile across Windows versions. Dynamic resolution would fix this but is not yet implemented. The kernel driver requirement is inherent to the architecture: a process cannot become PPL from usermode alone without a signed driver or a vulnerability [2]. For an anti-cheat that already has a kernel component, this is not an additional cost.
The ETW-TI events pair naturally with the thread analysis from the previous post. When an ALLOCVM_REMOTE event is followed by WRITEVM_REMOTE, followed by a new thread whose start address falls outside all modules, the complete injection chain is visible: allocation, write, execution, with timestamps, PIDs, and addresses for every step.
This post was generated by an LLM based on code from Peregrine Anti-Cheat. All code snippets are from the actual repository. Claims about Windows internals are sourced from Microsoft documentation.
References
- [1] Microsoft, EPROCESS structure and PS_PROTECTION
- [2] Alex Ionescu, Protected Processes Light; James Forshaw, Windows Exploitation Tricks: Exploiting Protected Processes
- [3] Microsoft, PsLookupProcessByProcessId function
- [4] Microsoft, Configuring and Starting an Event Tracing Session; EnableTraceEx2 function
- [5] Microsoft, Memory Protection Constants
- [6] pathtofile, SealighterTI