almost done however kernel base scan messes up due to low cores

This commit is contained in:
lily 2026-04-12 01:54:31 -04:00
parent ec6dd07488
commit 806782bc7f
8 changed files with 461 additions and 86 deletions

View file

@ -1,32 +0,0 @@
# KVM Hypervisor Introspector
## Project
Rust-based KVM hypervisor introspector targeting a quickemu Windows 11 guest VM.
Performs live memory forensics from outside the guest OS (ring -1).
## Goals
- Walk guest EPROCESS list via CR3 + DirectoryTableBase
- Cross-view diff EPROCESS vs PspCidTable to detect DKOM-hidden processes
- Find rwx / non-image-backed VADs
- Inline asm for VMREAD/VMWRITE, CR3 manipulation, VMX instruction wrappers
- Or asm outside of inline
- possibly finding a vuln in qemu or quickemu however this would be difficult to do
## Stack
- Rust, `kvm-ioctls`, `vmm-sys-util`, `iced-x86`
- Target guest: quickemu Windows 11 VM on arch-uwu
- KVM via /dev/kvm ioctls
## Code Style
- No unnecessary comments inside of the code
- Unsafe blocks are expected and fine — document WHY not WHAT
- Inline asm preferred over wrappers where it's cleaner
- No clippy noise about unsafe
## Key Paths
- Guest VM socket/pid: resolve from quickemu at runtime
- Windows kernel offsets: hardcode initially for a known Win11 build, document the build
## Context
This is hackathon work. Prioritize working PoC over perfect architecture.
Security research / digital forensics framing.

120
src/eprocess.rs Normal file
View file

@ -0,0 +1,120 @@
use crate::kernel;
use crate::mem::GuestMem;
use crate::offsets;
use crate::paging;
pub struct Process {
pub eprocess_va: u64,
pub pid: u64,
pub dtb: u64,
pub name: String,
}
/// Resolve the current EPROCESS via the KPCR on the vCPU whose GS.base is supplied.
///
/// SAFETY precondition: the vCPU must be in ring 0 (CS.selector & 3 == 0)
/// In ring 3 GS.base points at the TEB, not the KPCR, and the offsets are wrong
pub fn find_initial_eprocess(
mem: &GuestMem,
cr3: u64,
gs_base: u64,
) -> Result<u64, String> {
let kthread = paging::read_virt_u64(
mem, cr3,
gs_base + offsets::KPCR_PRCB + offsets::KPRCB_CURRENT_THREAD,
).map_err(|e| format!("read KPRCB.CurrentThread: {}", e))?;
if kthread < 0xffff_8000_0000_0000 {
return Err(format!("CurrentThread {:#x} is not a kernel VA", kthread));
}
let eprocess = paging::read_virt_u64(mem, cr3, kthread + offsets::KTHREAD_PROCESS)
.map_err(|e| format!("read KTHREAD.Process: {}", e))?;
if eprocess < 0xffff_8000_0000_0000 {
return Err(format!("EPROCESS {:#x} is not a kernel VA", eprocess));
}
Ok(eprocess)
}
/// Walk the circular ActiveProcessLinks list starting from `initial_eprocess_va`.
///
/// Flink points into the *next* EPROCESS's links field, so subtracting
/// EPROC_LINKS recovers the EPROCESS base. The loop ends when Flink brings
/// us back to the starting links entry (full circle) or a bad VA is read.
pub fn walk_eprocess_list(
mem: &GuestMem,
cr3: u64,
initial_eprocess_va: u64,
) -> Vec<Process> {
let mut out = Vec::new();
let mut current = initial_eprocess_va;
loop {
match read_process(mem, cr3, current) {
Some(p) => out.push(p),
None => break,
}
let flink = match paging::read_virt_u64(mem, cr3, current + offsets::EPROC_LINKS) {
Ok(v) => v,
Err(_) => break,
};
// Flink is a VA pointing at the links field of the next node.
if flink < 0xffff_8000_0000_0000 {
break;
}
let next = flink - offsets::EPROC_LINKS;
// Full circle: Flink of last entry == links field of the first entry.
if next == initial_eprocess_va {
break;
}
current = next;
}
out
}
fn read_process(mem: &GuestMem, cr3: u64, eprocess_va: u64) -> Option<Process> {
let pid = paging::read_virt_u64(mem, cr3, eprocess_va + offsets::EPROC_PID).ok()?;
let dtb = paging::read_virt_u64(mem, cr3, eprocess_va + offsets::EPROC_DTB).ok()?;
let name = read_name(mem, cr3, eprocess_va + offsets::EPROC_NAME);
Some(Process { eprocess_va, pid, dtb, name })
}
/// Find the System EPROCESS via `PsInitialSystemProcess` in the kernel export table.
/// Doesn't depend on KTHREAD.Process offset — works regardless of build.
///
/// PsInitialSystemProcess is a PEPROCESS (pointer to EPROCESS), so we read the
/// export VA to get the pointer, then dereference it to get the EPROCESS itself.
pub fn find_initial_eprocess_via_export(
mem: &GuestMem,
cr3: u64,
kernel_base: u64,
) -> Result<u64, String> {
let ps_va = kernel::find_export(mem, cr3, kernel_base, "PsInitialSystemProcess")
.ok_or("PsInitialSystemProcess not found in export table")?;
let eprocess = paging::read_virt_u64(mem, cr3, ps_va)
.map_err(|e| format!("deref PsInitialSystemProcess at {:#x}: {}", ps_va, e))?;
if eprocess < 0xffff_8000_0000_0000 {
return Err(format!("PsInitialSystemProcess value {:#x} is not a kernel VA", eprocess));
}
Ok(eprocess)
}
fn read_name(mem: &GuestMem, cr3: u64, gva: u64) -> String {
let mut buf = [0u8; 15];
if paging::read_virt(mem, cr3, gva, &mut buf).is_err() {
return String::from("?");
}
let len = buf.iter().position(|&b| b == 0).unwrap_or(15);
String::from_utf8_lossy(&buf[..len]).into_owned()
}

