content/blog/woce-6.md (view raw)
1+++
2title = "Writing our own Cheat Engine: Pointers"
3date = 2021-03-13
4updated = 2021-03-13
5[taxonomies]
6category = ["sw"]
7tags = ["windows", "rust", "hacking"]
8+++
9
10This is part 6 on the *Writing our own Cheat Engine* series:
11
12* [Part 1: Introduction](/blog/woce-1) (start here if you're new to the series!)
13* [Part 2: Exact Value scanning](/blog/woce-2)
14* [Part 3: Unknown initial value](/blog/woce-3)
15* [Part 4: Floating points](/blog/woce-4)
16* [Part 5: Code finder](/blog/woce-5)
17* Part 6: Pointers
18
19In part 5 we wrote our very own debugger. We learnt that Cheat Engine is using hardware breakpoints to watch memory change, and how to do the same ourselves. We also learnt that hardware points are not the only way to achieve the effect of watchpoints, although they certainly are the fastest and cleanest approach.
20
21In this post, we will be reusing some of that knowledge to find out a closely related value, the *pointer* that points to the real value[^1]. As a quick reminder, a pointer is nothing but an `usize`[^2] representing the address of another portion of memory, in this case, the actual value we will be scanning for. A pointer is a value that, well, points elsewhere. In Rust we normally use reference instead, which are safer (typed and their lifetime is tracked) than pointers, but in the end we can achieve the same with both.
22
23Why care about pointers? It turns out that things, such as your current health in-game, are very unlikely to end up in the same memory position when you restart the game (or even change to another level, or even during gameplay). So, if you perform a scan and find that the address where your health is stored is `0x73AABABE`, you might be tempted to save it and reuse it next time you launch the game. Now you don't need to scan for it again! Alas, as soon as you restart the game, the health is now stored at `0x5AADBEEF`.
24
25Not all hope is lost! The game must *somehow* have a way to reliably find this value, and the way it's done is with pointers. There will always be some base address that holds a pointer, and the game code knows where to find this pointer. If we are also able to find the pointer at said base address, and follow it ourselves ("dereferencing" it), we can perform the same steps the game is doing, and reliably find the health no matter how much we restart the game[^3].
26
27## Code finder
28
29<details open><summary>Cheat Engine Tutorial: Step 6</summary>
30
31> In the previous step I explained how to use the Code finder to handle changing locations. But that method alone makes it difficult to find the address to set the values you want. That's why there are pointers:
32>
33> At the bottom you'll find 2 buttons. One will change the value, and the other changes the value AND the location of the value. For this step you don't really need to know assembler, but it helps a lot if you do.
34>
35> First find the address of the value. When you've found it use the function to find out what accesses this address.
36>
37> Change the value again, and a item will show in the list. Double click that item. (or select and click on more info) and a new window will open with detailed information on what happened when the instruction ran.
38>
39> If the assembler instruction doesn't have anything between a '[' and ']' then use another item in the list. If it does it will say what it think will be the value of the pointer you need.
40>
41> Go back to the main cheat engine window (you can keep this extra info window open if you want, but if you close it, remember what is between the \[ and \]) and do a 4 byte scan in hexadecimal for the value the extra info told you. When done scanning it may return 1 or a few hundred addresses. Most of the time the address you need will be the smallest one. Now click on manually add and select the pointer checkbox.
42>
43> The window will change and allow you to type in the address of a pointer and a offset. Fill in as address the address you just found. If the assembler instruction has a calculation (e.g: [esi+12]) at the end then type the value in that's at the end. else leave it 0. If it was a more complicated instruction look at the calculation.
44>
45> Example of a more complicated instruction:
46>
47> [EAX*2+EDX+00000310] eax=4C and edx=00801234.
48>
49> In this case EDX would be the value the pointer has, and EAX\*2+00000310 the offset, so the offset you'd fill in would be 2\*4C+00000310=3A8. (this is all in hex, use calc.exe from windows in scientific mode to calculate).
50>
51> Back to the tutorial, click OK and the address will be added, If all went right the address will show P->xxxxxxx, with xxxxxxx being the address of the value you found. If thats not right, you've done something wrong. Now, change the value using the pointer you added in 5000 and freeze it. Then click Change pointer, and if all went right the next button will become visible.
52>
53> *extra*: And you could also use the pointer scanner to find the pointer to this address.
54
55</details>
56
57## On-access watchpoints
58
59Last time we managed to learn how hardware breakpoints were being set by observing Cheat Engine's behaviour. I think it's now time to handle this properly instead. We'll check out the [CPU Registers x86 page on OSDev][dbg-reg] to learn about it:
60
61* DR0, DR1, DR2 and DR3 can hold a memory address each. This address will be used by the breakpoint.
62* DR4 is actually an [obsolete synonym][dr4] for DR6.
63* DR5 is another obsolete synonym, this time for DR7.
64* DR6 is debug status. The four lowest bits indicate which breakpoint was hit, and the four highest bits contain additional information. We should make sure to clear this ourselves when a breakpoint is hit.
65* DR7 is debug control, which we need to study more carefully.
66
67Each debug register DR0 through DR3 has two corresponding bits in DR7, starting from the lowest-order bit, to indicate whether the corresponding register is a **L**ocal or **G**lobal breakpoint. So it looks like this:
68
69```
70 Meaning: [ .. .. | G3 | L3 | G2 | L2 | G1 | L1 | G0 | L0 ]
71Bit-index: 31-08 | 07 | 06 | 05 | 04 | 03 | 02 | 01 | 00
72```
73
74Cheat Engine was using local breakpoints, because the zeroth bit was set. Probably because we don't want these breakpoints to infect other programs! Because we were using only one breakpoint, only the lowermost bit was being set. The local 1st, 2nd and 3rd bits were unset.
75
76Now, each debug register DR0 through DR4 has four additional bits in DR7, two for the **C**ondition and another two for the **S**ize:
77
78```
79 Meaning: [ S3 | C3 | S2 | C2 | S1 | C1 | S0 | C0 | .. .. ]
80Bit-index: 31 30 | 29 28 | 27 26 | 25 24 | 23 22 | 21 20 | 19 18 | 17 16 | 15-00
81```
82
83The two bits of the condition mean the following:
84
85* `00` execution breakpoint.
86* `01` write watchpoint.
87* `11` read/write watchpoint.
88* `10` unsupported I/O read/write.
89
90When we were using Cheat Engine to add write watchpoints, the bits 17 and 16 were indeed set to `01`, and the bits 19 and 18 were set to `11`. Hm, but *11<sub>2</sub> = 3<sub>10</sub>* , and yet, we were watching writes to 4 bytes. So what's up with this? Is there a different mapping for the size which isn't documented at the time of writing? Seems we need to learn from Cheat Engine's behaviour one more time.
91
92For reference, this is what DR7 looked like when we added a single write watchpoint:
93
94```
95hex: 000d_0001
96bin: 00000000_00001101_00000000_00000001
97```
98
99And this is the code I will be using to check the breakpoints of different sizes:
100
101```
102thread::enum_threads(pid)
103 .unwrap()
104 .into_iter()
105 .for_each(|tid| {
106 let thread = thread::Thread::open(tid).unwrap();
107 let ctx = thread.get_context().unwrap();
108 eprintln!("hex: {:08x}", ctx.Dr7);
109 eprintln!("bin: {:032b}", ctx.Dr7);
110 });
111```
112
113Let's compare this to watchpoints for sizes 1, 2, 4 and 8 bytes:
114
115```
1161 byte
117hex: 0001_0401
118bin: 00000000_00000001_00000100_00000001
119
1202 bytes
121hex: 0005_0401
122bin: 00000000_00000101_00000100_00000001
123
1244 bytes
125hex: 000d_0401
126bin: 00000000_00001101_00000100_00000001
127
1288 bytes
129hex: 0009_0401
130bin: 00000000_00001001_00000100_00000001
131 ^ wut?
132```
133
134I have no idea what's up with that stray tenth bit. Its use does not seem documented, and things worked fine without it, so we'll ignore it. The lowest bit is set to indicate we're using DR0, bits 17 and 16 represent the write watchpoint, and the size seems to be as follows:
135
136* `00` for a single byte.
137* `01` for two bytes (a "word").
138* `11` for four bytes (a "double word").
139* `10` for eight bytes (a "quadruple word").
140
141Doesn't make much sense if you ask me, but we'll roll with it. Just to confirm, this is what the "on-access" breakpoint looks like according to Cheat Engine:
142
143```
144hex: 000f_0401
145bin: 00000000_00001111_00000100_00000001
146```
147
148So it all checks out! The bit pattern is `11` for read/write (technically, a write is also an access). Let's implement this!
149
150## Proper breakpoint handling
151
152The first thing we need to do is represent the possible breakpoint conditions:
153
154```rust
155#[repr(u8)]
156pub enum Condition {
157 Execute = 0b00,
158 Write = 0b01,
159 Access = 0b11,
160}
161```
162
163And also the legal breakpoint sizes:
164
165```rust
166#[repr(u8)]
167pub enum Size {
168 Byte = 0b00,
169 Word = 0b01,
170 DoubleWord = 0b11,
171 QuadWord = 0b10,
172}
173```
174
175We are using `#[repr(u8)]` so that we can convert a given variant into the corresponding bit pattern. With the right types defined in order to set a breakpoint, we can start implementing the method that will set them (inside `impl Thread`):
176
177```rust
178pub fn add_breakpoint(&self, addr: usize, cond: Condition, size: Size) -> io::Result<Breakpoint> {
179 let mut context = self.get_context()?;
180 todo!()
181}
182```
183
184First, let's try finding an "open spot" where we could set our breakpoint. We will "slide" a the `0b11` bitmask over the lowest eight bits, and if and only if both the local and global bits are unset, then we're free to set a breakpoint at this index[^4]:
185
186```rust
187let index = (0..4)
188 .find_map(|i| ((context.Dr7 & (0b11 << (i * 2))) == 0).then(|| i))
189 .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "no debug register available"))?;
190```
191
192Once an `index` is found, we can set the address we want to watch in the corresponding register and update the debug control bits:
193
194```rust
195let addr = addr as u64;
196match index {
197 0 => context.Dr0 = addr,
198 1 => context.Dr1 = addr,
199 2 => context.Dr2 = addr,
200 3 => context.Dr3 = addr,
201 _ => unreachable!(),
202}
203
204let clear_mask = !((0b1111 << (16 + index * 4)) | (0b11 << (index * 2)));
205context.Dr7 &= clear_mask;
206
207context.Dr7 |= 1 << (index * 2);
208
209let sc = (((size as u8) << 2) | (cond as u8)) as u64;
210context.Dr7 |= sc << (16 + index * 4);
211
212self.set_context(&context)?;
213Ok(Breakpoint {
214 thread: self,
215 clear_mask,
216})
217```
218
219Note that we're first creating a "clear mask". We switch on all the bits that we may use for this breakpoint, and then negate. Effectively, `Dr7 & clear_mask` will make sure we don't leave any bit high on accident. We apply the mask before OR-ing the rest of bits to also clear any potential garbage on the size and condition bits. Next, we set the bit to enable the new local breakpoint, and also store the size and condition bits at the right location.
220
221With the context updated, we can set it back and return the `Breakpoint`. It stores the `thread` and the `clear_mask` so that it can clean up on `Drop`. We are technically relying on `Drop` to run behaviour here, but the cleanup is done on a best-effort basis. If the user intentionally forgets the `Breakpoint`, maybe they want the `Breakpoint` to forever be set.
222
223This logic is begging for a testcase though; I'll split it into a new `Breakpoint::update_dbg_control` method and test that out:
224
225```rust
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn brk_add_one() {
233 // DR7 starts with garbage which should be respected.
234 let (clear_mask, dr, dr7) =
235 Breakpoint::update_dbg_control(0x1700, Condition::Write, Size::DoubleWord).unwrap();
236
237 assert_eq!(clear_mask, 0xffff_ffff_fff0_fffc);
238 assert_eq!(dr, DebugRegister::Dr0);
239 assert_eq!(dr7, 0x0000_0000_000d_1701);
240 }
241
242 #[test]
243 fn brk_add_two() {
244 let (clear_mask, dr, dr7) = Breakpoint::update_dbg_control(
245 0x0000_0000_000d_0001,
246 Condition::Write,
247 Size::DoubleWord,
248 )
249 .unwrap();
250
251 assert_eq!(clear_mask, 0xffff_ffff_ff0f_fff3);
252 assert_eq!(dr, DebugRegister::Dr1);
253 assert_eq!(dr7, 0x0000_0000_00dd_0005);
254 }
255
256 #[test]
257 fn brk_try_add_when_max() {
258 assert!(Breakpoint::update_dbg_control(
259 0x0000_0000_dddd_0055,
260 Condition::Write,
261 Size::DoubleWord
262 )
263 .is_none());
264 }
265}
266```
267
268```
269running 3 tests
270test thread::tests::brk_add_one ... ok
271test thread::tests::brk_add_two ... ok
272test thread::tests::brk_try_add_when_max ... ok
273```
274
275Very good! With proper breakpoint handling usable, we can continue.
276
277## Inferring the pointer value
278
279After scanning memory for the location we're looking for (say, our current health), we then add an access watchpoint, and wait for an exception to occur. As a reminder, here's the page with the [Debugging Events][dbg-events]:
280
281```rust
282let addr = ...;
283let mut threads = ...;
284
285let _watchpoints = threads
286 .iter_mut()
287 .map(|thread| {
288 thread
289 .add_breakpoint(addr, thread::Condition::Access, thread::Size::DoubleWord)
290 .unwrap()
291 })
292 .collect::<Vec<_>>();
293
294loop {
295 let event = debugger.wait_event(None).unwrap();
296 if event.dwDebugEventCode == winapi::um::minwinbase::EXCEPTION_DEBUG_EVENT {
297 let exc = unsafe { event.u.Exception() };
298 if exc.ExceptionRecord.ExceptionCode == winapi::um::minwinbase::EXCEPTION_SINGLE_STEP {
299 todo!();
300 }
301 }
302 debugger.cont(event, true).unwrap();
303}
304```
305
306Now, inside the `todo!()` we will want to do a few things, namely printing out the instructions "around this location" and dumping the entire thread context on screen. To print the instructions, we need to import `iced_x86` again, iterate over all memory regions to find the region where the exception happened, read the corresponding bytes, decode the instructions, and when we find the one with a corresponding instruction pointer, print "around it":
307
308```rust
309use iced_x86::{Decoder, DecoderOptions, Formatter, Instruction, NasmFormatter};
310
311let addr = exc.ExceptionRecord.ExceptionAddress as usize;
312let region = process
313 .memory_regions()
314 .into_iter()
315 .find(|region| {
316 let base = region.BaseAddress as usize;
317 base <= addr && addr < base + region.RegionSize
318 })
319 .unwrap();
320
321let bytes = process
322 .read_memory(region.BaseAddress as usize, region.RegionSize)
323 .unwrap();
324
325let mut decoder = Decoder::new(64, &bytes, DecoderOptions::NONE);
326decoder.set_ip(region.BaseAddress as _);
327
328let mut formatter = NasmFormatter::new();
329let mut output = String::new();
330
331let instructions = decoder.into_iter().collect::<Vec<_>>();
332for (i, ins) in instructions.iter().enumerate() {
333 if ins.next_ip() as usize == addr {
334 let low = i.saturating_sub(5);
335 let high = (i + 5).min(instructions.len());
336 for j in low..high {
337 let ins = &instructions[j];
338 print!("{} {:016X} ", if j == i { ">>>" } else { " " }, ins.ip());
339 let k = (ins.ip() - region.BaseAddress as usize as u64) as usize;
340 let instr_bytes = &bytes[k..k + ins.len()];
341 for b in instr_bytes.iter() {
342 print!("{:02X}", b);
343 }
344 if instr_bytes.len() < 10 {
345 for _ in 0..10usize.saturating_sub(instr_bytes.len()) {
346 print!(" ");
347 }
348 }
349
350 output.clear();
351 formatter.format(ins, &mut output);
352 println!(" {}", output);
353 }
354 break;
355 }
356}
357debugger.cont(event, true).unwrap();
358break;
359```
360
361The result is pretty fancy:
362
363```
364 000000010002CAAC 48894DF0 mov [rbp-10h],rcx
365 000000010002CAB0 488955F8 mov [rbp-8],rdx
366 000000010002CAB4 48C745D800000000 mov qword [rbp-28h],0
367 000000010002CABC 90 nop
368 000000010002CABD 488B050CA02D00 mov rax,[rel 100306AD0h]
369>>> 000000010002CAC4 8B00 mov eax,[rax]
370 000000010002CAC6 8945EC mov [rbp-14h],eax
371 000000010002CAC9 B9E8030000 mov ecx,3E8h
372 000000010002CACE E88D2FFEFF call 000000010000FA60h
373 000000010002CAD3 8945E8 mov [rbp-18h],eax
374```
375
376Cool! So `rax` is holding an address, meaning it's a pointer, and the value it reads (dereferences) is stored back into `eax` (because it does not need `rax` anymore). Alas, the current thread context has the register state *after* the instruction was executed, and `rax` no longer contains the address at this point. However, notice how the previous instruction writes a fixed value to `rax`, and then that value is used to access memory, like so:
377
378```rust
379let eax = memory[memory[0x100306AD0]];
380```
381
382The value at `memory[0x100306AD0]` *is* the pointer! No offsets are used, because nothing is added to the pointer after it's read. This means that, if we simply scan for the address we were looking for, we should find out where the pointer is stored:
383
384```rust
385let addr = ...;
386let scan = process.scan_regions(®ions, Scan::Exact(addr as u64));
387
388scan.into_iter().for_each(|region| {
389 region.locations.iter().for_each(|ptr_addr| {
390 println!("[{:x}] = {:x}", ptr_addr, addr);
391 });
392});
393```
394
395And just like that:
396
397```
398[100306ad0] = 15de9f0
399```
400
401Notice how the pointer address found matches with the offset used by the instructions:
402
403```
404 000000010002CABD 488B050CA02D00 mov rax,[rel 100306AD0h]
405 this is the same as the value we just found ^^^^^^^^^^
406```
407
408Very interesting indeed. We were actually very lucky to have only found a single memory location containing the pointer value, `0x15de9f0`. Cheat Engine somehow knows that this value is always stored at `0x100306ad0` (or rather, at `Tutorial-x86_64.exe+306AD0`), because the address shows green. How does it do this?
409
410## Base addresses
411
412Remember back in [part 2](/blog/woce-2) when we introduced the memory regions? They're making a comeback! A memory region contains both the current memory protection option *and* the protection level when the region was created. If we try printing out the protection levels for both the memory region containing the value, and the memory region containing the pointer, this is what we get (the addresses differ from the ones previously because I restarted the tutorial):
413
414```
415Region holding the value:
416 BaseAddress: 0xb0000
417 AllocationBase: 0xb0000
418 AllocationProtect: 0x4
419 RegionSize: 1007616
420 State: 4096
421 Protect: 4
422 Type: 0x20000
423
424Region holding the pointer:
425 BaseAddress: 0x100304000
426 AllocationBase: 0x100000000
427 AllocationProtect: 0x80
428 RegionSize: 28672
429 State: 4096
430 Protect: 4
431 Type: 0x1000000
432```
433
434Interesting! According to the [`MEMORY_BASIC_INFORMATION` page][meminfo], the type for the first region is `MEM_PRIVATE`, and the type for the second region is `MEM_IMAGE` which:
435
436> Indicates that the memory pages within the region are mapped into the view of an image section.
437
438The protection also changes from `PAGE_EXECUTE_WRITECOPY` to simply `PAGE_READWRITE`, but I don't think it's relevant. Neither the type seems to be much more relevant. In [part 2](/blog/woce-2) we also mentioned the concept of "base address", but decided against using it, because starting to look for regions at address zero seemed to work fine. However, it would make sense that fixed "addresses" start at some known "base". Let's try getting the [base address for all loaded modules][baseaddr]. Currently, we only get the address for the base module, in order to retrieve its name, but now we need them all:
439
440```rust
441pub fn enum_modules(&self) -> io::Result<Vec<winapi::shared::minwindef::HMODULE>> {
442 let mut size = 0;
443 if unsafe {
444 winapi::um::psapi::EnumProcessModules(
445 self.handle.as_ptr(),
446 ptr::null_mut(),
447 0,
448 &mut size,
449 )
450 } == FALSE
451 {
452 return Err(io::Error::last_os_error());
453 }
454
455 let mut modules = Vec::with_capacity(size as usize / mem::size_of::<HMODULE>());
456 if unsafe {
457 winapi::um::psapi::EnumProcessModules(
458 self.handle.as_ptr(),
459 modules.as_mut_ptr(),
460 (modules.capacity() * mem::size_of::<HMODULE>()) as u32,
461 &mut size,
462 )
463 } == FALSE
464 {
465 return Err(io::Error::last_os_error());
466 }
467
468 unsafe {
469 modules.set_len(size as usize / mem::size_of::<HMODULE>());
470 }
471
472 Ok(modules)
473}
474```
475
476The first call is used to retrieve the correct `size`, then we allocate just enough, and make the second call. The returned type are pretty much memory addresses, so let's see if we can find regions that contain them:
477
478```rust
479let mut bases = 0;
480let modules = process.enum_modules().unwrap();
481let regions = process.memory_regions();
482regions.iter().for_each(|region| {
483 if modules.iter().any(|module| {
484 let base = region.AllocationBase as usize;
485 let addr = *module as usize;
486 base <= addr && addr < base + region.RegionSize
487 }) {
488 bases += 1;
489 }
490});
491
492println!(
493 "{}/{} regions have a module address within them",
494 bases,
495 regions.len()
496);
497```
498
499```
50041/353 regions have a module address within them
501```
502
503Exciting stuff! It appears `base == addr` also does the trick[^5], so now we could build a `bases: HashSet<usize>` and simply check if `bases.contains(®ion.AllocationBase as usize)` to determine whether `region` is a base address or not[^6]. So there we have it! The address holding the pointer value does fall within one of these "base regions". You can also get the name from one of these module addresses, and print it in the same way as Cheat Engine does it (such as `Tutorial-x86_64.exe+306AD0`).
504
505## Finale
506
507So, there's no "automated" solution to all of this? That's the end? Well, yes, once you have a pointer you can dereference it once and then write to the given address to complete the tutorial step! I can understand how this would feel a bit underwhelming, but in all fairness, we were required to pretty-print assembly to guess what pointer address we could potentially need to look for. There is an [stupidly large amount of instructions][isa], and I'm sure a lot of them can access memory, so automating that would be rough. We were lucky that the instructions right before the one that hit the breakpoint were changing the memory address, but you could imagine this value coming from somewhere completely different. It could also be using a myriad of different techniques to apply the offset. I would argue manual intervention is a must here[^7].
508
509We have learnt how to pretty-print instructions, and had a very gentle introduction to figuring out what we may need to look for. The code to retrieve the loaded modules, and their corresponding regions, will come in handy later on. Having access to this information lets us know when to stop looking for additional pointers. As soon as a pointer is found within a memory region corresponding to a base module, we're done! Also, I know the title doesn't really much the contents of this entry (sorry about that), but I'm just following the convention of calling it whatever the Cheat Engine tutorial calls them.
510
511The [code for this post][code] is available over at my GitHub. You can run `git checkout step6` after cloning the repository to get the right version of the code, although you will have to `checkout` to individual commits if you want to review, for example, how the instructions were printed out. Only the code necessary to complete the step is included at the `step6` tag.
512
513In the next post, we'll tackle the sixth step of the tutorial: Code Injection. This will be pretty similar to part 5, but instead of writing out a simple NOP instruction, we will have to get a bit more creative.
514
515### Footnotes
516
517[^1]: This will only be a gentle introduction to pointers. Part 8 of this series will have to rely on more advanced techniques.
518
519[^2]: Kind of. The size of a pointer isn't necessarily the size as `usize`, although `usize` is guaranteed to be able of representing every possible address. For our purposes, we can assume a pointer is as big as `usize`.
520
521[^3]: Game updates are likely to pull more code and shuffle stuff around. This is unfortunately a difficult problem to solve. But storing a pointer which is usable across restarts for as long as the game doesn't update is still a pretty darn big improvement over having to constantly scan for the locations we care about. Although if you're smart enough to look for certain unique patterns, even if the code is changed, finding those patterns will give you the new updated address, so it's not *impossible*.
522
523[^4]: `bool::then` is a pretty recent addition at the time of writing (1.50.0), so make sure you `rustup update` if it's erroring out!
524
525[^5]: I wasn't sure if there would be some metadata before the module base address but within the region, so I went with the range check. What *is* important however is using `AllocationBase`, not `BaseAddress`. They're different, and this did bite me.
526
527[^6]: As usual, I have no idea if this is how Cheat Engine is doing it, but it seems reasonable.
528
529[^6]: But nothing's stopping you from implementing some heuristics to get the job done for you. If you run some algorithm in your head to find what the pointer value could be, you can program it in Rust as well, although I don't think it's worth the effort.
530
531[dbg-reg]: https://wiki.osdev.org/CPU_Registers_x86#Debug_Registers
532[dr4]: https://en.wikipedia.org/wiki/X86_debug_register
533[dbg-events]: https://docs.microsoft.com/en-us/windows/win32/debug/debugging-events
534[meminfo]: https://docs.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-memory_basic_information
535[baseaddr]: https://stackoverflow.com/a/26573045/4759433
536[isa]: https://www.intel.com/content/www/us/en/architecture-and-technology/64-ia-32-architectures-software-developer-vol-2a-manual.html
537[code]: https://github.com/lonami/memo