Skip to content

Place the main binary at 0x400000 only when that space is free - #776

Open
zardus wants to merge 1 commit into
masterfrom
feature/main-binary-base
Open

zardus wants to merge 1 commit into
masterfrom
feature/main-binary-base

Conversation

@zardus

@zardus zardus commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

The main binary's conventional base is handed out without being asked for. Two
objects marked as the main binary, dynamically loaded into a loader for
binaries/tests/i386/manysum:

    first  0x400000-0x400fff
    second 0x400000-0x400fff  ON TOP OF THE FIRST

That is not a contrived pair. A container backend loads its children through the
loader, and a Mach-O executable refuses to load as anything but the main binary,
so Universal2 marks every slice it loads. On
binaries/tests/multi_arch/fauxware_macho_multiarch the i386 slice lands on the
container object itself:

Universal2   0x400000-0x400000 is_main_bin=True
MachO        0x400000-0x40ffff is_main_bin=True
ExternObject 0x500000-0x500030 is_main_bin=False
MachO        0x100000000-0x100003fff is_main_bin=True

Root cause

_map_object asks whether a range is free before it uses a custom base or a
linked base, and does not ask before it uses 0x400000:

            elif not obj.is_main_bin:
                base_addr = self._find_safe_rebase_addr(obj_size)
            else:
                base_addr = 0x400000

The else is unconditional, so the main-binary case has no fallback at all: the
second object to ask gets the same address as the first.

Fix

Ask before taking it, and fall back to the ordinary rebase search when it is
taken. The main binary keeps its conventional base whenever that base is free,
which is every single-main-object load.

    first  0x400000-0x400fff
    second 0x8200000-0x8200fff
Universal2   0x400000-0x400000 is_main_bin=True
MachO        0x500000-0x50ffff is_main_bin=True
ExternObject 0x600000-0x600030 is_main_bin=False
MachO        0x100000000-0x100003fff is_main_bin=True

Testing

tests/test_rebase.py::test_the_main_binary_base_is_not_handed_out_twice places
two objects marked as the main binary and asserts the first takes 0x400000 and
the second starts past the first's end; on the merge base both start at
0x400000.

Five unavailable binaries recorded by the campaign reproduce the same failure.
On the exact patch parent 46a37333f4f59b0facf8774ee743ebc4cc074e9b,
each raises ValueError because address 0x400000 is already backed. On head
3443309fefc6d821bcac352beaf23002d6026a50, all five load successfully. They
are identified only by SHA-256:

  • 19be29f38a574b5b1448a6256f2cec0f2ed850c750792dd46a57fa4a37b8e4ea
  • 1a74eab65c67676a4584c7316929054cc861bc6dbad65ebb9d8f530e9da9e3a8
  • 24e042930c58dbc0c832f896b2569fa6e2160bd86cb877d2d476621fa9159577
  • 7389f21dcf8406ea78817e5ed47985b2eeff59beb92e617879cbd90febb2f13e
  • 751e8656af24f4a5af5614167071c326df333c4046466573e9b34d61eb715908

The matched regression set was the other 482 campaign-recorded Mach-O objects
in the retained 487-object selection. It was unchanged: 345 loaded on both
sides, 137 were refused identically on both sides, all 482 kept the same load
verdict and metadata, and zero got worse. Each side used one rooted environment;
the commands were:

./nix/run.sh --env-link scratch/campaign/jobs/j-523/evidence/env-base --tools-link scratch/campaign/jobs/j-523/evidence/tools --repos-root /home/yans/angr/nonagentic --no-build -- python3 -P scratch/campaign/jobs/j-461/work/scripts/measure_loads.py --jobs scratch/campaign/jobs/j-461/work/out/regression_jobs.jsonl --out scratch/campaign/jobs/j-523/evidence/base.jsonl --workers 4 --timeout 300
./nix/run.sh --env-link scratch/campaign/jobs/j-523/evidence/env-head --tools-link scratch/campaign/jobs/j-523/evidence/tools --repos-root /home/yans/angr/nonagentic --no-build -- python3 -P scratch/campaign/jobs/j-461/work/scripts/measure_loads.py --jobs scratch/campaign/jobs/j-461/work/out/regression_jobs.jsonl --out scratch/campaign/jobs/j-523/evidence/head.jsonl --workers 4 --timeout 300
python3 -P scratch/campaign/jobs/j-461/work/scripts/compare.py scratch/campaign/jobs/j-461/work/out/regression_selection.jsonl scratch/campaign/jobs/j-523/evidence/base.jsonl scratch/campaign/jobs/j-523/evidence/head.jsonl scratch/campaign/jobs/j-523/evidence/compare.json

#674 would hold the default universal load to a single slice, which hides this for
fat binaries without fixing the unchecked placement.

Validation: #776 (comment)

session: sharpen

@zardus

zardus commented Aug 23, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 3443309fefc6d821bcac352beaf23002d6026a50 against baseline 46a37333f4f59b0facf8774ee743ebc4cc074e9b.

  • Regression: python -m pytest tests/test_rebase.py::test_the_main_binary_base_is_not_handed_out_twice — fails on baseline (both objects are placed at 0x400000), passes on head
  • Focused: python -m pytest tests/test_rebase.py — 4 passed
  • Full suite: python -m pytest tests/ — 240 passed, 9 skipped
  • Lint/type: pylint and pyright, merge-base relative, per changed file — 9.93 -> 9.93 on cle/loader.py, 10.00 -> 10.00 on the test, badness unchanged on both
  • Hooks: pre-commit run --all-files — passed, tree unchanged
  • Workspace gate: cle only, in a detached worktree with its own virtualenv

Corpus reproducers, six universal Mach-O libraries that fail on the baseline and load on the head (private dataset, cited by digest). Each is a two-slice fat file whose x86-64 and arm64 slices are both MH_DYLIB with __TEXT at vmaddr 0, and each fails on the baseline with Address 0x400000 is already backed!:

sha256
1a74eab65c67676a4584c7316929054cc861bc6dbad65ebb9d8f530e9da9e3a8
28b95a44763254ec212839e8555abb609a25ef29b2409876c5aa443aa07dbce1
40ef7b22b7c6b30a61de0787bc4823caf1a890c50bca0470b0fd2ea14cd3fc77
62f48bc807392ffc0b5bc751cd4e3ea6d5c647bf613b3eb8f73997dd46b359cd
7389f21dcf8406ea78817e5ed47985b2eeff59beb92e617879cbd90febb2f13e
c0977b28a37f7e26faf396ba71a71451dfcf5069c4934f65696f3e8091e7d531

Each was loaded with cle.Loader(path, auto_load_libs=False, main_opts={"backend": "Universal2"}); on the head the two slices are laid out side by side. Loading every file under angr/binaries/tests (1039 objects, binaries at 12d67ecc2aabebc54b7aa245485e69edeb29af4c) with cle.Loader(path, auto_load_libs=False) changes two loads and produces no new error:

  • tests/multi_arch/fauxware_macho_multiarch: the second slice moves from 0x400000, where it sat on top of the outer object, to 0x500000.
  • tests/avr/isqrt_atmega128.o: a relocatable object on a 16-bit architecture moves from 0x400000, which its address space cannot represent, to 0. A CFGFast over it fails identically on both revisions, for an unrelated reason (SimSegfaultException: 0x8000 (stack collided with heap)).

Composition with the open loader changes was checked on the same objects: with #730 applied, the baseline still fails all six and the head still loads all six; with #765 applied, the head loads all six and the AVR object lands at 0x1000 instead of 0. Neither conflicts with this diff in cle/loader.py. #717 and #765 both append to tests/test_rebase.py at the same place this does, so whichever lands first the others will need a trivial rebase.

Caveats: the suites that did not run are angr, angr-management, archinfo, claripy, pypcode, pyvex, the Rust and GUI suites, and the angr-agentic workspace checks — a live corpus sweep pins the shared virtualenv, so this is the scoped cle-only equivalent rather than the complete workspace gate. pip check in that isolated environment reports the expected 9.3.3.dev0/9.3.4.dev0 skew between the branch and the pinned sibling wheels.

Re-keyed 2026-08-28. The figures above were measured at 6f08fcbbc93feb4d0e653d8998e8c57cca538608 on baseline 6951e9221554f987e3971aa51f9c280a67d60324, which is the head the opening line named until now; the branch is at 3443309fefc6d821bcac352beaf23002d6026a50 on 46a37333f4f59b0facf8774ee743ebc4cc074e9b. git range-diff 6951e9221554f987e3971aa51f9c280a67d60324..6f08fcbbc93feb4d0e653d8998e8c57cca538608 46a37333f4f59b0facf8774ee743ebc4cc074e9b..3443309fefc6d821bcac352beaf23002d6026a50 reports every commit unchanged and git diff 6f08fcbbc93feb4d0e653d8998e8c57cca538608 3443309fefc6d821bcac352beaf23002d6026a50 differs only by master's own advance (2 files changed, 4 insertions(+), 24 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_776

Every other placement in _map_object asks the address space first. The
position-independent main binary does not: it takes 0x400000 unconditionally,
which is right while it is the only object being placed and wrong as soon as it
is not.

A container backend loads its children through the loader, and a child can come
out marked as the main binary. Universal2 marks every slice it loads, because a
Mach-O executable refuses to load as anything else. The slices of a universal
Mach-O therefore all asked for 0x400000, the first one got the backer, and the
rest failed with "Address 0x400000 is already backed!". Six universal Mach-O
libraries in a corpus sweep failed to load this way.

Ask _is_range_free first and fall back to the ordinary rebase search, so the
main binary keeps its conventional base whenever that base is available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/main-binary-base branch from 6f08fcb to 3443309 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

Two objects marked as the main binary, and the slices of a universal Mach-O, which marks every slice it loads. Both fixtures are already on binaries master.

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("multi_arch", "fauxware_macho_multiarch")
ld = cle.Loader(path, auto_load_libs=False)
for o in ld.all_objects:
    print(f"{type(o).__name__:<12} {o.min_addr:#x}-{o.max_addr:#x} is_main_bin={o.is_main_bin}")
print("--- two container children both marked as the main binary")
class Mock(cle.backends.Backend):
    def __init__(self, size, **kw):
        super().__init__("/dev/zero", None, **kw)
        self.size = size
        self.pic = True
        self.has_memory = False
    @property
    def max_addr(self):
        return self.mapped_base + self.size - 1
ld2 = cle.Loader(T("i386", "manysum"), auto_load_libs=False)
first = Mock(0x1000, arch=ld2.main_object.arch, is_main_bin=True)
second = Mock(0x1000, arch=ld2.main_object.arch, is_main_bin=True)
ld2.dynamic_load(first)
print(f"    first  {first.min_addr:#x}-{first.max_addr:#x}")
try:
    ld2.dynamic_load(second)
    print(f"    second {second.min_addr:#x}-{second.max_addr:#x}"
          f"{'  ON TOP OF THE FIRST' if second.min_addr <= first.max_addr else ''}")
except Exception:  # noqa: BLE001
    print("    second: EXCEPTION: " + traceback.format_exc().strip().splitlines()[-1])

Before — 0x400000 is taken without asking, so the second main-binary object lands on the first:

cle master at 46a37333f4f59b0facf8774ee743ebc4cc074e9b
Universal2   0x400000-0x400000 is_main_bin=True
MachO        0x400000-0x40ffff is_main_bin=True
ExternObject 0x500000-0x500030 is_main_bin=False
MachO        0x100000000-0x100003fff is_main_bin=True
--- two container children both marked as the main binary
    first  0x400000-0x400fff
    second 0x400000-0x400fff  ON TOP OF THE FIRST

After — the conventional base is used when it is free and the rebase search is used when it is not:

with this change, at 3443309fefc6d821bcac352beaf23002d6026a50
Universal2   0x400000-0x400000 is_main_bin=True
MachO        0x500000-0x50ffff is_main_bin=True
ExternObject 0x600000-0x600030 is_main_bin=False
MachO        0x100000000-0x100003fff is_main_bin=True
--- two container children both marked as the main binary
    first  0x400000-0x400fff
    second 0x8200000-0x8200fff

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