109
src/kernel.rs Normal file
View file

@ -0,0 +1,109 @@
use crate::mem::GuestMem;
use crate::paging;
// Windows KASLR aligns the kernel load address to 2 MiB boundaries.
const STEP_2M: u64 = 0x200_000;
const MAX_SCAN: u64 = 0x4000_0000; // 1 GiB
/// Find ntoskrnl base from a VA known to be inside it.
///
/// Two-pass strategy:
/// 1. Coarse: 2 MiB steps over 1 GiB (fast, handles standard KASLR alignment).
/// 2. Fine: 4 KiB steps over 32 MiB (fallback if the base is not 2 MiB-aligned).
/// Each candidate is validated against the `PsInitialSystemProcess` export so we
/// don't mistake a driver or HAL for ntoskrnl.
pub fn find_kernel_base(mem: &GuestMem, cr3: u64, hint_va: u64) -> Option<u64> {
if let Some(b) = scan(mem, cr3, hint_va, STEP_2M, MAX_SCAN) {
return Some(b);
}
scan(mem, cr3, hint_va, 0x1000, 0x200_0000) // 4 KiB steps, 32 MiB
}
fn scan(mem: &GuestMem, cr3: u64, hint_va: u64, step: u64, range: u64) -> Option<u64> {
let mut addr = hint_va & !(step - 1);
let floor = hint_va.saturating_sub(range) & !(step - 1);
while addr >= floor {
if is_pe_header(mem, cr3, addr) &&
find_export(mem, cr3, addr, "PsInitialSystemProcess").is_some()
{
return Some(addr);
}
if addr < step { break; }
addr -= step;
}
None
}
/// Read an x64 IDT gate entry and return the full 64-bit handler VA.
///
/// x64 IDT entry layout (16 bytes):
/// +0 u16 offset[15:0]
/// +2 u16 selector
/// +4 u8 ist
/// +5 u8 type/attr
/// +6 u16 offset[31:16]
/// +8 u32 offset[63:32]
/// +12 u32 reserved
pub fn read_idt_handler(mem: &GuestMem, cr3: u64, idt_va: u64, idx: u64) -> Option<u64> {
let mut e = [0u8; 16];
paging::read_virt(mem, cr3, idt_va + idx * 16, &mut e).ok()?;
let lo = u16::from_le_bytes([e[0], e[1]]) as u64;
let mid = u16::from_le_bytes([e[6], e[7]]) as u64;
let hi = u32::from_le_bytes([e[8], e[9], e[10], e[11]]) as u64;
Some((hi << 32) | (mid << 16) | lo)
}
fn is_pe_header(mem: &GuestMem, cr3: u64, va: u64) -> bool {
let mut mz = [0u8; 2];
if paging::read_virt(mem, cr3, va, &mut mz).is_err() { return false; }
if mz != [0x4D, 0x5A] { return false; }
let pe_off = match paging::read_virt_u32(mem, cr3, va + 0x3C) {
Ok(v) if v < 0x1000 => v as u64,
_ => return false,
};
let mut sig = [0u8; 4];
if paging::read_virt(mem, cr3, va + pe_off, &mut sig).is_err() { return false; }
sig == [0x50, 0x45, 0x00, 0x00]
}
/// Resolve a named export from the PE image at `base_va`.
/// For data exports (like PsInitialSystemProcess) this is the variable's VA;
/// the caller must dereference it to get the pointed-to value.
///
/// IMAGE_OPTIONAL_HEADER64 starts at pe_off + 4 (PE sig) + 20 (IMAGE_FILE_HEADER).
/// DataDirectory[0] (export) is at optional_header + 0x70.
pub fn find_export(mem: &GuestMem, cr3: u64, base_va: u64, target: &str) -> Option<u64> {
let pe_off = paging::read_virt_u32(mem, cr3, base_va + 0x3C).ok()? as u64;
let export_rva = paging::read_virt_u32(mem, cr3, base_va + pe_off + 0x18 + 0x70).ok()? as u64;
if export_rva == 0 { return None; }
let export_va = base_va + export_rva;
let num_names = paging::read_virt_u32(mem, cr3, export_va + 0x18).ok()? as u64;
let names_rva = paging::read_virt_u32(mem, cr3, export_va + 0x20).ok()? as u64;
let ords_rva = paging::read_virt_u32(mem, cr3, export_va + 0x24).ok()? as u64;
let funcs_rva = paging::read_virt_u32(mem, cr3, export_va + 0x1C).ok()? as u64;
for i in 0..num_names {
let name_rva = paging::read_virt_u32(mem, cr3, base_va + names_rva + i * 4).ok()? as u64;
if !name_matches(mem, cr3, base_va + name_rva, target) { continue; }
let mut ord_bytes = [0u8; 2];
paging::read_virt(mem, cr3, base_va + ords_rva + i * 2, &mut ord_bytes).ok()?;
let ordinal = u16::from_le_bytes(ord_bytes) as u64;
let func_rva = paging::read_virt_u32(mem, cr3, base_va + funcs_rva + ordinal * 4).ok()? as u64;
return Some(base_va + func_rva);
}
None
}
fn name_matches(mem: &GuestMem, cr3: u64, va: u64, target: &str) -> bool {
let mut buf = [0u8; 64];
if paging::read_virt(mem, cr3, va, &mut buf).is_err() { return false; }
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
&buf[..len] == target.as_bytes()
}

View file

@ -1,6 +1,9 @@
mod asm;
mod eprocess;
mod kernel;
mod kvm;
mod mem;
mod offsets;
mod paging;
mod ptrace_inject;
mod qemu;
@ -10,31 +13,82 @@ fn main() {
println!("[+] pid={} vm_fd={} vcpu_fds={:?} remote={:?}",
vm.pid, vm.vm_fd, vm.vcpu_fds, vm.vcpu_fds_remote);
vm.freeze();
let sregs = kvm::get_sregs_injected(vm.pid, vm.vcpu_fds_remote[0])
.expect("KVM_GET_SREGS injection failed");
let regs = kvm::get_regs_injected(vm.pid, vm.vcpu_fds_remote[0])
.expect("KVM_GET_REGS injection failed");
vm.thaw();
println!("[+] vcpu0 CR3={:#018x}", sregs.cr3);
println!("[+] vcpu0 RIP={:#018x} RSP={:#018x}", regs.rip, regs.rsp);
let (eax, _, _, _) = asm::cpuid(0, 0);
println!("[+] CPUID max_leaf={:#x} svm={}", eax, asm::svm_supported());
// Try each vCPU looking for one in ring 0 (kernel mode).
// Windows KVAS gives user-mode vCPUs a shadow CR3 with no kernel mapping;
// the kernel scan only works from a ring-0 CR3.
// inject_ioctl does PTRACE_DETACH after each call which resumes the main
// thread — re-freeze before the next injection to put it back into group-stop.
let mut found = None;
for i in 0..vm.vcpu_fds_remote.len() {
vm.freeze();
let Ok(sregs) = kvm::get_sregs_injected(vm.pid, vm.vcpu_fds_remote[i]) else { continue };
let cpl = sregs.cs.selector & 3;
if cpl != 0 {
println!("[~] vCPU{} CPL={} (user mode), skipping", i, cpl);
continue;
}
vm.freeze();
let Ok(regs) = kvm::get_regs_injected(vm.pid, vm.vcpu_fds_remote[i]) else { continue };
found = Some((sregs, regs, i));
break;
}
vm.thaw();
let (sregs, regs, vcpu_idx) = match found {
Some(v) => v,
None => {
println!("[-] no vCPU found in ring 0");
return;
}
};
println!("[+] vCPU{} ring0: CR3={:#018x} RIP={:#018x} RSP={:#018x}",
vcpu_idx, sregs.cr3, regs.rip, regs.rsp);
let guest = mem::GuestMem::attach(vm.pid).expect("failed to map guest RAM");
for r in &guest.regions {
println!("[+] mem region GPA {:#010x}..{:#010x} (HVA {:#010x})",
r.gpa_base, r.gpa_base + r.size, r.hva_base);
}
// Smoke-test the page table walker: translate CR3 itself (it's a GPA, not
// a GVA, so we translate an arbitrary kernel VA to prove the walk works)
// Real EPROCESS walk comes next
let cr3 = sregs.cr3;
println!("[+] CR3 (guest PML4 GPA) = {:#018x}", cr3);
match paging::translate(&guest, cr3, sregs.cr3) {
Ok(gpa) => println!("[+] translate(CR3 as GVA) -> GPA {:#018x}", gpa),
Err(e) => println!("[-] translate: {}", e),
// Primary hint: RIP (usually inside ntoskrnl).
// Fallback: IDT entry 0 = KiDivideErrorFault, which is always in ntoskrnl
// and is typically within the first few MiB of the image.
let idt_handler = kernel::read_idt_handler(&guest, cr3, sregs.idt.base, 0);
println!("[+] IDT base={:#018x} entry[0]={:#018x}",
sregs.idt.base,
idt_handler.unwrap_or(0));
let hints: &[u64] = &[
regs.rip,
idt_handler.unwrap_or(0),
];
let base_opt = hints.iter()
.filter(|&&h| h >= 0xffff_8000_0000_0000)
.find_map(|&h| kernel::find_kernel_base(&guest, cr3, h));
match base_opt {
None => println!("[-] kernel base scan failed"),
Some(base) => {
println!("[+] kernel base = {:#018x}", base);
match eprocess::find_initial_eprocess_via_export(&guest, cr3, base) {
Err(e) => println!("[-] PsInitialSystemProcess: {}", e),
Ok(ep) => {
println!("[+] System EPROCESS = {:#018x}", ep);
let procs = eprocess::walk_eprocess_list(&guest, cr3, ep);
println!("[+] {} processes:", procs.len());
println!(" {:>6} {:>18} {:>18} name",
"PID", "EPROCESS", "DTB");
for p in &procs {
println!(" {:>6} {:#018x} {:#018x} {}",
p.pid, p.eprocess_va, p.dtb, p.name);
}
}
}
}
}
}

18
src/offsets.rs Normal file
View file

@ -0,0 +1,18 @@
// Windows 11 22H2 build 22621.x x64
// Derived from public PDB symbols via WinDbg:
// dt nt!_KPCR; dt nt!_KPRCB; dt nt!_KTHREAD; dt nt!_EPROCESS
// KPCR → embedded KPRCB → CurrentThread
pub const KPCR_PRCB: u64 = 0x180; // _KPCR.Prcb (embedded _KPRCB)
pub const KPRCB_CURRENT_THREAD: u64 = 0x008; // _KPRCB.CurrentThread
// KTHREAD → owning process
// _KTHREAD.Process (Ptr64 → _KPROCESS = _EPROCESS base on x64)
pub const KTHREAD_PROCESS: u64 = 0x220;
// _EPROCESS / embedded _KPROCESS fields
pub const EPROC_DTB: u64 = 0x028; // _KPROCESS.DirectoryTableBase
pub const EPROC_PID: u64 = 0x440; // _EPROCESS.UniqueProcessId
pub const EPROC_LINKS: u64 = 0x448; // _EPROCESS.ActiveProcessLinks (Flink)
pub const EPROC_NAME: u64 = 0x5A8; // _EPROCESS.ImageFileName[15]
pub const EPROC_VAD_ROOT: u64 = 0x7D8; // _EPROCESS.VadRoot (RTL_AVL_TREE)

View file

@ -26,8 +26,59 @@ fn mem_read(pid: u32, addr: u64, buf: &mut [u8]) -> std::io::Result<()> {
f.read_exact(buf)
}
/// Search the target process's executable mappings for a `syscall` instruction
/// (0x0f 0x05) and return its virtual address.
///
/// Writing shellcode to the stack is blocked by the NX (no-execute) bit:
/// attempting to execute it raises SIGSEGV, which QEMU's signal handler converts
/// to SIGKILL (status=0x9 from waitpid). Using an existing gadget in already-
/// executable memory avoids this entirely.
///
/// The vdso is tried first — it's tiny, always present, and always contains
/// `syscall` — then all other executable regions are searched.
fn find_syscall_gadget(pid: u32) -> Option<u64> {
let maps = std::fs::read_to_string(format!("/proc/{}/maps", pid)).ok()?;
let mut mem = File::open(format!("/proc/{}/mem", pid)).ok()?;
// Collect executable regions; float the vdso to the front.
let mut vdso: Vec<(u64, u64)> = Vec::new();
let mut rest: Vec<(u64, u64)> = Vec::new();
for line in maps.lines() {
let cols: Vec<&str> = line.splitn(6, ' ').collect();
if cols.len() < 2 { continue; }
if !cols[1].contains('x') { continue; }
let mut parts = cols[0].split('-');
let Ok(start) = u64::from_str_radix(parts.next().unwrap_or(""), 16) else { continue };
let Ok(end) = u64::from_str_radix(parts.next().unwrap_or(""), 16) else { continue };
if end <= start { continue; }
if cols.get(5).map_or(false, |p| p.trim() == "[vdso]") {
vdso.push((start, end));
} else {
rest.push((start, end));
}
}
for (start, end) in vdso.into_iter().chain(rest) {
// Cap per-region scan to 4 MiB so startup stays fast.
let size = ((end - start) as usize).min(4 << 20);
let mut buf = vec![0u8; size];
if mem.seek(SeekFrom::Start(start)).is_err() { continue; }
if mem.read_exact(&mut buf).is_err() { continue; }
for i in 0..buf.len().saturating_sub(1) {
if buf[i] == 0x0f && buf[i + 1] == 0x05 {
return Some(start + i as u64);
}
}
}
None
}
/// Inject `ioctl(remote_fd, ioctl_nr, ptr)` into the main QEMU thread (TID=PID)
/// using a `syscall; int3` gadget placed below the SysV red zone.
/// using an existing `syscall` gadget found in QEMU's already-executable memory.
///
/// **Caller must call freeze() (SIGSTOP) before this function.**
/// SIGSTOP guarantees every vCPU thread has exited KVM_RUN and called vcpu_put(),
@ -35,13 +86,17 @@ fn mem_read(pid: u32, addr: u64, buf: &mut [u8]) -> std::io::Result<()> {
/// The injected ioctl then runs inside QEMU's mm so the kernel mm-check passes,
/// and can acquire vcpu->mutex (which is free) to read the register state.
///
/// PTRACE_ATTACH is used instead of PTRACE_SEIZE+PTRACE_INTERRUPT because:
/// - The vCPU threads block SIGTRAP in their signal mask, making int3 unreliable.
/// - PTRACE_CONT on PTRACE_SEIZE'd threads in group-stop immediately re-stops.
/// - PTRACE_ATTACH on the main thread (which has a normal signal mask and is
/// already group-stopped) gives a clean ptrace-stop that PTRACE_CONT resumes
/// correctly, escaping the group-stop for that one thread while the rest stay
/// stopped.
/// PTRACE_SINGLESTEP is used instead of a `syscall; int3` shellcode gadget because:
/// - The stack is NX (no-execute); writing code there and jumping to it raises
/// SIGSEGV, which QEMU's signal handler escalates to SIGKILL.
/// - PTRACE_SINGLESTEP sets the TF (Trap Flag) and executes exactly one
/// instruction — the `syscall` — then stops, giving the same "stop after ioctl"
/// semantics without touching executable memory.
///
/// PTRACE_SEIZE is used instead of PTRACE_ATTACH for the same reasons as before:
/// it reports the existing group-stop immediately so waitpid returns right away,
/// and PTRACE_CONT data=0 on the seized thread escapes group-stop for that one
/// thread while the rest stay stopped.
pub fn inject_ioctl(
pid: u32,
remote_fd: RawFd,
@ -52,10 +107,9 @@ pub fn inject_ioctl(
let null = 0usize as *mut c_void;
let tid = pid as pid_t;
// PTRACE_ATTACH sends SIGSTOP internally; on an already group-stopped thread
// this does NOT generate a new ptrace-stop event, so the subsequent waitpid
// blocks forever. PTRACE_SEIZE instead reports the *existing* group-stop as
// an immediate notification — waitpid returns right away.
let gadget_addr = find_syscall_gadget(pid)
.ok_or("no syscall gadget found in target executable mappings")?;
let r = unsafe { libc::ptrace(PTRACE_SEIZE as _, tid, null, null) };
if r != 0 {
return Err(format!("PTRACE_SEIZE: {}", std::io::Error::last_os_error()).into());
@ -77,14 +131,9 @@ pub fn inject_ioctl(
return Err(format!("PTRACE_GETREGS: {}", std::io::Error::last_os_error()).into());
}
// Scratch layout below the 128-byte SysV AMD64 red zone (descending):
// gadget_addr : 0f 05 cc (syscall; int3) — 8-byte aligned
// struct_addr : zeroed ioctl struct — 16-byte aligned
// rsp is left unchanged: neither `syscall` nor `int3` touch the user stack.
let gadget_addr = (orig.rsp - 128 - 8) & !7u64;
let struct_addr = (gadget_addr - struct_size as u64) & !15u64;
mem_write(pid, gadget_addr, &[0x0f, 0x05, 0xcc])?;
// Place the ioctl output struct below the SysV AMD64 red zone.
// The stack is writable (just not executable), so this is fine for data.
let struct_addr = (orig.rsp - 128 - struct_size as u64) & !15u64;
mem_write(pid, struct_addr, &vec![0u8; struct_size])?;
let mut regs = orig;
@ -92,7 +141,7 @@ pub fn inject_ioctl(
regs.rdi = remote_fd as u64;
regs.rsi = ioctl_nr;
regs.rdx = struct_addr;
regs.rip = gadget_addr;
regs.rip = gadget_addr; // existing executable `syscall` instruction
let r = unsafe {
libc::ptrace(PTRACE_SETREGS as _, tid, null, &regs as *const _ as *mut c_void)
@ -102,19 +151,25 @@ pub fn inject_ioctl(
return Err(format!("PTRACE_SETREGS: {}", std::io::Error::last_os_error()).into());
}
// PTRACE_CONT with data=0 suppresses the pending SIGSTOP for this thread,
// resuming it while every other thread stays in group-stop.
// Loop until rip passes the 2-byte `syscall` instruction (gadget_addr+2).
// Stops at gadget_addr itself are signal-delivery-stops before the syscall;
// suppress each and retry.
// PTRACE_SINGLESTEP sets TF and executes exactly one instruction.
// data=0 on the first call suppresses the pending SIGSTOP, escaping group-stop.
// After `syscall` completes and sysret restores RFLAGS (with TF set), the
// CPU raises #DB and the thread stops with rip = gadget_addr + 2.
// If a different signal interrupts before the instruction executes (rip still
// at gadget_addr), suppress it and retry.
let mut ret_regs: libc::user_regs_struct = unsafe { std::mem::zeroed() };
loop {
unsafe { libc::ptrace(libc::PTRACE_CONT, tid, null, null) };
unsafe { libc::ptrace(libc::PTRACE_SINGLESTEP, tid, null, null) };
unsafe { libc::waitpid(tid, &mut status, libc::__WALL) };
if libc::WIFEXITED(status) || libc::WIFSIGNALED(status) {
return Err(format!("QEMU exited during injection (status={:#x})", status).into());
}
unsafe {
libc::ptrace(PTRACE_GETREGS as _, tid, null, &mut ret_regs as *mut _ as *mut c_void)
};
if ret_regs.rip >= gadget_addr + 2 { break; }
if ret_regs.rip == gadget_addr + 2 {
break; // syscall completed; rip now points past the 2-byte `syscall`
}
if ret_regs.rip != gadget_addr {
unsafe { libc::ptrace(libc::PTRACE_DETACH, tid, null, null) };
return Err(format!(
@ -122,6 +177,8 @@ pub fn inject_ioctl(
ret_regs.rip, gadget_addr
).into());
}
// rip == gadget_addr: a signal fired before the instruction; suppressed
// by passing data=0 in the next PTRACE_SINGLESTEP.
}
let syscall_ret = ret_regs.rax as i64;

View file

@ -27,19 +27,31 @@ impl Drop for QemuVm {
}
impl QemuVm {
/// SIGSTOP the QEMU process and spin until /proc/<pid>/status shows State: T.
/// Required before KVM_GET_REGS / KVM_GET_SREGS — the kernel returns EIO
/// if the vCPU thread is actively inside KVM_RUN when you call those ioctls.
/// SIGSTOP is async; the threads only exit KVM_RUN once the kernel delivers it.
/// SIGSTOP the QEMU process and spin until every thread in /proc/<pid>/task/
/// shows State: T (stopped) or t (tracing stop).
///
/// Checking the main thread alone is insufficient: vCPU threads run inside
/// KVM_RUN (a blocking ioctl) and must exit before they enter group-stop and
/// release vcpu->mutex. Spinning until all TIDs are stopped ensures the mutex
/// is free before the injected ioctl tries to acquire it.
///
/// Previous bug: the check used `l.contains('t')` which is always true because
/// "State:" itself contains the letter 't' — freeze() returned immediately.
pub fn freeze(&self) {
unsafe { libc::kill(self.pid as libc::pid_t, libc::SIGSTOP); }
let status_path = format!("/proc/{}/status", self.pid);
let task_dir = format!("/proc/{}/task", self.pid);
loop {
let Ok(s) = fs::read_to_string(&status_path) else { break };
// "State:\tT (stopped)" or "State:\tt (tracing stop)"
if s.lines().any(|l| l.starts_with("State:") && (l.contains('T') || l.contains('t'))) {
break;
}
let Ok(dir) = fs::read_dir(&task_dir) else { break };
let all_stopped = dir
.filter_map(|e| e.ok())
.all(|e| {
let path = format!("{}/{}/status",
task_dir, e.file_name().to_string_lossy());
let Ok(s) = fs::read_to_string(&path) else { return true };
// Match "State:\tT ..." or "State:\tt ..." only — not the word "State" itself.
s.lines().any(|l| l.starts_with("State:\tT") || l.starts_with("State:\tt"))
});
if all_stopped { break; }
std::hint::spin_loop();
}
}

View file

@ -62,3 +62,40 @@ wrmsr:
shrq $32, %rdx # high 32 bits
wrmsr
ret
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
##
##
#
#
#
#
##
#
#
#
# kurva