Skip to content

COFF: place a section at an address its alignment allows - #804

Open
zardus wants to merge 2 commits into
masterfrom
feature/coff-section-alignment
Open

COFF: place a section at an address its alignment allows#804
zardus wants to merge 2 commits into
masterfrom
feature/coff-section-alignment

Conversation

@zardus

@zardus zardus commented Sep 1, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

An MSVC-built ARM64 COFF object's code is placed where no instruction can be decoded, so
CFGFast recovers nothing from it. zlib-ng-static.dir\Release\insert_string_roll.obj
(sha256 41eb600a40a3fd47b32c77ad3cb51cceabce5a86389924bfdb2f5749f7844eba), a member of
lib/zlibstatic-ng.lib in the official
zlib-ng 2.3.3 Windows ARM64 release,
has three .text$mn sections and three function symbols. Loaded with the ARM64 machine
type that #724 adds:

symbol                             addr  % 4  block bytes  in CFG
insert_string_roll             0x4001cb    3            0      no
quick_insert_string_roll       0x400233    3            0      no
update_hash_roll               0x400283    3            0      no
functions recovered: 0

Across 66 ARM64 COFF objects from four corpus collections, 298 of 880
function symbols lift to a zero-length block, 16 objects recover none of their
own, and CFGFast covers 73% of their executable bytes.

Root cause

The backend maps an object at its own file offsets and gives every section
vaddr = PointerToRawData. A file offset is only as aligned as the file's packing leaves
it, and MSVC packs raw data with no padding between sections, so a section commonly begins
where the IMAGE_SCN_ALIGN_* in its own header does not allow. The tracked
binaries/tests/x86/fauxware.obj shows the same thing: all eight of its .text$mn
sections state 16-byte alignment and six of them start at a file offset that is not a
multiple of 16.

section       file offset  declared align      vaddr  vaddr % 16
.text$mn             6874              16   0x401ada          10
.text$mn             7061              16   0x401b95           5
.text$mn             7585              16   0x401da1           1
.text$mn             8218              16   0x40201a          10
.text$mn             8688              16   0x4021f0           0
.text$mn             9716              16   0x4025f4           4
.text$mn            11143              16   0x402b87           7
.text$mn            11792              16   0x402e10           0

x86 and AMD64 never notice, because instruction_alignment is 1 for both. AARCH64
states 4 and ARMEL 2, so a .text$mn on an odd address holds nothing that decodes,
project.factory.block() returns a zero-length block at every function symbol in it, and
CFGFast has no block to start from.

Fix

A section whose file offset does not satisfy the alignment its own header states now gets
space of its own past the image, with its bytes copied in. A section whose header states no
alignment states no requirement and is left alone; 16 stays the default only for choosing
an address for a section that has to be placed somewhere.

symbol                             addr  % 4  block bytes  in CFG
insert_string_roll             0x400480    0           36     yes
quick_insert_string_roll       0x4004e8    0           48     yes
update_hash_roll               0x400538    0           16     yes
functions recovered: 3

Testing

tests/test_coff.py::TestCoff::test_sections_are_placed_at_the_alignment_they_state
asserts that each of the eight .text$mn sections in binaries/tests/x86/fauxware.obj is
placed on a 16-byte boundary and holds the bytes at its file offset; the alignment
assertion fails on the merge base. Over 163 COFF objects the ARM64 function symbols
recovered go from 582 of 880 to 880 of 880, while every x86, AMD64 and ARMNT
object recovers exactly the same symbols on both sides. Asserting the ARM64 half here
needs the machine type from #724, so this repository cannot cover it until that
merges.

This is built on #764. Merge angr/binaries#184 first.

Validation: #804 (comment)

sync: angr/binaries#184

session: sharpen

@zardus

zardus commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head f75cec678b2778c5e2e82ef784bd6e58d6f83807 against baseline
9f8416511e8b9822e0e85a78b5bc3b80cd284e38, which is this pull request's own first commit and
#764's current head.

Replaces the record for the previous head ce0d435e, whose first commit carried #764's
superseded content. #764 was force-pushed from 51c1f241 to 9f841651 on 2026-09-02, adding an
IMAGE_SCN_CNT_UNINITIALIZED_DATA condition and a MAX_IMAGE_SIZE ceiling. This branch is
ce0d435e replayed onto that head; both files conflicted and were resolved by hand.

