Skip to content

COFF: bound the offsets and counts a COFF object declares - #807

Open
zardus wants to merge 2 commits into
masterfrom
feature/coff-reloc-bounds
Open

COFF: bound the offsets and counts a COFF object declares#807
zardus wants to merge 2 commits into
masterfrom
feature/coff-reloc-bounds

Conversation

@zardus

@zardus zardus commented Sep 2, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

The COFF backend reads and writes at addresses it computes from fields in the object's own
header, and checks none of them against the file. Three failures follow.

A relocation whose field lies past the end of its own section is applied anyway. On
tests/x86/coff_reloc_outside_section.obj, a relocation belonging to a .text of 0x10 bytes
has its four-byte field at offset 0x10, which is the first byte of .data. The load returns
normally, .data comes back rewritten with aa aa aa become 16 ab ea, and nothing is
reported.

A relocation whose field lies past the end of the file crashes. On
tests/x86/coff_reloc_outside_file.obj the field is at 0x4000000 in a 108-byte object, and
cle.Loader(path, auto_load_libs=False, perform_relocations=True) raises:

  File "cle/backends/coff.py", line 338, in value
    org_bytes = self.owner.memory.load(self.relative_addr, 4)
  File "cle/memory.py", line 422, in load
    raise KeyError(addr)
KeyError: 67108924

And CoffParser._parse reads the section table, the symbol and string table, and each
section's relocation table at offsets and counts the file supplies, bounding none of them.
Each of those three reads can leave cle.Loader as struct.error or as ValueError from
ctypes, neither of which is a CLEError, so nothing catching CLEError catches them.
Loading all 16677 truncations of the tracked tests/x86/fauxware.obj, 15457 fail that way.

Root cause

The backend maps the whole file as one backer at address 0, so every offset inside the file
resolves and nothing outside it does. _add_relocs adds a relocation's VirtualAddress to its
section's PointerToRawData and registers the result unchecked, so an offset the section does
not contain still lands somewhere live: another section's raw data, the relocation table, the
symbol table. _parse has the same shape one layer earlier, taking NumberOfSections,
PointerToSymbolTable, NumberOfSymbols and PointerToRelocations as read.

Fix

Two bounds.

A relocation is registered only if its field lies wholly inside its own section's raw data and
wholly inside the file; otherwise it is skipped with a log.warning, which is what the PE
backend does with a section whose raw data the file does not hold. The width comes from
struct.calcsize on the relocation class's PACK_FORMAT, so a four-byte field starting on the
last byte of a section is out of bounds.

Two details in that bound serve the other open COFF branches and change nothing here: the
section is read by index out of self._coff.sections rather than off the loop variable, and
the file-size half is bounded by self._image_vmem rather than by self._data. Both are the
same object here. Without them, #764 and #804, which conflict with this nowhere, stop four of
the COFF objects angr/binaries tracks from loading.

The parser refuses a header table the file does not hold, raising CLEInvalidBinaryError that
names the field, the bytes it wanted and the size of the file. A table with no entries is
exempt, because nothing dereferences its pointer. Nothing else changes about which objects
load: across a sweep of the five fields patched into tests/x86/coff_reloc_dir32.obj, master
and this branch accept the same objects and reject the same ones, and what changes is only the
exception. One bound covers two reads, because the string table begins on the byte after the
last symbol, so a file holding the four-byte string table size holds every symbol — across
those 16677 truncations, not one reaches the symbol table read.

A malformed table is fatal rather than skippable because the parser has no partial product:
_add_relocs indexes self._coff.symbols by an index from the file, so a short symbol list
becomes an IndexError elsewhere. pe.py takes the other option for its analogue, warning and
returning no symbols when a PE symbol table ends past the file. A COFF object has no second
source.

Deliberately not done: bounding a section's own raw data by the file size, which is #806; and
the fixed-size header read on the first line of _parse, which still raises ValueError for
18 of the 20 truncations shorter than twenty bytes, the other two never reaching this backend.
That read takes no field from the file, and whether such a file should reach the parser is
Coff.is_compatible's question.

