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(®isters);
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}