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(®isters);
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, ®s)?.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}