Testing

Five regressions in tests/test_coff.py, all loading committed objects. The out-of-section one
asserts .data still reads aa sixteen times, which is what a fix that stopped the crash but
kept the silent write would fail; the three parser ones assert CLEInvalidBinaryError and the
field it names, so each pins a different bound. All five fail on master, on the byte
comparison, on the KeyError above, and three times on struct.error or ValueError.

A sixth test checks that an unmodified tests/x86/fauxware.obj still yields 29 sections and
225 relocations, and the cle suite goes from 261 to 267 passed with 9 skipped. Of the 16677
truncations, the same 1200 lengths load before and after. Of the ten COFF objects in
angr/binaries at the fixture head, the five well-formed ones keep their relocation counts exactly, 225 and 126
among them, and the two carrying a loose relocation drop from one registered relocation to
none. With perform_relocations=True, the default, master loads six of the ten and this branch
loads seven.

The fixtures live in the angr/binaries pull request below, which must merge first.

Validation: #807 (comment)

sync: angr/binaries#223

session: sharpen

@zardus

zardus commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head ac80e76b96986430193506c22fe912ae50279086 against baseline 0e77ade3c39a3cee05f65051e57955675e1ac21b.

This replaces the record for d1943dca1418fc0778086a6601a5abdeb85357b3. The branch was rebased onto that baseline, and the bounds block in _add_relocs now reads the section by index and bounds against the mapped image: section_size = self._coff.sections[section_idx].SizeOfRawData and image_size = len(self._image_vmem), in place of section.SizeOfRawData and len(self._data), with the log message saying image size rather than file size. Neither changes anything on this branch. self._coff.sections[section_idx] is what enumerate binds to section on the line above, and self._image_vmem is assigned from self._data in Coff.__init__ and never rebound, with no subclass of Coff in cle. Every number below was regenerated against the new head and baseline.

  • Regression: python -m pytest tests/test_coff.py -v --no-header — 11 passed on head. With only cle/backends/coff.py restored to the baseline, 5 failed and 6 passed: the .data comparison (16 ab ea aa ... against aa aa aa aa ...), KeyError: 67108924 from cle/memory.py, ValueError: Buffer size too small (108 instead of at least 67108874 bytes), and struct.error: unpack requires a buffer of 4 bytes twice. Full output is in the before/after comment.
  • Full suite: python -m pytest tests -q — 267 passed, 9 skipped on head; 261 passed, 9 skipped at the baseline. The six new tests are the five regressions and one checking that an unmodified tests/x86/fauxware.obj still yields 29 sections and 225 relocations.
  • Lint/type: pylint 10.00 -> 10.00 and pyright errors 0 -> 0 on both changed files, scored against the base the way the hosted Lint and Typecheck jobs score them. black 26.5.1 and ruff 0.16.5 are clean on both, on the head and on every merged tree named below.
  • Hooks: pre-commit run --all-files in this checkout — 24 hooks, every one passed or skipped for having no matching file, and nothing was rewritten.
  • Acceptance: five header fields of tests/x86/coff_reloc_dir32.obj, a 108-byte object, patched with every combination of NumberOfSections in 0, 1, 2, 16, 65535; PointerToSymbolTable in 0, 86, 104, 105, 108, 109, 0x4000000; NumberOfSymbols in 0, 1, 2, 4096, 0xffffffff; PointerToRelocations in 0, 76, 107, 108, 0x4000000; and NumberOfRelocations in 0, 1, 256, 65535 — 86, 76 and 1 being the file's own values, and the rest bracketing zero, the last bytes of the file, one past its end and an offset far beyond it. That is 3500 combinations, and the baseline and the head accept exactly the same 136 and reject exactly the same 3364. The bounds change which exception a rejected object raises, not which objects load: the baseline rejects with struct.error 2900 times, ValueError 398, UnicodeDecodeError 40 and IndexError 26, the head raises CLEInvalidBinaryError on those same 3338 rows, and the 26 IndexError rows are identical on both sides — 20 at self._coff.sections[sym.SectionNumber - 1] in get_symbol and 6 at self._coff.symbols[reloc.SymbolTableIndex] in _add_relocs, both pre-existing and unguarded on either revision. A table with no entries is exempt from its bound: a section declaring zero relocations with PointerToRelocations at 0x4000000 in this file loads on both.
  • Truncation sweep, every length: loading all 16677 truncations of tests/x86/fauxware.obj, from 0 bytes to its whole 16676, through cle.Loader(..., perform_relocations=True).
