diff --git a/PyMemoryEditor/macos/functions.py b/PyMemoryEditor/macos/functions.py index 5d5931a..80f88f6 100644 --- a/PyMemoryEditor/macos/functions.py +++ b/PyMemoryEditor/macos/functions.py @@ -209,7 +209,21 @@ def _region_is_shared(task: int, address: int, basic_shared: int) -> bool: return _region_user_tag(task, address) == VM_MEMORY_SHARED_PMAP -def get_memory_regions(task: int) -> Generator[MemoryRegion, None, None]: +_PROC_REGIONFILENAME_BUF_SIZE = 1024 + + +def _get_region_filename(pid: int, address: int) -> str: + """Return the file path backing the region at *address*, or ``""``.""" + buf = ctypes.create_string_buffer(_PROC_REGIONFILENAME_BUF_SIZE) + length = libsystem.proc_regionfilename( + pid, address, buf, _PROC_REGIONFILENAME_BUF_SIZE + ) + if length <= 0: + return "" + return buf.value.decode("utf-8", errors="replace") + + +def get_memory_regions(task: int, pid: int = 0) -> Generator[MemoryRegion, None, None]: """ Yield a :class:`MemoryRegion` describing each memory region of the task. @@ -266,6 +280,7 @@ def get_memory_regions(task: int) -> Generator[MemoryRegion, None, None]: address=address.value, size=size.value, struct=region_struct, + path=_get_region_filename(pid, address.value) if pid else "", ) if size.value == 0: diff --git a/PyMemoryEditor/macos/libsystem.py b/PyMemoryEditor/macos/libsystem.py index 14ac6ec..5fbe4e3 100644 --- a/PyMemoryEditor/macos/libsystem.py +++ b/PyMemoryEditor/macos/libsystem.py @@ -237,6 +237,18 @@ class rusage_info_v0(ctypes.Structure): ) libsystem.proc_pidpath.restype = ctypes.c_int +# int proc_regionfilename(int pid, uint64_t address, void *buffer, uint32_t buffersize); +# Returns the file path backing the memory region at *address* (like Linux's +# /proc//maps 6th column). Returns the path length on success, 0 when the +# region has no backing file or on failure. +libsystem.proc_regionfilename.argtypes = ( + ctypes.c_int, + ctypes.c_uint64, + ctypes.c_void_p, + ctypes.c_uint32, +) +libsystem.proc_regionfilename.restype = ctypes.c_int + # char *mach_error_string(mach_error_t error_value); libsystem.mach_error_string.argtypes = (ctypes.c_int,) libsystem.mach_error_string.restype = ctypes.c_char_p diff --git a/PyMemoryEditor/macos/process.py b/PyMemoryEditor/macos/process.py index 910d5d4..78f226e 100644 --- a/PyMemoryEditor/macos/process.py +++ b/PyMemoryEditor/macos/process.py @@ -142,7 +142,7 @@ def _detect_is_64bit(self) -> Optional[bool]: def get_memory_regions(self) -> Generator[MemoryRegion, None, None]: self.__require_open() - return get_memory_regions(self.__task) + return get_memory_regions(self.__task, self.pid) def get_threads(self) -> Generator[ThreadInfo, None, None]: self.__require_open() diff --git a/PyMemoryEditor/process/region.py b/PyMemoryEditor/process/region.py index b4fa4bc..5139767 100644 --- a/PyMemoryEditor/process/region.py +++ b/PyMemoryEditor/process/region.py @@ -64,8 +64,9 @@ class MemoryRegion: :param is_executable: ``True`` when the region contains executable code. :param is_shared: ``True`` when the region is a shared/file-backed mapping. :param path: best-effort path of the file backing the region, or ``""`` - when unknown (Linux exposes this directly from ``/proc//maps``; - Windows and macOS would need extra syscalls and report ``""``). + when unknown or anonymous. Linux reads it from ``/proc//maps``; + Windows uses ``GetMappedFileNameW`` (NT device path); macOS uses + ``proc_regionfilename``. """ address: int @@ -164,9 +165,8 @@ def region_path(struct: Any) -> str: """ Best-effort path of the file backing the region, or "" when unknown. - Linux can derive it from /proc//maps (already populated). Win32 and - macOS would require extra syscalls (GetMappedFileName / proc_regionfilename) - that the backends don't currently make. + Linux reads it from /proc//maps. Win32 uses GetMappedFileNameW + (returns NT device paths). macOS uses proc_regionfilename. """ if hasattr(struct, "Path"): try: @@ -185,13 +185,18 @@ def region_path(struct: Any) -> str: return "" -def make_region(address: int, size: int, struct: Any) -> MemoryRegion: +def make_region(address: int, size: int, struct: Any, *, path: str = "") -> MemoryRegion: """Build a fully-populated :class:`MemoryRegion` from a platform struct. Backends call this once per region instead of constructing a dict and enriching it in a second step. Computing every boolean upfront keeps the public type immutable and removes a class of "what fields are populated?" bugs that the old enrichment pattern was prone to. + + :param path: optional pre-resolved file path for the region. When non-empty + this overrides the struct-based ``region_path()`` lookup (used by the + Windows and macOS backends which resolve the path via dedicated syscalls + rather than embedding it in the struct). """ return MemoryRegion( address=address, @@ -201,7 +206,7 @@ def make_region(address: int, size: int, struct: Any) -> MemoryRegion: is_writable=is_region_writable(struct), is_executable=is_region_executable(struct), is_shared=is_region_shared(struct), - path=region_path(struct), + path=path or region_path(struct), ) diff --git a/PyMemoryEditor/win32/functions.py b/PyMemoryEditor/win32/functions.py index af6a17f..6653043 100644 --- a/PyMemoryEditor/win32/functions.py +++ b/PyMemoryEditor/win32/functions.py @@ -200,6 +200,23 @@ # works for both data and injected code (matches the common tooling default). _DEFAULT_ALLOC_PROTECT = MemoryProtectionsEnum.PAGE_EXECUTE_READWRITE.value +# psapi.dll — used for GetMappedFileNameW to resolve backing file paths for +# memory regions. The "K32" prefixed versions live in kernel32.dll on Win7+, +# but importing psapi directly works from XP through 11. +psapi = ctypes.WinDLL("psapi.dll", use_last_error=True) + +# DWORD GetMappedFileNameW(HANDLE hProcess, LPVOID lpv, +# LPWSTR lpFilename, DWORD nSize); +psapi.GetMappedFileNameW.argtypes = ( + ctypes.wintypes.HANDLE, + ctypes.wintypes.LPVOID, + ctypes.wintypes.LPWSTR, + ctypes.wintypes.DWORD, +) +psapi.GetMappedFileNameW.restype = ctypes.wintypes.DWORD + +_MAPPED_FILENAME_BUF_SIZE = 512 + system_information = SYSTEM_INFO() kernel32.GetSystemInfo(ctypes.byref(system_information)) @@ -321,6 +338,24 @@ def CloseProcessHandle(process_handle: int) -> int: return kernel32.CloseHandle(process_handle) +def _get_mapped_filename(process_handle: int, address: int) -> str: + """Return the NT device path backing *address*, or ``""`` on failure. + + Wraps ``GetMappedFileNameW`` (psapi.dll). The result is an NT path like + ``\\Device\\HarddiskVolume3\\Windows\\System32\\ntdll.dll`` — the same + format Cheat Engine and other tools display. Converting to a DOS path + (``C:\\...``) would require ``QueryDosDevice`` for each volume, which is + costly and unnecessary for display purposes. + """ + buf = ctypes.create_unicode_buffer(_MAPPED_FILENAME_BUF_SIZE) + length = psapi.GetMappedFileNameW( + process_handle, address, buf, _MAPPED_FILENAME_BUF_SIZE + ) + if length == 0: + return "" + return buf.value + + def GetMemoryRegions(process_handle: int) -> Generator[MemoryRegion, None, None]: """ Yield a :class:`MemoryRegion` for every region in the target's address space. @@ -387,6 +422,7 @@ def GetMemoryRegions(process_handle: int) -> Generator[MemoryRegion, None, None] yield make_region( address=current_address, size=region.RegionSize, struct=region, + path=_get_mapped_filename(process_handle, current_address), ) if region.RegionSize == 0: diff --git a/tests/process/test_memory_regions.py b/tests/process/test_memory_regions.py new file mode 100644 index 0000000..db407d9 --- /dev/null +++ b/tests/process/test_memory_regions.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- + +""" +Tests for :meth:`AbstractProcess.get_memory_regions`. + +Validates the basic contract of the generator across all platforms: +regions are non-empty, have sane sizes, carry consistent boolean flags, +and expose file-backed paths on supported OSes. +""" + +import os +import sys + +import pytest + +from PyMemoryEditor import OpenProcess +from PyMemoryEditor.process.region import MemoryRegion, default_scan_filter + + +@pytest.fixture() +def self_process(): + """Open the current process and close it after the test.""" + process = OpenProcess(pid=os.getpid()) + yield process + process.close() + + +class TestGetMemoryRegions: + """Core tests for get_memory_regions().""" + + def test_returns_non_empty(self, self_process): + """A live process must always have at least one memory region.""" + regions = list(self_process.get_memory_regions()) + assert regions + + def test_yields_memory_region_instances(self, self_process): + """Every yielded item must be a MemoryRegion.""" + regions = list(self_process.get_memory_regions()) + for r in regions: + assert isinstance(r, MemoryRegion) + + def test_address_and_size_are_positive(self, self_process): + """Each region must have a non-negative address and a positive size.""" + regions = list(self_process.get_memory_regions()) + for r in regions: + assert r.address >= 0 + assert r.size > 0 + + def test_no_overlapping_regions(self, self_process): + """Regions should not overlap (sorted by address, each ends before the next starts).""" + regions = sorted(self_process.get_memory_regions(), key=lambda r: r.address) + for i in range(len(regions) - 1): + end = regions[i].address + regions[i].size + assert end <= regions[i + 1].address, ( + f"Region at 0x{regions[i].address:X} (size {regions[i].size}) " + f"overlaps with next at 0x{regions[i + 1].address:X}" + ) + + def test_at_least_one_readable_region(self, self_process): + """The process must have at least one readable region (code/data are readable).""" + regions = list(self_process.get_memory_regions()) + assert any(r.is_readable for r in regions) + + def test_at_least_one_writable_region(self, self_process): + """The process must have at least one writable region (stack/heap are writable).""" + regions = list(self_process.get_memory_regions()) + assert any(r.is_writable for r in regions) + + def test_at_least_one_executable_region(self, self_process): + """The process must have at least one executable region (code segment).""" + regions = list(self_process.get_memory_regions()) + assert any(r.is_executable for r in regions) + + def test_default_scan_filter_excludes_shared(self, self_process): + """default_scan_filter must exclude shared/file-backed regions.""" + regions = list(self_process.get_memory_regions()) + shared = [r for r in regions if r.is_shared] + if shared: + assert all(not default_scan_filter(r) for r in shared) + + def test_struct_is_populated(self, self_process): + """Every region must carry its platform-specific struct.""" + regions = list(self_process.get_memory_regions()) + for r in regions: + assert r.struct is not None + + +class TestRegionPath: + """Tests for the file-backed path field of memory regions.""" + + def test_some_regions_have_path(self, self_process): + """At least one region should have a non-empty path (mapped libraries).""" + regions = list(self_process.get_memory_regions()) + paths = [r.path for r in regions if r.path] + assert paths, ( + "No memory region reported a non-empty path — " + "the path resolution syscall integration may be broken" + ) + + def test_path_contains_known_library(self, self_process): + """At least one region path should reference a recognizable system library.""" + regions = list(self_process.get_memory_regions()) + paths = [r.path for r in regions if r.path] + + if sys.platform == "win32": + assert any("ntdll" in p.lower() for p in paths), ( + f"Expected ntdll.dll in region paths; got: {paths[:10]}" + ) + elif sys.platform == "darwin": + assert any("libSystem" in p or "dyld" in p for p in paths), ( + f"Expected libSystem or dyld in region paths; got: {paths[:10]}" + ) + else: + # Linux: libc or ld-linux should be mapped. + assert any("libc" in p or "ld-linux" in p or "ld-musl" in p for p in paths), ( + f"Expected libc or ld-linux in region paths; got: {paths[:10]}" + ) + + def test_path_is_always_str(self, self_process): + """The path field must always be a str, even when empty.""" + regions = list(self_process.get_memory_regions()) + for r in regions: + assert isinstance(r.path, str)