Skip to content

CGC: Repair only the header bytes that are loaded - #726

Open
zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-cgc-offset
Open

zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-cgc-offset

Conversation

@zardus

@zardus zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

A CGC binary whose first PT_LOAD starts past file offset 0 does not load. Moving that segment in a copy of tests/cgc/CADET_00002 to offset 0xa0, so the 16-byte header is not mapped at all:

    RAISED TypeError: "unsupported operand type(s) for -: 'NoneType' and 'int'"
      | return cls(owner.offset_to_addr(raw) - (owner.mapped_base if owner._is_mapped else owner.linked_base), owner)
      | TypeError: unsupported operand type(s) for -: 'NoneType' and 'int'

The failure is in the constructor, so the whole object is lost — entry point, segments and symbols — over bytes that were never going to be in memory. Real challenge binaries with no PT_PHDR and a first PT_LOAD past the header have this shape.

Root cause

The CGC backend substitutes an ELF magic into the stream so pyelftools will parse the file, then stores the real CGC header back into memory with one unconditional write:

self.memory.store(AT.from_raw(0, self).to_rva(), CGC_HEADER)  # repair the CGC header

AT.from_raw(0, self) calls owner.offset_to_addr(0), which returns None when no segment maps file offset 0, and the address translator then subtracts an int from it. Nothing in ELF requires the header to be mapped.

Fix

The backend that makes the substitution is what has to undo it, so it now stores back only the bytes that were loaded, walking the 16 header bytes and writing each one whose file offset maps to an address. A binary mapping none of the header loads unchanged, and one whose first PT_LOAD begins partway into the header keeps its own bytes rather than the substituted magic. The substitution in the stream itself is untouched.

--- stock binary:                 LOADED CGC entry=0x804909a  offset_to_addr(0)=0x8048000
    memory at offset 0x0: 7f43474301010143014d6572696e6f00   (== CGC_HEADER)
--- first PT_LOAD at offset 0xa0: LOADED CGC entry=0x804909a  offset_to_addr(0)=None
    ELF magic in any backer: False
