Skip to content

COFF: Expose undefined externals through self.imports - #761

Open
zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-coff-imports
Open

zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-coff-imports

Conversation

@zardus

@zardus zardus commented Aug 17, 2026 •

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

A COFF object never says what it needs from elsewhere. On
binaries/tests/x86_64/fauxware.obj:

obj.imports: 0 entries

Project._register_object reads self.imports and nothing else to decide what
to hook, so a call to strcmp or exit in this object lands in the extern
object's zero fill and is decoded as instructions. The object's ten undefined
externals are all reachable from its code.

Root cause

A COFF object names an undefined external by giving the symbol section number
zero. cle already allocates each of those an extern address and relocates against
it -- the addresses exist -- but nothing on the symbol says it is an import, so
self.imports is never populated. The second half is Type: a COFF symbol says
0x20 for a function and 0 for "no type information", which is what every
GCC-family toolchain emits, and cle read 0 as data. An import listed as data is
listed and then skipped rather than hooked.

Fix

Say so on the symbol -- the generic relocation machinery already does the rest --
and map an untyped COFF symbol to TYPE_NONE, as a relocatable ELF's
STT_NOTYPE symbols already are, rather than to a data symbol.

obj.imports: 10 entries
    ?_OptionsStorage@?1??__local_stdio_printf_options@@9@9 TYPE_NONE      -> 0x500000 in ExternObject
    _RTC_CheckStackVars                                  TYPE_FUNCTION  -> 0x500028 in ExternObject
    _RTC_InitBase                                        TYPE_FUNCTION  -> 0x500040 in ExternObject
    _RTC_Shutdown                                        TYPE_FUNCTION  -> 0x500048 in ExternObject
    __imp___acrt_iob_func                                TYPE_NONE      -> 0x500030 in ExternObject
    __imp___stdio_common_vfprintf                        TYPE_NONE      -> 0x500008 in ExternObject
    __imp__open                                          TYPE_NONE      -> 0x500018 in ExternObject
    __imp__read                                          TYPE_NONE      -> 0x500020 in ExternObject
    __imp_exit                                           TYPE_NONE      -> 0x500038 in ExternObject
    strcmp                                               TYPE_FUNCTION  -> 0x500010 in ExternObject

Testing

tests/test_coff.py::TestCoff::test_undefined_externals_are_imports asserts that
the fixture reports strcmp and _RTC_CheckStackVars among its imports, that
main is not among them, and that every import carries an is_import symbol
resolved into the extern object. obj.imports is empty on the merge base, so it
fails on the first assertion.

Fixes #746. Validation: #761 (comment)

session: sharpen

@zardus

zardus commented Aug 17, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 7acefe5f44dacbd6afa8db0f2bc26733dcd4502f against baseline 46a37333f4f59b0facf8774ee743ebc4cc074e9b.

  • Regression: pytest tests/test_coff.py::TestCoff::test_undefined_externals_are_imports — fails on the baseline, where imports is empty; passes on head
  • Focused: pytest tests/test_coff.py tests/test_pe_coff_symbols.py — 8 passed
  • Full suite: cle 229 passed, 9 skipped; angr 2479 passed, 46 skipped, 2 xfailed, 260 subtests; angr Rust 35 passed
  • Lint/type: run-ci-diff-checks.py --repository cle — pylint and pyright unchanged or better on every changed file
  • Workspace gate: cle and angr adopted in one feature instance; archinfo, pypcode, pyvex, claripy and angr-management skipped as unadopted and untouched — pass

Reproducer on a public fixture: angr.Project("binaries/tests/x86_64/fauxware.obj", main_opts={"backend": "COFF"}). On the baseline loader.main_object.imports is {}, no extern symbol is hooked, and CFGFast makes a function of the eight zero bytes at 0x500010 (strcmp) and 0x500028 (_RTC_CheckStackVars). On head both are ReturnUnconstrained stubs and neither address is decoded.

Corpus A/B over 402 COFF objects, sampled 25 per (collection, architecture) from a private corpus of 25,051, one object per forked child and one side per child, each side run twice:

baseline head
Objects scored 249 249
CFG blocks 69,939 68,906
Blocks inside a CLE pseudo-object 1,033 0
Functions 15,039 15,082
Extern symbols hooked 644 2,270
Entries in imports 0 1,626
  • Deterministic deltas: every one of the 73 objects whose block count changed lost exactly the blocks it had inside a pseudo-object, and no object gained a block; 172 objects are bit-identical.
  • Nondeterministic deltas: none. Repeating either side gives an identical block list and function count on all 402 objects.
  • Errors: 153 objects fail identically on all four runs — 150 NotImplementedError: Unsupported machine type on ARM and AArch64 COFF, which Load ARM64 and ARMNT COFF objects #724 addresses, and 3 ValueError: Address ... is already backed!.
  • Timing: 127 s to 120 s over the whole sample. Peak RSS 292 MB to 293 MB.

Caveats: corpus objects are referred to by architecture, container and digest because the dataset is not public; the reproducer above uses a fixture already in angr/binaries.

