Skip to content

fix(macos): emit loadable Mach-O with true ELF segment mapping - #10

Merged
aarvsn merged 4 commits into
aarvsn:mainfrom
BioXeon:fix/macho-writer-segments
Oct 10, 2026
Merged

aarvsn merged 4 commits into
aarvsn:mainfrom
BioXeon:fix/macho-writer-segments

Conversation

@BioXeon

@BioXeon BioXeon commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Fixes the macOS writer problems described in #9.

What changed

  • Parse the incoming (relinked) ELF program headers and emit one
    LC_SEGMENT_64 per PT_LOAD, mapping guest vaddrs exactly — guest
    absolute addresses keep working, no rebasing.
  • Merge page-rounded overlapping PT_LOADs into a single segment backed by a
    synthetic vaddr-indexed image (ELF bss holes zero-filled).
  • Embed a proper section_64 __text inside its __TEXT segment command
    (section_64 now has all 12 fields incl. reserved3, cmdsize 72+80).
  • Drop the malformed embedded code-signature blob; emit an empty
    __LINKEDIT so codesign(1) can add a real ad-hoc signature.
  • Clear MH_PIE (dyld must not slide the guest image).
  • Patch trampoline bodies into a dedicated RX segment placed after the guest
    image; jump displacements computed against true guest vaddrs.
  • Pad the file to __LINKEDIT start so no segment points past EOF.
  • Update TestMacOsPatcher to build a well-formed minimal ELF64 and assert
    the new per-segment layout.

Verification (Apple Silicon, M2)

Follow-up idea (not in this PR): ship the loader PoC as a runtime helper so
low-address PS5 titles can be executed on Apple Silicon despite Rosetta
reserving the low address space.

The macOS writer previously emitted a monolithic __TEXT at 0x100000000
with nsects=0, RWX max protection and a malformed embedded ad-hoc code
signature blob, which macOS/AMFI rejects (SIGKILL) and codesign(1)
refuses to re-sign ('main executable failed strict validation').

Rewrite the writer to produce a faithful host image of the guest:

- Parse the incoming (relinked) ELF program headers and emit one
  LC_SEGMENT_64 per PT_LOAD, mapping guest vaddrs exactly (no rebasing
  of absolute guest addresses).
- Merge page-rounded overlapping PT_LOADs into a single segment backed
  by a synthetic vaddr-indexed image (ELF bss holes zero-filled).
- Embed a proper section_64 (__text) inside its __TEXT segment command
  (cmdsize 72+80=152, section_64 now has all 12 fields incl. reserved3).
- Drop the broken embedded code signature; leave an empty __LINKEDIT so
  codesign(1) can add a real ad-hoc signature after linking.
- Clear MH_PIE: guest absolute addresses must not be slid by dyld.
- Patch trampoline bodies into a dedicated RX segment placed after the
  guest image; jump displacements computed against true guest vaddrs.
- Pad the file to __LINKEDIT start so no segment points past EOF.

Verified on Apple Silicon (M2, macOS 27.2):
- otool -l clean (no 'Inconsistent size'), codesign ad-hoc succeeds.
- End-to-end proof: PS5-style ELF -> ryty --macos -> image -> custom
  loader -> Rosetta executes guest x86_64 code; crash report shows the
  fault exactly at a magic address encoded in the guest entry stub.
- Note for PS5 titles loading below ~0x10000000: Rosetta occupies the
  low address space in translated processes, so such images must be run
  through a loader rather than as the main executable.
TestMacOsPatcher previously fed a 256-byte buffer with ELF magic but no
program headers and asserted the legacy layout (14 load commands,
monolithic __TEXT at a fixed base). The rewritten writer maps guest
PT_LOAD segments at their own vaddrs, so the fixture now builds a
minimal well-formed ELF64 with a single R|X PT_LOAD at 0x10000000 and
asserts:
- 12 load commands (1 guest segment + trampoline + LINKEDIT + fixed),
- first command is an executable LC_SEGMENT_64 named __TEXT,
- the segment vmaddr equals the guest PT_LOAD vaddr (no rebasing).
@aarvsn

aarvsn commented Oct 9, 2026

Copy link
Copy Markdown
Owner

@BioXeon hey, I appreciate your changes but these 2 workflows are failing if you want your PR to be merged please fix this.

Apple-Silicon Rosetta reserves the low address space of translated
processes, so PS5 titles that load below ~0x10000000 (Stray, Biomutant —
the standard PS5 layout, entry at vaddr 0x80) cannot be mapped at their
own vaddrs, and page 0 is unmappable outright.

When the guest loads below 0x10000000, shift the whole image to
0x10000000 and fix up everything that stores an absolute guest pointer:

- apply DT_RELA R_X86_64_RELATIVE (write addend+delta) and
  R_X86_64_64/GLOB_DAT against in-image symbols (via DT_SYMTAB),
- shift PLT/GOT lazy-binding slots (DT_JMPREL, R_X86_64_JUMP_SLOT)
  whose stored value points at a PLT stub in the old space,
- shift DT_INIT/DT_FINI function pointers in the dynamic section.

Exec segments need no rewriting: PS5 code is compiled RIP-relative —
zero absolute instruction forms were found in the exec segments of
either title examined (verified by scanning for movabs with in-image
immediates).

Verified end-to-end with Biomutant (PPSA06255): the relinked image is
signed with codesign(1), mapped by the loader PoC, and the guest runs
its own init until the first NID import call — captured by the loader's
NID tracer.
@BioXeon

BioXeon commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Added a third commit that removes the main execution blocker described in #9: when the guest loads below 0x10000000 (the standard PS5 layout — both titles examined load at vaddr 0 with entry 0x80), the writer now shifts the image to 0x10000000 and applies its own dynamic relocations (943,577 R_X86_64_RELATIVE + 9,101 R_X86_64_64/GLOB_DAT against in-image symbols + 2,158 PLT/GOT lazy slots from DT_JMPREL + DT_INIT/DT_FINI). Exec segments need no rewriting — PS5 code is RIP-relative; a scan for movabs with in-image immediates found zero absolute instruction forms.

End-to-end result with Biomutant (PPSA06255) on Apple Silicon: ryty --macos → codesign(1) → loader PoC maps the guest at the shifted addresses → the game's own init code executes under Rosetta until its first NID import call, captured by a tracer: bzQExy189ZI#2#0. So on macOS the pipeline now reaches exactly the same frontier as the Linux backend: game code runs until the PS5 runtime (libs/*.prx implementations) is required.

The expression `~0xFFFull` produces `unsigned long long` while `hi` is
`std::uint64_t` (aka `unsigned long` on Linux GCC), preventing template
argument deduction in std::max. Cast explicitly.
@BioXeon

BioXeon commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the heads-up! Fixed — the failure was a GCC 13 template argument deduction issue: std::max(uint64_t&, unsigned long long) where ~0xFFFull produces unsigned long long but hi is uint64_t (= unsigned long on Linux). Added an explicit cast in commit 54f681b. CI should pass now — please re-run the checks.

@aarvsn

aarvsn commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Still fails, I will be resolving it on my end.

@aarvsn
aarvsn merged commit 1f45578 into aarvsn:main Oct 10, 2026
2 of 4 checks passed
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