The resolution puts the section with no file bytes and the section whose offset does not satisfy
its alignment through one branch, sharing #764's cursor and its MAX_IMAGE_SIZE ceiling. Two
things about it are load-bearing and are checked below:

  • Sharing the cursor. With a cursor each, binaries/tests/x86/coff_bss.obj places its .bss on
    top of the .text this change moves, and COFF: Give a section with no file bytes an address of its own #764's own
    test_uninitialized_section_gets_space_of_its_own fails.
  • Where the ceiling would stop the move, the section keeps its file offset instead of being
    placed where nothing backs it. A section with no file bytes has nothing to lose by being left
    unbacked; one with file bytes loses its bytes, and its relocations then resolve into
    unallocated space. Probe: a 108-byte object whose one section states SizeOfRawData
    0x20000000 at a misaligned offset loads on master, on 9f841651 and on this head, all three
    mapping 108 bytes. An earlier draft of this resolution raised KeyError: 512 on it.

Suites

  • pytest tests on this head: 258 passed, 9 skipped. On the baseline: 257 passed, 9 skipped, the
    difference being this commit's new test. Both runs used a frozen copy of the revision on
    PYTHONPATH, against a binaries checkout carrying the Add COFF objects whose .bss has no bytes in the file binaries#184 fixtures.
  • tests/test_coff.py::TestCoff::test_sections_are_placed_at_the_alignment_they_state fails on
    the baseline at assert section.vaddr % 16 == 0 and passes on this head.
  • test_an_uninitialized_section_past_the_ceiling_is_not_materialized, which COFF: Give a section with no file bytes an address of its own #764 adds, changes
    one assertion here: the image is now the file plus the .text this change moves, not the file
    alone.
  • _section_alignment(section, unstated=1) decides only whether a section has to move, so a
    header stating no alignment never moves one. binaries/tests/aarch64/coff_reloc_arm64.obj,
    already on binaries master, is why: its single .text is executable, states no alignment, and
    sits at file offset 0x3c, which is not a multiple of 16. Reading that field's zero as a
    16-byte requirement would move it and break Load ARM64 and ARMNT COFF objects #724's test_arm64, whose aligned symbol has to
    stay 16-aligned for IMAGE_REL_ARM64_PAGEOFFSET_12L to encode.
  • pylint and pyright scored per changed file against the merge base the way the hosted Lint
    and Typecheck jobs score them: cle/backends/coff.py 10.00 -> 10.00 and badness 0.0 -> 0.0,
    tests/test_coff.py the same. black --check (26.5.1, the pinned hook version) and
    ruff check clean on both files.
  • binaries at feature/coff-bss, the Add COFF objects whose .bss has no bytes in the file binaries#184 branch, which is now level with binaries
    master: compare/master...feature/coff-bss reports ahead 3, behind 0.
  • Not run here: the workspace-wide gate, which builds the native libraries. Five corpus sweep
    lanes pin libpyvex.so, unicornlib.so, rustylib*.so and pypcode_native*.so by content, so
    nothing that rebuilds them may run. What ran is cle's own suite against a frozen copy of this
    head, the merge-base lint and type comparison, and the formatters. Hosted CI covers the rest.

CI prediction, written before the first job on this head finished

Every check should pass: ci / Build, ci / Lint, ci / Typecheck, ten ci / Test shards,
ci / Decompiler Snapshot Testing, ci / Publish Unit Tests Results, Test (Pyodide),
Test windows-2022, Test macos-15, docs/readthedocs.org:cle and pre-commit.ci - pr. The
previous head was green on all twenty, and the angr/binaries branch this one references holds
everything binaries master holds. Anything red that this prediction does not name is a defect in
this validation, not a CI quirk.

COFF A/B, 163 objects

The figures below are the ones measured for head ce0d435e. They describe this head too, and
that is a measurement, not an assumption. Both arms were reloaded on the 163 objects -- the
before arm at 51c1f241 and at 9f841651, the after arm at ce0d435e and at this head, each
with the same six machine-type lines standing in for #724 -- and the section name, file offset,
vaddr, file size and memory size of all 7,968 sections, plus each object's mapped byte total, are
identical across every pair. 09fcf86b, which the published arms were built from, has
byte-identical cle/backends/coff.py and tests/test_coff.py to 51c1f241. Nothing the A/B
measures depends on anything but where the sections land.

The header scan says why: of those 7,968 section headers, 92 have no bytes in the file and every
one of them is marked IMAGE_SCN_CNT_UNINITIALIZED_DATA, so #764's new condition never changes a
placement here, and no object grows far enough for the new ceiling to fire.

