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