Skip to content

Fix Clemory backer removal and the BackedCGC backend - #718

Open
zardus wants to merge 1 commit into
masterfrom
feature/backedcgc
Open

Fix Clemory backer removal and the BackedCGC backend#718
zardus wants to merge 1 commit into
masterfrom
feature/backedcgc

Conversation

@zardus

@zardus zardus commented Aug 9, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

Loading anything with the backedcgc backend fails. On
binaries/tests/i386/patchrex/memory_scanner, with or without a process dump:

  File "cle/backends/cgc/backedcgc.py", line 61, in __init__
    self.memory.remove_backer(start)
  File "cle/memory.py", line 283, in remove_backer
    raise ValueError("Can't find backer to remove")
ValueError: Can't find backer to remove

The blast radius is wider than the backend. Clemory.remove_backer() is the
primitive under split_backer() and add_backer(overwrite=True), so all three
raise on a Clemory holding backers at 0, 10 and 20:

>>> c.remove_backer(0)
ValueError: Can't find backer to remove
>>> c.split_backer(4)
ValueError: Can't find backer to remove
>>> c.add_backer(2, b"xy", overwrite=True)
ValueError: Can't find backer to remove

Root cause

remove_backer bisects right:

backer_idx = bisect.bisect(self._backers, start, key=lambda x: x[0])

bisect.bisect is bisect_right, so for a backer that starts exactly at
start it returns the index after it. The guard on the next line then
compares self._backers[backer_idx][0] != start against the following backer
and raises, so no removal ever found its target -- it raises whether the address
is a backer start, inside a backer, or outside every backer. 87be7b53
(2025-08-14) introduced this, and nothing caught it because master has no test
that asserts anything about remove_backer or split_backer. split_backer
has not split anything since: the one path through it that would do the
splitting calls remove_backer first.

Behind that, the backend has its own defects. It compares linked segment
vaddrs against self.memory._backers, whose keys are relative to the object
base -- 0x8048000 against 0x8048000 minus 0x60006c4 -- so the comparison never
matches. It also mutates self.memory._backers while iterating it, which skips
every other entry: with only the bisect_left fix applied, memory_scanner
keeps one executable backer and one data backer and drops the other two. It
assigns to Region.is_readable and is_writable, which are read-only
properties, and thread_registers() returns dict.items() where Backend
documents a mapping.

Fix

Use bisect_left, and reset min_addr/max_addr/consecutive when the last
backer goes, so an emptied Clemory matches a fresh one. In the backend,
translate every address through AT.from_lva(...).to_rva() before it reaches
self.memory, walk a copy of the backer list, give FakeSegment a read-only
is_executable property instead of assigning to Region's, and default both
backers to {} so thread_registers() returns a mapping either way.
memory_scanner then loads:

loaded: <BackedCGC Object memory_scanner, maps [0x60006c4:0xbaaabfff]>
memory[0x8048000:4] = b'\x7fCGC'
memory[0xbaaab000:4] = b'\x11\x11\x11\x11'
memory[0x60006c4:4]   = KeyError (dropped, as the dump requires)
entry = 0x8048100
thread_registers() = {'eip': 134512896, 'esp': 3131748352}

Testing

tests/test_backedcgc.py loads memory_scanner with a dump and with neither
backer; tests/test_clemory.py covers remove_backer, split_backer and
add_backer(overwrite=True) directly, including that removing the last backer
leaves min_addr == max_addr == 0. All five fail on the merge base with
ValueError: Can't find backer to remove.

Validation: #718 (comment)

sync: angr/angr#6795

session: sharpen

@zardus

zardus commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head eb09731c7be9b888c13f243f7f9c294a3d9dd804 against baseline 0e77ade3c39a3cee05f65051e57955675e1ac21b, with fixtures from angr/binaries 9d9d9e0079a1f67e99046f1ebb9d4625b41152de.