Two frozen cle trees, both 09fcf86b with #724's commit 90846328 cherry-picked on top -- ARM64
COFF does not load at all without it -- differing by exactly this commit's hunk in
cle/backends/coff.py and by nothing else. angr pinned at
acfcdecb02bed4b46b0035b893d65b509de1752a; cle.__file__ and angr.__file__ asserted inside
each arm's own tree in every process. Per object:
angr.Project(path, main_opts={"backend": "COFF"}, auto_load_libs=False) then
CFGFast(normalize=True). No load error and no timeout on either side.

163 objects from seeded draws over five collections of a private corpus of compiler output: MSVC
and MSYS2 Windows release archives, two generated C and C++ toolchain matrices, and a copy of the
public angr/binaries repository contributing two objects, one of them the fixture the new test
loads.

arch objects function symbols recovered before recovered after block bytes before block bytes after executable bytes
AARCH64 66 880 582 880 205,544 281,768 281,792
ARMNT 24 441 441 441 19,198 19,198 19,198
AMD64 34 1486 1486 1486 396,193 396,418 394,013
X86 39 751 750 750 301,027 302,071 336,905
  • 34 objects change symbol recovery and every one is AARCH64, so syms and syms_recovered are
    identical between the arms on every x86, AMD64 and ARMNT object and none of the movement in
    their function counts can be a symbol-named function. Those counts fall by 38 and 44 while the
    code bytes covered rise on both. The single x86 symbol never recovered is never recovered on
    either side.
  • 16 ARM64 objects recovered none of their own function symbols before; none does after.
  • In both arms and on all 163 objects, the symbols recovered are exactly the symbols whose address
    lifts to a block of non-zero length: 3259 of 3558 before, 3557 after. The recovery failure is
    the lifting failure.
  • Block bytes above the executable bytes are CFG blocks that overlap or run past a section end.
  • A second independent run of the before arm gives an identical function count, function address
    list and every other recorded field on all 163 objects, so this is the change and not CFGFast
    nondeterminism.

Reproducer provenance

https://github.com/zlib-ng/zlib-ng/releases/download/2.3.3/zlib-ng-win-arm64.zip was downloaded
for the earlier head; its sha256 is
2d8e1a34534be21148c8fd688847d6212eed778a833ba54216472b70638ab601. Its lib/zlibstatic-ng.lib
(sha256 6f7f1dc0b92d1d4147cf354165602af33e7c9b10d7c202447872f0704090ab89) holds the member
zlib-ng-static.dir\Release\insert_string_roll.obj, 1150 bytes, sha256
41eb600a40a3fd47b32c77ad3cb51cceabce5a86389924bfdb2f5749f7844eba, byte-identical to the object
measured above. That object is one of the 163, so its section map is covered by the comparison
above.

Limits

  • The ARM64 figures require Load ARM64 and ARMNT COFF objects #724 in both arms; on cle master these objects raise
    NotImplementedError: Unsupported machine type.
  • ARMNT is untested against a misaligned executable section. This change moves 80 of the 371
    sections in the 24 ARMNT objects and none of their 155 executable ones: they are clang output
    that is already aligned, and the corpus holds no MSVC-produced ARM32 relocatable archive.
  • A section that moves leaves its bytes at the file offset as well, unreferenced by any section or
    segment, the same way the header and symbol table are already mapped. The image grows by the
    size of the sections that move: median 1.167x, maximum 1.902x over the 163.
  • The fallback for a move the ceiling stops has no test. Reaching it needs a section stating
    SizeOfRawData at or above 0x10000000 at a misaligned offset, and no object in angr/binaries
    has one. It is measured on a scratch probe, above, and not committed anywhere.
  • CoffParser still never checks PointerToRawData + SizeOfRawData against the file, so a header
    stating a size the file does not have makes the image that large, up to the MAX_IMAGE_SIZE
    ceiling COFF: Give a section with no file bytes an address of its own #764 sets. A 108-byte object whose one section states SizeOfRawData 0x4000000 at a
    misaligned offset maps 67,109,376 bytes on this head and 108 on master. COFF: bound a section's raw data by the size of the file #806 is that bound and
    brings the same object back to 560 bytes; it is not in this change, and it conflicts with this
    branch, so whichever of the two merges second needs a rebase.

@zardus

zardus commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Complete section map, function symbols and CFG for
zlib-ng-static.dir\Release\insert_string_roll.obj, the 1150-byte member of
lib/zlibstatic-ng.lib in the official
zlib-ng 2.3.3 Windows ARM64 release
(archive sha256 2d8e1a34534be21148c8fd688847d6212eed778a833ba54216472b70638ab601, object sha256
41eb600a40a3fd47b32c77ad3cb51cceabce5a86389924bfdb2f5749f7844eba). Both sides carry
#724, without which the object does not load at all.

