Pointer scan (reverse)ο
A scanned address is gone the next launch β the OS loads everything somewhere new every time (ASLR). A pointer chain is the cure, but where do you get the chain?
scan_pointer_paths is the inverse of resolve_pointer_chain: give it the
valueβs address right now, and the library finds the static paths
(module + offsets) that lead to it.
The basic scanο
# The value is at this address right now (e.g. from search_by_value).
for path in process.scan_pointer_paths(0x1FA3C140):
print(path)
# "game.exe"+0x10F4F4 -> [+0x0] -> +0x158
print(hex(path.resolve(process)))
Each result is a PointerPath β see API reference.
It carries everything you need to reconstruct the chain in another run.
Method signatureο
- scan_pointer_paths(target_address, *, max_depth=5, max_offset=0x400, ptr_size=None, aligned=True, writable_only=True, static_ranges=None, max_results=None, memory_regions=None, progress_callback=None)
- Parameters:
target_address (int) β the dynamic address to find pointer paths to (typically just found via
search_by_value()).max_depth (int) β maximum number of pointer levels (offsets) in a chain. Deeper scans find more paths but cost exponentially more β 1β7 is typical.
max_offset (int) β largest positive offset a single hop may add (the struct-size window). Bigger values catch fields deeper inside objects at the cost of many more candidate paths.
ptr_size (int) β pointer width β
8for 64-bit,4for 32-bit. LeaveNone(the default) to use the targetβspointer_size, detected automatically.aligned (bool) β only consider pointers at natural alignment (default, much faster). Set
Falseto also scan misaligned slots (slow).writable_only (bool) β build the pointer map from writable memory only (default β faster and usually correct).
static_ranges β explicit
(start, size)ranges to treat as valid chain bases. Defaults to the image range of every loaded module.max_results (int) β stop after yielding this many paths. Recommended for shallow exploration.
memory_regions β optional snapshot from
snapshot_memory_regions().progress_callback β a callable
callback(fraction)invoked as the pointer map is built (the long phase),fractionin[0, 1].
- Returns:
a generator of
PointerPath.
Tuning the scanο
A first scan usually finds many candidates. Start narrow and widen only if needed:
for path in process.scan_pointer_paths(
address,
max_depth=2, # start shallow
max_offset=0x100, # smaller struct window
max_results=20, # don't enumerate forever
):
print(path)
The cost grows exponentially with max_depth β going from 4 to 6 is
usually a 10Γ slowdown.
macOS note
On macOS, ModuleInfo.size covers only the __TEXT segment, so global
pointers in __DATA may fall outside the default static set. The library
overrides this by walking every Mach-O segment internally, but if you pass
static_ranges= explicitly, remember to include __DATA ranges as well.
Narrowing down to the real pointerο
The reliable pointers are the ones that keep working after the value moves. So you save a scan, restart the target, and rescan β keeping only the paths that still land on the value. Repeat a couple of times and a handful of solid pointers remain.
Save β restart β rescanο
# Run 1 β scan and save.
pointer_paths = process.scan_pointer_paths(address)
process.save_pointer_paths(pointer_paths, "scan1.json")
# ... close the target, restart it, find the value's new address again ...
# Run 2 β keep only the saved paths that still reach it.
survivors = process.rescan_pointer_paths("scan1.json", new_address)
process.save_pointer_paths(survivors, "scan2.json")
Compare independent scansο
Prefer working from independent scans? Save one per run, then intersect them β the paths present in every file are your stable pointers (no live address needed):
stable = process.compare_pointer_scans(
"scan1.json", "scan2.json", "scan3.json",
)
Once youβre down to one pointer, use it forever:
live = path.rebase(process).to_pointer(process, pytype=int, bufflength=4)
live.value = 9999
Persistence helpersο
| Method | What it does |
|---|---|
save_pointer_paths(paths, file) | Serialize a list of paths to a JSON file. |
load_pointer_paths(file) | Re-create the list from a saved file. |
rescan_pointer_paths(paths, target) | Keep only the paths that still resolve to target. |
compare_pointer_scans(*sources) | Intersect several saved scans β paths present in every one. |
The saved file stores each pathβs module + offsets β the ASLR-independent part β so it stays valid even though absolute addresses change.
The PointerPath dataclassο
A summary of the methods youβll use most:
- class PointerPath
- resolve(process)
Walk this path in
processand return the final target address.
- to_pointer(process, *, pytype=int, bufflength=None)
Build a live
RemotePointerfor the value at the end of this path.
- rebase(process)
Return a copy with
base_addressrecomputed from the moduleβs current load address β the call that makes a saved path valid again after a restart.
- to_dict()
Serialise to a JSON-friendly dict (hex strings) for export.
- classmethod from_dict(data)
Rebuild a
PointerPathfromto_dict()output.
See the full reference at API β PointerPath.
See also
Pointers β walking chains you already know.