Repository navigation
fix(relinker): bind guest function exports at address 0 on Linux - #10495
oneandonlydean merged 2 commits into
Conversation
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.
|
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.
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.
|
MarcZX10
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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_trampolinespass. - Red/green: with the writer hunk reverted and the test and CMake kept, the test fails
AssertionError: (18, 0)and the provider'sshared#gueststays atst_value0; restored, it passes. - Output:
objdump -Ton the producedprovider.prx.guest.prxshows0000000000004000 g DF .text ... shared#guest, the bytes at 0x4000 aree9 fb bf ff ff(jmpto 0), the file bytes at VA 0 are the provider'smov eax, 22; ret, and the gained segment isLOAD ... flags rwx. - Variants on the PR build: weak (
st_info0x22) and protected (st_other3) exports get a thunk; hidden and internal stay at 0 withst_otherkept; 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 byGuestImageReader(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;, anddo_lookup_xskips 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-lldalways passes-T ldscripts/elf_x86_64.xand clears-piefor--shared, that script's first output section is.textwith no address assignment and no FILEHDR/PHDRS, lld's image base for PIC output is 0, andsamples/hello_so/libA.candlibB.ceach define one function. Not verified by a link run. - Windows and macOS: both map a guest VA through
WindowsLoadImage::GetRvawithLoadRva0x10000, so an export can never land at RVA 0 there; leaving them unchanged is right. check_conventions.py --base upstream/mainpasses;pr_overlap.pyreports no overlap.
Nits
docs/dev/debt/relinker.md:6: three consequences are missing. Several function exports aliasing address 0 each get their ownjmp, 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 aboveINT32_MAX - 5fails withGuest 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#guestUND,DT_NEEDEDon the provider andBIND_NOW, and fails only when glibc skips the export; a FUNC-typed module import already fails withGuest import/export type mismatch). That last gap is pre-existing onmainand this PR narrows it;GuestModuleBuilderhas the export'sValue,SectionandInfoat 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 mirrorsguest_unused_import_dependenciesminus itsLABELS relinkerline and without a TIMEOUT; both are inert in CI (the relinker jobs runctestwithout 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) |
There was a problem hiding this comment.
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.
| ### 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. |
There was a problem hiding this comment.
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.
|
@roboterspinne Thanks for the review. Addressed in c01486a:
|
|
@wail-arch Thanks, re-checked c01486a merged onto
Two small notes, neither blocking: 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. |
|
@roboterspinne Thanks for re-checking. Both notes stay as they are for this PR: the offsets are the layout |
ahmadmuj
left a comment
There was a problem hiding this comment.
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_exportson 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 withconsumer.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
#guestexport stays at 0. With the fix:global#guest0x4000,weak#guest0x4005,protected#guest0x400a,alias#guest0x400f, each ajmpto 0;internal,hidden,object#guestand an extrauntyped#guest(st_info0x10) stay at 0. So the new assertions do fail on main, and the untyped case behaves as the debt entry says. - Full relinker-only
cteston 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.
What
glibc's loader skips a defined symbol whose value is 0:
check_matchinelf/dl-lookup.crejectsst_value == 0unless the symbol isSHN_ABSor 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.textat address 0. The first function of every SDK module is therefore exported at 0, and nothing on Linux can import it. The SDK's ownhello_sosample shows it:libB.soexportslibB_randomandlibA.soexportslibA_hexdumpat 0, and the converted title stops withlibtest.so.guest.prx: undefined symbol: libB_random#guest, althoughlibB.so.guest.prxis loaded and defines it.LinuxGuestModuleWriternow writes, for each global or weak, default or protected function export at address 0, ajmpto it in the segment the module gains, and sets the export's value to thatjmp. 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.linux_guest_zero_address_exports: a provider module whose code segment starts at address 0 exportssharedthere; the output exportsshared#guestat ajmpwhose target is 0. A second module exports global, weak, protected and a second global name at 0, which each get their ownjmpto 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. Onmainit fails at the first check ((18, 0)); with only the runtime part, the executable stops withconsumer.prx.guest.prx: undefined symbol: shared#guest.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: onmain(6c52b64),undefined symbol: libB_random#guest. With this change,LD_DEBUG=bindingsshowslibB_random#guestandlibA_hexdump#guestbound in their modules, and the title stops in the SDK's own start-up code atrip = 1, ashello_worlddoes (relinker, libkernel: ps5-payload-dev SDK payloads need a loader entry with kernel access (payload_args_t); in scope? #9381).libprobe.soexportsprobe_lineat 0, called from an executable with its own_start: onmain,undefined symbol: probe_line#guest; with this change it printsmodule imports resolved, pid validand exits 9,probe_line#guestexported at0xc000.core/relinker/CMakeLists.txt(guest_*,linux_guest_*,linux_named_imports,windows_named_imports) pass on this branch over6c52b64, the new one among them. Fullctest -j4on1bee5f3over 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_lengthandguest_tcpstop on IPv6 sockets (errno 47), which this container lacks, andagc_driver_pipelined_metadata_passand its draw-pipeline variant find no Vulkan driver (vkCreateInstancereturns -9). Windows and macOS were not run here; the hosted relinker jobs passed on both.check_conventions.py --base upstream/mainpasses. Merged into currentmain, 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
main; no other open PR implements the same functionsNotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file.mdfiles or images added to the repository (attach them to this PR)