Re-keyed 2026-08-28. The figures above were measured at 461bb7323ac6f1d0914ac61fcee5189d2a53e947 on baseline 45c6509c753d07f740099035cd41f7f473dc6f31, which is the head the opening line named until now; the branch is at 7acefe5f44dacbd6afa8db0f2bc26733dcd4502f on 46a37333f4f59b0facf8774ee743ebc4cc074e9b. git range-diff 45c6509c753d07f740099035cd41f7f473dc6f31..461bb7323ac6f1d0914ac61fcee5189d2a53e947 46a37333f4f59b0facf8774ee743ebc4cc074e9b..7acefe5f44dacbd6afa8db0f2bc26733dcd4502f reports every commit unchanged and git diff 461bb7323ac6f1d0914ac61fcee5189d2a53e947 7acefe5f44dacbd6afa8db0f2bc26733dcd4502f differs only by master's own advance (12 files changed, 353 insertions(+), 44 deletions(-)). Master touched none of the files this change touches between the two baselines, so every figure above still describes this head.

@angr-bot

Copy link
Copy Markdown
Member

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

A COFF object names what it needs from elsewhere by giving the symbol section
number zero. The loader already allocates each of those an extern address and
relocates against it, but the symbols never say they are imports, so
Relocation.__init__ does not record them and self.imports stays empty.

angr's Project._register_object iterates obj.imports and nothing else, so a COFF
object gets nothing hooked: a call to an undefined external lands in the extern
object's zero fill and is decoded as instructions, and under symbolic execution
it executes that zero fill instead of a stub.

Say so on the symbol; the generic relocation machinery does the rest, __imp_*
entries included, which are data and so are listed without being hooked. A COFF
symbol's Type field also says 0x20 for a function and 0 for nothing at all,
which is what every GCC-family toolchain emits, and reading 0 as data meant
those imports were listed and then skipped rather than hooked. TYPE_NONE is how
CLE spells "the file did not say", and it is already what a relocatable ELF's
STT_NOTYPE symbols become.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/fix-cle-coff-imports branch from 22f6feb to 7acefe5 Compare August 26, 2026 22:46
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

The imports cle reports for the x86-64 COFF fixture, with the symbol type and the extern address each resolves to.

the reproducer
import logging, os
logging.getLogger("cle").setLevel(logging.CRITICAL)
import cle

BIN = os.environ["BINARIES"]   # a checkout of angr/binaries
T = lambda *p: os.path.join(BIN, "tests", *p)

path = T("x86_64", "fauxware.obj")
ld = cle.Loader(path, auto_load_libs=False)
obj = ld.main_object
print(f"obj.imports: {len(obj.imports)} entries")
for name in sorted(obj.imports):
    reloc = obj.imports[name]
    sym = reloc.resolvedby
    where = ld.find_object_containing(sym.rebased_addr) if sym is not None else None
    kind = reloc.symbol.type.name if reloc.symbol is not None else "?"
    print(f"    {name:<52} {kind:<14} -> {sym.rebased_addr:#x} in {type(where).__name__}"
          if sym is not None else f"    {name:<52} {kind:<14} unresolved")

Before — the object's undefined externals never reach self.imports, so angr hooks none of them:

cle master at 46a37333f4f59b0facf8774ee743ebc4cc074e9b
obj.imports: 0 entries

After — all ten are imports resolved into the extern object:

with this change, at 7acefe5f44dacbd6afa8db0f2bc26733dcd4502f
obj.imports: 10 entries
    ?_OptionsStorage@?1??__local_stdio_printf_options@@9@9 TYPE_NONE      -> 0x500000 in ExternObject
    _RTC_CheckStackVars                                  TYPE_FUNCTION  -> 0x500028 in ExternObject
    _RTC_InitBase                                        TYPE_FUNCTION  -> 0x500040 in ExternObject
    _RTC_Shutdown                                        TYPE_FUNCTION  -> 0x500048 in ExternObject
    __imp___acrt_iob_func                                TYPE_NONE      -> 0x500030 in ExternObject
    __imp___stdio_common_vfprintf                        TYPE_NONE      -> 0x500008 in ExternObject
    __imp__open                                          TYPE_NONE      -> 0x500018 in ExternObject
    __imp__read                                          TYPE_NONE      -> 0x500020 in ExternObject
    __imp_exit                                           TYPE_NONE      -> 0x500038 in ExternObject
    strcmp                                               TYPE_FUNCTION  -> 0x500010 in ExternObject

@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. It replaces a # FIXME with real
data rather than making anything load, so a load-depth probe reports zero for
it; what it populates, and what that does downstream, are both measurable.

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 (7acefe5f44dacbd6afa8db0f2bc26733dcd4502f) in one environment, and the
loaded object is inspected directly. Separately all 468 run a complete
CFGFast(normalize=True, resolve_indirect_jumps=True) on both sides.

What changes. On master, self.imports is empty for all 432 loaded
COFF objects. On this head, 35 of them expose 566 imports between them — one
object as many as 83 — and they are exactly the objects that reference undefined
externals: 18 x86 and 17 x86-64 relocatables, 33 from a MinGW/MSYS2 collection
and 2 from an MSVC one. No object without undefined externals gains an import.

What that does to analysis. Recovered functions go from 15,457 to 15,479
(+22) and CFG nodes from 39,225 to 39,229 (+4), across 23 objects — all 23 of
them inside the 35 that gained imports
, and no object outside that set changes
at all. Eighteen of the 23 gain exactly one function and one block, which is the
extern stub becoming a function; four lose two to eight blocks, which is an
extern stopping being scanned as if it were local code. The effect is small and
confined to the objects the change is about, which is the shape a reviewer would
want.

Overlap with the other open COFF changes. #724, #764 and #775 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, #764 moves a section with no file
bytes off the image header on 398 of the 432, and #775 extends the mapped span
on all 432.

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

session: sharpen

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.

COFF undefined externals never reach self.imports, so angr never hooks them

2 participants