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 β€” 8 for 64-bit, 4 for 32-bit. Leave None (the default) to use the target’s pointer_size, detected automatically.

  • aligned (bool) – only consider pointers at natural alignment (default, much faster). Set False to 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), fraction in [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

MethodWhat 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 process and return the final target address.

to_pointer(process, *, pytype=int, bufflength=None)

Build a live RemotePointer for the value at the end of this path.

rebase(process)

Return a copy with base_address recomputed 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 PointerPath from to_dict() output.

See the full reference at API β†’ PointerPath.

See also