Re-keyed from b104fe992b7a5d01c37312b9730a28f1b1edbe5f on baseline 46a37333f4f59b0facf8774ee743ebc4cc074e9b. git range-diff marks the commit ! rather than =, but the branch's own added and removed lines are byte-identical across the move: the whole difference is index lines, hunk offsets and hunk context, because master added sys and unittest imports to tests/test_clemory.py beside this branch's import hunk. Three commits separate the two baselines and none of them touches cle/backends/cgc/ or cle/memory.py. Every figure below was re-measured at this head anyway, and two of them moved.

The fixture revision on the opening line is the one this re-measurement used; the figures the earlier record carried were measured against angr/binaries 9a8974a63422e155e4af54edceebd099e19aad64, which is an ancestor of it.

Measured configuration: a detached worktree of cle at the revision named, this workspace's pinned Python 3.12 environment, nice -n 19, no xdist, -p no:randomly. The baseline arm reverts only cle/backends/cgc/backedcgc.py and cle/memory.py to c7e0d4db and keeps the branch's tests, so the two arms differ by the production change alone. pylint is run with the CI configuration from angr/ci-settings, ci-image/conf/pylintrc, because cle declares no [tool.pylint] table and the bare defaults score about two points lower on every file.

  • Regression: python -m pytest -q tests/test_backedcgc.py tests/test_clemory.py8 passed on this head; on the baseline 5 failed, 3 passed, and every failure is ValueError: Can't find backer to remove. They are test_backed_cgc_keeps_the_code_and_maps_the_dump, test_backed_cgc_loads_without_either_backer, test_remove_backer, test_split_backer and test_add_backer_overwrite
  • Full suite: python -m pytest tests247 passed, 9 skipped in 24.4 s. The earlier record's 207 passed is master's growth, not this branch's
  • Consumer: python -m pytest -q tests/simos/test_backedcgc.py from SimCGC: Keep the CGC defaults when a dump omits its optional backers. angr#6795 at 24d77ca349a99461f521618c91d265d1a2056d0d, with cle on PYTHONPATH2 passed against this head and 2 failed against the baseline, the failures being test_entry_state_without_recorded_writes_or_an_allocation_base and test_entry_state_replays_the_dumped_writes_and_allocation_base. That file holds two tests now; the earlier record's four was its shape at the time
  • Loader non-regression, re-run: every file under binaries/tests/ in cgc, i386, x86, armel, armhf, mips, mipsel, ppc, ppc64, riscv, riscv64 and sh4399 objects — loaded with cle.Loader(path, auto_load_libs=False) on both arms, recording each object's min_addr, max_addr, the start and length of every backer, and a checksum over the backer bytes. 381 load on both arms and 18 are rejected on both, with the same five rejection classes (CLECompatibilityError, CLEInvalidBinaryError, CLEError, AssertionError, struct.error). Zero objects differ between the arms on any recorded field
  • Lint: pylint per changed file, this head against the baseline — cle/backends/cgc/backedcgc.py 9.78 -> 10.00, cle/memory.py flat at 10.00, tests/test_clemory.py 5.12 -> 6.46, and the new tests/test_backedcgc.py at 10.00. No file regresses

Removed rather than re-keyed. The pyright badness figures and the "cle and angr built from source together" ecosystem run (cle 207 passed/9 skipped, angr 2475 passed/46 skipped/2 xfailed/260 subtests, cargo test --release 35 passed) were measured against a base master has moved past, and this workspace has not repeated either; ci / Typecheck covers the first at this head and the hosted shards cover the second. The local pre-commit run --all-files row is dropped in favour of pre-commit.ci - pr at this exact head.

Caveats: the backend still marks dump-only regions non-executable, as it always intended to; a CGC replay that needs an executable stack passes permissions_map, which angr's SimCGC.state_entry prefers.

Hosted CI at head b3e60a7d3c2b8f3681206ee8a84d34e4684ca3c8, read live 2026-08-29T20:20Z: 18 check runs, every one success, and both legacy commit statuses greenpre-commit.ci - pr and docs/readthedocs.org:cle — for 20 terminal green checks and nothing outside success. That includes ci / Lint, ci / Typecheck, ci / Build, all eleven ci / Test shards, Test (Pyodide), Test windows-2022 and Test macos-15. The workflow run is https://github.com/angr/cle/actions/runs/33239560995, concluded success at this head.