--- first PT_LOAD at offset 0x8:  LOADED CGC entry=0x804909a  offset_to_addr(0)=None
    memory at offset 0x8: 014d6572696e6f000200030001000000   (== CGC_HEADER[8:] then the file's own bytes)

Testing

tests/test_cgc.py::test_cgc_mapped_header pins the stock repair, and ::test_cgc_unmapped_header and ::test_cgc_partially_mapped_header move the first PT_LOAD of tests/cgc/CADET_00002 in a temporary copy, keeping the bytes it maps at the addresses they had. The load-bearing assertion is ld.memory.load(tail_addr, len(CGC_HEADER) - PARTIAL_OFFSET) == CGC_HEADER[PARTIAL_OFFSET:]. The two moved cases fail on the merge base with the TypeError above, and no new fixture is needed.

Validation: #726 (comment)

session: sharpen

@zardus

zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head e8e98e171d44ed52f50fe735c83a01cdc216496d against baseline b58ea02a446106647cdaae32bdf91b7062404cc1.

  • Regression: pytest tests/test_cgc.py::test_cgc_unmapped_header — on baseline fails with TypeError: unsupported operand type(s) for -: 'NoneType' and 'int' at cle/address_translator.py:63, passes on head
  • Regression: pytest tests/test_cgc.py::test_cgc_partially_mapped_header — fails against a variant that only checks file offset 0, which loads eight zero bytes where the file has 01 4d 65 72 69 6e 6f 00; passes on head
  • Focused: pytest tests/test_cgc.py — 3 passed
  • Full suite: pytest tests/ — 205 passed, 9 skipped on head; 202 passed, 9 skipped on baseline
  • Lint/type: pre-commit run --all-files — 24 hooks pass, no file rewritten; pylint 10.00 -> 10.00 and pyright badness 0.0 -> 0.0 on both changed files against the baseline
  • Workspace gate: cle — pass (Python 3.12.13, pyright 1.1.411)

Sweep over a copy of tests/cgc/CADET_00002 whose first PT_LOAD is moved to a range of file offsets, comparing loaded memory against the file bytes each segment maps:

First PT_LOAD Baseline Head
0x0, as shipped matches file matches file
0x1, 0x4, 0x8, 0xf TypeError matches file
0x10, 0x40, 0xa0 TypeError matches file
0x0, mapping 8 bytes KeyError: 8 matches file

The last row is a separate pre-existing crash from the same assumption, fixed by the same change. No substituted ELF magic survives in the loaded image in any row.

Caveats: the unmapped-header layout comes from real CGC challenge binaries seen in a corpus sweep, none of which can be redistributed here; every CGC fixture in angr/binaries maps its header at file offset 0, which is why CI never reached this. A first PT_LOAD that begins inside the header is not a layout that sweep saw, and it is covered because the fix would otherwise have left substituted bytes in memory there. Load time for tests/cgc/CADET_00002 is unchanged, 1.2-1.7 ms per load over three runs of 20 on both revisions.

Rebased 2026-08-27. This record was measured at aa99be0055126c0c1bfa7f05a8eb5e94a240b7f1. The branch has since been rebased onto master at e8e98e171d44ed52f50fe735c83a01cdc216496d; git range-diff reports the commit unchanged, so only the base moved and every figure above still describes this head.


Corpus evidence, 2026-08-28

Load-only measurement over a private corpus, so objects are cited by architecture, container format and sha256 only. Each object is loaded in its own process as angr.Project(path, auto_load_libs=False, use_sim_procedures=False) with the backend its recipe names; no analysis runs. Populations are deduplicated by object digest, because a ledger row is a run and a retry gives one object several rows. The master arm is cle d2ecea068794d20b1f14d90eecc1bc4bc4cfa431; master has since moved one commit, to a4fb800, which touches only cle/backends/macho/.

277 distinct objects terminate in cle/address_translator.py:from_raw:63 — 276 CGC and one CaRT-wrapped x86 executable.

None of them is fixed on master: 30 of a 30-object sample still terminate there.

On this head, e8e98e171d44ed52f50fe735c83a01cdc216496d, 30 of 30 load.

@angr-bot

Copy link
Copy Markdown
Member

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

The CGC backend substitutes an ELF magic into the stream so pyelftools will
parse the file, then writes the real CGC header back into memory afterwards.
That store assumed the whole 16-byte header has a virtual address, but nothing
requires the header to be inside a PT_LOAD. When file offset 0 is not loaded,
offset_to_addr returns None and AT.from_raw raises TypeError from inside
address arithmetic, so the load fails outright. Real CGC challenge binaries are
laid out this way, with no PT_PHDR and a first PT_LOAD starting well past the
header.

Store back only the bytes that the substitution actually reached. A binary that
maps none of the header now loads unchanged, and one whose first PT_LOAD starts
partway into the header keeps the bytes it really contains instead of the
substituted magic.
@zardus
zardus force-pushed the feature/fix-cle-cgc-offset branch from f59456e to e8e98e1 Compare August 26, 2026 22:45
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full load report for tests/cgc/CADET_00002 and two copies of it, before and after this change. The copies rewrite the program header of the PT_LOAD that starts at file offset 0 so it starts at 0xa0 (the 16-byte header is not mapped at all) and at 0x8 (only its tail is), keeping the bytes each maps at the virtual addresses they had. Each case prints the entry point, what offset_to_addr(0) answers, the bytes in memory at the segment's first mapped offset beside the same bytes in the file, and whether an ELF magic — the substitution this backend has to undo — survives in any backer.

Before — the stock binary loads, but both moved copies die inside AT.from_raw on the address that does not exist:

cle at the merge base, 46a3733
cle: <cle at the merge base>/cle/__init__.py
fixture: tests/cgc/CADET_00002
--- stock binary (first PT_LOAD at file offset 0; header fully mapped)
    LOADED  CGC entry=0x804909a (e_entry=0x804909a)
    offset_to_addr(0)=0x8048000
    memory at offset 0x0 (addr 0x8048000): 7f43474301010143014d6572696e6f00
    file      at offset 0x0:              7f43474301010143014d6572696e6f00
    CGC_HEADER=7f43474301010143014d6572696e6f00 ELF magic in any backer: False
--- copy with first PT_LOAD moved to file offset 0xa0 (header not mapped at all)
    RAISED TypeError: "unsupported operand type(s) for -: 'NoneType' and 'int'"
      | return cls(owner.offset_to_addr(raw) - (owner.mapped_base if owner._is_mapped else owner.linked_base), owner)
      | ~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
      | TypeError: unsupported operand type(s) for -: 'NoneType' and 'int'
--- copy with first PT_LOAD moved to file offset 0x8 (only the header's tail is mapped)
    RAISED TypeError: "unsupported operand type(s) for -: 'NoneType' and 'int'"
      | return cls(owner.offset_to_addr(raw) - (owner.mapped_base if owner._is_mapped else owner.linked_base), owner)
      | ~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
      | TypeError: unsupported operand type(s) for -: 'NoneType' and 'int'

After — all three load, the unmapped copy keeps no substituted magic anywhere, and the partially mapped one gets exactly the tail of CGC_HEADER that is in memory:

with this change, e8e98e1
cle: <cle with this change>/cle/__init__.py
fixture: tests/cgc/CADET_00002
--- stock binary (first PT_LOAD at file offset 0; header fully mapped)
    LOADED  CGC entry=0x804909a (e_entry=0x804909a)
    offset_to_addr(0)=0x8048000
    memory at offset 0x0 (addr 0x8048000): 7f43474301010143014d6572696e6f00
    file      at offset 0x0:              7f43474301010143014d6572696e6f00
    CGC_HEADER=7f43474301010143014d6572696e6f00 ELF magic in any backer: False
--- copy with first PT_LOAD moved to file offset 0xa0 (header not mapped at all)
    LOADED  CGC entry=0x804909a (e_entry=0x804909a)
    offset_to_addr(0)=None
    memory at offset 0xa0 (addr 0x80480a0): 5589e583ec78c745fc00000000e82e09
    file      at offset 0xa0:              5589e583ec78c745fc00000000e82e09
    CGC_HEADER=7f43474301010143014d6572696e6f00 ELF magic in any backer: False
--- copy with first PT_LOAD moved to file offset 0x8 (only the header's tail is mapped)
    LOADED  CGC entry=0x804909a (e_entry=0x804909a)
    offset_to_addr(0)=None
    memory at offset 0x8 (addr 0x8048008): 014d6572696e6f000200030001000000
    file      at offset 0x8:              014d6572696e6f000200030001000000
    CGC_HEADER=7f43474301010143014d6572696e6f00 ELF magic in any backer: False

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

How much of a corpus's failure surface this removes, measured rather than
argued.

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, 169 of them CGC. Rates carry 95%
Wilson intervals.

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

Before. 7 / 11,989 = 0.06% of the sample (CI 0.03–0.12), and 4.1% of its
CGC binaries, fail with TypeError: unsupported operand type(s) for -: 'NoneType' and 'int' at address_translator.py:from_raw. The None is
Backend.offset_to_addr(0) for a DECREE binary whose first PT_LOAD does not
map file offset 0, and the only production caller of AT.from_raw in the tree
is the CGC header repair. All 7 are 32-bit x86 DECREE executables from the DARPA
CGC corpus.

After. This head clears all 7, and all 7 go on to reach CFG — the whole
class, with no residual in it. The other 162 CGC binaries in the sample already
loaded on master and are unaffected.

Control. 722 objects that already reached CFG on master are unchanged on
this head — 0 of 722 differ.

The corpus is not redistributable, so its objects are described by architecture,
format and OS rather than named; none of the 7 is byte-identical to anything
tracked in angr/binaries.

session: sharpen

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