Before — every .text$mn sits on an odd address, so every function symbol lifts to a
zero-length block and nothing is recovered:

cle#764 head
sections
name         file offset  states align      vaddr  memsize  exec
.drectve             260             1   0x400104       47
.debug$S             307             1   0x400133      152
.text$mn             459             8   0x4001cb      104     x
.text$mn             563             8   0x400233       80     x
.text$mn             643             8   0x400283       16     x
.chks64              659          none   0x400293       48

function symbols
name                               addr  % 4  block bytes  in CFG
insert_string_roll             0x4001cb    3            0      no
quick_insert_string_roll       0x400233    3            0      no
update_hash_roll               0x400283    3            0      no

CFGFast: 0 functions, 0 nodes, 0 block bytes

After — the three .text$mn sections move to the 8-byte alignment they state, the two
that state 1 and the one that states none stay where they are, and all three functions are
recovered:

with this change
sections
name         file offset  states align      vaddr  memsize  exec
.drectve             260             1   0x400104       47
.debug$S             307             1   0x400133      152
.text$mn             459             8   0x400480      104     x
.text$mn             563             8   0x4004e8       80     x
.text$mn             643             8   0x400538       16     x
.chks64              659          none   0x400293       48

function symbols
name                               addr  % 4  block bytes  in CFG
insert_string_roll             0x400480    0           36     yes
quick_insert_string_roll       0x4004e8    0           48     yes
update_hash_roll               0x400538    0           16     yes

CFGFast: 3 functions, 9 nodes, 200 block bytes

The recovered code is the source's, first eight instructions of each:

disassembly
== insert_string_roll @ 0x400480 size 104
   0x00400480  ldp x9, x7, [x0, #0x58]
   0x00400484  ldr x13, [x0, #0x68]
   0x00400488  ldr w15, [x0, #0x48]
   0x0040048c  add x9, x9, w1, uxtw
   0x00400490  add x11, x9, #2
   0x00400494  add x14, x11, w2, uxtw
   0x00400498  uxth w9, w1
   0x0040049c  cmp x11, x14

== quick_insert_string_roll @ 0x4004e8 size 80
   0x004004e8  ldr x8, [x0, #0x58]
   0x004004ec  ubfx x10, x1, #0, #0x20
   0x004004f0  ldr w9, [x0, #0x70]
   0x004004f4  add x8, x8, x10
   0x004004f8  ldrb w8, [x8, #2]
   0x004004fc  eor w8, w8, w9, lsl #5
   0x00400500  and w9, w8, #0x7fff
   0x00400504  ldr x8, [x0, #0x68]

== update_hash_roll @ 0x400538 size 16
   0x00400538  uxtb w8, w1
   0x0040053c  eor w8, w8, w0, lsl #5
   0x00400540  and w0, w8, #0x7fff
   0x00400544  ret

@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_804

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>
The backend maps an object at its own file offsets and gives each section
vaddr = PointerToRawData. That offset is only as aligned as the file's packing
leaves it, and MSVC packs raw data with no padding between sections, so a
section commonly begins where the IMAGE_SCN_ALIGN_* in its own header does not
allow. All eight .text$mn sections in tests/x86/fauxware.obj state 16-byte
alignment and six of them start at a file offset that is not a multiple of 16.

x86 and AMD64 never notice, because their instructions have no alignment
requirement. ARM64 and ARMNT, whose machine types #724 adds, do: a .text$mn
placed on an odd address holds no instruction anything can decode, every
function symbol in it lifts to a zero-length block, and CFGFast recovers
nothing from it. Over 66 ARM64 COFF objects, 298 of 880 function symbols went
unrecovered and 16 objects recovered none of their own; with the sections
placed where their headers ask, all 880 are recovered.

A section whose file offset does not satisfy its alignment now gets space of
its own past the image with its bytes copied in, from the same cursor and under
the same MAX_IMAGE_SIZE ceiling as the section marked
IMAGE_SCN_CNT_UNINITIALIZED_DATA that already goes there. Where that ceiling
would stop the move, the section keeps its file offset, which is where its
bytes are. A header that states no alignment states no requirement and its
section is left where it is; the 16 _section_alignment returns for that case is
only where to put a section that has to be placed somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/coff-section-alignment branch from ce0d435 to f75cec6 Compare September 2, 2026 14:55
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