Skip to content

fix(relinker): bind guest function exports at address 0 on Linux - #10495

Merged
oneandonlydean merged 2 commits into
boykopovar:mainfrom
wail-arch:fix/linux-guest-zero-address-exports
Nov 2, 2026
Merged

oneandonlydean merged 2 commits into
boykopovar:mainfrom
wail-arch:fix/linux-guest-zero-address-exports

Conversation

@wail-arch

@wail-arch wail-arch commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

What

glibc's loader skips a defined symbol whose value is 0: check_match in elf/dl-lookup.c rejects st_value == 0 unless the symbol is SHN_ABS or TLS. A Linux shared object normally maps its ELF header at 0, but a guest module's addresses are kept as on the console, and the ps5-payload-dev SDK links a shared object's .text at address 0. The first function of every SDK module is therefore exported at 0, and nothing on Linux can import it. The SDK's own hello_so sample shows it: libB.so exports libB_random and libA.so exports libA_hexdump at 0, and the converted title stops with libtest.so.guest.prx: undefined symbol: libB_random#guest, although libB.so.guest.prx is loaded and defines it.

LinuxGuestModuleWriter now writes, for each global or weak, default or protected function export at address 0, a jmp to it in the segment the module gains, and sets the export's value to that jmp. Every symbol-based reference, from other modules, the executable or the module's own relocations, binds to it; the module's direct calls and RIP-relative addresses still reach the function itself, so a pointer taken through the symbol differs from one the module computes. A data or untyped export at address 0 cannot be forwarded this way and stays unresolvable. The debt entry records both. Windows and macOS are unchanged.

Tested

Ubuntu 24.04, GCC 13.3, full build with -DBUILD_TESTING=ON.

  • New linux_guest_zero_address_exports: a provider module whose code segment starts at address 0 exports shared there; the output exports shared#guest at a jmp whose target is 0. A second module exports global, weak, protected and a second global name at 0, which each get their own jmp to 0, and internal, hidden and object exports, which stay at 0. On x86-64 Linux, a consumer module's initializer calls the export and the relinked executable exits 42. On main it fails at the first check ((18, 0)); with only the runtime part, the executable stops with consumer.prx.guest.prx: undefined symbol: shared#guest.
  • Homebrew built with the SDK (a7685ce, clang 18), relinked with --to-intel --module-dir .; SDK modules load since fix(relinker): bind SDK guest module imports on Linux #9261:
    • hello_so: on main (6c52b64), undefined symbol: libB_random#guest. With this change, LD_DEBUG=bindings shows libB_random#guest and libA_hexdump#guest bound in their modules, and the title stops in the SDK's own start-up code at rip = 1, as hello_world does (relinker, libkernel: ps5-payload-dev SDK payloads need a loader entry with kernel access (payload_args_t); in scope? #9381).
    • A probe whose libprobe.so exports probe_line at 0, called from an executable with its own _start: on main, undefined symbol: probe_line#guest; with this change it prints module imports resolved, pid valid and exits 9, probe_line#guest exported at 0xc000.
  • The 29 guest-module and named-import tests registered in core/relinker/CMakeLists.txt (guest_*, linux_guest_*, linux_named_imports, windows_named_imports) pass on this branch over 6c52b64, the new one among them. Full ctest -j4 on 1bee5f3 over the same base: 2009 of 2016 pass. None of the 7 failures runs the relinker: guest_net_buffer_validation, guest_socket_options, guest_socket_stream_options, guest_socket_address_length and guest_tcp stop on IPv6 sockets (errno 47), which this container lacks, and agc_driver_pipelined_metadata_pass and its draw-pipeline variant find no Vulkan driver (vkCreateInstance returns -9). Windows and macOS were not run here; the hosted relinker jobs passed on both.
  • check_conventions.py --base upstream/main passes. Merged into current main, the branch adds no conflict with open pull requests. fix(relinker): preserve guest finalizers at address zero #10142 handles a finalizer at address 0 (DT_FINI) in the same writer; the two changes are independent and merge cleanly together.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

glibc's loader skips a defined symbol whose value is 0, and the
ps5-payload-dev SDK links a shared object's code at address 0, so the
first function of every SDK module could not be imported. Export such
a function through a jmp in the module's added segment.
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 13962 core/relinker/CMakeLists.txt L809

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5021 core/relinker/CMakeLists.txt
docs/dev/debt/relinker.md
PR 10479 docs/dev/debt/relinker.md
PR 12678 core/relinker/CMakeLists.txt
docs/dev/debt/relinker.md
PR 14212 docs/dev/debt/relinker.md