Lengths Baseline Head
0 and 1 ValueError, CLECompatibilityError, raised before the COFF backend unchanged
2..19 ValueError from the 20-byte header read unchanged, and deliberately not fixed
20..1179 struct.error at the string table size read CLEInvalidBinaryError, section table
1180..15476 struct.error at the string table size read CLEInvalidBinaryError, symbol and string table
15477..16676 loaded loaded

Known and not addressed here: two indexes taken from the file are still unguarded, and both behave identically on the two revisions. reloc.SymbolTableIndex indexes self._coff.symbols in _add_relocs, and sym.SectionNumber - 1 indexes self._coff.sections in get_symbol, so an object naming a symbol or a section that is not there raises IndexError out of cle.Loader. Both are pre-existing and belong in their own change.

CI prediction: every cle job should be green while the angr/binaries pull request is open, because cle/.github/workflows/ci.yml resolves a referenced angr/binaries pull request through angr/ci-settings/actions/binaries-ref in its Pyodide and macOS/Windows jobs, and the reusable angr/ci-settings workflow resolves it in Build through resolve_refs.py.

@zardus

zardus commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full output of python -m pytest tests/test_coff.py -v --no-header for the five new
regressions, which load coff_reloc_outside_section.obj, coff_reloc_outside_file.obj,
coff_truncated_section_table.obj, coff_truncated_symbol_table.obj and
coff_reloc_table_past_file.obj from angr/binaries. Both sides ran on this branch with
angr/binaries at the pull request below; the before side has only cle/backends/coff.py
restored to master.

Before — the out-of-section relocation silently rewrites the first three bytes of .data,
the out-of-file one raises KeyError out of Clemory.load, and the three malformed objects
raise struct.error or ValueError, none of which is a CLEError:

angr/cle master
============================= test session starts ==============================
collecting ... collected 11 items

tests/test_coff.py::TestCoff::test_a_relocation_past_the_end_of_its_section_is_skipped FAILED [  9%]
tests/test_coff.py::TestCoff::test_a_relocation_past_the_end_of_the_file_is_skipped FAILED [ 18%]
tests/test_coff.py::TestCoff::test_a_relocation_table_past_the_end_of_the_file_is_rejected FAILED [ 27%]
tests/test_coff.py::TestCoff::test_a_section_table_longer_than_the_file_is_rejected FAILED [ 36%]
tests/test_coff.py::TestCoff::test_a_symbol_table_past_the_end_of_the_file_is_rejected FAILED [ 45%]
tests/test_coff.py::TestCoff::test_dir32_relocation_wraps_at_the_field_width PASSED [ 54%]
tests/test_coff.py::TestCoff::test_long_section_names_come_from_the_string_table PASSED [ 63%]
tests/test_coff.py::TestCoff::test_rel32_relocation_encodes_a_negative_displacement PASSED [ 72%]
tests/test_coff.py::TestCoff::test_the_whole_object_still_loads PASSED   [ 81%]
tests/test_coff.py::TestCoff::test_x86 PASSED                            [ 90%]
tests/test_coff.py::TestCoff::test_x86_64 PASSED                         [100%]

=================================== FAILURES ===================================
______ TestCoff.test_a_relocation_past_the_end_of_its_section_is_skipped _______

self = <test_coff.TestCoff testMethod=test_a_relocation_past_the_end_of_its_section_is_skipped>

    def test_a_relocation_past_the_end_of_its_section_is_skipped(self):
        # The object's one relocation sits at offset 0x10 of a .text section holding 0x10 bytes, so
        # its four-byte field falls on the start of .data, which is filled with 0xaa.
        exe = os.path.join(TEST_BASE, "tests", "x86", "coff_reloc_outside_section.obj")
        ld = cle.Loader(exe, auto_load_libs=False, perform_relocations=True)