Re-keyed 2026-09-04, after a rebase onto cle master 0e77ade3c39a3cee05f65051e57955675e1ac21b. The branch moved from b3e60a7d3c2b8f3681206ee8a84d34e4684ca3c8 to 382e6ab0dd9afdf367a34b4c538ddd56ef2a7e0d because angr master bumped its sibling pins from 9.3.4.dev0 to 9.3.5.dev0 on 2026-09-02, and ci / Build installs pyproject.toml with uv pip install --no-sources, so a fresh run on the old head died on a .dev version no index publishes. check-stale-pins.py refuses b3e60a7d and exits 0 on 382e6ab0. This was a plain replay: no conflict, no hand resolution, no fixup. git range-diff c7e0d4db..b3e60a7d 0e77ade3..382e6ab0 reports every commit =, the branch's own added and removed lines are byte-identical across the move at 172 lines on each side, and master touched none of the files this change touches in the range rebased over. So every figure above describes the same patch on a new base.

Hosted CI at 382e6ab0dd9afdf367a34b4c538ddd56ef2a7e0d, read 2026-09-04T16:24Z: 11 checks -- 6 success, 4 skipped, 1 failure. Test (Pyodide), Test windows-2022 and Test macos-15 are green. ci / Build fails, and the rebase of this branch cannot fix it, because the pin that bites is not this branch's:

+ uv pip install --no-sources -f ./src/pyvex/dist './src/angr[angrdb,llm,unicorn]'
  Because there is no version of pyvex==9.3.4.dev0 ...

./src/angr is the sibling this pull request names, angr/angr#6795, whose head 24d77ca34 still pins pyvex==9.3.4.dev0, archinfo==9.3.4.dev0 and cle==9.3.4.dev0. ci / Lint, ci / Typecheck, ci / Test and ci / Decompiler Snapshot Testing are skipped because Build failed, so this head has no matrix result yet. Rebasing angr/angr#6795 onto angr master is what clears it.

Re-keyed 2026-09-04, after an amendment at the same base. The branch moved from 382e6ab0dd9afdf367a34b4c538ddd56ef2a7e0d to eb09731c7be9b888c13f243f7f9c294a3d9dd804. This was not a rebase and no rebase language applies to it: the base is the same cle master 0e77ade3c39a3cee05f65051e57955675e1ac21b, the branch's single commit was amended in place with its message and its author identity and date byte-identical, and git diff 382e6ab0 eb09731c is 4 insertions and 3 deletions in one file, tests/test_clemory.py. The change is an added import archinfo, and cle.Clemory(None, root=True) becoming cle.Clemory(archinfo.ArchAMD64(), root=True) in the three tests this branch adds -- test_remove_backer, test_split_backer and test_add_backer_overwrite. No production file moved.

That is what the per-file type gate wanted. Under the rule the hosted job applies -- angr/ci-settings origin/master b23d782e1052da0e512b1a1ef1902d06e0545730, ci-image/scripts/typecheck.py, a per-file pyright error count that fails when a changed file gains one -- pyright 1.1.411 reports tests/test_clemory.py at 9 errors on the base and 9 on this head, where the previous head raised it to 12: one per new test, each Argument of type "None" cannot be assigned to parameter "arch" of type "Arch" in function "__init__". The other three changed files are cle/backends/cgc/backedcgc.py 5 -> 0, cle/memory.py 0 -> 0, and the new tests/test_backedcgc.py at 0. Lint is unchanged from the row above except tests/test_clemory.py, now 5.12 -> 6.49.

The four pre-existing cle.Clemory(None, root=True) calls elsewhere in the file are deliberately left alone. They are already counted in the base's 9, so converting them would only lower a number while putting four unrelated hunks into a BackedCGC bug fix.

