Skip to content

ELF: Stop unloaded sections from claiming a relocatable object's addresses - #739

Open
zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-rel-note-address
Open

zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-rel-note-address

Conversation

@zardus

@zardus zardus commented Aug 13, 2026 •

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

In a relocatable object, a section that cle deliberately does not map still takes an address from the layout, so it sits on top of the section that follows. On tests/x86_64/switch_default_abort.o the SHF_ALLOC .note.gnu.property lands at the address .eh_frame starts at, and find_section_containing then answers None over the whole of .eh_frame:

  .eh_frame              +0x90     size=0x40     occ=True
  .note.gnu.property     +0x90     size=0x20     occ=True
--- overlaps among sections reported as occupying memory ---
  .note.gnu.property(+0x90,0x20) overlaps .eh_frame(+0x90)
  +0x80    loader=.rodata
  +0xc0    loader=None

Whatever the shadowed section is, consumers that gate on the containing section lose it: CFGFast discards blocks whose section is not executable, and JumpTableResolver rejects a table that falls outside a mapped section.

Root cause

ELF._load_sections lays a relocatable object's allocated sections out itself, because their sh_addr values are meaningless. A section whose type is in _NON_ALLOCATED_SECTION_NAMES is given the running address but reserves no space and gets no backer:

if sec_readelf.header["sh_type"] not in _NON_ALLOCATED_SECTION_NAMES:
    new_addr += sec_readelf.header["sh_size"]

ELFSection.occupies_memory then had no way to know that, being only self.flags & self.SHF_ALLOC != 0 and self.memsize > 0, and this note section is SHF_ALLOC with sh_size 0x20. Regions documents that its members do not overlap and bisects on their end addresses, so one such section makes the lookup return None — or a stale neighbour, depending on what was looked up before — over the range it covers.

Fix

_load_sections carries an occupies_memory flag, sets it false for exactly the types it already refuses to reserve space for, and passes it to ELFSection, whose property ANDs it in. That is what the constant's own comment, "Sections that do not occupy memory at runtime", already claims about them. No address, byte, relocation or symbol assignment moves — the sections keep the addresses they were given and stop claiming to occupy memory there:

  .note.gnu.property     +0x90     size=0x20     occ=False
--- overlaps among sections reported as occupying memory ---
  none
  +0xc0    loader=.eh_frame

Testing

TestRelocatableSections.test_every_mapped_address_resolves_to_its_section walks every section that reports occupies_memory and asserts self.assertEqual(name_of(ld.find_section_containing(addr)), section.name) at each of its addresses; test_unloaded_section_claims_no_address pins that the note section reports no memory. Both fail on the merge base and pass on this head, on tests/x86_64/switch_default_abort.o, which is already on angr/binaries master.

Validation: #739 (comment)

session: sharpen

@zardus

zardus commented Aug 13, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head ab2d0dab552c351fa90ad0a8795836b59463d5b7 against baseline d2ecea068794d20b1f14d90eecc1bc4bc4cfa431.

  • Regression: python -m pytest tests/test_regions.py::TestRelocatableSections -q — fails on baseline (.note.gnu.property overlaps .eh_frame at 0x400090, and .note.gnu.property.occupies_memory is True), passes on head
  • Focused: python -m pytest tests -q — 236 collected, 227 passed, 9 skipped, 0 failed
  • Lint/type: pre-commit run --all-files clean; ruff and pyright changed-file comparison against the baseline shows no regression on any of the three files
  • Workspace gate: complete local gate over cle plus the workspace's own checks — passed
  • Hosted CI: every check terminal and green, including ci / Typecheck, ci / Lint and all ten ci / Test shards
  • dec-snapshots comparison for this PR reports identical, no file changed

Loader comparison over 1710 corpus objects spanning 55 architectures and 10 container formats, and separately over 39 relocatable ELF objects that carry an allocated note or .reginfo section. Each object is loaded on both revisions and reduced to a digest of every object's base, entry, bounds, section and segment table, and of every byte the loader maps.

Measure 1710-object sample 39 affected objects
Loaded / errored 659 / 1051 on both 39 / 0 on both
Mapped bytes differing 0 0
Addresses, sizes, permissions, entry or bounds differing 0 0
Relocation or symbol counts differing 0 0
Objects whose occupies_memory flags change 14 39

CFGFast (normalize=True, resolve_indirect_jumps=True, data_references=False, force_complete_scan=False) over the same 1710 objects: output byte-identical on every object, no new error and no new timeout.

Over the 39 affected objects:

Measure Baseline Head
Errors / timeouts 0 0
Mapped addresses find_section_containing() cannot resolve 95434 of 160641 0 of 160641
Recovered blocks 17657 17663
Function entries 2603 2594
Recovered instruction bytes lost against baseline — 0

Six objects change; every difference:

  • d08e00d2a2c98acdc04cce7e336383f33eb470dd2fdba1e26bc434dd593b6c8a (ARM, ET_REL): .text is 4 bytes and .note.Linux covered .init.text and part of .exit.text, so CFGFast dropped that code as non-executable. Recovery goes from 3 blocks and 6 instructions to 5 blocks and 17 instructions, and .init.text at 0x400004 and .exit.text at 0x40002c become function entries in place of the mid-function 0x400030.
  • 19475c90d964f3b3c46b1e7cf7261ee5a1cebb2fc14f92bd539f1a9778516a1e and 211ef307cbe4f8c2d6e018fdd9fb8b1e6d6aa4e448ddd6a0c4f561cb548dd9c7 (ARM, ET_REL): three jump tables now pass the mapped-section check, so 16 blocks that were orphan function entries become blocks of the function that switches to them. All 16 are still in the graph. One UnresolvableJumpTarget disappears where a table resolves, and one appears on the second object where newly reached code has its own unresolved jump.
  • 05c76d5f6d2b943cf9b13f2a3b67b9060fe3ec5b7ad3e27f2a6e7dfd9724ce69, 892e87957a373e133edd96e2985109dc1da43b48aea3e8fbfa8701c971604183, a3e588c7f2d21984946ce8df1f78ed3ea25d7fa0493d23160b69dbd2cdca3c1a (MIPS, ET_REL): one extern function each (module_put, kvfree_call_rcu, crypto_shash_final). _addrs_belong_to_same_section can now find the source section of a j to an external symbol, so the tail call is recognised as leaving the function. The block set and every block boundary are unchanged on all three.

Net across the 39: 18 function entries removed, 9 added, 0 bytes of recovered code lost, 44 gained.

The CFGFast comparison and the loader comparison were run in opposite revision orders and agree exactly on all 39 objects in both revisions, so none of these differences is nondeterminism.

Caveats: the corpus objects are named only by architecture, container and sha256 because the dataset is not public; tests/x86_64/switch_default_abort.o reproduces the loader half of the bug from angr/binaries master. This covers only the layout cle invents for a relocatable object. A linked ELF whose .tbss overlaps the section after it still produces overlapping mapped sections, which Regions handles by luck rather than by design; that is untouched here.


Corpus measurement, 2026-08-19

A sweep of this corpus ranks blocks_in_data — a block decoded over a section that holds no code — as its second largest finding category: 4,991 objects, 17,269 instances. Discriminating relocatable ELFs by their absent program headers (segments == 0):

objects instances
whole category 4,991 17,269
relocatable ELF 4,984 (99.9%) 16,116 (93.3%)

By architecture: mips 2,654, arm 1,083, x86 775, aarch64 287, ppc64 166, xtensa 15, hppa 2, x86_64 1.

So essentially the entire category is this defect. On one of them, a mips kernel module, master places .note.Linux and .text at the same address:

master   sections occupying memory=7   overlap at 0x400000: ['.note.Linux', '.text']
this PR  sections occupying memory=6   no overlap; 0x400000 claimed by ['.text'] alone

and the finding clears with the analysis untouched — 301 blocks on both sides, blocks_in_data 3 → 0, at exactly the addresses the report samples (0x400000, 0x400014, 0x400020).

It is also visible without the corpus: 51 relocatable fixtures in angr/binaries overlap on master, including ten tests/x86_64/decompiler/*.o where .note.gnu.property lands on .eh_frame, and tests/mips/mips-hilo.o where .reginfo lands on .MIPS.abiflags.

Corpus evidence added 2026-08-26, measured on head 110e458ec. Objects come from a sweep over material that is not public and are described by architecture and container only.

A sweep of 452,707 objects counts basic blocks that land inside a region the loader reports as data. 14,503 objects carry at least one, 48,658 instances in total, and 99.99% of those objects are relocatable — 14,416 kernel modules plus 84 other relocatables. The distribution is flat rather than outlier-driven: median 3 per object, mean 3.4, and the hundred worst objects hold only 5.5% of the instances.

The mechanism is this defect. On a MIPS kernel module the section table reads:

.note.gnu.build-id  0x400000  memsize 36    occupies_memory 1
.note.Linux         0x400000  memsize 64    occupies_memory 1
.text               0x400000  memsize 1296  X  occupies_memory 1

Both notes keep the address .text is given, so any consumer reading the section table sees the head of .text as data. The blocks flagged there are real code: 0x400000 disassembles as lui $v1, 0x50 / daddiu / move $at, $ra, inside the function symbol dac7612_read_raw.

Measured over 108 objects spanning the affected architectures, comparing this head against master:

instances
master 255
this head 14

Every architecture except xtensa goes to 0; the 14 that remain are a separate issue in the p-code lifter overrunning a block budget by one byte, which has nothing to do with the loader. Extrapolated over the sweep, this accounts for roughly 46,900 of the 48,658 instances, about 96%. The remainder is the COFF .bss case in #764.

Reproducing the count on the pinned toolchain the sweep used gives 255 as well, so this is not something that has drifted on master: master and the sweep's pin agree exactly, and only this branch moves the number.

Re-keyed 2026-08-28. The figures above were measured at eb63f1e26700e428693de40b33f706c6589602f7 on baseline 45c6509c753d07f740099035cd41f7f473dc6f31, which is the head the opening line named until now; the branch is at ab2d0dab552c351fa90ad0a8795836b59463d5b7 on d2ecea068794d20b1f14d90eecc1bc4bc4cfa431. git range-diff 45c6509c753d07f740099035cd41f7f473dc6f31..eb63f1e26700e428693de40b33f706c6589602f7 d2ecea068794d20b1f14d90eecc1bc4bc4cfa431..ab2d0dab552c351fa90ad0a8795836b59463d5b7 reports every commit unchanged and git diff eb63f1e26700e428693de40b33f706c6589602f7 ab2d0dab552c351fa90ad0a8795836b59463d5b7 differs only by master's own advance (15 files changed, 423 insertions(+), 44 deletions(-)). Master did touch cle/backends/elf/elf.py between the two baselines (it now skips a relocation section whose symbol table was stripped), so the regression was re-run at this head rather than assumed: python -m pytest tests/test_regions.py::TestRelocatableSections — 2 passed at ab2d0da; with cle/backends/elf/elf.py and cle/backends/elf/regions.py reverted to the new baseline d2ecea06, both fail. The corpus addendum measured at 110e458e is unchanged by this move.


Corpus attribution, 2026-08-28, keyed to head ab2d0dab552c351fa90ad0a8795836b59463d5b7 against baseline d2ecea068794d20b1f14d90eecc1bc4bc4cfa431. Objects come from material that is not public and are named by architecture, container and sha256 only.

A category sweep, deduplicated by object digest, scores 460,618 distinct objects and asks whether a recovered block overlaps a section the loader reports as non-executable data. 14,503 objects carry at least one, 48,658 blocks in total. Attributing those by mechanism, from the objects' own section headers read straight out of the file rather than through CLE:

mechanism objects blocks
an allocated SHT_NOTE / SHT_MIPS_REGINFO section in an ET_REL object taking the address of the section behind it 14,445 (99.6%) 46,930 (96.4%)
a COFF section with no file bytes landing on the image base (#764) 13 1,683
a p-code block keeping an instruction wider than the bytes it was given (angr/angr#6816) 44 44
one linked ET_DYN object that does not reproduce at the sweep's revisions 1 1

The first row is this change. It is the largest single mechanism the sweep attributed anywhere in this work, ahead of the 12,204 Mach-O objects of the outside-executable category. __register_sections gives a SHF_ALLOC note section a real address and then declines to advance the layout cursor past it (cle/backends/elf/elf.py:1454 at this baseline), so .text starts on top of it and the first blocks of real code are reported as data.

Measured on 69 stratified objects across four containers and ten architectures, which reproduce the sweep's own count on 68 of 69 at its revisions:

flagged blocks objects at zero
cle master 1,291 1 of 69
master plus this change and #764 6 63 of 69

The six survivors are Xtensa objects and are the p-code row above, not this. Cost of the pair over the same 69: recovered blocks +162, recovered functions -71 — the function delta is entirely two ARM kernel modules losing spurious heads planted inside real functions, and the COFF objects gain both blocks and functions because .text$mn stops being shadowed.

Independent of any CFG run, a loader-only check over 262 objects of this category asks whether any section the scorer would call data overlaps any section it would call executable: 237 of 262 collide, and every colliding ELF object is ET_REL. The 25 that do not are the Xtensa objects, which carry no note section, and the single ET_DYN.

Nothing upstream has fixed this: over the same 69 objects, cle master scores 1,291 against 1,289 at the sweep's revisions, and not one object goes to zero.

@angr-bot

Copy link
Copy Markdown
Member

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

A relocatable object carries no addresses of its own, so __register_sections
lays its allocated sections out itself. A section whose type is in
_NON_ALLOCATED_SECTION_NAMES took an address from that layout without
reserving any space, and no backer is ever added for it, so it landed on top
of the section that follows. On a Linux kernel module the allocated
.note.Linux therefore covers the first 0x30 bytes of .text, and on MIPS
.reginfo shares an address with .MIPS.abiflags.

Regions documents that its members do not overlap and finds one by bisecting
on their end addresses, so a single overlapping section leaves that list
unsorted by the search key and the lookup misses regions that are really
there. find_section_containing() then returns None for most of the object's
mapped range, or a stale section, depending on what was looked up before it,
because Backend caches the previous hit. CFGFast reads it twice: a block
whose section is not executable is discarded, and JumpTableResolver requires
the table to be inside a mapped section when the object has no segments, so
an ARM module loses both the code the note covers and every jump table.

These sections now report that they occupy no memory, which is what the
constant already claims about them. Every section address, every loaded
byte, every relocation and every symbol stays as it was; the sections only
leave the address map, the way a section without SHF_ALLOC already does.
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full section-layout report for tests/x86_64/switch_default_abort.o, loaded with cle.Loader(path, main_opts={"base_addr": 0x400000}), before and after this change. The script prints every section with the address it was laid out at and whether it reports occupies_memory, then the overlaps among the ones that do, then what Loader.find_section_containing answers at a fixed set of offsets.

Before — .note.gnu.property claims .eh_frame's address, and every offset inside .eh_frame resolves to nothing:

cle at the merge base, d2ecea0
Symbol imported without a known size; emulation may fail if it is used non-opaqely: abort, puts. See https://docs.angr.io/extending-angr/environment#simdata
cle: <cle at the merge base>/cle/__init__.py
fixture: tests/x86_64/switch_default_abort.o
mapped_base: 0x400000
--- sections that claim memory (name, vaddr-base, memsize, occupies_memory) ---
                         +0x0      size=0x0      occ=False
  .comment               +0x0      size=0x26     occ=False
  .note.GNU-stack        +0x0      size=0x0      occ=False
  .rela.eh_frame         +0x0      size=0x30     occ=False
  .rela.rodata           +0x0      size=0x78     occ=False
  .rela.text             +0x0      size=0x120    occ=False
  .rela.text.unlikely    +0x0      size=0x18     occ=False
  .shstrtab              +0x0      size=0x9c     occ=False
  .strtab                +0x0      size=0x59     occ=False
  .symtab                +0x0      size=0x150    occ=False
  .text                  +0x0      size=0x6c     occ=True
  .bss                   +0x6c     size=0x0      occ=False
  .data                  +0x6c     size=0x0      occ=False
  .rodata.str1.1         +0x6c     size=0xa      occ=True
  .text.unlikely         +0x76     size=0x6      occ=True
  .rodata                +0x7c     size=0x14     occ=True
  .eh_frame              +0x90     size=0x40     occ=True
  .note.gnu.property     +0x90     size=0x20     occ=True
--- overlaps among sections reported as occupying memory ---
  .note.gnu.property(+0x90,0x20) overlaps .eh_frame(+0x90)
--- find_section_containing(mapped_base + off) ---
  .text at +0x0 size 0x6c
  +0x0     loader=.text                    regions=.text                   
  +0x4     loader=.text                    regions=.text                   
  +0x10    loader=.text                    regions=.text                   
  +0x20    loader=.text                    regions=.text                   
  +0x40    loader=.text                    regions=.text                   
  +0x60    loader=.text                    regions=.text                   
  +0x80    loader=.rodata                  regions=.rodata                 
  +0xc0    loader=None                     regions=None                    
  +0x100   loader=None                     regions=None                    
  +0x140   loader=None                     regions=None                    
--- .note.gnu.property ---
  type=SHT_NOTE vaddr=+0x90 memsize=0x20 occupies_memory=True
  .text.vaddr == mapped_base: True

After — the note section claims no memory, no two mapped sections overlap, and +0xc0 resolves to .eh_frame:

with this change, ab2d0da
Symbol imported without a known size; emulation may fail if it is used non-opaqely: abort, puts. See https://docs.angr.io/extending-angr/environment#simdata
cle: <cle with this change>/cle/__init__.py
fixture: tests/x86_64/switch_default_abort.o
mapped_base: 0x400000
--- sections that claim memory (name, vaddr-base, memsize, occupies_memory) ---
                         +0x0      size=0x0      occ=False
  .comment               +0x0      size=0x26     occ=False
  .note.GNU-stack        +0x0      size=0x0      occ=False
  .rela.eh_frame         +0x0      size=0x30     occ=False
  .rela.rodata           +0x0      size=0x78     occ=False
  .rela.text             +0x0      size=0x120    occ=False
  .rela.text.unlikely    +0x0      size=0x18     occ=False
  .shstrtab              +0x0      size=0x9c     occ=False
  .strtab                +0x0      size=0x59     occ=False
  .symtab                +0x0      size=0x150    occ=False
  .text                  +0x0      size=0x6c     occ=True
  .bss                   +0x6c     size=0x0      occ=False
  .data                  +0x6c     size=0x0      occ=False
  .rodata.str1.1         +0x6c     size=0xa      occ=True
  .text.unlikely         +0x76     size=0x6      occ=True
  .rodata                +0x7c     size=0x14     occ=True
  .eh_frame              +0x90     size=0x40     occ=True
  .note.gnu.property     +0x90     size=0x20     occ=False
--- overlaps among sections reported as occupying memory ---
  none
--- find_section_containing(mapped_base + off) ---
  .text at +0x0 size 0x6c
  +0x0     loader=.text                    regions=.text                   
  +0x4     loader=.text                    regions=.text                   
  +0x10    loader=.text                    regions=.text                   
  +0x20    loader=.text                    regions=.text                   
  +0x40    loader=.text                    regions=.text                   
  +0x60    loader=.text                    regions=.text                   
  +0x80    loader=.rodata                  regions=.rodata                 
  +0xc0    loader=.eh_frame                regions=.eh_frame               
  +0x100   loader=None                     regions=None                    
  +0x140   loader=None                     regions=None                    
--- .note.gnu.property ---
  type=SHT_NOTE vaddr=+0x90 memsize=0x20 occupies_memory=False
  .text.vaddr == mapped_base: True

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