Skip to main content

windows_breakpoint_manager/
windows-breakpoint-manager.rs

1//! This example demonstrates how to use the [`BreakpointManager`] and
2//! [`PageTableMonitor`] to intercept calls to selected kernel routines
3//! on a Windows guest.
4
5use std::sync::{
6    Arc,
7    atomic::{AtomicBool, Ordering},
8};
9
10use isr::{Profile, cache::IsrCache, macros::symbols};
11use vmi::{
12    MemoryAccess, Va, VcpuId, View, VmiContext, VmiCore, VmiError, VmiEventResponse, VmiHandler,
13    VmiSession,
14    arch::amd64::{Amd64, EventMonitor, EventReason, ExceptionVector, Interrupt},
15    driver::{VmiFullDriver, xen::VmiXenDriver},
16    os::{
17        ProcessObject, VmiOsProcess as _,
18        windows::{WindowsFileObject, WindowsOs, WindowsOsExt as _},
19    },
20    trace::Hex,
21    utils::{
22        bpm::{Breakpoint, BreakpointController, BreakpointManager},
23        ptm::PageTableMonitor,
24    },
25};
26use xen::XenStore;
27
28symbols! {
29    #[derive(Debug)]
30    pub struct Symbols {
31        NtCreateFile: u64,
32        NtWriteFile: u64,
33
34        PspInsertProcess: u64,
35        MmCleanProcessAddressSpace: u64,
36
37        // `symbols!` macro also accepts an `Option<u64>` as a value,
38        // where `None` means that the symbol is not present in the profile.
39        // MiInsertVad: Option<u64>,
40        // MiInsertPrivateVad: Option<u64>,
41        // MiGetWsAndInsertVad: Option<u64>,
42        // MiDeleteVad: Option<u64>,
43        // MiDeletePartialVad: Option<u64>,
44        // MiDeleteVirtualAddresses: Option<u64>,
45        // MiRemoveVadAndView: Option<u64>,
46    }
47}
48
49/// Per-VM monitoring state used by the example's [`VmiHandler`].
50pub struct Monitor<Driver>
51where
52    Driver: VmiFullDriver<Architecture = Amd64>,
53{
54    terminate_flag: Arc<AtomicBool>,
55    view: View,
56    bpm: BreakpointManager<BreakpointController<Driver>>,
57    ptm: PageTableMonitor<Driver>,
58}
59
60#[expect(non_snake_case)]
61impl<Driver> Monitor<Driver>
62where
63    Driver: VmiFullDriver<Architecture = Amd64>,
64{
65    /// Creates a new [`Monitor`] and installs breakpoints on the kernel
66    /// routines tracked by this example.
67    pub fn new(
68        session: &VmiSession<WindowsOs<Driver>>,
69        profile: &Profile,
70        terminate_flag: Arc<AtomicBool>,
71    ) -> Result<Self, VmiError> {
72        // Capture the current state of the vCPU and get the base address of
73        // the kernel.
74        //
75        // This base address is essential to correctly offset monitored
76        // functions.
77        //
78        // NOTE: `kernel_image_base` tries to find the kernel in the memory
79        //       with the help of the CPU registers. On AMD64 architecture,
80        //       the kernel image base is usually found using the `MSR_LSTAR`
81        //       register, which contains the address of the system call
82        //       handler. This register is set by the operating system during
83        //       boot and is left unchanged (unless some rootkits are involved).
84        //
85        //       Therefore, we can take an arbitrary registers at any point
86        //       in time (as long as the OS has booted and the page tables are
87        //       set up) and use them to find the kernel image base.
88        let registers = session.registers(VcpuId(0))?;
89        let vmi = session.with_registers(&registers);
90
91        let kernel_image_base = vmi.os().kernel_image_base()?;
92        tracing::info!(%kernel_image_base);
93
94        // Get the system process.
95        //
96        // The system process is the first process created by the kernel.
97        // In Windows, it is referenced by the kernel symbol `PsInitialSystemProcess`.
98        // To monitor page table entries, we need to locate the translation root
99        // of this process.
100        let system_process = vmi.os().system_process()?;
101        tracing::info!(system_process = %system_process.object()?);
102
103        // Get the translation root of the system process.
104        // This is effectively "the CR3 of the kernel".
105        //
106        // The translation root is the root of the page table hierarchy (also
107        // known as the Directory Table Base or PML4).
108        let root = system_process.translation_root()?;
109        tracing::info!(%root);
110
111        // Load the symbols from the profile.
112        let symbols = Symbols::new(profile)?;
113
114        // Enable monitoring of the INT3 and singlestep events.
115        //
116        // INT3 is used to monitor the execution of specific functions.
117        // Singlestep is used to monitor the modifications of page table
118        // entries.
119        vmi.monitor_enable(EventMonitor::Interrupt(ExceptionVector::Breakpoint))?;
120        vmi.monitor_enable(EventMonitor::Singlestep)?;
121
122        // Create a new view for the monitor.
123        // This view is used for monitoring function calls and memory accesses.
124        let view = vmi.create_view(MemoryAccess::RWX)?;
125        vmi.switch_to_view(view)?;
126
127        // Create a new breakpoint controller.
128        //
129        // The breakpoint controller is used to insert breakpoints for specific
130        // functions.
131        //
132        // From the guest's perspective, these breakpoints are "hidden", since
133        // the breakpoint controller will unset the read/write access to the
134        // physical memory page where the breakpoint is inserted, while keeping
135        // the execute access.
136        //
137        // This way, the guest will be able to execute the code, but attempts to
138        // read or write the memory will trigger the `memory_access` callback.
139        //
140        // When a vCPU tries to execute the breakpoint instruction:
141        // - an `interrupt` callback will be triggered
142        // - the breakpoint will be handled (e.g., log the function call)
143        // - a fast-singlestep[1] will be performed over the INT3 instruction
144        //
145        // When a vCPU tries to read from this page (e.g., a PatchGuard check):
146        // - `memory_access` callback will be triggered (with the `MemoryAccess::R`
147        //   access type)
148        // - fast-singlestep[1] will be performed over the instruction that tried to
149        //   read the memory
150        //
151        // This way, the instruction will read the original memory content.
152        //
153        // [1] Fast-singlestep is a VMI feature that allows to switch the vCPU
154        //     to a different view, execute a single instruction, and then
155        //     switch back to the original view. In this case, the view is
156        //     switched to the `default_view` (which is unmodified).
157        let mut bpm = BreakpointManager::new();
158
159        // Create a new page table monitor.
160        //
161        // The page table monitor is used to monitor the page table entries of
162        // the hooked functions.
163        //
164        // More specifically, it is used to monitor the pages that the breakpoint
165        // was inserted into. This is necessary to handle the case when the
166        // page containing the breakpoint is paged out (and then paged in
167        // again).
168        //
169        // `PageTableMonitor` works by unsetting the write access to the page
170        // tables of the hooked functions. When the page is paged out, the
171        // `PRESENT` bit in the page table entry is unset and, conversely, when
172        // the page is paged in, the `PRESENT` bit is set again.
173        //
174        // When that happens:
175        // - the `memory_access` callback will be triggered (with the `MemoryAccess::R`
176        //   access type)
177        // - the callback will mark the page as dirty in the page table monitor
178        // - a singlestep will be performed over the instruction that tried to modify
179        //   the memory containing the page table entry
180        // - the `singlestep` handler will process the dirty page table entries and
181        //   inform the breakpoint controller to handle the changes
182        let mut ptm = PageTableMonitor::new();
183
184        // Pause the VM to avoid race conditions between inserting breakpoints
185        // and monitoring page table entries. The VM resumes when the pause
186        // guard is dropped.
187        let _pause_guard = vmi.pause_guard()?;
188
189        // Insert breakpoint for the `NtCreateFile` function.
190        let va_NtCreateFile = kernel_image_base + symbols.NtCreateFile;
191        let cx_NtCreateFile = (va_NtCreateFile, root);
192        let bp_NtCreateFile = Breakpoint::new(cx_NtCreateFile, view)
193            .global()
194            .with_tag("NtCreateFile");
195        bpm.insert(&vmi, bp_NtCreateFile)?;
196        ptm.monitor(&vmi, cx_NtCreateFile, view, "NtCreateFile")?;
197        tracing::info!(%va_NtCreateFile);
198
199        // Insert breakpoint for the `NtWriteFile` function.
200        let va_NtWriteFile = kernel_image_base + symbols.NtWriteFile;
201        let cx_NtWriteFile = (va_NtWriteFile, root);
202        let bp_NtWriteFile = Breakpoint::new(cx_NtWriteFile, view)
203            .global()
204            .with_tag("NtWriteFile");
205        bpm.insert(&vmi, bp_NtWriteFile)?;
206        ptm.monitor(&vmi, cx_NtWriteFile, view, "NtWriteFile")?;
207        tracing::info!(%va_NtWriteFile);
208
209        // Insert breakpoint for the `PspInsertProcess` function.
210        let va_PspInsertProcess = kernel_image_base + symbols.PspInsertProcess;
211        let cx_PspInsertProcess = (va_PspInsertProcess, root);
212        let bp_PspInsertProcess = Breakpoint::new(cx_PspInsertProcess, view)
213            .global()
214            .with_tag("PspInsertProcess");
215        bpm.insert(&vmi, bp_PspInsertProcess)?;
216        ptm.monitor(&vmi, cx_PspInsertProcess, view, "PspInsertProcess")?;
217
218        // Insert breakpoint for the `MmCleanProcessAddressSpace` function.
219        let va_MmCleanProcessAddressSpace = kernel_image_base + symbols.MmCleanProcessAddressSpace;
220        let cx_MmCleanProcessAddressSpace = (va_MmCleanProcessAddressSpace, root);
221        let bp_MmCleanProcessAddressSpace = Breakpoint::new(cx_MmCleanProcessAddressSpace, view)
222            .global()
223            .with_tag("MmCleanProcessAddressSpace");
224        bpm.insert(&vmi, bp_MmCleanProcessAddressSpace)?;
225        ptm.monitor(
226            &vmi,
227            cx_MmCleanProcessAddressSpace,
228            view,
229            "MmCleanProcessAddressSpace",
230        )?;
231
232        Ok(Self {
233            terminate_flag,
234            view,
235            bpm,
236            ptm,
237        })
238    }
239
240    #[tracing::instrument(skip_all)]
241    fn memory_access(
242        &mut self,
243        vmi: &VmiContext<WindowsOs<Driver>>,
244    ) -> Result<VmiEventResponse<Amd64>, VmiError> {
245        let memory_access = vmi.event().reason().as_memory_access();
246
247        tracing::trace!(
248            pa = %memory_access.pa,
249            va = %memory_access.va,
250            access = %memory_access.access,
251        );
252
253        if memory_access.access.contains(MemoryAccess::W) {
254            // It is assumed that a write memory access event is caused by a
255            // page table modification.
256            //
257            // The page table entry is marked as dirty in the page table monitor
258            // and a singlestep is performed to process the dirty entries.
259            self.ptm
260                .mark_dirty_entry(memory_access.pa, self.view, vmi.event().vcpu_id());
261
262            Ok(VmiEventResponse::singlestep().with_view(vmi.default_view()))
263        }
264        else if memory_access.access.contains(MemoryAccess::R) {
265            // When the guest tries to read from the memory, a fast-singlestep
266            // is performed over the instruction that tried to read the memory.
267            // This is done to allow the instruction to read the original memory
268            // content.
269            Ok(VmiEventResponse::fast_singlestep(vmi.default_view()))
270        }
271        else {
272            panic!("Unhandled memory access: {memory_access:?}");
273        }
274    }
275
276    #[tracing::instrument(skip_all, fields(pid, process))]
277    fn interrupt(
278        &mut self,
279        vmi: &VmiContext<WindowsOs<Driver>>,
280    ) -> Result<VmiEventResponse<Amd64>, VmiError> {
281        let tag = match self.bpm.get_by_event(vmi.event(), ()) {
282            Some(breakpoints) => {
283                // Breakpoints can have multiple tags, but we have set only one
284                // tag for each breakpoint.
285                let first_breakpoint = breakpoints.into_iter().next().expect("breakpoint");
286                first_breakpoint.tag()
287            }
288            None => {
289                if BreakpointController::is_breakpoint(vmi, vmi.event())? {
290                    // This breakpoint was not set by us. Reinject it.
291                    tracing::warn!("Unknown breakpoint, reinjecting");
292                    return Ok(VmiEventResponse::reinject_interrupt());
293                }
294                else {
295                    // We have received a breakpoint event, but there is no
296                    // breakpoint instruction at the current memory location.
297                    // This can happen if the event was triggered by a breakpoint
298                    // we just removed.
299                    tracing::warn!("Ignoring old breakpoint event");
300                    return Ok(VmiEventResponse::fast_singlestep(vmi.default_view()));
301                }
302            }
303        };
304
305        let process = vmi.os().current_process()?;
306        let process_id = process.id()?;
307        let process_name = process.name()?;
308        tracing::Span::current()
309            .record("pid", process_id.0)
310            .record("process", process_name);
311
312        match tag {
313            "NtCreateFile" => self.NtCreateFile(vmi)?,
314            "NtWriteFile" => self.NtWriteFile(vmi)?,
315            "PspInsertProcess" => self.PspInsertProcess(vmi)?,
316            "MmCleanProcessAddressSpace" => self.MmCleanProcessAddressSpace(vmi)?,
317            _ => panic!("Unhandled tag: {tag}"),
318        }
319
320        Ok(VmiEventResponse::fast_singlestep(vmi.default_view()))
321    }
322
323    #[tracing::instrument(skip_all)]
324    fn singlestep(
325        &mut self,
326        vmi: &VmiContext<WindowsOs<Driver>>,
327    ) -> Result<VmiEventResponse<Amd64>, VmiError> {
328        // Get the page table modifications by processing the dirty page table
329        // entries.
330        let ptm_events = self.ptm.process_dirty_entries(vmi, vmi.event().vcpu_id())?;
331
332        // Let the breakpoint controller handle the page table modifications.
333        self.bpm.handle_ptm_events(vmi, ptm_events)?;
334
335        // Disable singlestep and switch back to our view.
336        Ok(VmiEventResponse::default().with_view(self.view))
337    }
338
339    #[tracing::instrument(skip_all)]
340    fn NtCreateFile(&mut self, vmi: &VmiContext<WindowsOs<Driver>>) -> Result<(), VmiError> {
341        //
342        // NTSTATUS
343        // NtCreateFile (
344        //     _Out_ PHANDLE FileHandle,
345        //     _In_ ACCESS_MASK DesiredAccess,
346        //     _In_ POBJECT_ATTRIBUTES ObjectAttributes,
347        //     _Out_ PIO_STATUS_BLOCK IoStatusBlock,
348        //     _In_opt_ PLARGE_INTEGER AllocationSize,
349        //     _In_ ULONG FileAttributes,
350        //     _In_ ULONG ShareAccess,
351        //     _In_ ULONG CreateDisposition,
352        //     _In_ ULONG CreateOptions,
353        //     _In_reads_bytes_opt_(EaLength) PVOID EaBuffer,
354        //     _In_ ULONG EaLength
355        //     );
356        //
357
358        let ObjectAttributes = Va(vmi.os().function_argument(2)?);
359
360        let object_attributes = vmi.os().object_attributes(ObjectAttributes)?;
361        let object_name = match object_attributes.object_name()? {
362            Some(object_name) => object_name,
363            None => {
364                tracing::warn!(%ObjectAttributes, "No object name found");
365                return Ok(());
366            }
367        };
368
369        tracing::info!(%object_name);
370
371        Ok(())
372    }
373
374    #[tracing::instrument(skip_all)]
375    fn NtWriteFile(&mut self, vmi: &VmiContext<WindowsOs<Driver>>) -> Result<(), VmiError> {
376        //
377        // NTSTATUS
378        // NtWriteFile (
379        //     _In_ HANDLE FileHandle,
380        //     _In_opt_ HANDLE Event,
381        //     _In_opt_ PIO_APC_ROUTINE ApcRoutine,
382        //     _In_opt_ PVOID ApcContext,
383        //     _Out_ PIO_STATUS_BLOCK IoStatusBlock,
384        //     _In_reads_bytes_(Length) PVOID Buffer,
385        //     _In_ ULONG Length,
386        //     _In_opt_ PLARGE_INTEGER ByteOffset,
387        //     _In_opt_ PULONG Key
388        //     );
389        //
390
391        let FileHandle = vmi.os().function_argument(0)?;
392
393        let file_object = match vmi
394            .os()
395            .current_process()?
396            .lookup_object::<WindowsFileObject<_>>(FileHandle)?
397        {
398            Some(file_object) => file_object,
399            None => {
400                tracing::warn!(FileHandle = %Hex(FileHandle), "No object found");
401                return Ok(());
402            }
403        };
404
405        let path = file_object.full_path()?;
406        tracing::info!(%path);
407
408        Ok(())
409    }
410
411    #[tracing::instrument(skip_all)]
412    fn PspInsertProcess(&mut self, vmi: &VmiContext<WindowsOs<Driver>>) -> Result<(), VmiError> {
413        //
414        // NTSTATUS
415        // PspInsertProcess (
416        //     _In_ PEPROCESS NewProcess,
417        //     _In_ PEPROCESS Parent,
418        //     _In_ ULONG DesiredAccess,
419        //     _In_ ULONG CreateFlags,
420        //     ...
421        //     );
422        //
423
424        let NewProcess = vmi.os().function_argument(0)?;
425        let Parent = vmi.os().function_argument(1)?;
426
427        let process = vmi.os().process(ProcessObject(Va(NewProcess)))?;
428        let process_id = process.id()?;
429
430        let parent_process = vmi.os().process(ProcessObject(Va(Parent)))?;
431        let parent_process_id = parent_process.id()?;
432
433        // We rely heavily on the 2nd argument to be the parent process object.
434        // If that ever changes, this assertion should catch it.
435        //
436        // So far it is verified that it works for Windows 7 up to Windows 11
437        // (23H2, build 22631).
438        debug_assert_eq!(parent_process_id, process.parent_id()?);
439
440        let name = process.name()?;
441        let image_base = process.image_base()?;
442        let peb = process.peb()?;
443
444        tracing::info!(
445            %process_id,
446            name,
447            %image_base,
448            ?peb,
449        );
450
451        Ok(())
452    }
453
454    #[tracing::instrument(skip_all)]
455    fn MmCleanProcessAddressSpace(
456        &mut self,
457        vmi: &VmiContext<WindowsOs<Driver>>,
458    ) -> Result<(), VmiError> {
459        //
460        // VOID
461        // MmCleanProcessAddressSpace (
462        //     _In_ PEPROCESS Process
463        //     );
464        //
465
466        let Process = vmi.os().function_argument(0)?;
467
468        let process = vmi.os().process(ProcessObject(Va(Process)))?;
469        let process_id = process.id()?;
470
471        let name = process.name()?;
472        let image_base = process.image_base()?;
473
474        tracing::info!(%process_id, name, %image_base);
475
476        Ok(())
477    }
478
479    fn dispatch(
480        &mut self,
481        vmi: &VmiContext<WindowsOs<Driver>>,
482    ) -> Result<VmiEventResponse<Amd64>, VmiError> {
483        let event = vmi.event();
484        let result = match event.reason() {
485            EventReason::MemoryAccess(_) => self.memory_access(vmi),
486            EventReason::Interrupt(_) => self.interrupt(vmi),
487            EventReason::Singlestep(_) => self.singlestep(vmi),
488            _ => panic!("Unhandled event: {:?}", event.reason()),
489        };
490
491        // If VMI tries to read from a page that is not present, it will return
492        // a page fault error. In this case, we inject a page fault interrupt
493        // to the guest.
494        //
495        // Once the guest handles the page fault, it will retry to execute the
496        // instruction that caused the page fault.
497        if let Err(VmiError::Translation(pf)) = result {
498            tracing::warn!(?pf, "Page fault, injecting");
499            vmi.inject_interrupt(event.vcpu_id(), Interrupt::page_fault(pf.va, 0))?;
500            return Ok(VmiEventResponse::default());
501        }
502
503        result
504    }
505}
506
507impl<Driver> VmiHandler<WindowsOs<Driver>> for Monitor<Driver>
508where
509    Driver: VmiFullDriver<Architecture = Amd64>,
510{
511    type Output = ();
512
513    fn handle_event(&mut self, vmi: VmiContext<WindowsOs<Driver>>) -> VmiEventResponse<Amd64> {
514        // Flush the V2P cache on every event to avoid stale translations.
515        vmi.flush_v2p_cache();
516
517        self.dispatch(&vmi).expect("dispatch")
518    }
519
520    fn poll(&self) -> Option<Self::Output> {
521        self.terminate_flag.load(Ordering::Relaxed).then_some(())
522    }
523}
524
525fn main() -> Result<(), Box<dyn std::error::Error>> {
526    tracing_subscriber::fmt()
527        .with_max_level(tracing::Level::DEBUG)
528        .init();
529
530    let domain_id = 'x: {
531        for name in &["win7", "win10", "win11", "ubuntu22"] {
532            if let Some(domain_id) = XenStore::new()?.domain_id_from_name(name)? {
533                break 'x domain_id;
534            }
535        }
536
537        panic!("Domain not found");
538    };
539
540    tracing::debug!(?domain_id);
541
542    // Setup VMI.
543    let driver = VmiXenDriver::<Amd64>::new(domain_id)?;
544    let core = VmiCore::new(driver)?;
545
546    // Try to find the kernel information.
547    // This is necessary in order to load the profile.
548    let kernel_info = {
549        let _pause_guard = core.pause_guard()?;
550        let regs = core.registers(0.into())?;
551
552        WindowsOs::find_kernel(&core, &regs)?.expect("kernel information")
553    };
554
555    // Load the profile.
556    // The profile contains offsets to kernel functions and data structures.
557    let isr = IsrCache::new("cache")?;
558    let entry = isr.entry_from_codeview(kernel_info.codeview)?;
559    let profile = entry.profile()?;
560
561    // Create the VMI session.
562    tracing::info!("Creating VMI session");
563    let terminate_flag = Arc::new(AtomicBool::new(false));
564    signal_hook::flag::register(signal_hook::consts::SIGHUP, terminate_flag.clone())?;
565    signal_hook::flag::register(signal_hook::consts::SIGINT, terminate_flag.clone())?;
566    signal_hook::flag::register(signal_hook::consts::SIGALRM, terminate_flag.clone())?;
567    signal_hook::flag::register(signal_hook::consts::SIGTERM, terminate_flag.clone())?;
568
569    let os = WindowsOs::<VmiXenDriver<Amd64>>::new(&profile)?;
570    let session = VmiSession::new(&core, &os);
571
572    session.handle(|session| Monitor::new(session, &profile, terminate_flag))?;
573
574    Ok(())
575}