Every figure above still describes this head. Clemory._arch is read only by unpack/unpack_word, pack/pack_word, the pickle hooks and ClemoryView/ClemoryTranslator construction, and the three tests call none of those -- they exercise add_backer, remove_backer, split_backer, backers(), load, min_addr, max_addr, consecutive and in. The same sequences produce an identical backer list with None and with ArchAMD64(), so no assertion in them changed.

The ci / Build failure recorded above is also gone, and not because of anything here: angr/angr#6795, the sibling whose pyproject.toml still pinned pyvex==9.3.4.dev0, has since been rebased and its head 38c437f59c19096b56fa4e5369d7a9f86186d68c pins 9.3.5.dev0.

Hosted CI at eb09731c7be9b888c13f243f7f9c294a3d9dd804, read 2026-09-04T19:25Z: 20 checks, all 20 success -- nothing failing, nothing cancelled, nothing still running. That is run 33908700615, attempt 1, itself concluded success. The count was read twice, with gh pr checks and with a rollup census that puts an unfinished CheckRun -- one whose conclusion is the empty string rather than null -- in the running bucket instead of dropping it; both instruments give 20 of 20. ci / Build is one of the 20, green for the reason the paragraph above gives rather than because of this push.

ci / Typecheck, which is what this amendment was for, passes in 1m6s at job 101141313350. To complete the record of the previous head: the 16:24Z reading above caught ci / Typecheck skipped behind the failed ci / Build, and a second attempt of that run -- 33891321341, attempt 2, job 101104223775, finished 2026-09-04T16:59:38Z -- got past Build and failed ci / Typecheck at 382e6ab0dd9afdf367a34b4c538ddd56ef2a7e0d. That failure is what the amendment closed, by amending the branch's single commit at the same base -- not by a rebase. The hosted job applies ci-image/scripts/typecheck.py from angr/ci-settings origin/master b23d782e1052da0e512b1a1ef1902d06e0545730 with pyright 1.1.411 against base 0e77ade3c39a3cee05f65051e57955675e1ac21b, and its verdict agrees with the per-file counts stated above, which the pre-publication review and the push reproduced independently of each other: tests/test_clemory.py 9 -> 9 and cle/backends/cgc/backedcgc.py 5 -> 0.

@angr-bot

angr-bot commented Aug 9, 2026

Copy link
Copy Markdown
Member

Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_718

@zardus
zardus force-pushed the feature/backedcgc branch 2 times, most recently from 2d584bf to f8ea7c4 Compare August 22, 2026 12:26
@zardus
zardus force-pushed the feature/backedcgc branch from f8ea7c4 to b104fe9 Compare August 26, 2026 22:46
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Clemory's removal primitives, and a backedcgc load of binaries/tests/i386/patchrex/memory_scanner, before and after this change.

Before — every removal raises, so the backend never gets past its first backer:

cle master
cle: .../wt/cle/base/cle/__init__.py

$ c = cle.Clemory(None, root=True); c.add_backer(0, b'A'); c.add_backer(10, b'BB'); c.add_backer(20, b'CCC')
$ c.remove_backer(0)
ValueError: Can't find backer to remove

$ c = cle.Clemory(None, root=True); c.add_backer(0, b'ABCDEFGH'); c.split_backer(4)
ValueError: Can't find backer to remove

$ c = cle.Clemory(None, root=True); c.add_backer(0, b'ABCDEFGH'); c.add_backer(2, b'xy', overwrite=True)
ValueError: Can't find backer to remove

