Skip to content

COFF: Give a section with no file bytes an address of its own - #764

Open
zardus wants to merge 1 commit into
masterfrom
feature/coff-bss
Open

COFF: Give a section with no file bytes an address of its own#764
zardus wants to merge 1 commit into
masterfrom
feature/coff-bss

Conversation

@zardus

@zardus zardus commented Aug 18, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

A COFF section with no bytes in the file is mapped over the code. On
binaries/tests/x86/coff_bss.obj, a mingw object whose .bss is 0x1000 long:

section         vaddr  memsize
.text        0x4000b4     0x40
.data        0x400000      0x0
.bss         0x400000   0x1000
.rdata$zzz   0x4000f4     0x14
find_section_containing(.text vaddr 0x4000b4) -> .bss
_buffer symbol at 0x400000
first 16 bytes at .bss: 4c010400000000001c0100000f000000

.bss starts on the file header and runs over the whole of .text. CFGFast drops
every block whose section is not executable, so the object's code is unreachable;
uninitialized data reads back as the COFF header (4c 01 is Machine
IMAGE_FILE_MACHINE_I386, 04 00 the section count); and _buffer, a .bss
symbol, gets an address inside the header rather than in its own storage.

It also loses whole objects. Four MSVC objects, in five copies, between 1.16 MB
and 4.86 MB do not load at all on master:

ValueError: Address 0x500000 is already backed!   (three of the four)
ValueError: Address 0x800000 is already backed!   (the 4.86 MB one)
  both from Clemory.add_backer, via Loader._map_object

The .bss at the image base stretches the main object's span over the address the
loader then places the extern object at. All four are in a working tree that is not
public, and no public object reproduces it: all 1,097 objects with this shape that
come from publicly downloadable packages load on master. So the reproducer cannot
be published.

Root cause

The backend maps the object at its file offsets and gives every section
vaddr = PointerToRawData:

vaddr = section.PointerToRawData
self.segments.append(Segment(section.PointerToRawData, vaddr, section.SizeOfRawData, vsize))

A section with no file bytes states that offset as 0, while still stating its
length elsewhere. The fixture's header says exactly that:

.text    SizeOfRawData=0x40   PointerToRawData=0xb4  Characteristics=0x60500020
.bss     SizeOfRawData=0x1000 PointerToRawData=0x0   Characteristics=0xc0600080

so .bss is placed at 0 and, being longer than the header and section table,
extends over everything that follows.

Fix

A section marked IMAGE_SCN_CNT_UNINITIALIZED_DATA gets zero-filled space of its
own past the image, at the alignment its IMAGE_SCN_ALIGN_* states. Relocation
patch offsets and symbol addresses come from a per-section address list rather
than from PointerToRawData, so they follow the new layout.

.bss         0x400260   0x1000
find_section_containing(.text vaddr 0x4000b4) -> .text
_buffer symbol at 0x400260
first 16 bytes at .bss: 00000000000000000000000000000000

The flag, not the zero pointer, is the condition, because the zero pointer alone
is something a file controls: a 120-byte object can state PointerToRawData 0
with SizeOfRawData 0x4000000 on a section marked code, and honouring that is
64 MiB of zero fill bought with one header field. The format already says which
sections have no bytes in the file, and CoffSection already exposes it as
only_contains_uninitialized_data.

A section without the flag is not refused. It keeps the address its header
states, which is exactly what master does with it, and cle stops inventing storage
for a shape nothing in the survey below produced. Nothing stops loading.

That is measured, not assumed. Across angr/binaries, four dataset trees and
every .o, .obj, .lib, .a and .syso under a 365,293-file Nix store --
731,573 COFF objects, 730,821 of them archive members -- 1,480 sections state
PointerToRawData 0 with a non-zero size, in 64 distinct shapes, and every one
sets the flag
. 1,097 of those sections, one per object, are in packages anyone can download, the
largest a 16,116,128-byte .bss in Go 1.26.7's race_windows.syso.

MAX_IMAGE_SIZE, 0x10000000, is the second line, for a section that does set
the flag and still states an absurd size -- the field is 32 bits wide, so it can
ask for close to 4 GiB. Past the bound the section is placed and reports its
stated size, but no zero fill is allocated and a warning names it, which is what
_get_memory_mapped_image does past max_virtual_address in
cle/backends/pe/pe.py. That constant is 0x100000000; this one is smaller
because PE bounds an address whose data the file still has to hold, while nothing
in a file bounds this.

A relocation that resolves into a section left unbacked that way raises
KeyError. That is out of scope here: the ceiling fires on 0 of the 731,573
objects surveyed above, whose largest image is 16,679,072 bytes against the
ceiling's 268,435,456.

Testing

Three tests in tests/test_coff.py, one per path. coff_bss_no_flag.obj carries
the fix's own condition: its .bss states 0x4000000 with the flag clear, and
the test asserts it keeps mapped_base and that the image is the file's 120
bytes. coff_huge_bss.obj states 0x20000000 with the flag set and asserts the
image stays at 580 bytes. coff_bss.obj asserts the ordinary case still gets its
space. Remove only the flag condition and the first fails at 4194432 == 4194304;
remove only the bound and the second fails at 536871520 == 580.

All three fixtures are in angr/binaries#184, which every job here resolves, so it
merges first. It conflicts with binaries 223 in one place, the builder's module
docstring, where 223 rewrites the paragraph into two; land 223 first and rebase.

Merge this and #806 in either order, then #804. Both conflict with this branch in
cle/backends/coff.py and tests/test_coff.py, textually and already at
51c1f241. #804 carries a copy of this commit and adds a second unbounded
allocation through its realignment path, which #806's file bound closes.

Validation: #764 (comment)

sync: angr/binaries#184

session: sharpen

@zardus

zardus commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 9f8416511e8b9822e0e85a78b5bc3b80cd284e38 against baseline 2f7657fda2a657ec76a201d5c63245c6262f60b7.

The COFF A/B below was measured at dda0a0e on baseline f101f3c, and the corpus figures at 45b1e7803511aeb86353c5919dcc9b1845dbc162. Master has touched neither cle/backends/coff.py nor tests/test_coff.py between f101f3c and this baseline. The commit is no longer the one those figures were measured on: this head adds the IMAGE_SCN_CNT_UNINITIALIZED_DATA condition, the MAX_IMAGE_SIZE bound and two more regressions. What that does to the figures is measured under "The flag condition and the bound" at the end, rather than argued.

Method

251 x86 and x86-64 COFF objects, one object per process and one A/B side per
process
, because angr keeps state between projects. Side A is master
(f101f3c) unmodified, side B is this branch; the two trees were frozen to disk
before the run and differ in cle/backends/coff.py and tests/test_coff.py and
in nothing else. Side A was run twice to separate this change from CFGFast's own
nondeterminism (angr#6840, angr#6844). Recorded per object: the sorted
(addr, size) block set as a digest, the function address set, every section's
name/vaddr/memsize/filesize/permissions, every segment, the symbol set, a digest
of every memory backer, min_addr/max_addr, and the wall time of
CFGFast(normalize=True, resolve_indirect_jumps=True).

The population: 242 objects sampled across every shard of a private COFF corpus
-- 90 mingw/msys2 builds, 30 MSVC builds, 120 from two generated C and C++
toolchain matrices, 2 from angr/binaries -- plus 5 more that a block-level
sweep of that corpus flagged, the COFF fixtures angr/binaries already carries,
and the new one. 157 load on both sides; 94 raise NotImplementedError on both
sides, identically, being the ARM64 and ARMNT machine types this backend does
not accept (#724).

Result

  • No new error and no new timeout. The same 94 objects fail with the same
    exception on both sides; nothing that loaded stops loading.
  • executable_ranges is byte-identical on all 157. The only sections that
    move are the 93 .bss sections of 79 objects, every one of them to an address
    past the image; max_addr grows on those 79 and shrinks on none. No section
    with file bytes moves by a byte.
  • 78 objects have a byte-identical section map, and every recorded output is
    bit-identical on them
    -- blocks, functions, symbols, backers, addresses.
  • Block sets: 152 of 157 bit-identical, 5 changed. Every one of the 5 is an
    object whose .bss covered its own code, and each is explained below.
  • Side A against its own repeat: 156 of 157 bit-identical. The one that differs,
    a mingw x86 object, has a zero-length .bss, so this change cannot touch its
    map -- and A and B agree on it anyway. That is angr#6840 / angr#6844.
  • Total CFGFast time across the 157: 73.7 s before, 71.9 s after.

The audit checker's blocks_in_data over the same 157 objects goes from 1,396
to 0, and its blocks_outside_executable from 1,889 to 1,901 -- every one of
the 12 new ones an import stub in cle's synthetic extern object, reached because
more calling code is now decoded.

The objects the map change reaches

Over the 157, 6 objects lose blocks to that check and 903 blocks are refused
in total under master; 0 objects and 0 blocks after this change.
A refusal
is counted when _generate_cfgnode returns None for a fresh address whose
find_section_containing answered a section that is not executable.

object blocks functions blocks refused for a non-executable section
binaries/tests/x86/coff_bss.obj 0 -> 4 0 -> 4 54 -> 0
x86-64 MSVC, 8bbbd263a4a655b8 176 -> 259 35 -> 47 795 -> 0
x86-64 mingw, 0de3206b4f618f18 2109 -> 2111 214 -> 216 5 -> 0
x86 mingw, dcfdcacfbabfef9b 2148 -> 2152 460 -> 459 7 -> 0
x86 mingw, 4887fbd39c325d5b 3478 -> 3478 264 -> 264 6 -> 0
x86 mingw, a14fcd2e5a4fab7c 2949 -> 2949 240 -> 240 36 -> 0
x86 mingw, e65a10ef07246be3 968 -> 968 51 -> 51 0 -> 0

The last two keep their block sets bit-identical: the 36 refusals on the first
are all re-reached later by another path, and the second never refuses at all
because every lookup in its 188-byte shadowed range happened while .text was
the cached _last_section. Both still report a section map that puts .bss
over .text, and both go from 196 and 34 blocks_in_data to 0.

coff_bss.obj is the fixture: its 0x1000-byte .bss covers the whole of
its 0x40-byte .text, so today angr recovers nothing at all from it.

8bbbd263a4a655b8 has a 0x3168-byte .bss and its .text$mn COMDATs run
0x1121..0x24e1, so every byte of code is under it. 83 more blocks and 12 more
functions, and 8 of the 12 new blocks_outside_executable are the import stubs
_glfwPlatformCreateMutex, _glfwPlatformCreateTls and six more that the newly
decoded code calls.

0de3206b4f618f18 is an OpenJPH C++ object whose .bss holds uvlc_tbl,
vlc_tbl0 and vlc_tbl1 at 0x400000..0x401200, over .text. The two new
functions are initialize_block_encoder_tables at 0x400bf4 and
ojph_encode_codeblock32 at 0x400cd4; instrumenting _generate_cfgnode shows
both jobs returning None with section .bss before and being kept with section
.text after. The two blocks that used to run through those entries are split
at them. Its one blocks_overlapping instance also goes away: an 88-byte block
over the extern object shrinks to 8 once memcpy is a function by the time it
is lifted.

dcfdcacfbabfef9b loses a function, and that is the point. sub_403710 is
the return site of call 0x400504 at 0x40370b, and 0x400504 is one of the 7
addresses refused for being in .bss: with the callee missing, angr could not
see the call return and promoted its return site to a function of its own. With
the callee decoded, 0x403710 is an ordinary block of its caller 0x403684. The
other 4 new blocks are the extern zero-run splitting differently.

4887fbd39c325d5b keeps all 3478 blocks and all 264 functions. Its only
difference is that two adjacent blocks inside cle's zero-filled extern object,
covering the same 164 bytes, are split 150+14 instead of 142+22: the 6 refusals
reorder which extern stub is lifted first. No block in the object's own image
changes.

Test

tests/test_coff.py::TestCoff::test_uninitialized_section_gets_space_of_its_own.
With the production change reverted it fails on
assert bss.vaddr >= text.vaddr + text.memsize -- .bss at 0x400000 against
.text at 0x4000b4, 4194304 >= 4194548.

Full cle suite at this head: 257 passed, 9 skipped, against the angr/binaries
branch this pull request resolves. pylint 10.00 -> 10.00 and pyright badness
0.0 -> 0.0 on both changed files, scored merge-base-relative the way the hosted
jobs do. Every configured pre-commit hook passes over all files.

CI

gh pr checks 764 listed 20 passing rows at the previous head 51c1f241: 18
repository CI jobs, including Test macos-15, Test windows-2022 and
Test (Pyodide), plus docs/readthedocs.org:cle and pre-commit.ci - pr.
cle/.github/workflows/ci.yml resolves angr/binaries through
angr/ci-settings/actions/binaries-ref in both of its checkout steps, so every
job sees the branch this description names. CI has not run at the head this
record is keyed to.

This change is independent of #761 and #724, which touch the same function but not
the layout.

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

The same sweep that motivated #739 counts basic blocks landing inside a region the loader reports as data. The COFF share of that count is this defect: Coff.__init__ takes a section's address from PointerToRawData, which is 0 for a section with no file bytes, so .bss is placed at the start of the image and covers the header and every .text that follows.

Measured over the affected COFF objects, master against this head:

instances
master 772
this head 0

The count goes to zero, and the change is not merely cosmetic for the metric: giving .bss an address of its own also un-hides 161 code blocks that were previously dropped because a data region sat on top of them. Those blocks are recovered on this head and were absent on master.

Across the whole sweep this accounts for 1,683 of 48,658 instances, about 3.5%; the remaining 96% is the ELF note-section case in #739. Reproducing the counts on the pinned toolchain the sweep used gives the same figures as master, so this has not drifted upstream — only this branch moves it.

Rebased 2026-08-27. Correcting the SHA this paragraph previously gave: the corpus figures were measured at head 45b1e7803511aeb86353c5919dcc9b1845dbc162, and the branch was then rebased onto master at 46a37333f4f59b0facf8774ee743ebc4cc074e9b. git range-diff reported the commit unchanged, so that rebase moved only the base.


Corpus attribution, 2026-08-28, keyed to head 09fcf86b948653f439838b998d39d6f4134a2c55 against baseline 46a37333f4f59b0facf8774ee743ebc4cc074e9b. 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. This mechanism owns 13 of those objects and 1,683 of the blocks, 3.5% of the category — small in objects, disproportionate in blocks, because a single shadowed .text produces one finding per block underneath it.

Read out of the COFF section table directly rather than through CLE, all ten of the sampled objects agree: the overlapping section is .bss, IMAGE_SCN_CNT_UNINITIALIZED_DATA is set, PointerToRawData is 0 while SizeOfRawData still states the real length, and cle/backends/coff.py:467 at this baseline takes the address from PointerToRawData. The section therefore lands on the image base and covers the .text / .text$mn that follow. Every one of the 1,683 blocks starts in a section the COFF header marks IMAGE_SCN_CNT_CODE | IMAGE_SCN_MEM_EXECUTE; the loader's addresses are what is wrong, not the recovery.

Measured on 69 stratified objects of the category, of which 10 are COFF:

flagged blocks on the 10 COFF objects flagged blocks over all 69
cle master 1,134 1,291
master plus this change and #739 0 6

63 of the 69 go to exactly zero; the six survivors are Xtensa objects and are a p-code read-ahead defect, angr/angr#6816, not this. The COFF objects gain recovery rather than losing it — two of them add 93 and 83 blocks and 7 and 12 functions once .text$mn stops being shadowed.

Nothing upstream has fixed this: cle master scores the same 1,134 on those ten objects as the sweep's own revisions did.


The flag condition and the bound, added 2026-09-02

Two mechanisms. A section with no bytes in the file gets zero-filled space of its
own only if it is marked IMAGE_SCN_CNT_UNINITIALIZED_DATA; one that is not keeps
the address its header states, which is master's behaviour, and is not refused.
MAX_IMAGE_SIZE, 0x10000000, then bounds what a flagged section can ask for.

  • Regression, flag condition: pytest tests/test_coff.py::TestCoff::test_a_section_with_no_file_bytes_and_no_flag_gets_no_space — with only that condition removed it fails on assert bss.vaddr == obj.mapped_base, 4194432 == 4194304
  • Regression, bound: pytest tests/test_coff.py::TestCoff::test_an_uninitialized_section_past_the_ceiling_is_not_materialized — with only the bound removed it fails on assert sum(len(backer) for _, backer in obj.memory.backers()) == os.path.getsize(exe), 536871520 == 580
  • Each mechanism was removed on its own, so neither test passes on the other's account
  • Focused: pytest tests/test_coff.py — 8 passed
  • Full suite: 257 passed, 9 skipped
  • Lint/type: run-ci-diff-checks.py at this exact head reports pylint 10.00 -> 10.00 and pyright badness 0.0 -> 0.0 on both changed files, merge-base relative. That run is the only evidence for it: the script builds a base worktree, so it cannot be re-run from a checkout where creating one is not allowed, and no bare pylint invocation substitutes for it -- the script supplies the CI image's pylintrc through PYLINTRC, and an unconfigured run scores 8.75 on cle/backends/coff.py and 8.27 on tests/test_coff.py, from advisories that configuration disables
  • Hooks: every configured pre-commit hook passes over all files, 22 passed and 0 failed
  • Workspace gate: not run. Corpus sweep lanes hold the shared toolchain and the complete gate rebuilds the native libraries, so the substitutes were cle's own suite on PYTHONPATH against the shared interpreter, run-ci-diff-checks.py, pre-commit run --all-files, and check-test-inputs.py over the cle worktree, which reports it clean. That checker exempts angr/binaries, so it says nothing about the fixture side. Nothing outside cle was validated at this head

Acceptance, measured rather than argued

Two arms, each printing cle.__file__ from inside the process, run from a neutral
working directory: the previously published 51c1f241 and this head. Per object
the record is the outcome and exception type, the mapped byte count, the full
[(name, vaddr, memsize, filesize)] section table, the sorted
(symbol, rebased_addr) set, and len(obj.relocs).

pool n tuples differing loaded before, not after
carrying a zero-pointer non-zero-size section 230 0 0
control, carrying none 400 0 0

The 230 are distinct by (machine, full section table) out of 1,209 copies,
restricted to the I386 and AMD64 machine types this backend accepts.

The same harness, unchanged, on the three fixtures, so that the empty diff is an
observation about a working instrument:

fixture image before image after .bss vaddr
coff_bss_no_flag.obj 67,108,992 120 0x400080 -> 0x400000
coff_huge_bss.obj 536,871,520 580 unchanged at 0x400260
coff_bss.obj 4,704 4,704 unchanged at 0x400260

What the flag condition costs

Nothing found. Every tracked regular file in angr/binaries and every file in
four dataset trees was opened and magic-checked rather than filtered by name,
along with every .o, .obj, .lib, .a and .syso in a 365,293-file Nix
store. Every figure in this section comes from that pool and excludes the
workspace's own features/ and scratch/ trees, one of which holds this pull
request's fixtures: 731,573 COFF objects, 730,821 of them archive members,
5,206,298 section headers
. 1,480 sections state
PointerToRawData 0 with a non-zero SizeOfRawData, in 1,219 objects and 64
distinct shapes, and all 1,480 set IMAGE_SCN_CNT_UNINITIALIZED_DATA. All
1,480 are named .bss.

statistic SizeOfRawData
median 4
p95 (nearest rank) 416
p99 (nearest rank) 2,464
largest 16,116,128

Public availability and tracked-ness are separate axes here.
1,097 of the 1,480 sections are in packages anyone can download -- 1,086 in Nix store packages and 11 in
fetched Go and rustup toolchains, the largest being a .bss in Go 1.26.7's
race_windows.syso, which ships in the public Go distribution. Those 11 happen to
sit in a locally-untracked directory, which does not make their contents private.
The other 383 are in an untracked working tree and are not public. None of the
1,480 is in a tracked file of any dataset repository, and none is in
angr/binaries.

Four limits of that enumeration, none of which changes the answer: the Nix store
was listed by those five extensions rather than magic-checked, and a 50,000-file
random sample of the 358,309 files that filter skipped yielded 0 COFF objects;
544 candidate images in this pool were rejected before their sections were read;
two archive walks stopped at a deliberately corrupt Go test fixture, skipping one
member each, neither COFF; and the machine whitelist is 26 values against the two
this backend accepts, so nothing it skipped could be loaded on either arm.

Correction, 2026-09-06. This section first reported 731,580 objects,
5,206,377 section headers and 546 rejected candidates. The survey listed
angr/binaries with find rather than git ls-files, so it also walked an
untracked nested worktree inside that checkout -- a second copy of the fixture
tree -- and counted every COFF object in it twice: 1,794 of its 3,599 rows for
that checkout were the copies, and its COFF population for angr/binaries came
out at 14 objects and 158 section headers where there are 7 and 79. Dropping the
copies gives the totals above. The archive-member count does not move, because
none of those objects is an archive member, and nothing else in this record
moves either: angr/binaries contributes no section with PointerToRawData 0
and a non-zero size, so 1,480 / 1,219 / 64, the SizeOfRawData row, the 1,097
and 383 split, and the 230-of-1,209 acceptance pool were all measured over
objects the duplication never reached.

Objects that stop failing

Four distinct MSVC objects, present as five copies, between 1.16 MB and
4.86 MB, fail to load on master and load on this head. Three raise
ValueError: Address 0x500000 is already backed! and the 4,863,637-byte one raises
Address 0x800000, both from Clemory.add_backer at cle/memory.py:248 via
Loader._map_object. Two of the five copies share a sha256 and differ only by
staging directory; the acceptance pool was not deduplicated by content, so they
appear twice. All four are in a working tree that is not public, so the reproducer
cannot be published, and the absence of a public one is measured rather than
assumed: all 1,097 objects with this shape that come from publicly downloadable
packages load on master.

Merge order

Bytes mapped by cle.Loader(path, auto_load_libs=False), one object per process:

object master this head #804
binaries/tests/x86/coff_huge_bss.obj 580 580 536,871,584
coff_reloc_dir32.obj with two header fields changed 108 108 67,109,376

The second is the tracked 108-byte binaries/tests/x86/coff_reloc_dir32.obj with
its section's SizeOfRawData set to 0x4000000 and IMAGE_SCN_ALIGN_512BYTES
added, the object #806's description already describes; its PointerToRawData
stays 0x3c, so this pull request's condition never sees it. Peak RSS tracks the
image: on #804's second row it moves about 192 MB, from a 71 to 74 MB baseline
depending on the run. Applying #806's _raw_data_in_file call to #804's head takes
that row to 560 bytes and leaves the first unchanged, which is what makes these two
separate bounds.

Both #804 and #806 conflict with this branch in cle/backends/coff.py and
tests/test_coff.py. Both conflicts are textual and both exist at the previously
published 51c1f241, so this update does not introduce them.

@angr-bot

Copy link
Copy Markdown
Member

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

@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

The three fixtures this change turns on, before and after. coff_bss_no_flag.obj carries
the fix's own condition, coff_huge_bss.obj exercises the bound behind it, and
coff_bss.obj is the ordinary case that must not move. BINARIES is a checkout of the
angr/binaries pull request linked from the description; each object is loaded in its own
process, so the peak RSS is that object's rather than a high-water mark.

the reproducer, run once per object
import logging, os, resource, sys
logging.getLogger("cle").setLevel(logging.CRITICAL)
import cle

# One object per process, so the peak RSS below belongs to this object and is not a
# high-water mark left by an earlier load.
name = sys.argv[1]
path = os.path.join(os.environ["BINARIES"], "tests", "x86", name)

before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024
ld = cle.Loader(path, auto_load_libs=False)
obj = ld.main_object
after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024

bss = next(s for s in obj.sections if s.name == ".bss")
text = next(s for s in obj.sections if s.name == ".text")
image = sum(len(backer) for _, backer in obj.memory.backers())
flag = "set" if bss.only_contains_uninitialized_data else "clear"
print(f"{name}  ({os.path.getsize(path)} bytes on disk)")
print(f"  .bss  SizeOfRawData {bss.memsize:#x}  uninitialized-data flag {flag}")
print(f"  .text at {text.vaddr:#x}   .bss placed at {bss.vaddr:#x}")
print(f"  image {image} bytes,  peak RSS {before} MB -> {after} MB  (+{after - before} MB)")

Before — every .bss lands on the image base and over .text, whatever it says about
itself. Nothing costs memory, because the image is the file:

cle master at 2f7657fda2a657ec76a201d5c63245c6262f60b7
coff_bss_no_flag.obj  (120 bytes on disk)
  .bss  SizeOfRawData 0x4000000  uninitialized-data flag clear
  .text at 0x400064   .bss placed at 0x400000
  image 120 bytes,  peak RSS 71 MB -> 71 MB  (+0 MB)
coff_huge_bss.obj  (580 bytes on disk)
  .bss  SizeOfRawData 0x20000000  uninitialized-data flag set
  .text at 0x4000b4   .bss placed at 0x400000
  image 580 bytes,  peak RSS 71 MB -> 71 MB  (+0 MB)
coff_bss.obj  (580 bytes on disk)
  .bss  SizeOfRawData 0x1000  uninitialized-data flag set
  .text at 0x4000b4   .bss placed at 0x400000
  image 580 bytes,  peak RSS 71 MB -> 71 MB  (+0 MB)

This pull request as previously published — every section with no file bytes got space
of its own, including the one whose header does not claim to be uninitialized data, so a
120-byte object bought 64 MiB and a 580-byte object bought half a gigabyte:

at 51c1f241d81aedd644312ee36990c48b260a0434
coff_bss_no_flag.obj  (120 bytes on disk)
  .bss  SizeOfRawData 0x4000000  uninitialized-data flag clear
  .text at 0x400064   .bss placed at 0x400080
  image 67108992 bytes,  peak RSS 70 MB -> 262 MB  (+192 MB)
coff_huge_bss.obj  (580 bytes on disk)
  .bss  SizeOfRawData 0x20000000  uninitialized-data flag set
  .text at 0x4000b4   .bss placed at 0x400260
  image 536871520 bytes,  peak RSS 71 MB -> 1607 MB  (+1536 MB)
coff_bss.obj  (580 bytes on disk)
  .bss  SizeOfRawData 0x1000  uninitialized-data flag set
  .text at 0x4000b4   .bss placed at 0x400260
  image 4704 bytes,  peak RSS 70 MB -> 70 MB  (+0 MB)

After — the flagless section keeps the address its header states, exactly as on master;
the flagged one gets its own space; and the flagged one that asks for half a gigabyte is
placed but not materialised:

with this change, at 9f8416511e8b9822e0e85a78b5bc3b80cd284e38
coff_bss_no_flag.obj  (120 bytes on disk)
  .bss  SizeOfRawData 0x4000000  uninitialized-data flag clear
  .text at 0x400064   .bss placed at 0x400000
  image 120 bytes,  peak RSS 73 MB -> 73 MB  (+0 MB)
coff_huge_bss.obj  (580 bytes on disk)
  .bss  SizeOfRawData 0x20000000  uninitialized-data flag set
  .text at 0x4000b4   .bss placed at 0x400260
  image 580 bytes,  peak RSS 71 MB -> 71 MB  (+0 MB)
coff_bss.obj  (580 bytes on disk)
  .bss  SizeOfRawData 0x1000  uninitialized-data flag set
  .text at 0x4000b4   .bss placed at 0x400260
  image 4704 bytes,  peak RSS 70 MB -> 70 MB  (+0 MB)

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

What this changes on a real corpus, measured. This is a memory-layout fix, not a
load fix, so a probe that only asks "did it load" reports zero for it; what it
changes is where sections land, and that is measurable directly.

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, 468 of them COFF. 432 of those load
on master; the other 36 stop at NotImplementedError: Unsupported machine type,
which is #724's class and is unchanged here.

Method. All 468 are loaded with the catalogue's declared recipe against
master (eac0e5540516b9199dd6a91933e80dc774ea3eac) and against this branch's
head (09fcf86b948653f439838b998d39d6f4134a2c55) in one environment, and the
loaded object is described from the outside — section extents, overlaps, mapped
span. Separately all 468 run a complete
CFGFast(normalize=True, resolve_indirect_jumps=True) on both sides.

What changes. 398 of the 432 loaded objects (92%) map a section
differently.
On master not one section in the whole COFF population reports
memsize > 0 with no file bytes behind it, because a section with
PointerToRawData == 0 is mapped onto file offset 0 — the image header — rather
than given space of its own. On this head 399 such sections appear across those
398 objects (397 with one, one with two), and the objects' mapped span grows by
357,316 bytes in total, a median of 830 bytes each. One object — an x86-64 MSVC
relocatable — has a genuinely overlapping pair of sections on master and none
here; that is the same defect showing up as a collision rather than as silent
aliasing.

What does not change. Recovered functions and CFG nodes are identical on
every one of the 432
— 15,457 functions and 39,225 nodes on both sides, with
zero objects differing. So on this corpus the fix is invisible to function
recovery and visible in the memory image, which is what a reviewer should expect
from it: nothing here reads the .bss-like section's contents, and everything
that does would previously have read the file header instead.

Note on a false result I nearly published. An earlier run showed four objects
timing out at 180 s on master and completing in 1–5 s here. Re-running those
four on master alone, they complete in 1.2–26 s with exactly the counts this
head gives. The timeouts were mine: I had paused that sweep's workers for three
minutes while the host was loaded, and the probe's wall clock kept running. They
are not evidence for this change.

Overlap with the other open COFF changes. #724, #775 and #761 each touch
coff.py too, and none of the four merges cleanly on top of another: every pair
collides in cle/backends/coff.py or tests/test_coff.py. Measured separately,
#724 clears the 36 machine-type failures, #775 extends the mapped span on all
432 objects, and #761 populates imports on 35.

The corpus is not redistributable, so its objects are described by architecture,
format and OS rather than named.

session: sharpen

The backend maps the object at its own file offsets and gives each section
vaddr = PointerToRawData. A section holding no bytes in the file states that
field as 0 -- that is what .bss is -- while SizeOfRawData still states its
length, so it lands on the file header and, once it is longer than the header
and section table, over the sections that follow. .text begins at file offset
0x104 in a six-section mingw object, so a 0x1300-byte .bss covers its first
0x11fc bytes.

find_section_containing() then answers .bss for real code, and
CFGFast._generate_cfgnode drops any block whose section is not executable, so
those functions are never recovered; uninitialized data reads as the file
header rather than zeros, and every .bss symbol is given an address inside the
code.

A section that states PointerToRawData 0 and marks itself
IMAGE_SCN_CNT_UNINITIALIZED_DATA now gets zero-filled space of its own past the
image, at the alignment its IMAGE_SCN_ALIGN_* states. Relocation patch offsets
and symbol addresses read the same layout, so they follow it.

The flag is the condition rather than the zero pointer alone, because the zero
pointer alone is what a file controls: a 120-byte object can state
PointerToRawData 0 with SizeOfRawData 0x4000000 on a section marked code, and
zero-filling that is 64 MiB of allocation bought with one header field. Across
1,480 sections with no bytes in the file, in 64 distinct shapes, every one sets
the flag, so requiring it costs nothing real. A section without it keeps the
address its header states, which is what master does with it.

MAX_IMAGE_SIZE bounds what the flag still admits. SizeOfRawData is 32 bits wide
and a section that does set the flag can still state close to 4 GiB, so past
0x10000000 the section is placed and reports its stated size but no zero fill is
allocated for it and a warning names it -- the outcome pe.py reaches through
max_virtual_address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zardus added a commit that referenced this pull request Sep 6, 2026
Coff._add_relocs took the patch address as section.PointerToRawData plus
reloc.VirtualAddress and registered a relocation there without checking it.
Neither bound was tested, and the two fail differently.

A field past the end of the file crashes. The backend maps the object as one
backer covering the file, so CoffRelocationDIR32.value asks Clemory for four
bytes at an address nothing maps, and cle.Loader(..., perform_relocations=True)
raises KeyError out of Clemory.load.

A field merely past the end of its own section does not crash, and that is the
worse half. Every offset in the file is mapped, so the store lands wherever the
arithmetic points -- another section's raw data, the relocation table, the
symbol table -- and the load returns normally with those bytes rewritten.

Check both bounds where the relocation is registered rather than in relocate().
A relocation that cannot be applied should not reach self.relocs at all: it is
handed to the symbol resolver, it can produce an extern symbol for a field that
will never be written, and it is visible to every consumer that iterates an
object's relocations. It is also where the PE backend drops a section whose raw
data the file does not hold.

The field's width comes from struct.calcsize on the relocation class's
PACK_FORMAT -- four bytes normally, eight for ADDR64, two for SECTION -- so a
four-byte field starting on the last byte of a section is out of bounds, which a
bound on the start offset alone would miss. PACK_FORMAT is declared on
CoffRelocation rather than on Relocation, so RELOC_CLASSES is annotated with the
class it actually holds.

This leaves the section mapping loop alone. Bounding a section's raw data by the
size of the file is #806; the two compose, because _add_relocs walks
self._coff.sections itself and would still register the relocations of a section
that loop has skipped.

Two details keep this bound correct against the other open COFF branches, and
change nothing on this one.

The section comes out of self._coff.sections by index rather than off the loop
variable. Both name the same object here, by the definition of enumerate. #764
rewrites this loop to walk indices and drops the variable, and the two branches
merge with no textual conflict, so with both applied and the loop variable read
_add_relocs raises NameError on the first relocation of a supported type. Of the
five COFF objects angr/binaries tracks that this backend loads, four carry such a
relocation and stop loading; the fifth has none. #804 is stacked on #764 and
carries the same rewrite.

The file-size half of the bound is taken against self._image_vmem, the bytes the
backend maps, rather than against self._data. Here the two are the same object:
_image_vmem is assigned from _data in __init__, never rebound, and cle defines no
subclass of Coff. #804 places a section whose file offset does not satisfy its
alignment past the end of the file and extends the image to cover it, so a
relocation into a moved section is past len(self._data) and inside the image, and
bounding on the file would skip it. With both applied and the file used,
x86/fauxware.obj keeps 177 of its 225 relocations and x86_64/fauxware.obj 66 of
126, and the test below asserting 225 fails.
zardus added a commit to angr/binaries that referenced this pull request Sep 6, 2026
cle's tests on master now load tests/aarch64/langdetect_go.macho and
tests/aarch64/relocatable_object.macho, which #193 and #224 added after this
branch was cut. angr/cle#764 and angr/cle#804 name this pull request in their
sync: lines, so CI checks this branch out instead of master; once either is
rebased onto current cle master those two files would be missing and the macOS
job would fail. Merging master in supplies them and leaves this branch's own
three objects and build script untouched.
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