commit aed7231b158ee8002e4f37a5caae2f86fde6ce90 Author: lily Date: Fri Apr 10 22:11:39 2026 -0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfa6940 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +CLAUDE.md diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e83e9c3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,62 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "KVM-hypervisor-introspector" +version = "0.1.0" +dependencies = [ + "kvm-bindings", + "kvm-ioctls", + "libc", + "vmm-sys-util", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "kvm-bindings" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b13baf7bdfda2e10bcb109fcb099ef40cff82374eb6b7cdcf4695bdec4e522c" +dependencies = [ + "vmm-sys-util", +] + +[[package]] +name = "kvm-ioctls" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "083c460d5a272c2f22205973e319147b791d92a288d7d7a8d4c6194f95229440" +dependencies = [ + "bitflags 2.11.0", + "kvm-bindings", + "libc", + "vmm-sys-util", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "vmm-sys-util" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1435039746e20da4f8d507a72ee1b916f7b4b05af7a91c093d2c6561934ede" +dependencies = [ + "bitflags 1.3.2", + "libc", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6fc3739 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "KVM-hypervisor-introspector" +version = "0.1.0" +edition = "2024" + +[dependencies] +kvm-ioctls = "0.21" +kvm-bindings = "0.11" +vmm-sys-util = "0.12" +libc = "0.2" + +[profile.release] +debug = true diff --git a/src/kvm.rs b/src/kvm.rs new file mode 100644 index 0000000..d25c821 --- /dev/null +++ b/src/kvm.rs @@ -0,0 +1,33 @@ +use std::os::unix::io::RawFd; + +use kvm_bindings::{kvm_regs, kvm_sregs, KVMIO}; +use vmm_sys_util::ioctl::ioctl_with_mut_ref; +use vmm_sys_util::ioctl_ioc_nr; + +// vmm_sys_util macros need AsRawFd; wrap stolen fds cheaply. +// SAFETY: caller must ensure the fd stays valid for the lifetime of Fd. +struct Fd(RawFd); +impl std::os::unix::io::AsRawFd for Fd { + fn as_raw_fd(&self) -> RawFd { self.0 } +} + +// KVMIO = 0xAE (174). These mirror the definitions in kvm_ioctls but we +// can't use VcpuFd directly because it expects to own the fd it creates. +vmm_sys_util::ioctl_ior_nr!(KVM_GET_REGS, KVMIO, 0x81, kvm_regs); +vmm_sys_util::ioctl_ior_nr!(KVM_GET_SREGS, KVMIO, 0x83, kvm_sregs); + +pub fn get_regs(vcpu_fd: RawFd) -> Result { + let mut regs = kvm_regs::default(); + // SAFETY: vcpu_fd is a valid KVM vcpu fd; kvm_regs is the correct type for this ioctl. + let ret = unsafe { ioctl_with_mut_ref(&Fd(vcpu_fd), KVM_GET_REGS(), &mut regs) }; + if ret < 0 { return Err(ret); } + Ok(regs) +} + +pub fn get_sregs(vcpu_fd: RawFd) -> Result { + let mut sregs = kvm_sregs::default(); + // SAFETY: vcpu_fd is a valid KVM vcpu fd; kvm_sregs is the correct type for this ioctl. + let ret = unsafe { ioctl_with_mut_ref(&Fd(vcpu_fd), KVM_GET_SREGS(), &mut sregs) }; + if ret < 0 { return Err(ret); } + Ok(sregs) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b5f5530 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,20 @@ +mod kvm; +mod mem; +mod qemu; + +fn main() { + let vm = qemu::find_windows_vm().expect("no quickemu Windows VM found"); + println!("[+] pid={} vm_fd={} vcpu_fds={:?}", vm.pid, vm.vm_fd, vm.vcpu_fds); + + let sregs = kvm::get_sregs(vm.vcpu_fds[0]).expect("KVM_GET_SREGS failed"); + println!("[+] vcpu0 CR3={:#018x}", sregs.cr3); + + let regs = kvm::get_regs(vm.vcpu_fds[0]).expect("KVM_GET_REGS failed"); + println!("[+] vcpu0 RIP={:#018x} RSP={:#018x}", 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); + } +} diff --git a/src/mem.rs b/src/mem.rs new file mode 100644 index 0000000..4412485 --- /dev/null +++ b/src/mem.rs @@ -0,0 +1,113 @@ +use std::fs::{self, File}; +use std::os::unix::fs::FileExt; + +// A contiguous range of guest physical memory backed by a region in QEMU's +// address space. gpa_base is where this slot starts in guest physical space. +// For most single-slot quickemu VMs this will be (gpa_base=0, hva_base=). +pub struct MemRegion { + pub gpa_base: u64, + pub hva_base: u64, + pub size: u64, +} + +pub struct GuestMem { + pub regions: Vec, + mem_fd: File, +} + +impl GuestMem { + // Open /proc//mem and locate guest RAM by scanning QEMU's + // address space for large (>=256 MiB) rw-p anonymous / memfd mappings. + // For a standard quickemu Windows 11 VM, guest physical memory starts at + // GPA 0 and is backed by one or two such mappings. + pub fn attach(qemu_pid: u32) -> Option { + let maps_path = format!("/proc/{}/maps", qemu_pid); + let mem_path = format!("/proc/{}/mem", qemu_pid); + + let maps = fs::read_to_string(&maps_path).ok()?; + let mem_fd = File::open(&mem_path).ok()?; + + let mut regions: Vec = parse_guest_ram_regions(&maps); + if regions.is_empty() { return None; } + + // Assign guest physical addresses in ascending HVA order — the first + // region gets GPA 0, successive ones are packed behind it. + // This is only heuristically correct; will be replaced with proper + // KVM_SET_USER_MEMORY_REGION slot enumeration once we need NUMA / holes. + regions.sort_by_key(|r| r.hva_base); + let mut cursor: u64 = 0; + for r in &mut regions { + r.gpa_base = cursor; + cursor += r.size; + } + + Some(GuestMem { regions, mem_fd }) + } + + pub fn read_phys(&self, gpa: u64, buf: &mut [u8]) -> Result<(), String> { + let region = self.regions.iter().find(|r| { + gpa >= r.gpa_base && gpa + buf.len() as u64 <= r.gpa_base + r.size + }).ok_or_else(|| format!("GPA {:#x} not covered by any known region", gpa))?; + + let hva = region.hva_base + (gpa - region.gpa_base); + // SAFETY: pread64 on /proc//mem at the HVA offset is how + // external processes read another process's virtual memory without + // ptrace — the kernel validates the address in the target process. + self.mem_fd.read_at(buf, hva).map_err(|e| format!("pread GPA {:#x}: {}", gpa, e))?; + Ok(()) + } + + pub fn read_u64(&self, gpa: u64) -> Result { + let mut buf = [0u8; 8]; + self.read_phys(gpa, &mut buf)?; + Ok(u64::from_le_bytes(buf)) + } + + pub fn read_u32(&self, gpa: u64) -> Result { + let mut buf = [0u8; 4]; + self.read_phys(gpa, &mut buf)?; + Ok(u32::from_le_bytes(buf)) + } +} + +const MIN_REGION_SIZE: u64 = 256 * 1024 * 1024; // 256 MiB + +fn parse_guest_ram_regions(maps: &str) -> Vec { + let mut out = Vec::new(); + + for line in maps.lines() { + // Format: addr_start-addr_end perms offset dev inode [path] + let mut parts = line.splitn(6, ' '); + let addrs = parts.next().unwrap_or(""); + let perms = parts.next().unwrap_or(""); + let _offset = parts.next().unwrap_or(""); + let _dev = parts.next().unwrap_or(""); + let _inode = parts.next().unwrap_or(""); + let path = parts.next().unwrap_or("").trim(); + + // Must be rw-p (private, read-write, not executable — guest RAM pages + // are not mapped executable in QEMU's address space). + if perms != "rw-p" { continue; } + + // Anonymous (no backing file) or memfd-backed. + // Exclude vvar, vsyscall, stack, heap labels, and QEMU's own segments. + let is_anon = path.is_empty(); + let is_memfd = path.starts_with("/memfd:"); + if !is_anon && !is_memfd { continue; } + + let (start, end) = parse_addr_range(addrs).unwrap_or((0, 0)); + if end <= start { continue; } + + let size = end - start; + if size < MIN_REGION_SIZE { continue; } + + out.push(MemRegion { gpa_base: 0, hva_base: start, size }); + } + + out +} + +fn parse_addr_range(s: &str) -> Option<(u64, u64)> { + let (a, b) = s.split_once('-')?; + Some((u64::from_str_radix(a, 16).ok()?, u64::from_str_radix(b, 16).ok()?)) +} diff --git a/src/qemu.rs b/src/qemu.rs new file mode 100644 index 0000000..fb459bf --- /dev/null +++ b/src/qemu.rs @@ -0,0 +1,105 @@ +use std::fs; +use std::os::unix::io::RawFd; + +// pidfd_open(2) — Linux 5.6+ +// pidfd_getfd(2) — Linux 5.6+ +// Neither is in libc yet so we syscall directly. +const SYS_PIDFD_OPEN: libc::c_long = 434; +const SYS_PIDFD_GETFD: libc::c_long = 438; + +pub struct QemuVm { + pub pid: u32, + pub vm_fd: RawFd, + pub vcpu_fds: Vec, +} + +impl Drop for QemuVm { + fn drop(&mut self) { + unsafe { + libc::close(self.vm_fd); + for fd in &self.vcpu_fds { + libc::close(*fd); + } + } + } +} + +pub fn find_windows_vm() -> Option { + for entry in fs::read_dir("/proc").ok()? { + let entry = entry.ok()?; + let pid_str = entry.file_name(); + let pid: u32 = pid_str.to_str()?.parse().ok()?; + + if !is_qemu_windows_process(pid) { + continue; + } + + if let Some(vm) = steal_kvm_fds(pid) { + return Some(vm); + } + } + None +} + +fn is_qemu_windows_process(pid: u32) -> bool { + let cmdline_path = format!("/proc/{}/cmdline", pid); + let Ok(cmdline) = fs::read(&cmdline_path) else { return false }; + + // cmdline is null-delimited; treat as bytes and look for qemu + windows indicators + let s = cmdline.split(|&b| b == 0) + .filter_map(|a| std::str::from_utf8(a).ok()) + .collect::>() + .join(" "); + + // must be a qemu-system-x86_64 process running something Windows-flavoured + s.contains("qemu-system-x86_64") && (s.contains("windows") || s.contains("win11") || s.contains("win10")) +} + +fn steal_kvm_fds(pid: u32) -> Option { + // open a pidfd so we can duplicate fds out of the target process + // SAFETY: raw syscall, pid is a valid u32 we read from /proc + let pidfd = unsafe { libc::syscall(SYS_PIDFD_OPEN, pid as libc::pid_t, 0u32) }; + if pidfd < 0 { + return None; + } + let pidfd = pidfd as RawFd; + + let mut vm_fd: Option = None; + let mut vcpu_fds: Vec = Vec::new(); + + let fd_dir = format!("/proc/{}/fd", pid); + for entry in fs::read_dir(&fd_dir).ok()? { + let entry = entry.ok()?; + let fd_num: RawFd = entry.file_name().to_str()?.parse().ok()?; + + let link_path = format!("/proc/{}/fd/{}", pid, fd_num); + let Ok(target) = fs::read_link(&link_path) else { continue }; + let target = target.to_string_lossy(); + + if target == "anon_inode:kvm-vm" { + let dup = dup_fd(pidfd, fd_num); + if dup >= 0 { vm_fd = Some(dup); } + } else if target.starts_with("anon_inode:kvm-vcpu:") { + let dup = dup_fd(pidfd, fd_num); + if dup >= 0 { vcpu_fds.push(dup); } + } + } + + unsafe { libc::close(pidfd) }; + + let vm_fd = vm_fd?; + if vcpu_fds.is_empty() { + unsafe { libc::close(vm_fd) }; + return None; + } + + // sort vcpu fds by vcpu index so vcpu_fds[0] == vcpu 0 + vcpu_fds.sort(); + + Some(QemuVm { pid, vm_fd, vcpu_fds }) +} + +fn dup_fd(pidfd: RawFd, target_fd: RawFd) -> RawFd { + // SAFETY: pidfd and target_fd are valid fds obtained above + unsafe { libc::syscall(SYS_PIDFD_GETFD, pidfd, target_fd, 0u32) as RawFd } +}