$ cle.Loader(memory_scanner, main_opts={'backend': 'backedcgc', 'memory_backer': {0xbaaab000: b'\x11'*0x1000}, 'register_backer': {'eip': 0x8048100, 'esp': 0xbaaab000}})
Traceback (most recent call last):
  File "repro718.py", line 11, in <module>
    ld = cle.Loader(B, auto_load_libs=False, main_opts={
  File "cle/loader.py", line 187, in __init__
    self.initial_load_objects = self._internal_load(
  File "cle/loader.py", line 805, in _internal_load
    obj = self._load_object_isolated(main_spec)
  File "cle/loader.py", line 1017, in _load_object_isolated
    result = backend_cls(binary, binary_stream, is_main_bin=self._main_object is None, loader=self, **options)
  File "cle/backends/cgc/backedcgc.py", line 61, in __init__
    self.memory.remove_backer(start)
  File "cle/memory.py", line 283, in remove_backer
    raise ValueError("Can't find backer to remove")
ValueError: Can't find backer to remove

$ cle.Loader(memory_scanner, main_opts={'backend': 'backedcgc'})   # neither backer
Traceback (most recent call last):
  File "repro718.py", line 30, in <module>
    ld = cle.Loader(B, auto_load_libs=False, main_opts={"backend": "backedcgc"})
  File "cle/loader.py", line 187, in __init__
    self.initial_load_objects = self._internal_load(
  File "cle/loader.py", line 805, in _internal_load
    obj = self._load_object_isolated(main_spec)
  File "cle/loader.py", line 1017, in _load_object_isolated
    result = backend_cls(binary, binary_stream, is_main_bin=self._main_object is None, loader=self, **options)
  File "cle/backends/cgc/backedcgc.py", line 61, in __init__
    self.memory.remove_backer(start)
  File "cle/memory.py", line 283, in remove_backer
    raise ValueError("Can't find backer to remove")
ValueError: Can't find backer to remove

After — removal finds its target, and the dump replaces every mapping except the code:

with this change
cle: .../wt/cle/h718/cle/__init__.py

$ c = cle.Clemory(None, root=True); c.add_backer(0, b'A'); c.add_backer(10, b'BB'); c.add_backer(20, b'CCC')
$ c.remove_backer(0)
backers now: [10, 20]

$ c = cle.Clemory(None, root=True); c.add_backer(0, b'ABCDEFGH'); c.split_backer(4)
backers now: [(0, b'ABCD'), (4, b'EFGH')]

$ c = cle.Clemory(None, root=True); c.add_backer(0, b'ABCDEFGH'); c.add_backer(2, b'xy', overwrite=True)
c.load(0, 8) = b'ABxyEFGH'

$ cle.Loader(memory_scanner, main_opts={'backend': 'backedcgc', 'memory_backer': {0xbaaab000: b'\x11'*0x1000}, 'register_backer': {'eip': 0x8048100, 'esp': 0xbaaab000}})
loaded: <BackedCGC Object memory_scanner, maps [0x60006c4:0xbaaabfff]>
memory[0x8048000:4] = b'\x7fCGC'
memory[0xbaaab000:4] = b'\x11\x11\x11\x11'
memory[0x60006c4:4]   = KeyError (dropped, as the dump requires)
memory[0x804f348:4]   = KeyError (dropped, as the dump requires)
memory[0x8063000:4]   = KeyError (dropped, as the dump requires)
entry = 0x8048100
thread_registers() = {'eip': 134512896, 'esp': 3131748352}

$ cle.Loader(memory_scanner, main_opts={'backend': 'backedcgc'})   # neither backer
loaded: <BackedCGC Object memory_scanner, maps [0x60006c4:0x8063577]>
memory[0x8048000:4] = b'\x7fCGC'
entry = 0x6000703
thread_registers() = {}

@zardus
zardus force-pushed the feature/backedcgc branch from b104fe9 to b3e60a7 Compare August 29, 2026 06:53
@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

A measurement of what this change does to a real corpus — which here bounds the
risk rather than showing a benefit.

Sample. 12,000 objects drawn uniformly at random, from a seeded permutation,
out of a 624,920-object internal corpus of compiler- and vendor-produced
binaries; 11,989 were retrievable and probed.

Method. Each object is loaded with the catalogue's declared load recipe and
CFGFast is taken as far as the first failure. A 1,241-object subset — the
whole of every failure class in the sample plus 722 objects that already reach
CFG — is probed against master (eac0e5540516b9199dd6a91933e80dc774ea3eac)
and against this branch's head (b3e60a7d3c2b8f3681206ee8a84d34e4684ca3c8) in
one environment, so before and after are the same objects. The comparison is
keyed on exception type and function, not on file:line.

Result. Nothing moves. All 722 control objects still reach CFG, and every
failure class lands exactly where it lands on master — the 95 minidumps at
region.py:is_executable, the 36 COFF objects at coff.py:_parse, the 53 ELF
objects on pyelftools' dynamic-table assertion, and the rest. Not one of the
1,241 objects gets a different outcome.

That is the honest reading in both directions. The sample contains 169 CGC
binaries and no object at all that declares the backedcgc backend, so a
corpus of this shape cannot show what the BackedCGC half of this change buys;
it has to be reviewed on the code. What it does establish is that the
bisect_left correction in Clemory.remove_backer changes no outcome anywhere
in the sample.

Correction. An earlier version of the sentence above said remove_backer
sits "under add_backer and update_backer, on the path every load takes".
There is no update_backer in cle — 87be7b53 deleted it in the same commit
that introduced this bug, and git grep update_backer finds nothing at master
a96c36c1. remove_backer has four callers: split_backer,
add_backer(overwrite=True) twice, and BackedCGC.__init__, which is the
backend this change fixes. Outside that backend, ELF is the only thing that
reaches it, through add_backer(..., overwrite=True) at one site in the segment
loop and two in the section loop.

Loading every ELF under tests/ in angr/binaries 45819e52 with
cle.Loader(path, auto_load_libs=False, main_opts={"backend": "elf"}) on cle
a96c36c1 — 743 of the 745 load — calls remove_backer 2,861 times, and all
2,861 raise
: 1,965 from the segment site and 896 from the two section sites.
The bare except ValueError in add_backer discards every one. Not one of the
2,861 had a backer to remove, split_backer performed no real split, and no
object finished with a duplicate backer start. Those figures are measured at a
later master than the corpus probe above, which ran at eac0e554.

session: sharpen

zardus added a commit that referenced this pull request Sep 4, 2026
split_backer finds its target with backers(), which recurses into nested
clemories and yields the child's own bytearrays, and then removes it with
remove_backer(), which only looks at self._backers, where the child itself
sits. Splitting on an address inside a child would remove the whole child
and put back two slices of one of the backers it held, dropping everything
else. On master remove_backer raises before that happens; with #718
applied it does not. Loader.memory is that shape, since a loaded object's
memory is a nested clemory inside it.

The guard meant to cover this tested the leaf backers() yielded, which is
never a clemory, so it could not fire. Raise unless the backer that was
found is one of this clemory's own, which is the precondition for
remove_backer to remove the thing being split.
Loading anything with the backedcgc backend raised "Can't find backer to
remove". Clemory.remove_backer() searched with bisect_right, which always
lands one index past the backer whose start matches, so no removal ever
found its target and BackedCGC.__init__ died on the first file backer it
tried to drop. split_backer(), and add_backer(overwrite=True) through it,
were broken the same way.

The backend had further defects on that path:

- It compared segment vaddrs and the caller's memory_backer keys, which are
  linked addresses, against the object's memory, which is keyed relative to
  the object's base. It dropped the executable segment it meant to keep and
  mapped the dump at the wrong addresses.
- It iterated the backer list while removing from it, so every backer
  following a removed one was skipped.
- FakeSegment assigned to is_readable, is_writable and is_executable, which
  are read-only properties on Region, so any non-empty memory_backer raised
  AttributeError before a byte of it was mapped.
- Both backers are documented as optional, but a missing memory_backer
  raised AttributeError, and thread_registers() returned None where Backend
  documents a mapping and angr's SimOS calls .items() on the result. Both
  are empty mappings now when the caller supplies nothing.

Removing a Clemory's last backer now happens for real, so _update_min_max()
restores the empty state a fresh Clemory has instead of asserting;
split_backer() passes through that state on every split.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants