From 82a00b2f8715438d4f7df692117afe82025d1807 Mon Sep 17 00:00:00 2001 From: Yan Date: Sun, 6 Sep 2026 01:52:51 +0000 Subject: [PATCH 1/2] Keep the Mach-O crypt fields across a pickle Clemory.__getstate__ builds its state dict from a fixed list of slots, and CryptSentinel keeps _crypt_start, _crypt_end and _is_encrypted in its own __dict__. Neither is in that list, so a round trip through pickle drops all three and the next read of the loader's memory raises AttributeError: 'CryptSentinel' object has no attribute '_is_encrypted' from CryptSentinel.backers, which Clemory.load walks into. Give CryptSentinel its own __getstate__ and __setstate__ that add the three fields. __setstate__ reads them with state.get so a pickle written before this change still loads, as an unencrypted sentinel. Co-Authored-By: Claude Opus 5 --- .../macho/encrypted_sentinel_backer.py | 13 +++++++++ tests/test_macho.py | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/cle/backends/macho/encrypted_sentinel_backer.py b/cle/backends/macho/encrypted_sentinel_backer.py index 2e0f72d83..1c03c3943 100644 --- a/cle/backends/macho/encrypted_sentinel_backer.py +++ b/cle/backends/macho/encrypted_sentinel_backer.py @@ -20,6 +20,19 @@ def __init__(self, arch, root=False): self._crypt_end = None self._is_encrypted: bool = False + def __getstate__(self): + s = super().__getstate__() + s["_crypt_start"] = self._crypt_start + s["_crypt_end"] = self._crypt_end + s["_is_encrypted"] = self._is_encrypted + return s + + def __setstate__(self, s): + super().__setstate__(s) + self._crypt_start = s.get("_crypt_start") + self._crypt_end = s.get("_crypt_end") + self._is_encrypted = s.get("_is_encrypted", False) + def load(self, addr, n): self._assert_unencrypted_access(addr, n) return super().load(addr, n) diff --git a/tests/test_macho.py b/tests/test_macho.py index 222119ea3..bd5ae3d33 100644 --- a/tests/test_macho.py +++ b/tests/test_macho.py @@ -3,6 +3,7 @@ import logging import os +import pickle import struct from io import BytesIO @@ -480,3 +481,29 @@ def test_relocatable_object(): # The defined symbols carry real section-relative addresses; undefined externals stay at 0. defined = {sym.name for sym in obj.symbols if sym.rebased_addr} assert defined, "no defined symbol carries an address" + + +def test_encryption_guard_survives_pickling(): + """ + This binary carries an LC_ENCRYPTION_INFO_64 command with cryptid 0, so it records an encrypted + range and holds nothing encrypted. Its memory is a CryptSentinel all the same, and every read of + that range has to work. Pickling the loader used to lose the sentinel's crypt fields, so the + first load after unpickling raised + AttributeError: 'CryptSentinel' object has no attribute '_is_encrypted'. + """ + machofile = os.path.join(TEST_BASE, "tests", "armhf", "FileProtection-05.arm64.macho") + ld = cle.Loader(machofile, auto_load_libs=False) + assert isinstance(ld.main_object, cle.MachO) + base = ld.main_object.mapped_base + + # 0x4688 is the first non-zero byte in the range the load command records, [0x4000, 0x8000). + assert ld.memory[base + 0x4688] == 0xF6 + assert base + 0x4688 in ld.memory + assert ld.memory.load(base + 0x4688, 4) == b"\xf6W\xbd\xa9" + + ld = pickle.loads(pickle.dumps(ld)) + base = ld.main_object.mapped_base + assert ld.memory[base + 0x4688] == 0xF6 + assert base + 0x4688 in ld.memory + assert ld.memory.load(base + 0x4688, 4) == b"\xf6W\xbd\xa9" + From bf7b73a4db627d414d4c70235865d85f8230fe52 Mon Sep 17 00:00:00 2001 From: Yan Date: Sun, 6 Sep 2026 01:52:59 +0000 Subject: [PATCH 2/2] Guard the reads of an encrypted Mach-O region CryptSentinel missed CryptSentinel overrides load, store, find and backers, but not __getitem__ or __setitem__. On an image whose LC_ENCRYPTION_INFO says cryptid is set, the item API therefore hands back the ciphertext that load refuses, and so does ClemoryBase.load_null_terminated_bytes, which is written on top of __getitem__. Guard both with _assert_unencrypted_access. Guarding __getitem__ breaks membership, because Clemory.__contains__ probes __getitem__ whenever the memory is not consecutive - so `rva in obj.memory` raised for an encrypted address, and with it Loader.find_object_containing. Whether an address is mapped is a question about the memory map and not about the bytes, so __contains__ answers it with an unguarded read whose result is discarded, and an address inside the region that no backer maps still answers False. ClemoryBase.unpack and ClemoryBase.pack reach the backer through backers(addr), whose guard only knows the start address, so a word read starting just before the region returned bytes from inside it. Override both in the sentinel, where the format string gives the size the guard needs; unpack_word and pack_word are written on top of them and are covered too. Clemory.__iter__ reads _backers directly and yields the byte values, so it served the whole region a byte at a time. Refuse it while the image is encrypted, the way find is already refused. _assert_unencrypted_access also refused an access that ends exactly at the first encrypted byte. An access covers the half-open interval [addr, addr + size), so one ending at _crypt_start is wholly outside the region; the old test asked whether addr + size was in range(_crypt_start, _crypt_end), which is true for that case. Compare the intervals instead, which also leaves a zero-length access alone. Co-Authored-By: Claude Opus 5 --- .../macho/encrypted_sentinel_backer.py | 46 ++++++++++-- tests/test_macho.py | 72 +++++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/cle/backends/macho/encrypted_sentinel_backer.py b/cle/backends/macho/encrypted_sentinel_backer.py index 1c03c3943..7705f9c7f 100644 --- a/cle/backends/macho/encrypted_sentinel_backer.py +++ b/cle/backends/macho/encrypted_sentinel_backer.py @@ -1,5 +1,7 @@ from __future__ import annotations +import struct + from cle.memory import Clemory @@ -20,6 +22,33 @@ def __init__(self, arch, root=False): self._crypt_end = None self._is_encrypted: bool = False + def __iter__(self): + if self._is_encrypted: + raise EncryptedDataAccessException("Cannot iterate encrypted memory region", self._crypt_start) + return super().__iter__() + + def __getitem__(self, k): + self._assert_unencrypted_access(k, 1) + return super().__getitem__(k) + + def __setitem__(self, k, v): + self._assert_unencrypted_access(k, 1) + return super().__setitem__(k, v) + + def __contains__(self, k): + try: + return super().__contains__(k) + except EncryptedDataAccessException: + # Clemory.__contains__ probes __getitem__ when the memory is not consecutive, and that + # probe goes through the guard above. Whether an address is mapped is a question about + # the memory map rather than about the bytes, so answer it with a read that skips the + # guard and discards the byte. + try: + Clemory.__getitem__(self, k) + except KeyError: + return False + return True + def __getstate__(self): s = super().__getstate__() s["_crypt_start"] = self._crypt_start @@ -41,6 +70,14 @@ def store(self, addr, data): self._assert_unencrypted_access(addr, len(data)) return super().store(addr, data) + def unpack(self, addr, fmt): + self._assert_unencrypted_access(addr, struct.calcsize(fmt)) + return super().unpack(addr, fmt) + + def pack(self, addr, fmt, *data): + self._assert_unencrypted_access(addr, struct.calcsize(fmt)) + return super().pack(addr, fmt, *data) + def find(self, data, search_min=None, search_max=None): if self._is_encrypted: raise EncryptedDataAccessException("Cannot search encrypted memory region", self._crypt_start) @@ -62,10 +99,8 @@ def _assert_unencrypted_access(self, addr, size): Make sure that the access does not cover encrypted memory regions If it does, raise an error - Cases: - - Access starts before encrypted region and ends after it - - Access starts within encrypted region - - Access ends within encrypted region + The access covers the half-open interval [addr, addr + size), so it overlaps the encrypted + region when it starts before the region ends and ends after the region starts. :param addr: :param size: @@ -74,8 +109,7 @@ def _assert_unencrypted_access(self, addr, size): if not self._is_encrypted: return - encrypted_range = range(self._crypt_start, self._crypt_end) - if addr in encrypted_range or (addr + size) in encrypted_range or (addr < self._crypt_start < addr + size): + if size > 0 and addr < self._crypt_end and addr + size > self._crypt_start: raise EncryptedDataAccessException("Accessing encrypted memory region", addr) diff --git a/tests/test_macho.py b/tests/test_macho.py index bd5ae3d33..2eff03c1b 100644 --- a/tests/test_macho.py +++ b/tests/test_macho.py @@ -12,6 +12,7 @@ import cle from cle import MachO from cle.backends.backend import FunctionHintSource +from cle.backends.macho.encrypted_sentinel_backer import CryptSentinel, EncryptedDataAccessException from cle.backends.macho.macho_enums import LoadCommands, MachoFiletype, SectionAttributes, SectionType from cle.backends.macho.section import MachOSection @@ -507,3 +508,74 @@ def test_encryption_guard_survives_pickling(): assert base + 0x4688 in ld.memory assert ld.memory.load(base + 0x4688, 4) == b"\xf6W\xbd\xa9" + +def test_encrypted_range_refuses_every_read(): + """ + The same binary with cryptid set to 1, which is what a genuinely encrypted one looks like. Every + read touching [0x4000, 0x8000) must raise rather than hand back the bytes on disk, which are + ciphertext. Single-byte reads and reads through the loader's own memory used to walk past the + guard and return them. + """ + machofile = os.path.join(TEST_BASE, "tests", "armhf", "FileProtection-05.arm64.macho") + ld = cle.Loader(machofile, auto_load_libs=False) + obj = ld.main_object + assert isinstance(obj, cle.MachO) + memory = obj.memory + assert isinstance(memory, CryptSentinel) + base = obj.mapped_base + + # The range the file's own load command records, with cryptid flipped on. + memory.set_crypt_info(1, 0x4000, 0x4000) + + with pytest.raises(EncryptedDataAccessException): + memory.load(0x4688, 4) + with pytest.raises(EncryptedDataAccessException): + _ = memory[0x4688] + with pytest.raises(EncryptedDataAccessException): + _ = ld.memory[base + 0x4688] + with pytest.raises(EncryptedDataAccessException): + ld.memory.load_null_terminated_bytes(base + 0x4688) + with pytest.raises(EncryptedDataAccessException): + iter(memory) + + # A word read starting two bytes before the range still covers its first two bytes. + with pytest.raises(EncryptedDataAccessException): + memory.unpack_word(0x3FFE, size=4) + with pytest.raises(EncryptedDataAccessException): + memory.pack_word(0x3FFE, 0, size=4) + + # A read ending exactly where the range starts does not touch it, and neither does one after it. + assert memory.load(0x3FFC, 4) == b"\x00" * 4 + assert memory.unpack_word(0x3FFC, size=4) == 0 + assert memory.load(0x8000, 4) == bytes((0xA0, 0x00, 0x10, 0x00)) + + # Refusing to read an address does not stop the loader knowing which object holds it. + assert ld.find_object_containing(base + 0x4688) is obj + + +def test_encrypted_range_answers_membership_across_a_gap(): + """ + A Mach-O whose object memory has holes in it. Refusing to read an encrypted address must not + stop the loader answering which object maps it, and Clemory.__contains__ only probes + __getitem__ when the memory is not consecutive, so this is the file that reaches that path. + """ + machofile = os.path.join(TEST_BASE, "tests", "aarch64", "langdetect_go.macho") + ld = cle.Loader(machofile, auto_load_libs=False) + obj = ld.main_object + assert isinstance(obj, cle.MachO) + memory = obj.memory + assert isinstance(memory, CryptSentinel) + assert not memory.consecutive + + base = obj.mapped_base + # The second backer ends at 0x16b718 and the third starts at 0x16c000, so 0x16b728 is mapped by + # no backer at all. Declare a range covering both it and a backed address. + backed, unmapped = 0x4688, 0x16B728 + memory.set_crypt_info(1, 0x4000, 0x168000) + + with pytest.raises(EncryptedDataAccessException): + _ = memory[backed] + assert backed in memory + assert unmapped not in memory + assert ld.find_object_containing(base + backed) is obj + assert ld.find_object_containing(base + unmapped) is None