Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions cle/backends/macho/encrypted_sentinel_backer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import struct

from cle.memory import Clemory


Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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)


Expand Down
99 changes: 99 additions & 0 deletions tests/test_macho.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import logging
import os
import pickle
import struct
from io import BytesIO

Expand All @@ -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

Expand Down Expand Up @@ -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
Loading