Skip to content

Relocate R_*_RELATIVE against the load bias, not the mapped base - #773

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

Relocate R_*_RELATIVE against the load bias, not the mapped base#773
zardus wants to merge 1 commit into
masterfrom
feature/relreloc-base

Conversation

@zardus

@zardus zardus commented Aug 22, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

Every R_*_RELATIVE relocation in an object the linker gave a non-zero text
segment address is written too high, by the linked base. On
binaries/tests/x86_64/relative_reloc_nonzero_base.so, linked with
-Wl,-Ttext-segment=0x400000, the three-entry pointer table in .data.rel.ro
points outside the image:

linked_base=0x400000 mapped_base=0x400000 image 0x400000-0x403fff
    table[0] = 0x801000   alpha = 0x401000   WRONG, OUTSIDE the image
    table[1] = 0x801010   beta = 0x401010   WRONG, OUTSIDE the image
    table[2] = 0x801020   delta = 0x401020   WRONG, OUTSIDE the image

That is the shape Go's linker emits. A pointer table that lands outside the image
is not a silent inaccuracy: CFGFast follows it, and the addresses resolve into
cle's extern object, so blocks get decoded out of zero fill while the real switch
targets lose their only in-edge.

Root cause

    def value(self):
        if self.resolvedby is not None:
            return self.resolvedby.rebased_addr
        return self.owner.mapped_base + self.addend

The ELF ABI defines this relocation as B + A, where B is the load bias --
mapped_base - linked_base -- and A is a link-time virtual address.
mapped_base + addend is the same number only when linked_base is zero, which
is the case for every ordinary shared library and almost every PIE. That is why
the confusion survives: it is correct on nearly everything.

Fix

Use the load-bias translation that GenericIRelativeReloc in the same file
already uses, AT.from_lva(self.addend, self.owner).to_mva(). The branch for a
relocation that resolved to a symbol is untouched.

linked_base=0x400000 mapped_base=0x400000 image 0x400000-0x403fff
    table[0] = 0x401000   alpha = 0x401000   ok, inside the image
    table[1] = 0x401010   beta = 0x401010   ok, inside the image
    table[2] = 0x401020   delta = 0x401020   ok, inside the image
linked_base=0x400000 mapped_base=0x1000000 image 0x1000000-0x1003fff
    table[0] = 0x1001000   alpha = 0x1001000   ok, inside the image
    table[1] = 0x1001010   beta = 0x1001010   ok, inside the image
    table[2] = 0x1001020   delta = 0x1001020   ok, inside the image

The angr-side issue angr/angr#6889 addresses only the
first half of the damage, the blocks decoded out of the extern object.

Testing

tests/test_relative_relocations.py loads the fixture at its preferred base and
rebased -- so that the two terms the old expression conflated are both non-zero
and different -- and checks each table entry against the symbol it should name.
test_relative_relocation_at_a_zero_linked_base is the negative case on
binaries/tests/x86_64/libc.so.6, which must not move. The first two fail on the
merge base. Reported as angr/angr#6834.

Validation: #773 (comment)

sync: angr/binaries#188

session: sharpen

@zardus

zardus commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 4c29d960adf7796ca181ae4dd10cdce366e69acb against baseline 0e77ade3c39a3cee05f65051e57955675e1ac21b. Also under test: angr/binaries a93c271 on origin/master 4bf754e4 (angr/binaries#188), with angr 5d32d30d6, archinfo e60d664, claripy 0d38bcee, pyvex dbc5b17, pypcode 559aacd, angr-management 24ba7df7, all at their origin/master.

The diff

-        return self.owner.mapped_base + self.addend
+        # B + A, where B is the load bias (mapped_base - linked_base) and A is a
+        # link-time virtual address.
+        return AT.from_lva(self.addend, self.owner).to_mva()

Regression

tests/test_relative_relocations.py. With the production change reverted and
the test kept, both positive cases fail:

test_relative_relocation_at_the_linked_base
E   assert 8392704 == 4198400    # 0x801000 != 0x401000 (alpha)
test_relative_relocation_rebased
E   assert 20975616 == 16781312  # 0x1401000 != 0x1001000 (alpha)

test_relative_relocation_at_a_zero_linked_base is the negative control: it
walks every R_X86_64_RELATIVE in an ordinary zero-linked libc.so.6 mapped at
a non-default base and asserts each one still resolves to mapped_base + addend
and lands inside the image. It passes on both revisions, which is the claim it
exists to hold. Focused suite on head: 242 passed, 9 skipped.

The CFGFast and census measurements below were computed on cle
b971a919b8ecfc37fea546bd36e883ec8b3ec7b9, which differs from the head under
test only in comment text in generic.py and in that added control test; no
executable production statement differs.

The object that surfaced it

Not redistributable, so it is named by its header rather than attached. Read
from the file, not from any catalog:

ELFCLASS64, little-endian, EM_X86_64, ET_DYN, e_flags=0, 5,892,184 bytes
sha256 5775bbd98cd3a09bfc3c2aaf0ec9e11d76c7cb9d7802665bbcc0d5c8ee789d10
lowest PT_LOAD p_vaddr = 0x400000
21,474 relocations, all R_X86_64_RELATIVE
sections include .go.buildinfo, .data.rel.ro.gopclntab, .typelink, .itablink

CLE maps it at its preferred base, so linked_base == mapped_base == 0x400000
and the bias is 0 — the addend is the answer. CLE adds 0x400000 to each:

reloc@0x7b46c0 addend=0x60a659 -> written 0xa0a659, should be 0x60a659
reloc@0x7b46c8 addend=0x60b4d7 -> written 0xa0b4d7, should be 0x60b4d7

19,394 of the 21,474 relocated pointers land outside the image
(0x400000-0x9c259f); 2,071 land inside the extern object CLE placed above
it. With the fix, 21,470 of 21,474 land inside the image.

The nine jump tables, all in .data.rel.ro, with every entry of every one
resolving into the extern object:

jump at 0x60a64a  table 0x7b46c0  368 bytes  46 targets, 46 in extern
jump at 0x61061c  table 0x7b2f20  176 bytes  22 targets, 22 in extern
jump at 0x619170  table 0x7b3ea0  256 bytes  32 targets, 32 in extern
jump at 0x6187dd  table 0x7b25c0  128 bytes  16 targets, 16 in extern
jump at 0x61891d  table 0x7b2640  128 bytes  16 targets, 16 in extern
jump at 0x62b65a  table 0x7b26c0  128 bytes  16 targets, 16 in extern
jump at 0x61b053  table 0x7b0300   64 bytes   8 targets,  8 in extern
jump at 0x6355a9  table 0x7b0340   64 bytes   8 targets,  8 in extern
jump at 0x637255  table 0x7b0380   64 bytes   8 targets,  8 in extern

Public reproducer

__attribute__((noinline)) static int alpha(void) { return 1; }
__attribute__((noinline)) static int beta(void)  { return 2; }
__attribute__((noinline)) static int delta(void) { return 3; }
void *const table[] = {(void *)alpha, (void *)beta, (void *)delta};
gcc -shared -fPIC -nostdlib -Wl,-Ttext-segment=0x400000 -Wl,--build-id=none \
    -o relative_reloc_nonzero_base.so relative_reloc_nonzero_base.c

Three R_X86_64_RELATIVE with addends 0x401000/0x401010/0x401020. Before
this change table[0] reads 0x801000 rather than 0x401000 and
loader.find_symbol() on that word returns None instead of alpha. This is
the fixture the regression uses.

CFGFast A/B on that object

CFGFast(normalize=True, data_references=True), use_sim_procedures=False, two
runs per arm, each run in its own process, compared by sha256 of the sorted
(addr, size) list. Each arm's two runs are bit-identical, so none of this is
CFGFast nondeterminism (angr/angr#6840, angr/angr#6844).

blocks digest in CLE-invented memory functions bytes covered
before 146,636 213006d0f93b6068 8,267 9,386 2,946,649
after 138,351 30e9349871397191 0 8,428 2,318,411

Every difference accounted for:

  • -8,267 blocks, all of them the extern object's zero fill: synth_blocks_all_zero = 8267, synth_blocks_nonzero = 0, 241,081 instruction addresses of
    add byte ptr [rax], al.
  • -75 functions in the extern object and -883 function heads in .text. The
    .text ones are not lost: all 883 are still decoded after the change, 863 of
    them as ordinary blocks reached by an edge from their own switch and the other
    20 absorbed mid-block. 0 of the 883 remain function heads, against 883 of
    883 before, and 754 are once again jump-table targets of the switch that names
    them. No function is gained.
  • Instruction addresses outside CLE-invented memory: 535,137 before and
    535,137 after, zero only-before and zero only-after.
    Not one byte of real
    code is lost.
  • 20 blocks exist only before and 2 only after; each is the same instruction
    stream split at a different offset because a function head moved.
  • Jump-table targets resolved 1,366 -> 1,371; memory_data items 15,773 ->
    19,729, of which pointer-array 359 -> 1,758 and code reference 428 -> 659.

Blast radius over 234,056 ELF objects

The change is provably a no-op when linked_base == 0. Of 234,056 real-world
ELF objects surveyed by header (the whole population, not a sample), 43,364
carry at least one relative relocation and 180 carry one and have
linked_base != 0. That is the complete affected set; the two candidate
definitions coincide exactly, with nothing in the gap. ET_REL can never be in
it, because cle derives linked_base from PT_LOAD only, and all 5,068 objects
using DT_RELR/SHT_RELR — which routes through this same class — link at 0.

All 180 were loaded under both arms and their relocated-value sets compared. 174
load under both; 6 fail to load identically on both arms (4 ppc64
AssertionError, 1 CLEOperationError: Ran out of room in address space, 1
ELFError: SHT_SYMTAB section points at section 0), so none is caused by this.
164 of the 174 produce a different set of values.

Over 8,586,108 relative relocations:

before after
values pointing outside their own image 817,209 991
values inside a CLE-invented object 12,880 415
values resolving to no mapped object 802,541 576
values outside every loaded object, invented ones included 107,830 22
arch type linked_base objs relocs outside image before after
x86_64 ET_DYN 0x400000 45 2,771,163 458,623 180
aarch64 ET_DYN 0x10000 45 2,771,025 10,550 180
ppc64 ET_DYN 0x10000 47 2,547,350 8,749 188
ppc64 ET_EXEC 0xc000000000000000 2 320,050 320,050 22
ppc64 ET_DYN 0x100000 2 157,338 136 6
arm ET_EXEC 0x60800000 2 15,193 15,193 0
s390x ET_EXEC 0x1000000 20 3,561 3,480 0
mips ET_EXEC 0x10000000 7 415 415 415
x86 ET_EXEC 0x7c000000 1 13 13 0

The two ppc64 ET_EXEC at 0xc000000000000000 are relocatable kernel images:
today every one of their 320,050 relative relocations writes a value outside the
image, and 22 remain afterwards. The mips row does not move, and should not —
those are R_MIPS_REL32 relocations carrying a symbol, so they take the
resolvedby is not None branch this change does not touch, and they point into
the extern object because they are imports. "Outside the image" is a floor
rather than a measure of the damage: a shift of 0x10000 usually keeps a
pointer inside the image and merely aims it at the wrong function. All 8.5
million are wrong; only 817,209 are wrong visibly.

Negative control

40 ELF objects that carry relative relocations but are linked at zero, spread
across architectures, through the same CFGFast A/B with two runs per arm and one
process per run: 40 of 40 identical block digests, every arm agreeing with
its own repeat. That is the expected result — when linked_base is 0 the old
and new expressions are the same number.

Full CFGFast A/B on 18 affected objects

Same protocol, covering every architecture and both ET_DYN and ET_EXEC.

object arch linked_base blocks before after funcs before after blocks in invented memory before after
7cc877a4bf97 x86 0x7c000000 101 101 45 45 12 12
984b58104e54 s390x 0x1000000 1,050 1,050 131 131 2 2
43dac31e7455 mips 0x10000000 1,988 1,988 227 227 75 75
e379b1acf43c s390x 0x1000000 9,040 9,040 854 854 2 2
7512c3969334 aarch64 0x1000000 21,259 21,259 1,685 1,685 2 2
d5c4e259bc6b arm 0x4000000 24,110 24,110 / 24,139 2,811 2,811 3 3
68569bd8b718 aarch64 0x80080000 24,393 24,393 1,798 1,798 2 2
09133b6e02cb arm 0x60800000 30,927 30,859 3,394 2,911 3 3
0a76781d40a1 arm 0x60800000 30,393 30,462 2,512 2,077 3 3
1e3f62d0570f x86_64 0x400000 50,847 50,498 3,419 3,154 31 31
9739a93e3466 aarch64 0x10000 52,715 52,471 5,387 5,091 29 29
d90fd17e728d x86_64 0x400000 52,363 52,141 3,590 3,218 31 31
21062d3b9adf aarch64 0x10000 54,445 54,192 5,578 5,264 29 29
30eb1ce4bb1f ppc64 0x10000 54,680 54,680 6,169 6,159 29 29
5a6d72d5a40b ppc64 0x10000 56,499 56,499 6,355 6,355 29 29
a80e06d0a613 x86_64 0x400000 67,542 66,963 4,437 3,995 598 67
14de90328845 ppc64 0x10000 285,151 285,176 32,755 32,706 29 29
154bba349d85 aarch64 0x10000 636,504 634,587 69,200 66,684 30 30

11 objects have a different block digest. 10 of them also have a different
relocated-value set, which is the change doing its job. The 11th is
d5c4e259bc6b, and it has zero relocations reaching the changed branch —
both arms' relocated-value digests are the sha256 of the empty string, so the
change provably cannot touch it. It is also the only object whose arms disagree
with their own repeats: its "fixed" arm produced the base arm's own digest in
one of its two runs, which is angr/angr#6840 / angr/angr#6844, not this change.
Four objects have a different relocated-value set but an identical CFG
(7cc877a4bf97, 984b58104e54, e379b1acf43c, 5a6d72d5a40b): the corrected
pointers are there but nothing in CFGFast's reachability depended on them.

No object gained an error and none gained a timeout. One mips object
(e7b35b337f90) hits a pre-existing angr assertion at cfg_fast.py:3343 on all
four of its runs, symmetrically. Peak RSS is unchanged (494/495, 586/548,
444/442 MB before/after on the three objects where it was sampled) and aggregate
CFGFast wall time is slightly lower after the change.

Merge-base lint and typecheck

===== Lint (pylint score per changed file, merge-base relative) =====
ok   cle/backends/elf/relocation/generic.py: 8.95 -> 8.95
ok   tests/test_relative_relocations.py: new file scores 10.00/10.00

===== Typecheck (pyright badness per changed file) =====
ok   cle/backends/elf/relocation/generic.py: badness 1.595330739299611 -> 1.5444015444015444
ok   tests/test_relative_relocations.py: badness 0.0 -> 0.0

No lint or type regressions against the base revision.

Complete workspace gate

ANGR_FEATURE=relreloc-base nix develop --command \
    ./.agents/skills/angr-validate-workspace/scripts/run-all-tests.sh

archinfo          19 passed
pypcode           46 passed, 187 subtests passed
pyvex             64 passed
cle              242 passed,  9 skipped
claripy          331 passed,  1 skipped
angr           2,497 passed, 46 skipped, 2 xfailed, 260 subtests passed
angr (Rust)       35 passed, 0 failed, 0 ignored
angr-management  626 passed
pysoot             6 passed,  2 skipped
plus: environment, angr-agentic workspace checks, test-inputs, all
configured pre-commit hooks over all files, per-feature instances

All selected angr workspace test suites passed.  Exit 0, no segfault,
no collection error, no unexpected xpass.

Two notes from checking prior art

cle/backends/elf/relocation/generic.py has been touched by exactly one merged
pull request in the last twelve months, #707, which added __slots__ and left
value alone; nothing open or merged changes this arithmetic, and no open issue
in cle or angr describes the symptom. Two things came out of that search that
are worth recording here:

  • DT_RELR routes through this same class. cle#600 constructs a
    GenericRelativeReloc for each RELR entry with addend=None, so the implicit
    addend is read from memory at construction and is a link-time virtual address
    — which is what the corrected expression wants. No RELR object measured is
    affected today, because all 5,068 of them link at 0, but the fix covers them
    the moment one does not.
  • cle#637, still open, adds the same expression again. Its new
    cle/backends/elf/relocation/riscv64.py defines R_RISCV_RELATIVE with a
    docstring reading Calculation: B + A directly above
    return self.owner.mapped_base + self.addend. There is no merge conflict with
    this change — different file — but the defect would come back with it. Raising
    it here rather than on that pull request, since it is the author's call how to
    fold it in.

Relation to angr/angr#6889 and angr/angr#6856

The A/B above is this cle change alone, with nothing applied on the angr side,
and it removes both halves of the damage: the 8,267 fabricated blocks and the
883 orphaned switch targets. angr/angr#6889 removes only the first — its guard
stops CFGFast decoding inside a CLE-invented object, which cannot re-attach the
883, because those are orphaned by the jump table resolving to a wrong address
rather than by anything being decoded (790 of them have no in-edge at all
before). With only #6889, this object still recovers 883 real switch targets as
standalone functions. The two are complementary: this removes the cause, and
#6889 remains worth having as a guard against whatever else produces the shape.

This is also not the Mach-O mechanism in angr/angr#6856 / angr/angr#6880, where
the jump table's address lies in zero fill; here the table is real
.data.rel.ro PROGBITS whose entries the loader wrote wrong.

Rebased 2026-08-27. This record was measured at 9e0e718. The branch has since been rebased onto master at 81ea6764585250dfadeadb0d4c58c1d3dec7c0cb; git range-diff reports the commit unchanged, so only the base moved and every figure above still describes this head.

Re-keyed 2026-08-28. The figures above were measured at 9e0e71887f5b2ae8678a4d123c623973185c2d7f on baseline 6951e9221554f987e3971aa51f9c280a67d60324, which is the pair the opening line named until now; the branch is at 81ea6764585250dfadeadb0d4c58c1d3dec7c0cb on 46a37333f4f59b0facf8774ee743ebc4cc074e9b, and master's own advance between the two baselines is 2 files changed, 4 insertions(+), 24 deletions(-). Two commands separate a rebase from new work and both were run: git range-diff 6951e9221554f987e3971aa51f9c280a67d60324..9e0e71887f5b2ae8678a4d123c623973185c2d7f 46a37333f4f59b0facf8774ee743ebc4cc074e9b..81ea6764585250dfadeadb0d4c58c1d3dec7c0cb marks every commit =, and the two branch patches — git diff 6951e9221554f987e3971aa51f9c280a67d60324 9e0e71887f5b2ae8678a4d123c623973185c2d7f against git diff 46a37333f4f59b0facf8774ee743ebc4cc074e9b 81ea6764585250dfadeadb0d4c58c1d3dec7c0cb, with index lines and hunk-header line numbers stripped and context lines discarded — are identical, so not one added or removed line moved. Master touched none of the files this change touches between the two baselines: git diff 6951e9221554f987e3971aa51f9c280a67d60324..46a37333f4f59b0facf8774ee743ebc4cc074e9b -- cle/backends/elf/relocation/generic.py tests/test_relative_relocations.py is empty, so every figure above still describes this head. The 2026-08-27 note above got one SHA wrong: it called 81ea6764585250dfadeadb0d4c58c1d3dec7c0cb the master the branch was rebased onto, and that SHA is the branch head. The master it was rebased onto, and the baseline the opening line now names, is 46a37333f4f59b0facf8774ee743ebc4cc074e9b.

Re-keyed 2026-09-04, after a rebase onto cle master 0e77ade3c39a3cee05f65051e57955675e1ac21b. The branch moved from f4d48fb323ebcb5a3b03c2a7036998844b4f5c2d to 4c29d960adf7796ca181ae4dd10cdce366e69acb because angr master bumped its sibling pins from 9.3.4.dev0 to 9.3.5.dev0 on 2026-09-02, and ci / Build installs the branch's own pyproject.toml with uv pip install --no-sources, so a fresh run on the old head died on a .dev version no index publishes. check-stale-pins.py refuses the old head and exits 0 on the new one. This was a plain replay: no conflict, no hand resolution, no fixup. git range-diff 929991db..f4d48fb3 0e77ade3..4c29d960 reports every commit =, and the branch's own added and removed lines are byte-identical across the move, 89 lines on each side. Master touched none of the files this change touches in the range rebased over. So every figure above describes the same patch on a new base. The opening line named 81ea6764585250dfadeadb0d4c58c1d3dec7c0cb before this edit, which is one rebase further back than the head this pass moved: the branch had already been replayed once without the record being re-keyed. Measured the same way over the whole chain, the added and removed lines at 81ea6764 are byte-identical to those at the new head, 89 on each side, so the record and the branch have never described different patches.

Hosted CI at 4c29d960adf7796ca181ae4dd10cdce366e69acb, read 2026-09-04T18:00Z: 20 checks, all green -- 18 check runs plus the docs/readthedocs.org:cle and pre-commit.ci - pr status contexts, in run 33891341822, attempt 2.

Correction, 2026-09-04. An earlier version of the paragraph above read this same head at 16:24Z as 15 success, 4 failure, 1 cancelled, with the failures on cle.errors.CLEFileNotFoundError for tests/aarch64/langdetect_go.macho and tests/aarch64/relocatable_object.macho -- fixtures cle master began needing on 2026-09-03 in b6ff025b (#808) and 3812052d (#787) -- and said the branch was waiting on a rebase of angr/binaries#188. angr/binaries#188 now sits on binaries master 003e82a2bfa641530924055695b36cec8af483ab and carries both fixtures, and the whole run was re-run at this unchanged cle head. Nothing on this branch moved.

@angr-bot

Copy link
Copy Markdown
Member

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

@zardus
zardus force-pushed the feature/relreloc-base branch from 9e0e718 to 81ea676 Compare August 26, 2026 22:47
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

The pointer table an ELF relative relocation fills, at the object's preferred base and rebased. The fixture comes from the angr/binaries pull request linked from the description.

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)

from cle.backends.elf.relocation.amd64 import R_X86_64_RELATIVE
path = T("x86_64", "relative_reloc_nonzero_base.so")
for base in (None, 0x1000000):
    opts = {"backend": "elf"}
    if base is not None:
        opts["base_addr"] = base
    ld = cle.Loader(path, auto_load_libs=False, main_opts=opts)
    obj = ld.main_object
    print(f"linked_base={obj.linked_base:#x} mapped_base={obj.mapped_base:#x} "
          f"image {obj.min_addr:#x}-{obj.max_addr:#x}")
    table = obj.get_symbol("table")
    for i, name in enumerate(("alpha", "beta", "delta")):
        word = ld.memory.unpack_word(table.rebased_addr + i * 8)
        want = obj.get_symbol(name).rebased_addr
        inside = obj.min_addr <= word <= obj.max_addr
        print(f"    table[{i}] = {word:#x}   {name} = {want:#x}   "
              f"{'ok' if word == want else 'WRONG'}, {'inside' if inside else 'OUTSIDE'} the image")

Before — mapped_base + addend puts every entry linked_base bytes too high, outside the image:

cle master at 46a37333f4f59b0facf8774ee743ebc4cc074e9b
linked_base=0x400000 mapped_base=0x400000 image 0x400000-0x403fff
    table[0] = 0x801000   alpha = 0x401000   WRONG, OUTSIDE the image
    table[1] = 0x801010   beta = 0x401010   WRONG, OUTSIDE the image
    table[2] = 0x801020   delta = 0x401020   WRONG, OUTSIDE the image
linked_base=0x400000 mapped_base=0x1000000 image 0x1000000-0x1003fff
    table[0] = 0x1401000   alpha = 0x1001000   WRONG, OUTSIDE the image
    table[1] = 0x1401010   beta = 0x1001010   WRONG, OUTSIDE the image
    table[2] = 0x1401020   delta = 0x1001020   WRONG, OUTSIDE the image

After — the load bias plus the addend lands on the symbols the table names:

with this change, at 81ea6764585250dfadeadb0d4c58c1d3dec7c0cb
linked_base=0x400000 mapped_base=0x400000 image 0x400000-0x403fff
    table[0] = 0x401000   alpha = 0x401000   ok, inside the image
    table[1] = 0x401010   beta = 0x401010   ok, inside the image
    table[2] = 0x401020   delta = 0x401020   ok, inside the image
linked_base=0x400000 mapped_base=0x1000000 image 0x1000000-0x1003fff
    table[0] = 0x1001000   alpha = 0x1001000   ok, inside the image
    table[1] = 0x1001010   beta = 0x1001010   ok, inside the image
    table[2] = 0x1001020   delta = 0x1001020   ok, inside the image

An ELF relative relocation writes B + A, where B is the load bias and A is a
link-time virtual address. The bias is mapped_base - linked_base. CLE wrote
mapped_base + addend, which is the same number only when the object was linked
at zero. Every shared library and almost every PIE is, which is why this has
held: on those objects linked_base is zero and the two expressions coincide.

They do not coincide on a PIE the linker gave a non-zero text segment address,
which is what Go's linker emits -- ET_DYN with its lowest PT_LOAD at 0x400000.
Loading one puts every relative relocation 0x400000 too high. On a 5.9 MB Go
executable 19,394 of 21,474 relocated pointers then point outside the image
altogether, 2,071 of them into the extern object CLE placed above it. The
switch tables in .data.rel.ro are relocated the same way, so CFGFast resolves
an indirect jump to the extern object and decodes 8,267 blocks of its zero
fill, while 883 real code addresses in .text -- 790 of them left without any
predecessor -- are recovered as standalone functions instead of as blocks of
the functions they belong to.

GenericIRelativeReloc, two classes above, already computes the same quantity
the correct way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/relreloc-base branch from f4d48fb to 4c29d96 Compare September 4, 2026 15:45
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