diff --git a/PyMemoryEditor/macos/functions.py b/PyMemoryEditor/macos/functions.py index f6f9bf9..f2d13a1 100644 --- a/PyMemoryEditor/macos/functions.py +++ b/PyMemoryEditor/macos/functions.py @@ -38,6 +38,7 @@ from .types import ( KERN_INVALID_ADDRESS, KERN_INVALID_ARGUMENT, + KERN_MEMORY_ERROR, KERN_NO_ACCESS, KERN_PROTECTION_FAILURE, KERN_SUCCESS, @@ -294,10 +295,20 @@ def get_memory_regions(task: int, pid: int = 0) -> Generator[MemoryRegion, None, # KERN_NO_ACCESS / KERN_INVALID_ARGUMENT can also surface for guard pages and # freshly-unmapped pages on modern macOS; treating them as fatal aborts a scan # that should just skip the page. +# +# KERN_MEMORY_ERROR is the same story one level down: the pager backing a page +# declined to produce its data. The kernel header calls that failure +# "temporary" in as many words, in explicit contrast with the permanent +# KERN_MEMORY_FAILURE beside it (the pair is spelled out in types.py). It is +# what file-backed, read-only mappings (code segments, dylibs, the dyld shared +# cache) return, so any scan that walks them — every pattern scan, and any value +# scan with "writable regions only" turned off — hits it routinely, and used to +# abort on the first one. _PAGE_GONE_KRS = ( KERN_INVALID_ADDRESS, KERN_NO_ACCESS, KERN_INVALID_ARGUMENT, + KERN_MEMORY_ERROR, ) diff --git a/PyMemoryEditor/macos/types.py b/PyMemoryEditor/macos/types.py index aa19f6b..1a7f1ab 100644 --- a/PyMemoryEditor/macos/types.py +++ b/PyMemoryEditor/macos/types.py @@ -65,6 +65,18 @@ KERN_INVALID_ARGUMENT = 4 KERN_FAILURE = 5 KERN_NO_ACCESS = 8 +# These two are neighbours in mach/kern_return.h and are the line between a page +# a scan may walk past and one it may not, so they are defined together: +# +# 9 "the target address refers to a memory object that has been destroyed. +# This failure is permanent." +# 10 "the memory object indicated that the data could not be returned. This +# failure may be temporary; future attempts to access this same data may +# succeed, as defined by the memory object." +# +# Only the second belongs in functions._PAGE_GONE_KRS. +KERN_MEMORY_FAILURE = 9 +KERN_MEMORY_ERROR = 10 class vm_region_basic_info_64(Structure): diff --git a/tests/platforms/test_macos_transient_reads.py b/tests/platforms/test_macos_transient_reads.py new file mode 100644 index 0000000..b109b25 --- /dev/null +++ b/tests/platforms/test_macos_transient_reads.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- + +""" +macOS-only test: which ``kern_return_t`` values a scan loop is allowed to walk +past. + +The scan loops (``iter_search_results`` / ``iter_pattern_results``) skip a chunk +they classify as transient and re-raise anything else, so this classification is +the line between "a scan of a live process completes" and "it aborts on the +first page the kernel declines to hand over". ``KERN_MEMORY_ERROR`` sat on the +wrong side of it: file-backed read-only mappings (code segments, dylibs, the +dyld shared cache) return it routinely, so every pattern scan — and any value +scan with ``writeable_only`` off — died partway through. +""" + +import sys + +import pytest + + +if sys.platform != "darwin": + pytest.skip("macOS-only module", allow_module_level=True) + + +from PyMemoryEditor.macos.functions import ( # noqa: E402 + MachPartialReadError, + MachReadError, + _is_transient, +) +from PyMemoryEditor.macos.types import ( # noqa: E402 + KERN_FAILURE, + KERN_INVALID_ADDRESS, + KERN_INVALID_ARGUMENT, + KERN_MEMORY_ERROR, + KERN_MEMORY_FAILURE, + KERN_NO_ACCESS, + KERN_PROTECTION_FAILURE, +) + + +# mach/kern_return.h documents each of these as a page the scan may walk past. +# KERN_MEMORY_ERROR's own comment is explicit: "This failure may be temporary; +# future attempts to access this same data may succeed." +@pytest.mark.parametrize( + "kr", + ( + KERN_INVALID_ADDRESS, + KERN_INVALID_ARGUMENT, + KERN_NO_ACCESS, + KERN_MEMORY_ERROR, + ), +) +def test_a_vanished_page_lets_the_scan_continue(kr): + assert _is_transient(MachReadError(kr, "read failed (kr=%d)" % kr)) + + +# The complement matters just as much: a permission or configuration problem +# must reach the caller instead of being silently scanned past. The header puts +# KERN_MEMORY_FAILURE directly above KERN_MEMORY_ERROR and documents it as +# permanent, which is exactly the distinction being drawn here; KERN_FAILURE is +# what task_for_pid returns without the debugger entitlement. +@pytest.mark.parametrize( + "kr", (KERN_FAILURE, KERN_PROTECTION_FAILURE, KERN_MEMORY_FAILURE) +) +def test_a_real_failure_still_propagates(kr): + assert not _is_transient(MachReadError(kr, "read failed (kr=%d)" % kr)) + + +def test_a_short_read_lets_the_scan_continue(): + """ + A partial read means the transfer straddled a page that went away, so the + chunk is skipped like any other vanished page — the class carries + KERN_INVALID_ADDRESS for exactly that reason. Pinned here because the + behaviour rides on that constructor choice rather than on anything local. + """ + assert _is_transient(MachPartialReadError(0x1000, 8, 64)) + + +def test_a_non_mach_error_is_never_transient(): + assert not _is_transient(OSError("some unrelated failure")) + assert not _is_transient(ValueError("not an OSError at all"))