diff --git a/cle/backends/macho/encrypted_sentinel_backer.py b/cle/backends/macho/encrypted_sentinel_backer.py index 2e0f72d83..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,46 @@ 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 + 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) @@ -28,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) @@ -49,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: @@ -61,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 222119ea3..2eff03c1b 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 @@ -11,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 @@ -480,3 +482,100 @@ 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" + + +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