@MarcZX10 MarcZX10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head 1bee5f35b7ccf8fea236da95d179e998e069ca53. The writer limits the thunk to defined global/weak function symbols with default/protected visibility, leaves address-zero data exports unchanged, and checks the signed rel32 range before emitting the jump. The regression checks both the generated thunk target and an executable consumer resolving/calling the function; the PR reports red/green behavior plus the focused/full relinker runs. Hosted conventions, intake, eligibility, labels, Linux/macOS/Windows relinker matrix, and ASan pass. I did not reproduce the local relinker build or SDK homebrew run.

@roboterspinne roboterspinne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed head 1bee5f3 merged onto upstream/main aeeaefa (2026-10-24); the merge is clean, also onto the current main b84322d and together with #10142, which edits only the HasFini lines (101, 180-183, 191-194, 208-211) of the same writer.

Environment: Windows 11 Home 10.0.26200, WinLibs GCC 15.2.0 UCRT, CMake 4.3.1, Ninja 1.13.2, Python 3.13.9, -DANYPS5_RELINKER_ONLY=ON -DBUILD_TESTING=ON, Release. My machine was under unrelated CPU load, so targeted tests only; the relinker matrix is green on the PR.

Results

  • Merged tree: linux_guest_zero_address_exports (static part; the runtime part is gated to Linux x86-64), guest_export_lookup, guest_module_identity, guest_absolute_symbols, guest_symbol_names, guest_unused_import_dependencies, guest_module_args, guest_deferred_dependencies, guest_module_fini, guest_intel_trampolines pass.
  • Red/green: with the writer hunk reverted and the test and CMake kept, the test fails AssertionError: (18, 0) and the provider's shared#guest stays at st_value 0; restored, it passes.
  • Output: objdump -T on the produced provider.prx.guest.prx shows 0000000000004000 g DF .text ... shared#guest, the bytes at 0x4000 are e9 fb bf ff ff (jmp to 0), the file bytes at VA 0 are the provider's mov eax, 22; ret, and the gained segment is LOAD ... flags rwx.
  • Variants on the PR build: weak (st_info 0x22) and protected (st_other 3) exports get a thunk; hidden and internal stay at 0 with st_other kept; OBJECT, NOTYPE and weak OBJECT stay at 0; two function exports aliasing 0 get two thunks (0x4000 and 0x4005); an IFUNC export (0xa2) is refused by GuestImageReader (Unsupported symbol attributes), so no silent path exists there.
  • Primary sources: glibc elf/dl-lookup.c, check_match: if (__glibc_unlikely ((sym->st_value == 0 /* No value. */ && sym->st_shndx != SHN_ABS && stt != STT_TLS) || ...)) return NULL;, and do_lookup_x skips hidden/internal and local symbols, so the writer's filter is exactly the set glibc would otherwise drop. The SDK claim holds from its sources: host/bin/prospero-lld always passes -T ldscripts/elf_x86_64.x and clears -pie for --shared, that script's first output section is .text with no address assignment and no FILEHDR/PHDRS, lld's image base for PIC output is 0, and samples/hello_so/libA.c and libB.c each define one function. Not verified by a link run.
  • Windows and macOS: both map a guest VA through WindowsLoadImage::GetRva with LoadRva 0x10000, so an export can never land at RVA 0 there; leaving them unchanged is right.
  • check_conventions.py --base upstream/main passes; pr_overlap.py reports no overlap.

