From 002901d0f160c435dfd1f2c4cbe85f1a859710a9 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 11:05:53 -0300 Subject: [PATCH 1/2] fix(macos): let a scan walk past a page whose pager declines to read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scan of a live process aborted with MachReadError: mach_vm_read_overwrite failed: (os/kern) memory error (kr=10) part-way through, discarding the rest of the address space. kr=10 is KERN_MEMORY_ERROR, and mach/kern_return.h documents it as "During a page fault, 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" — in deliberate contrast with KERN_MEMORY_FAILURE (9) directly above it, whose comment ends "This failure is permanent." So by the kernel's own definition it belongs in _PAGE_GONE_KRS beside the three codes already there; it was simply missed, and wasn't even defined in types.py. The tolerance machinery was already in place and already wired into all three entry points — the code was just on the wrong side of the line _is_transient draws, so iter_search_results and iter_pattern_results re-raised it and killed the scan. It surfaces on file-backed read-only mappings — code segments, dylibs, the dyld shared cache — which is why a pattern scan hit it first: it deliberately ignores writeable_only (an AOB signature is normally in code), so it walks 2.3x the regions a default value scan does on a trivial process. Turning "writable regions only" off reproduces it with any value type. On this machine the whole memory/scan suite failed this way; it now passes, and those tests plant a value and assert they find it, so the scans are completing rather than quietly matching nothing. --- PyMemoryEditor/macos/functions.py | 10 +++ PyMemoryEditor/macos/types.py | 6 ++ tests/platforms/test_macos_transient_reads.py | 82 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 tests/platforms/test_macos_transient_reads.py diff --git a/PyMemoryEditor/macos/functions.py b/PyMemoryEditor/macos/functions.py index f6f9bf9..eecb869 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,19 @@ 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. 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..1e8a5ba 100644 --- a/PyMemoryEditor/macos/types.py +++ b/PyMemoryEditor/macos/types.py @@ -65,6 +65,12 @@ KERN_INVALID_ARGUMENT = 4 KERN_FAILURE = 5 KERN_NO_ACCESS = 8 +# "During a page fault, 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." — mach/kern_return.h. +# Note the deliberate contrast with KERN_MEMORY_FAILURE (9) directly above it +# in that header, whose comment ends "This failure is permanent." +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..791ed47 --- /dev/null +++ b/tests/platforms/test_macos_transient_reads.py @@ -0,0 +1,82 @@ +# -*- 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_INVALID_ADDRESS, + KERN_INVALID_ARGUMENT, + KERN_MEMORY_ERROR, + KERN_NO_ACCESS, + KERN_PROTECTION_FAILURE, + KERN_SUCCESS, +) + + +# 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. KERN_MEMORY_ +# FAILURE (9) sits directly above KERN_MEMORY_ERROR in the header and is +# documented as permanent, which is exactly the distinction being drawn. +KERN_MEMORY_FAILURE = 9 + + +@pytest.mark.parametrize( + "kr", (KERN_SUCCESS, 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")) From 7ef9f0ece161b0937b135b82434361bfb7033e3f Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 11:23:25 -0300 Subject: [PATCH 2/2] refactor(macos): define the permanent/temporary kern_return pair together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review follow-ups, no behaviour change. The comment justifying the fix leaned on KERN_MEMORY_FAILURE (9) as the contrast case, but that constant wasn't in types.py — leaving a gap in the sequence (2, 4, 5, 8, 10) and a comment pointing at something the file didn't contain. The module's existing convention is to define the codes it talks about even when only prose refers to them (KERN_FAILURE is already there on those terms), so the two neighbours are now defined together with the header text that separates them. The test was declaring its own copy of 9, which is the kind of local constant that drifts from the library it describes. The "must still propagate" case tested KERN_SUCCESS, a state no MachReadError can carry — MachPartialReadError is raised on success but passes KERN_INVALID_ADDRESS. Swapped for KERN_FAILURE, which is what task_for_pid returns without the debugger entitlement and is a real error this must never swallow. --- PyMemoryEditor/macos/functions.py | 9 +++++---- PyMemoryEditor/macos/types.py | 16 +++++++++++----- tests/platforms/test_macos_transient_reads.py | 15 +++++++-------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/PyMemoryEditor/macos/functions.py b/PyMemoryEditor/macos/functions.py index eecb869..f2d13a1 100644 --- a/PyMemoryEditor/macos/functions.py +++ b/PyMemoryEditor/macos/functions.py @@ -299,10 +299,11 @@ def get_memory_regions(task: int, pid: int = 0) -> Generator[MemoryRegion, None, # 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. 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. +# 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, diff --git a/PyMemoryEditor/macos/types.py b/PyMemoryEditor/macos/types.py index 1e8a5ba..1a7f1ab 100644 --- a/PyMemoryEditor/macos/types.py +++ b/PyMemoryEditor/macos/types.py @@ -65,11 +65,17 @@ KERN_INVALID_ARGUMENT = 4 KERN_FAILURE = 5 KERN_NO_ACCESS = 8 -# "During a page fault, 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." — mach/kern_return.h. -# Note the deliberate contrast with KERN_MEMORY_FAILURE (9) directly above it -# in that header, whose comment ends "This failure is permanent." +# 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 diff --git a/tests/platforms/test_macos_transient_reads.py b/tests/platforms/test_macos_transient_reads.py index 791ed47..b109b25 100644 --- a/tests/platforms/test_macos_transient_reads.py +++ b/tests/platforms/test_macos_transient_reads.py @@ -28,12 +28,13 @@ _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, - KERN_SUCCESS, ) @@ -54,14 +55,12 @@ def test_a_vanished_page_lets_the_scan_continue(kr): # The complement matters just as much: a permission or configuration problem -# must reach the caller instead of being silently scanned past. KERN_MEMORY_ -# FAILURE (9) sits directly above KERN_MEMORY_ERROR in the header and is -# documented as permanent, which is exactly the distinction being drawn. -KERN_MEMORY_FAILURE = 9 - - +# 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_SUCCESS, KERN_PROTECTION_FAILURE, KERN_MEMORY_FAILURE) + "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))