Skip to content

fix(relinker): write Linux PT_LOAD headers in ascending address order - #1559

Merged
adriadam10 merged 1 commit into
boykopovar:mainfrom
ctj01:fix/relinker-pt-load-order
Oct 10, 2026
Merged

adriadam10 merged 1 commit into
boykopovar:mainfrom
ctj01:fix/relinker-pt-load-order

Conversation

@ctj01

@ctj01 ctj01 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

What

LinuxElfPatcher wrote the program header table as PT_PHDR, the header-block PT_LOAD, the kept original headers, the extra-block PT_LOAD, PT_DYNAMIC and PT_INTERP. The header block is placed after the extra block, so it has the highest address of all loadable segments while being the first PT_LOAD entry in the table. The ELF gABI requires loadable segment entries to appear in ascending p_vaddr order, and PT_PHDR and PT_INTERP to precede every loadable entry.

Linux before 5.18 sizes the ET_DYN mapping as last PT_LOAD p_vaddr + p_memsz - page_start(first PT_LOAD p_vaddr) in table order (total_mapping_size, fs/binfmt_elf.c v5.15). For a relinked executable that difference underflows, elf_map fails after the point of no return, and the process dies with SIGSEGV before the dynamic linker runs. On 5.18 and later the kernel takes the minimum and maximum over all PT_LOAD entries, so the file loads, but the first mapping still reserves the range starting at the header block instead of the lowest segment.

Kernel coverage, from fs/binfmt_elf.c of each release:

Kernel main This pull request
5.18 and later runs runs
5.15.33 to 5.17, 5.10.110 and later stable (WSL2 5.15.167 included) SIGSEGV at exec runs
5.15.0 to 5.15.32, 5.10.109 and earlier SIGSEGV at exec still fails

The last row lacks the backport of the 5.18 AT_PHDR fix, so AT_PHDR is computed as the first PT_LOAD's p_vaddr - p_offset plus e_phoff. That points into the header block only while the header block is the first PT_LOAD, which is exactly the order the table-order total_mapping_size rejects. Supporting those kernels would need the program header table moved to a segment whose file-to-address delta matches the lowest PT_LOAD; that is a separate change. No kernel runs main and fails with this pull request.

ProgramHeaderLayoutBuilder::WriteLayout now writes PT_PHDR, PT_INTERP, the kept original headers with their PT_LOAD entries sorted by p_vaddr (the Windows image builder already sorts them in WindowsLoadImage), the extra-block PT_LOAD, the header-block PT_LOAD and PT_DYNAMIC. The guest module writer already orders its segments this way.

Rebased on main after #1586 merged: its PT_GNU_EH_FRAME slot check and warning are kept, and a dropped PT_GNU_EH_FRAME is removed from the kept headers before they are laid out.

Tested

WSL2 Ubuntu 24.04 (kernel 5.15.167.4-microsoft-standard-WSL2, glibc 2.39, GCC 13.3), Linux build with -DBUILD_TESTING=ON.

  • Before the change linux_entry_argv fails there: the output exits with SIGSEGV (-11) instead of SIGTRAP. Reordering the PT_LOAD entries of that output by hand makes it run, which isolated the cause.
  • After the change the full suite passes on current main: 514/514 with ctest --test-dir build -j8 (23 GPU tests skipped by the WSL Vulkan device), including linux_entry_argv, linux_eh_frame_header and the new test.
  • The same Build workflow on the fork for the previous head (stacked on fix(relinker): keep PT_GNU_EH_FRAME in the relinked executable #1586): Linux 457/457, Windows 449/449 (https://github.com/ctj01/AnyPS5/actions/runs/37812797526).
  • New linux_program_header_order test: a single-segment image, a three-segment image (text, rodata, data with bss) with a kept PT_OS_PROCPARAM entry, and the same image with its PT_LOAD entries out of order in the input. It checks PT_PHDR first, PT_INTERP before the first PT_LOAD, strictly ascending PT_LOAD addresses, the preserved non-load entry, that the 5.15 table-order span equals the address span, that PT_PHDR is covered by a PT_LOAD, and on Linux x86-64 it executes the outputs and expects SIGTRAP from the entry.

No title tested.

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 in TechnicalDebt
  • 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
  • AI-assisted: yes

@ctj01

ctj01 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on #1586, which changes the same lines of WriteLayout, and marked it as a dependency. Its slot check for PT_GNU_EH_FRAME is kept; a dropped header is now removed from the kept list before the table is laid out. Full suite on top of #1586: 454/454 on WSL2 (kernel 5.15), including linux_eh_frame_header.

@ctj01

ctj01 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

While the upstream runs wait for approval (#1631), the same Build workflow ran on the fork for this head (aaf33add): https://github.com/ctj01/AnyPS5/actions/runs/37812797526

  • Linux: 457/457 tests passed (linux_program_header_order, linux_entry_argv and linux_eh_frame_header included).
  • Windows: 449/449 tests passed; the new test converts to a Linux ELF there and skips the execution step.

@ctj01

ctj01 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

The ElfConstants.hpp L64 clash with #1506 reported above comes from #1586, which this pull request is based on (PT_GNU_EH_FRAME vs #1506's PT_GNU_STACK on adjacent lines). The commit of this pull request does not change ElfConstants.hpp; the report will clear once #1586 and #1506 are reconciled or either one merges.

The header-block PT_LOAD has the highest address but was the first
loadable entry in the table. Linux before 5.18 sizes the ET_DYN mapping
from the first and last PT_LOAD entries in table order, so the output
failed to exec with SIGSEGV. Write PT_PHDR and PT_INTERP first, sort the
kept PT_LOAD entries by address, and place the extra and header blocks
after them.
@ctj01
ctj01 force-pushed the fix/relinker-pt-load-order branch from aaf33ad to 1352b9b Compare October 9, 2026 11:38
@ctj01

ctj01 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on main now that #1586 is merged; the dependency note is gone and the pull request is back to its single commit (1352b9b3). Full suite on current main (d70b899) on WSL2, kernel 5.15: 514/514, including linux_entry_argv, linux_eh_frame_header and jmprel_table_bounds.

@Dextroid17 Dextroid17 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.

Verified on Linux, with the artifact and the kernel source both in hand.

  • base d70b8998 (upstream/main) + head 1352b9b3, merged clean.
  • Arm A: linux_program_header_order Passed (0.11 s).
  • Arm B (only ProgramHeaderLayoutBuilder.{cpp,hpp} reverted to main, the PR's test kept): fails — AssertionError: ('single-load', [6, 1, 1, 1, 2, 3]), i.e. PT_LOAD second and PT_INTERP last, exactly the ordering the test exists to reject.

Your kernel citation checks out — I read it rather than trusting it. fs/binfmt_elf.c v5.15, line 402:

static unsigned long total_mapping_size(const struct elf_phdr *cmds, int nr)
{
	int i, first_idx = -1, last_idx = -1;
	for (i = 0; i < nr; i++)
		if (cmds[i].p_type == PT_LOAD) {
			last_idx = i;
			if (first_idx == -1) first_idx = i;
		}
	if (first_idx == -1) return 0;
	return cmds[last_idx].p_vaddr + cmds[last_idx].p_memsz -
				ELF_PAGESTART(cmds[first_idx].p_vaddr);
}

first_idx/last_idx are chosen by table index, so the result genuinely depends on table order — the mechanism is real, not inferred. And v5.18 replaces it with min/max over all PT_LOAD entries (line 404 of that version), so the second half of the description is right too.

I then computed that function's value on the produced file (/bin/true through each binary — 15 headers, but the first and last PT_LOAD in table order are what matter):

main PR
type sequence PHDR, LOAD, PHDR, INTERP, LOAD x4 ... PHDR, INTERP, PHDR, INTERP, LOAD x4 ...
PT_INTERP before every load no yes
PT_LOAD p_vaddr ascending no (0x11000 first, 0xf938 last) yes (0x0 -> 0x11000)
<5.18 total_mapping_size (0xf938 + 0xd5d) - page_start(0x11000) = -2411 -> as u64 0xfffffffffffff695 (0x11000 + 0x350) - 0 = 0x11350
>=5.18 min/max span 0x11350 0x11350

So the underflow you describe is reproducible by evaluation, not just by argument: on main the kernel's own quantity goes negative, which is the elf_map failure. With the change both kernel eras agree on 0x11350, which is the tidiest statement of why the reorder is correct.

What I could not do: reproduce the SIGSEGV itself. This box runs 7.2.9, i.e. the >=5.18 form, so the failure mode is out of reach here and I am not claiming otherwise — the arithmetic above is the substitute, and it is the same quantity the pre-5.18 path computes.

Two notes. Pre-existing, not introduced here: the output carries two PT_PHDR and two PT_INTERP entries (one synthetic, one kept from the input) in both arms — harmless since both interpreter entries name the same path, but since this PR is the one rearranging that table it may be worth a line in the body. And for sequencing: #2274 edits the same lines of ProgramHeaderLayoutBuilder.cpp (keeping < and kSyntheticProgramHeaderCount = 4), so whichever of the two lands second will need a rebase.

Written with AI assistance.

@Dextroid17

Copy link
Copy Markdown
Contributor

One more instrument, since the table order is the whole subject of this PR. REA's pwntools-elf adapter, run on both artefacts (digest-anchored; same pinned toolchain as the CI adapter — pwntools 4.15.0 / pyelftools 0.33 / unicorn 2.1.2):

  • PR head (sha256 a83fbd38…): type sequence PT_PHDR, PT_INTERP, PT_PHDR, PT_INTERP, PT_LOAD x4, PT_NOTE x3, PT_GNU_EH_FRAME, PT_LOAD, PT_LOAD, PT_DYNAMIC; PT_LOAD vaddrs 0x0, 0x2000, 0xb000, 0xebd0, 0xf938, 0x11000 — ascending, PT_PHDR first, PT_INTERP before every load.
  • main: PT_PHDR, PT_LOAD, PT_PHDR, PT_INTERP, … with PT_LOAD vaddrs 0x11000, 0x0, 0x2000, 0xb000, 0xebd0, 0xf938 — not ascending, and PT_INTERP after the loads.

That is a third independent read of the same structure (the other two being readelf and a direct PHDR parse), agreeing on both the order and the addresses.

Written with AI assistance.

@ctj01

ctj01 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@Dextroid17 thank you for the arm A/B run and for evaluating total_mapping_size on a real output; the 0x11350 agreement is a cleaner statement than mine.

Checking your kernel reading led me to a gap in my description, now corrected. Before 5.18, AT_PHDR was first PT_LOAD p_vaddr - p_offset + e_phoff. The 5.18 fix that derives it from the PT_LOAD containing e_phoff was backported to stable only in 5.15.33 and 5.10.110 (5.15.32 and 5.10.109 still have the old form). So:

  • 5.18+: both main and this PR run.
  • 5.15.33-5.17 and 5.10.110+ stable, WSL2's 5.15.167 among them: main fails at exec, this PR runs.
  • Older 5.15/5.10: both fail. With the header block first, total_mapping_size underflows; with it last, the old AT_PHDR no longer points at the table.

No kernel goes from working to failing, but the description now says exactly that instead of claiming every 5.15 or 5.10.

On the duplicate PT_PHDR and PT_INTERP: agreed, pre-existing and worth fixing, but in a separate PR. It matters more than it looks. glibc's rtld.c takes the load bias from every PT_PHDR in turn (l_addr = phdr - p_vaddr), so the last one wins, and here that is the input's entry, whose p_vaddr does not describe where the table is mapped. The kernel takes the first PT_INTERP, which this PR makes the synthetic one. I'll open a follow-up that drops the input's PT_PHDR and PT_INTERP in SegmentFilter.

On #2274: it also edits these lines of WriteLayout. I'll rebase whichever way they land.

Written with AI assistance.

@Dextroid17

Copy link
Copy Markdown
Contributor

The corrected description is the right granularity: it names which kernels move and, more importantly, states that no kernel goes from working to failing. Splitting 5.15.33-5.17 and 5.10.110+ (WSL2's 5.15.167 included) from older 5.15/5.10 is a much stronger claim than "every 5.15 or 5.10", because the older pair failing in both directions is a real bound rather than a hedge. I cannot add a kernel-version data point myself — this box runs 7.2 — so I am taking the backport versions on your reading.

On the PT_PHDR/PT_INTERP follow-up: the l_addr = phdr - p_vaddr point is what makes it a defect rather than tidiness, and the asymmetry you found is the interesting part — glibc's last-wins against the kernel's first-wins means the two consumers disagree about the same table. That is worth the separate PR, and dropping the input's entries in SegmentFilter sounds like the right place. Happy to run it independently when it exists; the relinker does not need a GPU, so I can do that properly here.

On #2274: agreed, rebase whichever lands first, and I will re-check the ordering claim on whichever head survives.

Written with AI assistance.

@Mouhand-Kaddo

Copy link
Copy Markdown
Contributor

Independent Windows and Arch WSL validation of head 1352b9b34d0301e71bc31e8260a16db73aed5b9b, applied unchanged to current main 8b393b6e in isolated checkouts:

  • Windows 11 build 26300, WinLibs GCC 15.2.0 r7 (x86_64-ucrt-posix-seh), CMake 4.3, Ninja, Python 3.12.14: Release relinker-only build, BUILD_TESTING=ON, 41/41 CTests passed, parallel 4, 30 s per-test timeout, 17.30 s total.
  • Arch Linux in WSL2, kernel 6.6.87.2-microsoft-standard-WSL2, GCC 16.2.1, CMake 4.4.4, Python 3.14.7: the same Release relinker-only configuration, 44/44 CTests passed, parallel 4, 30 s per-test timeout, 4.71 s total. This includes linux_program_header_order and native Linux entry execution.

Additional title observation: Demon's Souls stopped on an unmapped global during initialization with the main-generated Linux executable, whose first PT_LOAD was the high-address header block. After reconverting with this PR, it passes that access, reports the direct-memory budget, and maps its initial heaps. It then faults later with guest allocations placed at native high Linux addresses; I am investigating #861 next. This is startup progress, not a playable-title claim. The runtime libraries for this title observation were main 8b393b6e plus #2310 and #2054, distinct from the isolated relinker test branches above.

No full runtime suite was run for this PR; these counts cover the complete relinker-only suites. Validation performed with OpenAI Codex assistance.

@github-actions

github-actions Bot commented Oct 9, 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
#2274 core/relinker/elfpatcher/src/general/ProgramHeaderLayoutBuilder.cpp L134-135, L144, L149-150

@adriadam10

Copy link
Copy Markdown
Collaborator

Checked: merges cleanly onto current main, relinker build + ctest 43/43 here; linux_program_header_order fails when ProgramHeaderLayoutBuilder is reverted to main and passes with the change. PT_PHDR/PT_INTERP first, PT_LOAD ascending, kept non-load entries preserved. Windows and WSL2 runs by @Mouhand-Kaddo and the kernel-source check by @Dextroid17 agree.

Note: #2274 edits the same part of WriteLayout; whichever lands second will need a rebase.

Merging.

@adriadam10
adriadam10 merged commit c9da0f9 into boykopovar:main Oct 10, 2026
6 of 7 checks passed
SleglJan pushed a commit to SleglJan/AnyPS5 that referenced this pull request Oct 10, 2026
fix(relinker): write Linux PT_LOAD headers in ascending address order
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.

4 participants