>       assert ld.memory.load(section_vaddr(ld.main_object, ".data"), 0x10) == b"\xaa" * 0x10
E       AssertionError: assert b'\x16\xab\xe...a\xaa\xaa\xaa' == b'\xaa\xaa\xa...a\xaa\xaa\xaa'
E         
E         At index 0 diff: b'\x16' != b'\xaa'
E         
E         Full diff:
E         - (b'\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa')
E         ?      ^^   ^  ^
E         + (b'\x16\xab\xea\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa')
E         ?      ^^   ^  ^

tests/test_coff.py:96: AssertionError
________ TestCoff.test_a_relocation_past_the_end_of_the_file_is_skipped ________

self = <test_coff.TestCoff testMethod=test_a_relocation_past_the_end_of_the_file_is_skipped>

    def test_a_relocation_past_the_end_of_the_file_is_skipped(self):
        # The object's one relocation sits at offset 0x4000000 of .text, which the whole file does
        # not reach.
        exe = os.path.join(TEST_BASE, "tests", "x86", "coff_reloc_outside_file.obj")
        with open(exe, "rb") as f:
            raw = f.read()
    
>       ld = cle.Loader(exe, auto_load_libs=False, perform_relocations=True)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

tests/test_coff.py:106: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cle/loader.py:187: in __init__
    self.initial_load_objects = self._internal_load(
cle/loader.py:950: in _internal_load
    obj.relocate()
cle/backends/backend.py:428: in relocate
    reloc.relocate()
cle/backends/coff.py:301: in relocate
    value = self.value
            ^^^^^^^^^^
cle/backends/coff.py:338: in value
    org_bytes = self.owner.memory.load(self.relative_addr, 4)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Clemory [0x0:0x6c]>, addr = 67108924, n = 4

    def load(self, addr, n):
        """
        Read up to `n` bytes at address `addr` in memory and return a bytes object.
    
        Reading will stop at the beginning of the first unallocated region found, or when
        `n` bytes have been read.
        """
        views = []
    
        for start, backer in self.backers(addr):
            if start > addr:
                break
            if isinstance(backer, list):
                raise TypeError("Can't load bytes from Clemory backed by list[int]")
            offset = addr - start
            if not views and offset + n < len(backer):
                return bytes(memoryview(backer)[offset : offset + n])
            size = len(backer) - offset
            views.append(memoryview(backer)[offset : offset + n])
    
            addr += size
            n -= size
    
            if n <= 0:
                break
    
        if not views:
>           raise KeyError(addr)
E           KeyError: 67108924

cle/memory.py:422: KeyError
____ TestCoff.test_a_relocation_table_past_the_end_of_the_file_is_rejected _____

self = <test_coff.TestCoff testMethod=test_a_relocation_table_past_the_end_of_the_file_is_rejected>

    def test_a_relocation_table_past_the_end_of_the_file_is_rejected(self):
        # An otherwise well-formed 108-byte object whose .text points its relocation table at
        # 0x4000000, so everything else the parser reads is in range.
        exe = os.path.join(TEST_BASE, "tests", "x86", "coff_reloc_table_past_file.obj")
        with self.assertRaisesRegex(cle.CLEInvalidBinaryError, "relocation table"):
>           cle.Loader(exe, auto_load_libs=False)

tests/test_coff.py:59: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cle/loader.py:187: in __init__
    self.initial_load_objects = self._internal_load(
cle/loader.py:805: in _internal_load
    obj = self._load_object_isolated(main_spec)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
cle/loader.py:1017: in _load_object_isolated
    result = backend_cls(binary, binary_stream, is_main_bin=self._main_object is None, loader=self, **options)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
cle/backends/coff.py:455: in __init__
    self._coff = CoffParser(self._data)
                 ^^^^^^^^^^^^^^^^^^^^^^
cle/backends/coff.py:174: in __init__
    self._parse()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

    def _parse(self) -> None:
        self.header = CoffFileHeader.from_buffer_copy(self.data)
        if self.header.Machine not in {
            IMAGE_FILE_MACHINE.I386,
            IMAGE_FILE_MACHINE.AMD64,
        }:
            raise NotImplementedError("Unsupported machine type")
    
        strings_offset = (
            self.header.PointerToSymbolTable + ctypes.sizeof(CoffSymbolTableEntry) * self.header.NumberOfSymbols
        )
        strings_size = struct.unpack("<I", self.data[strings_offset : strings_offset + 4])[0]
        self.strings: bytes = self.data[strings_offset : strings_offset + strings_size]
    
        self.symbols = []
        self.symbol_name_to_idx = {}
        self.idx_to_symbol_name = {}
    
        offset = self.header.PointerToSymbolTable
        aux = 0
        for i in range(self.header.NumberOfSymbols):
            symbol = CoffSymbolTableEntry.from_buffer_copy(self.data, offset)
            offset += ctypes.sizeof(CoffSymbolTableEntry)
            self.symbols.append(symbol)
            if aux:
                aux -= 1
                continue
            idx = len(self.symbols) - 1
            name = self.get_symbol_name(idx)
            aux = symbol.NumberOfAuxSymbols
    
            # Ensure unique symbol names
            i = 1
            base_name = name
            while name in self.symbol_name_to_idx:
                name = base_name + f"__{i}"
                i += 1
            self.symbol_name_to_idx[name] = idx
            self.idx_to_symbol_name[idx] = name
    
        self.sections = []
        self.relocations = []
    
        for i in range(self.header.NumberOfSections):
            offset = ctypes.sizeof(self.header) + ctypes.sizeof(CoffSectionTableEntry) * i
            section = CoffSectionTableEntry.from_buffer_copy(self.data, offset)
            self.sections.append(section)
    
            # Relocations
            relocs = []
            offset = section.PointerToRelocations
            for i in range(section.NumberOfRelocations):
>               reloc = CoffRelocationTableEntry.from_buffer_copy(self.data, offset)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E               ValueError: Buffer size too small (108 instead of at least 67108874 bytes)

cle/backends/coff.py:228: ValueError
________ TestCoff.test_a_section_table_longer_than_the_file_is_rejected ________

self = <test_coff.TestCoff testMethod=test_a_section_table_longer_than_the_file_is_rejected>

    def test_a_section_table_longer_than_the_file_is_rejected(self):
        # The first 512 bytes of x86/fauxware.obj. Its header declares 29 sections, whose table
        # needs 0x49c bytes counted from the start of the file.
        exe = os.path.join(TEST_BASE, "tests", "x86", "coff_truncated_section_table.obj")
        with self.assertRaisesRegex(cle.CLEInvalidBinaryError, "section table"):
>           cle.Loader(exe, auto_load_libs=False)

tests/test_coff.py:45: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cle/loader.py:187: in __init__
    self.initial_load_objects = self._internal_load(
cle/loader.py:805: in _internal_load
    obj = self._load_object_isolated(main_spec)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
cle/loader.py:1017: in _load_object_isolated
    result = backend_cls(binary, binary_stream, is_main_bin=self._main_object is None, loader=self, **options)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
cle/backends/coff.py:455: in __init__
    self._coff = CoffParser(self._data)
                 ^^^^^^^^^^^^^^^^^^^^^^
cle/backends/coff.py:174: in __init__
    self._parse()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

    def _parse(self) -> None:
        self.header = CoffFileHeader.from_buffer_copy(self.data)
        if self.header.Machine not in {
            IMAGE_FILE_MACHINE.I386,
            IMAGE_FILE_MACHINE.AMD64,
        }:
            raise NotImplementedError("Unsupported machine type")
    
        strings_offset = (
            self.header.PointerToSymbolTable + ctypes.sizeof(CoffSymbolTableEntry) * self.header.NumberOfSymbols
        )
>       strings_size = struct.unpack("<I", self.data[strings_offset : strings_offset + 4])[0]
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       struct.error: unpack requires a buffer of 4 bytes

cle/backends/coff.py:187: error
______ TestCoff.test_a_symbol_table_past_the_end_of_the_file_is_rejected _______

self = <test_coff.TestCoff testMethod=test_a_symbol_table_past_the_end_of_the_file_is_rejected>

    def test_a_symbol_table_past_the_end_of_the_file_is_rejected(self):
        # The first 2048 bytes of x86/fauxware.obj, which is long enough to hold the whole
        # section table and not the 152 symbols at 0x31c1 or the string table after them.
        exe = os.path.join(TEST_BASE, "tests", "x86", "coff_truncated_symbol_table.obj")
        with self.assertRaisesRegex(cle.CLEInvalidBinaryError, "symbol table"):
>           cle.Loader(exe, auto_load_libs=False)

tests/test_coff.py:52: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cle/loader.py:187: in __init__
    self.initial_load_objects = self._internal_load(
cle/loader.py:805: in _internal_load
    obj = self._load_object_isolated(main_spec)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
cle/loader.py:1017: in _load_object_isolated
    result = backend_cls(binary, binary_stream, is_main_bin=self._main_object is None, loader=self, **options)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
cle/backends/coff.py:455: in __init__
    self._coff = CoffParser(self._data)
                 ^^^^^^^^^^^^^^^^^^^^^^
cle/backends/coff.py:174: in __init__
    self._parse()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

    def _parse(self) -> None:
        self.header = CoffFileHeader.from_buffer_copy(self.data)
        if self.header.Machine not in {
            IMAGE_FILE_MACHINE.I386,
            IMAGE_FILE_MACHINE.AMD64,
        }:
            raise NotImplementedError("Unsupported machine type")
    
        strings_offset = (
            self.header.PointerToSymbolTable + ctypes.sizeof(CoffSymbolTableEntry) * self.header.NumberOfSymbols
        )
>       strings_size = struct.unpack("<I", self.data[strings_offset : strings_offset + 4])[0]
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       struct.error: unpack requires a buffer of 4 bytes

cle/backends/coff.py:187: error
============================= fixture duration top =============================
total          name        num med            max           
0:00:00.000048 grand total   1 0:00:00.000048 0:00:00.000048
============================ test call duration top ============================
total          name        num med            max           
0:00:00.010088 grand total  11 0:00:00.000632 0:00:00.001914
=========================== test setup duration top ============================
total          name        num med            max           
0:00:00.001059 grand total  11 0:00:00.000107 0:00:00.000148
========================== test teardown duration top ==========================
total          name        num med            max           
0:00:00.000760 grand total  11 0:00:00.000037 0:00:00.000124
=========================== short test summary info ============================
FAILED tests/test_coff.py::TestCoff::test_a_relocation_past_the_end_of_its_section_is_skipped
FAILED tests/test_coff.py::TestCoff::test_a_relocation_past_the_end_of_the_file_is_skipped
FAILED tests/test_coff.py::TestCoff::test_a_relocation_table_past_the_end_of_the_file_is_rejected
FAILED tests/test_coff.py::TestCoff::test_a_section_table_longer_than_the_file_is_rejected
FAILED tests/test_coff.py::TestCoff::test_a_symbol_table_past_the_end_of_the_file_is_rejected
========================= 5 failed, 6 passed in 0.38s ==========================

After — the two relocations are skipped with a warning and the three malformed objects are
refused with CLEInvalidBinaryError naming the table that does not fit:

with this change
============================= test session starts ==============================
collecting ... collected 11 items

tests/test_coff.py::TestCoff::test_a_relocation_past_the_end_of_its_section_is_skipped PASSED [  9%]
tests/test_coff.py::TestCoff::test_a_relocation_past_the_end_of_the_file_is_skipped PASSED [ 18%]
tests/test_coff.py::TestCoff::test_a_relocation_table_past_the_end_of_the_file_is_rejected PASSED [ 27%]
tests/test_coff.py::TestCoff::test_a_section_table_longer_than_the_file_is_rejected PASSED [ 36%]
tests/test_coff.py::TestCoff::test_a_symbol_table_past_the_end_of_the_file_is_rejected PASSED [ 45%]
tests/test_coff.py::TestCoff::test_dir32_relocation_wraps_at_the_field_width PASSED [ 54%]
tests/test_coff.py::TestCoff::test_long_section_names_come_from_the_string_table PASSED [ 63%]
tests/test_coff.py::TestCoff::test_rel32_relocation_encodes_a_negative_displacement PASSED [ 72%]
tests/test_coff.py::TestCoff::test_the_whole_object_still_loads PASSED   [ 81%]
tests/test_coff.py::TestCoff::test_x86 PASSED                            [ 90%]
tests/test_coff.py::TestCoff::test_x86_64 PASSED                         [100%]

============================= fixture duration top =============================
total          name        num med            max           
0:00:00.000068 grand total   1 0:00:00.000068 0:00:00.000068
============================ test call duration top ============================
total          name        num med            max           
0:00:00.008880 grand total  11 0:00:00.000501 0:00:00.001764
=========================== test setup duration top ============================
total          name        num med            max           
0:00:00.000808 grand total  11 0:00:00.000060 0:00:00.000199
========================== test teardown duration top ==========================
total          name        num med            max           
0:00:00.000358 grand total  11 0:00:00.000032 0:00:00.000048
============================== 11 passed in 0.29s ==============================

@angr-bot

angr-bot commented Sep 2, 2026

Copy link
Copy Markdown
Member

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

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.
CoffParser._parse reads three tables at offsets and counts that come out of the
file, and bounds none of them. A truncated or malformed object therefore leaves
cle.Loader by an exception a caller cannot name: struct.error from the string
table size read, and ValueError from ctypes for the section and relocation
tables. Neither is a CLEError, so nothing catching CLEError catches them.

Bound all three against the size of the file and raise CLEInvalidBinaryError
naming the field, the bytes it wanted and the size of the file. The message
follows the register of the PE backend's out-of-bounds section warning. A table
with no entries is exempt, because nothing dereferences its pointer: a section
declaring zero relocations and a PointerToRelocations past the end of the file
loads on master and has to keep loading.

The bounds change no acceptance decision. What changes is the exception: a
rejection that was struct.error or ValueError is now CLEInvalidBinaryError.

One bound covers two of the reads. The string table begins on the byte after the
last symbol, so a file that holds the four-byte string table size also holds
every symbol. Loading all 16677 truncations of x86/fauxware.obj, from zero bytes
to the whole file: on master 15457 of them -- every length from 20 to 15476 --
fail at the string table size read and not one reaches the symbol table read.
15477 is the first length that holds that size field, and the 1200 lengths from
there up load. A separate bound on the symbol table would be unreachable.

Those same 1200 lengths still load with these bounds applied. The 15457 that
failed now fail as CLEInvalidBinaryError, 1160 of them on the section table and
14297 on the symbol and string table.

Malformed here is fatal rather than skippable, because the parser has no partial
product to hand back. _add_relocs indexes self._coff.symbols by an index taken
from the file, so a short symbol list turns a truncated object into an IndexError
somewhere with no information about why. That index is itself unbounded on both
revisions and stays that way here; bounding it is a separate change. The
constructor already treats an unusable COFF as fatal for an unsupported machine
type and for a /GL object, so this replaces an accidental exception with a named
one rather than adding a new failure.

Not bounded: CoffFileHeader.from_buffer_copy on the first line of _parse, which
still raises ValueError for 18 of the 20 truncations shorter than the twenty-byte
header. The other two never reach the COFF backend at all. That read takes no
field from the file. Whether a two-byte file should reach the parser is
Coff.is_compatible's question, since it claims a file on its first two bytes, and
it is a separate change.
@zardus
zardus force-pushed the feature/coff-reloc-bounds branch from d1943dc to ac80e76 Compare September 6, 2026 05:27
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#807 names this pull request in its sync: line, so CI
checks this branch out instead of master and those two files were missing:
3 failed, 264 passed on the macOS job. Merging master in supplies them and
leaves this branch's own five 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