Skip to content

COFF: bound a section's raw data by the size of the file - #806

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

COFF: bound a section's raw data by the size of the file#806
zardus wants to merge 1 commit into
masterfrom
feature/coff-raw-bounds

Conversation

@zardus

@zardus zardus commented Sep 1, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

A COFF section header says where a section's raw data starts and how much of it
there is, and cle checks neither field against the file.

Take tests/x86/coff_reloc_dir32.obj, 108 bytes with one section, and set that
section's SizeOfRawData to 0x4000000. On master 2f7657fd cle believes it:

seg vaddr=0x40003c filesize=0x4000000 memsize=0x4000000
main_object.max_addr = 0x440003b

A 108-byte file gets a segment and a section claiming 64 MiB of memory that
nothing backs, and max_addr lands 64 MiB past the end of the image. Set
PointerToRawData to 0x4000000 instead and the section is placed 64 MiB out
whole. SizeOfRawData is 32 bits wide, so a header can claim close to 4 GiB
this way.

None of that allocates on master. The COFF image is the file itself, so the
header buys a wrong extent rather than memory: the first 0x30 bytes are real
and read fine, and everything the section claims past them is unbacked, so
ld.memory.load(0x40006c, 4) raises KeyError.

It stops being only an extent once the backend materialises what the header
states, which is what #804 does. #804 is unmerged, so this is not something cle
does today. On its head, with the section additionally marked
IMAGE_SCN_ALIGN_512BYTES so that its file offset 0x3c fails its own stated
alignment and the realignment branch runs, that same 108-byte object produces a
67,109,376 byte image and takes the process from 70 MB to 262 MB RSS over three
runs. Bounding the field is cheaper before that lands than after.

Root cause

Coff.__init__ copies both fields straight out of the section table:

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

Nothing between the header and the Segment compares PointerToRawData + SizeOfRawData against len(self._data). The PE backend does compare them, in
_get_memory_mapped_image in cle/backends/pe/pe.py: it cuts a section the file
ends inside, and skips one that starts past the end. The COFF path never got
that check.

Fix

Map what the file holds, and skip a section the file does not reach at all --
the same two outcomes PE has. The two objects above:

seg vaddr=0x40003c filesize=0x30 memsize=0x30
main_object.max_addr = 0x40006b

(no segments, no sections)
main_object.max_addr = 0x400000

tests/x86/fauxware.obj is untouched either way: image 16,676 bytes,
max_addr 0x4031c0.

Both filesize and memsize are clamped. A COFF section header does carry a
VirtualSize, but it is zero in every section of all five COFF objects
angr/binaries tracks that this backend can load, so SizeOfRawData is the
only size on offer and an unclamped memsize is memory with nothing behind it.
The header's own value is still readable as section._coff_sec.SizeOfRawData.

Deliberately not done: PointerToRawData 0 says the section has no raw data in
the file at all and gives its size in memory instead, which the file does not
bound. That case is left alone, so a section stating it keeps whatever
SizeOfRawData its header asks for. Whether anything is allocated for one is
#764's: it gives space of its own only to a section also marked
IMAGE_SCN_CNT_UNINITIALIZED_DATA, and caps that space at MAX_IMAGE_SIZE.

Also left alone: a section stating SizeOfRawData 0 at a PointerToRawData
past the end of the file is still kept, at an address the image does not cover,
exactly as on master.

The bound is a three-argument function rather than an inline expression so that
the test below can reach it.

Merge order

Seven open pull requests touch cle/backends/coff.py: #724, #761, #764, #775,
#799, #804 and #807. Against this branch #724, #764 and #804 conflict in
coff.py, and #724, #761, #764, #775 and #804 conflict in tests/test_coff.py;
#799 and #807 are clean. Whichever lands second resolves them, and the
resolution in coff.py is to keep the bounded size. On #804 that is one line:

vsize = _raw_data_in_file(raw_ptr, section.SizeOfRawData, len(self._data))

Applied that way, #804's 67,109,376 byte image becomes 560 bytes and RSS does
not move. That line alone is not the whole resolution: without the skip beside
it, a section the file does not reach still gets a zero-length segment at an
address outside the image, and max_addr lands below that segment's own
vaddr. Carry both.

#775 caps max_addr at the image's own length, so on a tree carrying it the
max_addr line above already reads 0x40006b; the filesize and memsize
lines hold either way.

Testing

tests/test_coff.py asserts the bound at its four boundaries -- raw data inside
the file, cut short by it, starting past its end, and the PointerToRawData 0
carve-out. That is the test that fails on master, where the function does not
exist. A second asserts that a well-formed object still maps every byte its
sections declare, checked against the file's own bytes; it passes on master too,
and guards against the bound cutting into a real object.

No fixture reaches the bound through cle.Loader and none can be built from
real toolchain output, because a well-formed object never overruns. Truncating a
real one does not reach the section loop either: raw data ends at or before the
symbol table in all five COFF objects angr/binaries tracks that this backend
can load, so a file cut short enough to overrun a section has also lost its
string table, which CoffParser._parse reads first. tests/x86/fauxware.obj
cut to 0x2000 bytes raises struct.error there.

Validation: #806 (comment)

session: sharpen

A COFF section header says where a section's raw data starts and how much of
it there is. The backend used both fields as they came, so an object can
declare more raw data than the file holds and cle builds a section and a
segment for memory nothing backs. A 108-byte object stating SizeOfRawData
0x4000000 gets a segment claiming 0x4000000 bytes, and max_addr reaches
0x440003b where the image ends at 0x40006b. Both fields are 32 bits wide.

Map only what the file holds, and skip a section the file does not reach at
all. The PE backend already bounds the same two fields this way; the COFF path
never got the check.

PointerToRawData 0 says the section has no raw data in the file and gives its
size in memory instead, which the file does not bound, so that case is left as
it was.
@zardus

zardus commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head d47696f2996e3ce32b28863c6e60253e0310d5c7, base cle master
2f7657fda2a657ec76a201d5c63245c6262f60b7.

A corpus sweep holds the primary checkouts and the shared virtualenv on this
machine, so the complete workspace gate was not run. This is the scoped
alternative: cle alone, read-only against the branch worktree. The angr,
angr-management, archinfo, claripy, pypcode and pyvex suites did not run.

Imports resolved to the branch, not to the workspace install:

.../features/coff-raw-bounds/repos/cle/cle/__init__.py

cle suite -- PYTHONPATH=<worktree> python -m pytest tests -q -p no:randomly

256 passed, 9 skipped in 11.62s

Merge-base lint and type comparison --
run-ci-diff-checks.py --repository <worktree>, 2 changed Python files.
Absolute pylint and pyright numbers depend on which stubs the environment has,
so read the verdicts rather than the floats.

ok   cle/backends/coff.py: 9.81 -> 9.81
ok   tests/test_coff.py: 10.00 -> 10.00
ok   cle/backends/coff.py: badness 0.018018018018018018 -> 0.017035775127768313
ok   tests/test_coff.py: badness 0.0 -> 0.0
No lint or type regressions against the base revision.

Pre-commit -- pre-commit run --files cle/backends/coff.py tests/test_coff.py,
every configured hook, all passed, tree unchanged.

Test inputs -- check-test-inputs.py --checkout <worktree>

Test inputs: 1 checkout(s) add no binary or manufactured input outside angr/binaries.

Before and after, on tests/x86/coff_reloc_dir32.obj (108 bytes, one
section) with one header field changed, loaded with perform_relocations=False.
Relocations are off because _add_relocs computes its patch offset from the
same unchecked PointerToRawData and raises KeyError on both revisions for
these objects -- a separate defect in a different loop, untouched here:

header before after
SizeOfRawData = 0x4000000 segment filesize/memsize 0x4000000 0x30
max_addr 0x440003b 0x40006b
PointerToRawData = 0x4000000 segments and sections 1 at 0x4400000 none
max_addr 0x440000f 0x400000

The image is 108 bytes on both revisions in both cases. All eight .obj files
tracked in angr/binaries behave identically on both revisions -- the five this
backend can load give the same max_addr and the same segment list, and the
three it cannot raise the same
CLECompatibilityError: Unable to find a loader backend.
tests/x86/fauxware.obj gives image 16,676 bytes and max_addr 0x4031c0
either way, with every section mapping its whole declared extent.

On the head of #804, which builds the image rather than mapping the file, the
same object with IMAGE_SCN_ALIGN_512BYTES also set gives 67,109,376 image
bytes and 70 -> 262 MB RSS over three runs. With this bound applied on that
branch: 560 image bytes, RSS flat at 70 MB over three runs.

On master tests/test_coff.py does not run at all: _raw_data_in_file does not
exist, so the module stops at collection with ImportError: cannot import name '_raw_data_in_file' from 'cle.backends.coff'. Deleting that import to get past
it, master gives 1 failed, 6 passed -- the one failure is
test_a_sections_raw_data_is_bounded_by_the_file, which is the test that
discriminates. test_a_well_formed_objects_sections_map_the_bytes_they_declare
passes on both revisions; it guards against the bound cutting into a well-formed
object rather than proving the bound exists.

session: sharpen

@zardus

zardus commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

The loaded image, segments and sections for the reproducer in the description:
tests/x86/coff_reloc_dir32.obj from angr/binaries, 108 bytes with one
section, with one header field changed. Captured with

cle.Loader(path, auto_load_libs=False, perform_relocations=False)

printing len(main_object._image_vmem), main_object.max_addr, and every
segment and section.

SizeOfRawData set to 0x4000000

Before — the 108-byte file gets a segment and a section claiming 64 MiB, and
max_addr runs 64 MiB past the end of the 108-byte image:

cle master 2f7657f
file size:   108
image bytes: 108
max_addr:    0x440003b
segment  vaddr=0x40003c filesize=0x4000000 memsize=0x4000000
section  .text  vaddr=0x40003c filesize=0x4000000 memsize=0x4000000

After — the section maps the 0x30 bytes the file actually holds, and
max_addr ends where the image does:

with this change
Section .text states 0x4000000 bytes of raw data at 0x3c, which a 0x6c byte file does not hold. Mapping the 0x30 bytes it does.
file size:   108
image bytes: 108
max_addr:    0x40006b
segment  vaddr=0x40003c filesize=0x30 memsize=0x30
section  .text  vaddr=0x40003c filesize=0x30 memsize=0x30

PointerToRawData set to 0x4000000

Before — the section is placed whole at an address the image does not cover:

cle master 2f7657f
file size:   108
image bytes: 108
max_addr:    0x440000f
segment  vaddr=0x4400000 filesize=0x10 memsize=0x10
section  .text  vaddr=0x4400000 filesize=0x10 memsize=0x10

After — the file holds none of that section, so it is skipped, as the PE
backend skips its equivalent:

with this change
Section .text states 0x10 bytes of raw data at 0x4000000, which a 0x6c byte file does not reach. Skipping this section.
file size:   108
image bytes: 108
max_addr:    0x400000

The warning lines come from log.warning in Coff.__init__; neither appears for
a well-formed object.

session: sharpen

@angr-bot

angr-bot commented Sep 1, 2026

Copy link
Copy Markdown
Member

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

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.
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