Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS Validation record for head Scope: Local validation. Every run from an empty directory with
Performance. Min over 8 runs per arm of min-of-9 over 50,000 calls, the arms interleaved and the order reversed within each round, every written value cycling so no write is a no-op. The host carries a load average around 40 on 20 cores, so single rounds move by tens of percent in both directions; interleaving is what makes the figures below reproduce.
Corpus search for a triggering input, negative:
Paths into the region this change does not close, measured identical on both arms. Closing either needs a check inside
Producer/consumer audit:
|
|
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS Every read this change moves, on the two Mach-O files the new tests use, plus a pickle round trip of the loader for one of them left as it ships, and one route that stays open. Captured on both arms with the same script; each run prints the Before — cle master CLE baselineAfter — with this change: with this change
|
|
Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_817 |
THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS
Problem
Every Mach-O gets a
CryptSentinelfor its object memory, and anLC_ENCRYPTION_INFO/_64command tells it which part of the image is encrypted. It overridesload,store,findandbackersto refuse, which also coversread, and coversunpackandpackwhen the access starts inside the region. It does not cover__getitem__,__setitem__,load_null_terminated_bytes,__iter__, or a word read that starts just before the region and runs into it. Those return the bytes on disk.On
binaries/tests/armhf/FileProtection-05.arm64.macho, with its own recorded range marked encrypted:246is the byte on disk. angr'sCFGBasereads a single byte withloader.memory[addr]and a run of bytes withloader.memory.load(...), so one analysis gets both answers about the same address. The0x3FFCline is the mirror defect: a read ending exactly at the first encrypted byte lies outside the region and is refused anyway.A second failure needs no encryption at all. Pickle the loader for any Mach-O and the next read raises:
Root cause
CryptSentineloverridesload,store,findandbackers.Clemory.__getitem__andClemory.__iter__readself._backersdirectly and never callbackers(), andload_null_terminated_bytesis built on__getitem__.ClemoryBase.unpackandClemoryBase.packdo callbackers(), but it gets a start address and no size, so it cannot see a word read that begins before the region and ends inside it.Clemory.__getstate__builds its state from a fixed list of slot names.CryptSentinelis the only subclass that adds attributes, and_crypt_start,_crypt_endand_is_encryptedlive in__dict__, so the round trip drops them and the first guarded read dies looking for one._assert_unencrypted_accesstestedaddr in range(start, end) or (addr + size) in range(start, end) or ..., which treats the exclusive end of the access as a member of the region.Fix
Guard
__getitem__and__setitem__with_assert_unencrypted_access. That alone breaks membership, becauseClemory.__contains__probes__getitem__whenever the memory is not consecutive, sorva in obj.memoryandLoader.find_object_containingstart raising for an encrypted address on any image with a hole in it —binaries/tests/aarch64/langdetect_go.machois one. Whether an address is mapped is a question about the memory map, not about the bytes, so__contains__answers it with a probe that skips the guard and discards the byte; an address inside the region that no backer maps still answersFalse.Guard
unpackandpacktoo, where the format string gives the sizebackers()never sees;unpack_wordandpack_wordare built on them. Refuse__iter__while the image is encrypted, asfindalready is.Give
CryptSentinela__getstate__/__setstate__pair that carries its three fields, usings.get()so a pickle written before this change still loads, as an unencrypted sentinel. Replace the membership test with the half-open overlap it meant:size > 0 and addr < self._crypt_end and addr + size > self._crypt_start.Two stay open, unchanged from master:
Loader.memory_ro_view, which flattens the backers into plainbytearrays when it is built and is what angr's VEX and p-code lifters read through; andbackers(), which is given an address and no size, so it hands out the wholebytearraycovering the region for angr's icicle engine to slice — which is also why an access straddling the region start still reaches bytes inside it through the loader's root memory. Closing either means changingcle/memory.py.addr in loader.memory— the root memory, not an object's — now raises for an address inside the region instead of answering. Nothing in cle or angr asks that.Byte access on a Mach-O costs one extra Python frame, about 45 ns, and
load_null_terminated_bytespays it per byte;unpack_wordandpack_wordpay a little more, once per call.load,storeand resident memory are unchanged, on Mach-O and on ELF alike.Testing
tests/test_macho.pygains three tests. Two loadbinaries/tests/armhf/FileProtection-05.arm64.macho, which records an encryption range over[0x4000, 0x8000)withcryptid=0: one pins the reads that must keep working across a pickle round trip; the other callsset_crypt_info(1, ...)and pins the reads that must be refused, iteration and a straddling word read among them, and the reads ending at the region start that must be allowed. The third loadsbinaries/tests/aarch64/langdetect_go.macho, whose object memory has holes, and pins that membership and object lookup still answer for an encrypted address while an address in a hole answersFalse. All three fail on master, and each of the six parts of the fix is individually load-bearing.No binary here triggers the guard for real. All 18 Mach-O files in
angr/binariesthat carry an encryption load command recordcryptid=0, and none of the 7,784 Mach-O images in the corpora we sweep carries one at all — though that walk reads top-level file magic, and acryptid=1App Store binary normally lives inside an.ipa. If you have a genuinely encrypted image, that is the test worth adding on top.#788 was our own draft over the same file, and its
cle/memory.pyrewrite would have closed the two paths above. It is now closed, so there is no conflict left and nothing lands second. Nothing outside cle writes through the objectbackers()hands out, and at that branch's headd341e928, against its own baselinea4fb8003, its snapshot cache costs 4.5x onpack_wordand 5.1x onpack— cle's own relocation path — keeps 35.5 MB perbackers()walk of a 37.6 MB image, and turns a write-then-read loop into a full-image copy per write, 0.0005 s to 1.00 s over 200 iterations. So the two paths above stay open, and closing either still means a check insidecle/memory.py.Validation: #817 (comment)
session: sharpen