Nits

  • docs/dev/debt/relinker.md:6: three consequences are missing. Several function exports aliasing address 0 each get their own jmp, so two names for one function resolve to two different addresses on Linux where the console gives them the same; a module whose gained segment lies above INT32_MAX - 5 fails with Guest export at address 0 exceeds relative branch range; and an import of a data or untyped export at 0 is not reported at conversion time (an executable importing a GLOBAL OBJECT at 0 converts with exit 0, shared#guest UND, DT_NEEDED on the provider and BIND_NOW, and fails only when glibc skips the export; a FUNC-typed module import already fails with Guest import/export type mismatch). That last gap is pre-existing on main and this PR narrows it; GuestModuleBuilder has the export's Value, Section and Info at the binding sites (~lines 246 and 291), so a conversion-time throw would be possible if wanted; otherwise a sentence like the neighbouring entry's "and nothing reports it" would do.
  • test_linux_guest_zero_address_exports.py: only a GLOBAL FUNC and a GLOBAL OBJECT are covered; weak and protected (thunked), hidden (must stay 0), two aliases at 0 and the range throw are not. I checked them by hand (above), so this is coverage, not behaviour.
  • Body, Tested: "the 12 guest-module and named-import tests" does not match the registrations (24 guest_* tests and three named-import tests); naming them or the ctest regex would make the claim checkable.
  • CMakeLists.txt:633-636: the registration mirrors guest_unused_import_dependencies minus its LABELS relinker line and without a TIMEOUT; both are inert in CI (the relinker jobs run ctest without label filters), so optional.

Not checked: the runtime part of the test and the hello_so / probe runs (Linux only); LD_DEBUG=bindings; the full ctest counts (1838 of 1842); a protected-visibility consumer's own reference; dladdr/backtrace behaviour for a thunked export whose st_size still describes the original function.

I found no blocking issue: the filter matches glibc's lookup rule exactly, the thunk is encoded and placed correctly, the counter-check holds, and Windows and macOS are unaffected. For disclosure: I have open relinker PRs (#10564, #10402 on LinuxElfPatcher.cpp, the executable side; #10401, #10403, #10390, #10391); none touches this writer.

Written with AI assistance (Claude Code). I ran the builds, tests and output inspection above on my Windows 11 machine; the Linux runtime part was not executed.


output = relink(relinker, work / "object", {"provider.prx": at_address_zero(provider(22), 0x11)})
image = (output.parent / "app0" / "sce_module" / "provider.prx.guest.prx").read_bytes()
assert exports(image)["shared#guest"] == (0x11, 0), exports(image)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit, optional: the filter has more arms than the two cases here. at_address_zero(provider(22), 0x22) (weak, thunked), a protected variant (st_other 3, thunked), a hidden one (st_other 2, must stay 0) and a second function at 0 (two distinct thunks) would pin them; I checked all four by hand on this build and they behave as the body describes.

Comment thread docs/dev/debt/relinker.md Outdated
### Functional

- [Executable guest imports](../../../core/relinker/relinker/src/guest/GuestModuleBuilder.cpp) - on Linux and macOS, a relocated executable import resolved to one guest provider requires that module and its dependencies at startup, even when it was discovered through `--module-dir` without a DT_NEEDED entry. Unreferenced symbols and no-op relocations do not require an otherwise deferred module. Console module-start ordering is unmeasured.
- [Guest exports at address 0 on Linux](../../../core/relinker/elfpatcher/src/linux/LinuxGuestModuleWriter.cpp) - the host loader skips a defined symbol whose value is 0 (`check_match` in glibc's `elf/dl-lookup.c`), and the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk) links a shared object's code at address 0, so its first function is exported at 0, as in the SDK's `hello_so` sample. Such a function export is written as a `jmp` to it in the segment the module gains, so a pointer to it taken through the symbol differs from one the module computes itself. A data or untyped export at address 0 stays unresolvable on Linux.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: three consequences of the writer are worth a sentence here. Several function exports aliasing address 0 each get their own jmp (verified: two GLOBAL FUNC symbols at 0 come out at 0x4000 and 0x4005), so two names for one function have different addresses on Linux where the console gives them the same; a module whose gained segment lies above INT32_MAX - 5 throws Guest export at address 0 exceeds relative branch range; and an import of a data or untyped export at 0 is not reported at conversion time (an executable importing a GLOBAL OBJECT at 0 converts with exit 0 and fails only at load; pre-existing on main, narrowed by this PR), in the style of the neighbouring entry's "and nothing reports it".

Test weak, protected and aliased function exports at address 0, which
each get their own jmp, next to internal, hidden and object exports,
which stay at 0. Record that aliases get separate addresses, that the
jmp must lie within 2 GiB of address 0 and that a data import at 0 is
reported only by the host loader. Label the test and give it a timeout
like its neighbours.
@wail-arch

Copy link
Copy Markdown
Contributor Author

@roboterspinne Thanks for the review. Addressed in c01486a:

  • The test now also relinks a module whose global, weak and protected exports and a second global name sit at 0 (each gets its own jmp to 0, at different addresses) next to internal, hidden and object exports (left at 0).
  • The debt entry now records that two names for one function get two jmps at different addresses, that a module whose jmp would lie 2 GiB or more above address 0 fails to convert, and that an import of a data export at 0 is reported only by the host loader.
  • The registration carries LABELS relinker and TIMEOUT 30 like guest_unused_import_dependencies and guest_export_lookup.
  • The description now names the tests: the 29 guest-module and named-import tests registered in core/relinker/CMakeLists.txt (guest_*, linux_guest_*, linux_named_imports, windows_named_imports), and gives the full-suite count on the current base with the reason for each failure.

@roboterspinne

Copy link
Copy Markdown
Contributor

@wail-arch Thanks, re-checked c01486a merged onto upstream/main c9b2660 (2026-10-24) on Windows 11 Home 10.0.26200, WinLibs GCC 15.2.0 UCRT, CMake 4.3.1, Ninja 1.13.2, Python 3.13.9, -DANYPS5_RELINKER_ONLY=ON -DBUILD_TESTING=ON, Release. The merge is clean, and LinuxGuestModuleWriter.cpp is byte-identical to 1bee5f3; the commit touches only the test, the registration and the debt entry.

  • Full suite on the merged tree: 242 tests, 0 failed, long_paths skipped (97 s, -j 8). linux_guest_zero_address_exports passes in its static part (the runtime part is gated to Linux x86-64), and ctest --show-only=json-v1 now lists it with LABELS relinker and TIMEOUT 30.
  • The test's new assertions hold on this build, and the seven-kinds module comes out as: global#guest at 0x4000, weak#guest (st_info 0x22) at 0x4005, protected#guest at 0x400a, alias#guest at 0x400f, each a jmp whose target is 0; internal and hidden keep st_value 0 with st_info 0x12, object#guest stays (0x11, 0). That is what the writer's consecutive 5-byte thunks from the segment start give, and its filter is the export filter of GuestModuleBuilder.cpp:149 (undefined, local or hidden/internal symbols skipped; every reserved section index treated like the builder's SHN_ABS) plus the function-type requirement, so a jmp is written for exactly the function symbols the relinker exports.
  • The debt entry now names the three consequences, and the description's "29 guest-module and named-import tests" matches the registrations in core/relinker/CMakeLists.txt (28 register on Windows; guest_intel_trampolines_execution is Linux x86-64 only). The revised Tested bullets read as the output above.

Two small notes, neither blocking: zero_address_module scans the dynamic section at the fixed file offset 0x600 and derives the symbol table as DT_SYMTAB - 0x1a00, both internals of guest_symbols in test_guest_export_lookup.py:36-44, so a layout change there fails this test with an unrelated-looking error (the same pattern as at_address_zero's 0x898, so consistent with the file); and the "left at 0" side covers STT_OBJECT only, while the writer also skips STT_NOTYPE and local-binding functions at 0, which the entry's "untyped" wording describes but no assertion exercises.

Red/green was not repeated: with the writer hunk removed locally I had seen the test go red on 1bee5f3, which covered the original assertions; the new kinds assertions were not run red. The Linux runtime part and the SDK runs remain unexecuted here (the hosted relinker jobs on c01486a are green). Nothing open from my side.

Written with AI assistance (Claude Code). I ran the build, tests and output inspection above on my Windows 11 machine.

@wail-arch

Copy link
Copy Markdown
Contributor Author

@roboterspinne Thanks for re-checking. Both notes stay as they are for this PR: the offsets are the layout guest_symbols in test_guest_export_lookup.py builds (dynamic table at 0x600, data segment at file offset + 0x1a00), so a change there fails this test instead of passing it; and the writer's filter already leaves untyped and local symbols at 0, as it does objects. If another change to this PR comes, I'll add an untyped and a local case with it.

@oneandonlydean oneandonlydean self-assigned this Oct 25, 2026

@ahmadmuj ahmadmuj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed head c01486aaf7, merged onto main 0119103fa9 (clean). WSL2 Ubuntu 24.04 x86-64, GCC 13.3, -DANYPS5_RELINKER_ONLY=ON -DBUILD_TESTING=ON, Release.

This is the first run of the Linux runtime part, which the earlier reviews couldn't do on Windows.

What I ran:

  • linux_guest_zero_address_exports on the merged tree: passes. With a print added, the relinked executable really runs and exits 42.
  • Red, writer hunk reverted on the merged tree: the test fails at AssertionError: (18, 0). Run on its own, the runtime part exits 127 with consumer.prx.guest.prx: undefined symbol: shared#guest, so the glibc skip is what breaks it.
  • The kinds module, which roboterspinne didn't run red. Reverted: every #guest export stays at 0. With the fix: global#guest 0x4000, weak#guest 0x4005, protected#guest 0x400a, alias#guest 0x400f, each a jmp to 0; internal, hidden, object#guest and an extra untyped#guest (st_info 0x10) stay at 0. So the new assertions do fail on main, and the untyped case behaves as the debt entry says.
  • Full relinker-only ctest on the merged tree: 297/297 pass.
  • check_conventions.py --base origin/main: pass.

Code: the thunks are appended through address() into the gained segment, which is PT_LOAD flags 7 (line 322), so they're executable. The value rewrite goes into the symbols copy that gets emitted, at the same index the reader used.

Not checked: the SDK hello_so and probe runs, a module's own GLOB_DAT to its export at 0 (no test covers it either; the debt entry already describes the result), macOS and Windows.

@oneandonlydean
oneandonlydean merged commit 2a9de0e into boykopovar:main Nov 2, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants