Skip to content

Keep a rebased object out of the null page - #765

Open
zardus wants to merge 3 commits into
masterfrom
feature/externzero
Open

Keep a rebased object out of the null page#765
zardus wants to merge 3 commits into
masterfrom
feature/externzero

Conversation

@zardus

@zardus zardus commented Aug 18, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

The objects cle invents get placed on the null page. Loading
binaries/tests/armel/i2c_master_read-nucleol152re.bin as an ARMEL blob at
0x90000000:

main object   0x0000000090000000-0x000000009000ff5f
extern object 0x0000000000000000-0x0000000000007fff
find_object_containing(0x0) -> <ExternObject Object cle##externs, maps [0x0:0x7fff]>
find_object_containing(0x4) -> <ExternObject Object cle##externs, maps [0x0:0x7fff]>

Address 0 is now inside a real object, so a null or uninitialized pointer in the
target reads as a live address instead of faulting, and the loader answers
find_object_containing(0) with the extern object rather than with nothing --
which is the answer every consumer uses to decide whether an address is mapped at
all.

Root cause

_find_safe_rebase_addr starts its search at zero for a whole class of images:

        if self.main_object.arch.bits < 32 or self.main_object.max_addr >= 2 ** (self.main_object.arch.bits - 1):
            # HACK: On small arches, we should be more aggressive in packing stuff in. An image
            # reaching into the top half of the address space leaves its free space underneath it.
            start = 0

The reasoning is sound -- an image in the top half of the address space may have
its free space underneath it, and a 16-bit address space cannot afford to waste
any -- but "underneath the image" was implemented as "from zero", and the first
gap found is then the null page itself.

Fix

Try the space above the image first, then the space at or above one page, and take
the null page only when nothing else fits. Only the branch that used to start at 0
changes; the ordinary branch is untouched. Where the loader was told the target's
page size, the guard tracks it rather than a fixed 4 KB.

main object   0x0000000090000000-0x000000009000ff5f
extern object 0x0000000090100000-0x0000000090107fff
find_object_containing(0x0) -> None
find_object_containing(0x4) -> None

Keeping the null page free is a preference, not a constraint. An ELFCore whose
first segment starts at 0 never reaches this function, and a 16-bit image with
everything else taken still gets the null page rather than failing the load -- the
case #745 names as blocking a fix.

Testing

tests/test_rebase.py::test_image_in_the_top_half_leaves_the_null_page_free is
the run above; test_narrow_address_space_leaves_the_null_page_free is the z80
16-bit case; test_null_page_is_used_when_the_address_space_has_nothing_else
asserts the fallback still happens; test_null_guard_tracks_the_loader_page_size
covers a non-default page_size. The first two fail on the merge base, where the
extern object is placed at 0.

Fixes #745. Validation: #765 (comment)

session: sharpen

@angr-bot

Copy link
Copy Markdown
Member

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

@zardus

zardus commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 525717e69882f8c9fa38a0860a935d81a4d6387b against baseline 3812052df2ad284cd16684fb7b7eb66e8d14dc6d. The production change is a single hunk in
cle/loader.py; every measurement below was taken with that same hunk on baseline
45c6509. Two commits have been added to the branch since: one test-only (Skip the p-code rebase tests where pypcode is not installed) and one that widens the guard to
max(self.page_size, NULL_PAGE_SIZE) without changing behaviour on anything measured.
The re-keying note at the end establishes that.

Focused regression, rerun at 99aa9bc. python -m pytest tests/test_rebase.py in a
clean worktree of that commit against an angr/binaries checkout: 6 passed. That is the
focused regression only; the suite, gate and corpus figures below are the earlier run on
45c6509 and were not repeated on this head.

Regression. tests/test_rebase.py fails without the loader change: with an ARMEL blob at base_addr=0x90000000, loader.memory.load(0, 8) returns zeros and find_object_containing(0) answers with the extern object. With the change the extern object is placed above the image and address 0 is unmapped.

Images that still map 0, and why that is correct. On the placement sample, 14 of 198 measured still map address 0: 12 are ELFCore images whose own first segment starts at 0, and the main object's placement never goes through _find_safe_rebase_addr; 2 are 16-bit blobs based at 0x210 and 0x2cd that run to 0xffff, where nothing fits above the image and nothing fits at or above the null page, so the ladder's last step behaves exactly as before. That second case is the one #745 names as blocking a fix.

Headline object. c299158142da0d55, an ARM blob at base_addr=0x90000000, use_sim_procedures=False, CFGFast(normalize=True, data_references=False, resolve_indirect_jumps=True). Each arm ran in its own forked child, because angr keeps process-global state between projects and scoring several objects in one interpreter does not give the same answer as scoring each alone.

arm blocks functions in invented memory fabricated invented functions bytes of the object covered maps 0 peak RSS
stock 76,856 14,821 8,192 8,187 4,096 32,762 of 32,768 yes 657 MB
loader change 68,666 10,727 2 0 2 2 no 579 MB
CFGFast change 68,669 10,730 5 0 5 5 yes 578 MB
both 68,666 10,727 2 0 2 2 no 579 MB

Every block remaining in an invented object is a hook node. Peak RSS falls 12%. The two changes are independently sufficient here and are not additive.

A 96-byte MIPS:BE:64:R6 blob at 0x1000, digest 315a2e40e3ba032b, is the same pathology in miniature: 1,523 blocks, 1,522 of them inside the extern object, all reached from a single Ijk_Boring edge out of the image.

Scale. Over the scored sample, 1,966 objects contribute 144,547 blocks decoded inside invented memory, split 922 objects / 53,507 blocks where the invented object sits below the image and 1,044 / 91,040 where it sits above:

backend placement objects blocks
blob below 916 52,003
blob above 114 45,294
ihex above 461 21,606
elf above 211 15,859
coff above 217 3,342
mach-o above 5 3,051
elf below 6 1,504
hex above 27 1,176

Noise floor. CFGFast is not reproducible on every input (angr/angr#6840), so the comparison is over sorted block sets rather than counts, and the stock arm was repeated in a second fresh child wherever it was cheap. Across 508 placement, 260 control and 33 synthetic objects the two stock runs disagreed 0 times. The one control-group difference observed is shown directly to be that nondeterminism rather than an effect of either change.

Full local gate. cle: 228 passed, 9 skipped. angr: 2,480 passed, 46 skipped, 2 xfailed, 260 subtests passed. angr Rust: 35 passed. Workspace checks, test-inputs and all pre-commit hooks passed with no rewrite.

Lint and type checks, merge-base relative, over the changed files:

ok   cle/loader.py: 9.93 -> 9.93                            badness 0.029049 -> 0.028902
ok   cle/tests/test_rebase.py: 10.00 -> 10.00               badness 0.0 -> 0.0
ok   angr/analyses/cfg/cfg_fast.py: 9.90 -> 9.90            badness 0.157150 -> 0.157076
ok   angr/tests/analyses/cfg/test_cfgfast.py: 9.91 -> 9.91  badness 0.086873 -> 0.084906

COFF is excluded from the credit. #761 makes a COFF object's undefined externals into imports, so angr hooks them and stops decoding them; a hooked address is exactly what the CFGFast guard lets through. On COFF the two reach the same end by different routes and are not additive, so the 41 COFF objects among the 4,327 measured are broken out and measured again with cle#761 applied to both sides rather than counted here.

Rebase. Rebased onto 5125f1ba0 with no conflict; git range-diff reports the commit identical. The previous head sat five commits back, before #762 added the Mach-O section-attribute predicates, so angr's tests/analyses/cfg/test_cfg_macho_sections.py — which master's angr now carries and which asserts __cstring and the ObjC string sections are not scanned — failed against it. Those two failures were the stale base, not this change: the diff touches only _find_safe_rebase_addr. On the rebased head pytest tests/test_rebase.py gives 6 passed. The population figures above were taken on 45c6509 and are carried forward; none of the five intervening commits touches cle/loader.py.


Cross-PR conflict, noted 2026-08-26.

This came out of assembling the angr/vibr preview snapshot (every green open PR merged together, then each component's full test suite run against the result with an angr/binaries checkout). Both PRs are green on their own.

Combined with #730 and #721, this PR fails two of their tests:

Cause: _find_safe_rebase_addr (cle/loader.py, around lines 1084-1091) now tries (above_image, NULL_PAGE_SIZE, 0) where the base tried 0. #730's object lands at 0xFFFFFFFF80100000 while the test asserts 0; on AVR (24-bit, same branch) the TLS object maps at 0x500000, so InternalTLSRelocation.value (cle/backends/tls/tls_object.py:69) adds mapped_base and the test sees 0x504006 instead of dtv_offset. Reverting only that hunk makes both files pass (7 passed each); this PR's own test_rebase.py tests pass either way.

This looks like the two detectors asserting a literal placement this PR deliberately changes rather than a defect here: #730's real invariant (rebase(wrapped) == rebase(plain)) still holds, and #721's expectation could be dtv_offset + mapped_base. Either side can resolve it. Following the preview's rule of excluding the PR that changes behaviour, this one is excluded from angr/vibr until one side changes (exclusions.txt in zardus/angr-agentic, skill angr-maintain-vibr); angr#6889 drops with it through its declared dependency.


Re-keyed 2026-08-28, and what moved. The opening line above named 99aa9bc8af7eb4676bd0f74f369c8e7a80291fe2; the head is now d160d9752c15b143f2f9a7a505f3abaa21c955fa on the same baseline 46a37333f4f59b0facf8774ee743ebc4cc074e9b — the base did not move, the branch grew. git range-diff 46a37333f..99aa9bc 46a37333f..d160d97 reports commits 1 and 2 unchanged and prints a third as -: ------- > 3: d160d97, which is a new commit rather than a rebase, so this is not a case of restamping a head. git diff 99aa9bc d160d97 is 2 files, 30 insertions and 4 deletions: tests/test_rebase.py +24, and cle/loader.py +6/-4, of which the only production line is

-            starts = (above_image, NULL_PAGE_SIZE, 0)
+            starts = (above_image, max(self.page_size, NULL_PAGE_SIZE), 0)

Loader.page_size defaults to 0x1, so on any load that does not state a target page size the new expression is max(1, 0x1000) — the old constant. No load recipe in the corpus below states one, checked over all 156. The figures in this record therefore still describe this head; the new commit answers the review question about hardcoding without changing behaviour on anything measured.

Corpus attribution, 2026-08-28. Objects come from material that is not public and are named by architecture, container and sha256 only.

A category sweep, deduplicated by object digest, scores 460,618 distinct objects and finds a recovered block starting inside memory CLE invented on 8,663 of them, 635,997 blocks in total. Intersecting each object's own loaded-object table with its flagged addresses, 3,905 of those objects — 220,690 blocks, 34.7% of the category — have a CLE pseudo-object mapped below 0x10000 and their flagged addresses inside it: 3,878 loaded with the blob backend and 27 ELF. That is the placement this change moves, at cle/loader.py:1080 on this baseline, where _find_safe_rebase_addr starts its search at address 0 whenever the architecture is narrower than 32 bits or the image reaches the top half of the address space.

The shape is consistent across the population: on an 8-bit blob the extern object lands at 0x0-0x1ff with the image at 0x8000, and the flagged addresses are 2, 5, 41, 44, 47 — the low addresses a garbage decode branches to when a null or uninitialised pointer resolves to a real object instead of to nothing.

The other 4,758 objects of the category do not show that shape: every one has a sampled flagged address, and none of them has it inside a pseudo-object mapped below 0x10000, so this change does not reach them and angr/angr#6889 is what covers them. On the objects where both apply the two are not additive, as the headline table above shows.

Decompilation snapshots, keyed to head d160d9752c15b143f2f9a7a505f3abaa21c955fa. ci / Decompiler Snapshot Testing (0) on this head completed successfully at 2026-08-28T09:25:05Z, and the angr/cle_765 branch of angr/dec-snapshots rests on 0373819651d8f091d543e753c5d814bbfc98ba45 — the dec-snapshots master commit current when the run finished, with no commit on top. The deputy adds a commit only when the corpus output differs, so the corpus decompiled byte-identically with this head: there are no differing functions to classify. That is corroboration rather than coverage: the snapshot corpus is x86-64 CGC binaries, which are neither narrower than 32 bits nor mapped into the top half of their address space, so they do not take the branch this change alters; the evidence for the affected population is the placement sample above.


Re-keyed 2026-09-04, after a rebase onto 3812052df2ad284cd16684fb7b7eb66e8d14dc6d. Previous head d160d9752c15b143f2f9a7a505f3abaa21c955fa on baseline 46a37333f4f59b0facf8774ee743ebc4cc074e9b; the head above is that same patch replayed onto the current base, with no conflict resolved and no commit amended.

The rebase was forced by the sibling version pins, not by anything in this change. angr master bumped archinfo and pyvex from ==9.3.4.dev0 to ==9.3.5.dev0 on 2026-09-02, and CI builds those siblings from their own master rather than downloading them, so 9.3.4.dev0 is a version no index publishes. ci / Build resolves refs/pull/765/head -- the branch tip, not the merge ref -- with uv pip install --no-sources, so it reads the branch's own pyproject.toml and the merge commit's clean pins never come into it. check-stale-pins.py refuses the old head, naming archinfo and pyvex, and exits 0 on the new one.

The green ci / Build recorded on the old head is not evidence against this: it completed at 2026-08-28T09:23Z, days before the bump, and no run has been scheduled on that head since. A fresh run there would fail to resolve, which is what happened to the twenty branches pushed on 2026-09-03 with a follow-master commit instead of a rebase.

git range-diff 46a37333f..d160d9752 3812052df..525717e69 reports every commit unchanged, and git diff against each head's own merge base is byte-identical, context included -- the two outputs do not differ in a single line. The figures above therefore describe the same patch on a new base.

Hosted CI at the new head, read 2026-09-04T14:56Z: 18 of 18 check runs concluded success, and both legacy commit statuses (pre-commit.ci - pr, docs/readthedocs.org:cle) are success too.

Comment thread cle/loader.py

# The lowest page of the address space. The loader hands it out only when there is nowhere else,
# because a null or uninitialized pointer in the target reads as an address inside it.
NULL_PAGE_SIZE = 0x1000

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be based on the actual page size instead of hardcoded?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

There is no actual page size to read here, so the constant is now a floor rather than the answer. What I checked:

  • No archinfo Arch defines a page-size attribute, on any architecture — surveyed AMD64, X86, ARM, AArch64, MIPS32, PPC64, S390X, RISCV64 and ArchPcode (including the 24-bit avr8:LE:24:xmega); none carries any page-related attribute.
  • The one page-size datum cle holds is Loader.page_size, which defaults to 0x1 — "the granularity with which data is mapped into memory", i.e. target page size unknown. It carries a real page size when a core dump's mappings note supplies one (elfcore.py sets self.loader.page_size from the parsed note) or when the caller states one. It is assigned in Loader.__init__ before any object is mapped, so it is available where _find_safe_rebase_addr runs.

So as of d160d97 the guard is max(self.page_size, NULL_PAGE_SIZE): one target page where the loader knows the page size, and the conventional 4 KB floor where it does not — max(1, 0x1000) leaves every existing configuration unchanged. A load stating page_size=0x2000 now keeps both pages a null pointer could land in out of the placement; test_rebase.py::test_null_guard_tracks_the_loader_page_size pins that (it places the object at 0x1000 without the change and at 0x2000 with it). Full cle suite: 243 passed, 9 skipped.

@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Where the loader places the objects it invents, for a blob image based at 0x90000000.

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("armel", "i2c_master_read-nucleol152re.bin")
ld = cle.Loader(path, auto_load_libs=False,
                main_opts={"backend": "blob", "arch": "ARMEL", "base_addr": 0x90000000})
main = ld.main_object
print(f"main object   {main.min_addr:#018x}-{main.max_addr:#018x}")
extern = ld.extern_object
print(f"extern object {extern.min_addr:#018x}-{extern.max_addr:#018x}")
print(f"find_object_containing(0x0) -> {ld.find_object_containing(0)}")
print(f"find_object_containing(0x4) -> {ld.find_object_containing(4)}")

Before — the extern object is placed on the null page, so address 0 is inside a real object:

cle master at 46a37333f4f59b0facf8774ee743ebc4cc074e9b
main object   0x0000000090000000-0x000000009000ff5f
extern object 0x0000000000000000-0x0000000000007fff
find_object_containing(0x0) -> <ExternObject Object cle##externs, maps [0x0:0x7fff]>
find_object_containing(0x4) -> <ExternObject Object cle##externs, maps [0x0:0x7fff]>

After — it goes above the image and the null page is left free:

with this change, at d160d9752c15b143f2f9a7a505f3abaa21c955fa
main object   0x0000000090000000-0x000000009000ff5f
extern object 0x0000000090100000-0x0000000090107fff
find_object_containing(0x0) -> None
find_object_containing(0x4) -> None

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Resolved: the two pull requests this could not be rolled up with

This pull request has been excluded from every mono rollup because it broke two
other green pull requests' tests. Both were assertions pinning the placement this
change moves, not disagreements about behaviour, and both have now been fixed on
the side that owns them.

Each fix still fails on its own branch's merge base with the error it was
written for, so neither regression lost its teeth against the defect it covers.

One consequence of this change is worth recording here rather than only on #730.
With the rebase search starting above the image, an outer object mapped at 0 can
only matter in the null-page fallback, which a 64-bit address space never
reaches -- so #730's _free_gaps outer-object skip becomes unobservable through
that path. Measured by removing the skip on the merged tree:

with the outer-object skip (as merged):  plain=0xffffffff80100000 wrapped=0xffffffff80100000 equal=True
without the outer-object skip:           plain=0xffffffff80100000 wrapped=0xffffffff80100000 equal=True

#730's _describe_range_conflict half is unaffected and stays covered by
test_outer_object_does_not_occupy_address_space.

Verification

master + #765 + #730 + #721 at heads d160d975, 6c31c31 and d124129, cle's
whole suite:

257 passed, 9 skipped

session: sharpen

zardus and others added 3 commits September 4, 2026 13:46
_find_safe_rebase_addr starts its search at address 0 when the architecture is
narrower than 32 bits or the main image reaches into the top half of the address
space, so the first object the loader places itself gets page zero. That object
is normally cle##externs. Page zero is then mapped, readable and executable: a
null or uninitialized pointer resolves inside a real object instead of faulting,
and on 8- and 16-bit targets it answers the RST, BIOS and direct-page addresses
those architectures transfer control to, which are outside the image on purpose.

Search the space above the image first, then the space at or above the null page,
then the null page itself, so a load with nowhere else to go still works. Only
the branch that used to start at 0 changes; on every other target the first
candidate is the address the function already returned.

check_sparse_elf asserted that the extern and TLS objects land below 0xf800. What
it guards is that they stay outside the main object's span, whose memory is one
backer from 0xf800 to 0xfff00fff, so anything inside it is unreachable through
Loader.memory. They now land above that span rather than below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two tests that pin the null page free on a narrow address space build their
architecture with archinfo.ArchPcode("z80:LE:16:default"), which raises
ArchError("pypcode not installed") when the p-code bindings are absent. pypcode
is an optional dependency of cle -- it is declared under the "pcode" extra and
in the testing group, and it has no wasm build -- so the Pyodide job, which
installs neither, failed both tests on a dependency rather than on the placement
they are about.

Guard them the way tests/test_arch_detect.py already guards its p-code
detection tests, so a runner without pypcode skips them and one with it runs
them unchanged. The other four tests in the module use no p-code architecture
and are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review asked whether the 4 KB null guard should be based on the actual page
size instead of hardcoded. The target's page size is not something the stack
models on the architecture: no archinfo Arch defines a page attribute, on any
architecture including ArchPcode. The one page-size datum cle holds is
Loader.page_size, which defaults to 1 -- data mapped byte-granularly, target
page size unknown -- and carries a real value when a core dump's mappings note
supplies one or the caller states one.

Size the guard as max(self.page_size, 0x1000): one target page where the
loader knows the page size, and the conventional 4 KB floor where it does not.
Every existing configuration is unchanged, since max(1, 0x1000) is the old
constant; a load stating page_size=0x2000 now keeps both pages a null pointer
could land in out of the placement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/externzero branch from d160d97 to 525717e Compare September 4, 2026 13:51
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.

The extern object is mapped over address zero when the image sits in the top half of the address space

3 participants