Skip to content

Give a pseudo-process child's emulator and pool copies its own ntdll - #141

Merged
willfaust merged 1 commit into
willfaust:mainfrom
bahacan16:pr/child-own-ntdll
Oct 4, 2026
Merged

willfaust merged 1 commit into
willfaust:mainfrom
bahacan16:pr/child-own-ntdll

Conversation

@bahacan16

Copy link
Copy Markdown
Contributor

Problem

An x64 pseudo-process child dies at its first x64 syscall. GTA V Enhanced (PlayGTAV.exe and GTA5_Enhanced.exe start each other as children) died this way in both start orders, and so did Ghost of Tsushima's crs-handler.exe. The child's thread hits NoExec instruction in entry block and ends in an access violation ([iOS-xquery] MISS ... addr=<child ntdll copy>+0x87050).

Once that was fixed, the same child died again later in two related ways: a [stale-heal] rewrote a slot in the child's emulator copy to the parent's ntdll, and the NtProtectVirtualMemory IAT sync wrote the child's ntdll change into the parent's ntdll copy.

Cause

A child runs a private ntdll copy (ios_jit_copy_module_for_child, owner_peb = the child), but three places still used the parent's copy:

  1. Alias drain. Every x64 pseudo-process loads its own emulator with its own alias table. unixcall_ios_push_jit_aliases skipped every owner-tagged mapping, so the child's emulator mapped ntdll to the PARENT's copy. ntdll's dispatch_syscall (EC code running from the child's copy) sets the x64 Pc to invoke_arm64ec_syscall in the child's pool copy; FEX can map a pool RIP back to its PE VA only through this table ([pool-rip-fix]), missed, and refused the block.
  2. Stale-pointer heal. ios_jit_patch_stale_pointer translated each copy by its owner_peb, which is set only for the per-process ntdll copies. Everything else a child maps (its emulator, exe, DLLs) has owner NULL, so a stale ntdll PE VA in it was healed to the session's ntdll copy. GTA's child then ran the parent's KiUserExceptionDispatcher on its next syscall (NoExec at the parent's invoke_arm64ec_syscall).
  3. IAT sync. The NtProtectVirtualMemory sync copied the region into the FIRST mapping whose PE range holds it. ntdll has the session copy plus one per child, so a child's change to ntdll went to the parent's copy.

Change

build/ntdll-unix/virtual_ios.c only:

  • struct ios_jit_mapping gets map_peb, the process whose thread registered the copy (ios_jit_add_mapping: ios_jit_current_peb(); child ntdll copy: the child).
  • The alias drain pushes the registering process's own copy of an image instead of the NULL-owner entry when it has one (FEX keeps one entry per PE range, so exactly one of the two is pushed). Other processes' copies are never pushed. A main process owns no copies, so its drain is unchanged.
  • ios_jit_reclaim_process drops the alias-push callback when the dying process is the one whose emulator holds it; otherwise the next image map would call into its reclaimed pool copy. The next emulator to register gets the whole table from its drain.
  • The stale heal translates each copy for owner_peb, else map_peb. A copy of an unknown process is left alone when the target image has per-process copies (the owner-aware exec-fault redirect keeps serving it). Tombstoned entries are skipped.
  • ios_iat_sync_pick_mapping: the copy owned by the writing process, else the first NULL-owner copy, else the first match (the rule of ios_jit_translate_addr_for_owner). The sync loop only syncs that copy.

New log lines: [alias-push] once per child emulator registration (only when it gets its own copy) and once when a callback is dropped; [stale-heal] ... left to the owner-aware fault redirect when a copy is skipped.

Host tests (ported from the fork, compile the production functions): tests/host/check-child-ntdll-alias.py (models one FEX alias table per emulator and replays the GTA child layout), check-stale-heal-owner.py, check-iat-sync-owner.py. All pass with ASan/UBSan, as do the existing check-image-retire, check-lazy-windows, check-small-va-band, check-unixlib-binder.

Evidence

iPhone 17 Pro Max, iOS 27, GTA V Enhanced:

  • Before: the child died at its first x64 syscall in both start orders.
  • With the drain fix: the child's emulator maps ntdll to its own copy ([alias-push] ... own copy), [pool-rip-fix] fires in the child, no NoExec; both start paths run the game child (also GTA5 -> PlayGTAV -> GTA5 grandchild).
  • With the heal fix: no crash any more; the game reaches its own in-game screen; the heal reports each copy translated for its process, no NoExec.
  • With the IAT-sync fix: the child's ntdll changes go to its own copy.
  • God of War and Ghost of Tsushima (main process only) unchanged.

Notes / risks

  • Main processes own no copies, so the drain, the heal and the sync behave exactly as before for them; only pseudo-process children change.
  • The fork had env switches for each of the three rules (MADEIRA_CHILD_OWN_NTDLL, MADEIRA_HEAL_OWNER, MADEIRA_IAT_SYNC_OWNER, =0 = old rule); they are dropped here since the change is device-proven and limited to children.
  • Not ported: in the fork a child's ntdll copy also gets the RtlPcToFileHeader pool-alias patch (ios_patch_rtl_pc_to_file_header_current); that patch is part of the separate C++ exception PR (pr/cxx-exceptions-pool-aliases), which already includes the child variant. Diagnostic-only lines (cross- process push census) were left out.
  • Known, not changed: the alias-push callback is global, so images mapped after a child registered its emulator go to that child's emulator, also when the main process maps them (they then take the exec-fault redirect in the main process).
  • Not compiled for iOS here; a differential clang -fsyntax-only of virtual_ios.c against upstream shows no new diagnostics.

🤖 Generated with Claude Code

Claude-Session: https://claude.ai/code/session_0189oLHghpaYKLk4f786a6bc

## Problem

An x64 pseudo-process child dies at its first x64 syscall. GTA V Enhanced
(PlayGTAV.exe and GTA5_Enhanced.exe start each other as children) died this
way in both start orders, and so did Ghost of Tsushima's `crs-handler.exe`.
The child's thread hits `NoExec instruction in entry block` and ends in an
access violation (`[iOS-xquery] MISS ... addr=<child ntdll copy>+0x87050`).

Once that was fixed, the same child died again later in two related ways: a
`[stale-heal]` rewrote a slot in the child's emulator copy to the parent's
ntdll, and the NtProtectVirtualMemory IAT sync wrote the child's ntdll change
into the parent's ntdll copy.

## Cause

A child runs a private ntdll copy (`ios_jit_copy_module_for_child`,
`owner_peb` = the child), but three places still used the parent's copy:

1. **Alias drain.** Every x64 pseudo-process loads its own emulator with its
   own alias table. `unixcall_ios_push_jit_aliases` skipped every owner-tagged
   mapping, so the child's emulator mapped ntdll to the PARENT's copy. ntdll's
   `dispatch_syscall` (EC code running from the child's copy) sets the x64 Pc to
   `invoke_arm64ec_syscall` in the child's pool copy; FEX can map a pool RIP
   back to its PE VA only through this table (`[pool-rip-fix]`), missed, and
   refused the block.
2. **Stale-pointer heal.** `ios_jit_patch_stale_pointer` translated each copy
   by its `owner_peb`, which is set only for the per-process ntdll copies.
   Everything else a child maps (its emulator, exe, DLLs) has owner NULL, so a
   stale ntdll PE VA in it was healed to the session's ntdll copy. GTA's child
   then ran the parent's `KiUserExceptionDispatcher` on its next syscall
   (NoExec at the parent's `invoke_arm64ec_syscall`).
3. **IAT sync.** The NtProtectVirtualMemory sync copied the region into the
   FIRST mapping whose PE range holds it. ntdll has the session copy plus one
   per child, so a child's change to ntdll went to the parent's copy.

## Change

`build/ntdll-unix/virtual_ios.c` only:

- `struct ios_jit_mapping` gets `map_peb`, the process whose thread
  registered the copy (`ios_jit_add_mapping`: `ios_jit_current_peb()`; child
  ntdll copy: the child).
- The alias drain pushes the registering process's own copy of an image
  instead of the NULL-owner entry when it has one (FEX keeps one entry per PE
  range, so exactly one of the two is pushed). Other processes' copies are
  never pushed. A main process owns no copies, so its drain is unchanged.
- `ios_jit_reclaim_process` drops the alias-push callback when the dying
  process is the one whose emulator holds it; otherwise the next image map
  would call into its reclaimed pool copy. The next emulator to register gets
  the whole table from its drain.
- The stale heal translates each copy for `owner_peb`, else `map_peb`. A copy
  of an unknown process is left alone when the target image has per-process
  copies (the owner-aware exec-fault redirect keeps serving it). Tombstoned
  entries are skipped.
- `ios_iat_sync_pick_mapping`: the copy owned by the writing process, else
  the first NULL-owner copy, else the first match (the rule of
  `ios_jit_translate_addr_for_owner`). The sync loop only syncs that copy.

New log lines: `[alias-push]` once per child emulator registration (only when
it gets its own copy) and once when a callback is dropped; `[stale-heal] ...
left to the owner-aware fault redirect` when a copy is skipped.

Host tests (ported from the fork, compile the production functions):
`tests/host/check-child-ntdll-alias.py` (models one FEX alias table per
emulator and replays the GTA child layout), `check-stale-heal-owner.py`,
`check-iat-sync-owner.py`. All pass with ASan/UBSan, as do the existing
`check-image-retire`, `check-lazy-windows`, `check-small-va-band`,
`check-unixlib-binder`.

## Evidence

iPhone 17 Pro Max, iOS 27, GTA V Enhanced:

- Before: the child died at its first x64 syscall in both start orders.
- With the drain fix: the child's emulator maps ntdll to its own copy
  (`[alias-push] ... own copy`), `[pool-rip-fix]` fires in the child, no
  NoExec; both start paths run the game child (also GTA5 -> PlayGTAV -> GTA5
  grandchild).
- With the heal fix: no crash any more; the game reaches its own in-game
  screen; the heal reports each copy translated for its process, no NoExec.
- With the IAT-sync fix: the child's ntdll changes go to its own copy.
- God of War and Ghost of Tsushima (main process only) unchanged.

## Notes / risks

- Main processes own no copies, so the drain, the heal and the sync behave
  exactly as before for them; only pseudo-process children change.
- The fork had env switches for each of the three rules
  (`MADEIRA_CHILD_OWN_NTDLL`, `MADEIRA_HEAL_OWNER`, `MADEIRA_IAT_SYNC_OWNER`,
  `=0` = old rule); they are dropped here since the change is device-proven
  and limited to children.
- Not ported: in the fork a child's ntdll copy also gets the RtlPcToFileHeader
  pool-alias patch (`ios_patch_rtl_pc_to_file_header_current`); that patch is
  part of the separate C++ exception PR (`pr/cxx-exceptions-pool-aliases`),
  which already includes the child variant. Diagnostic-only lines (cross-
  process push census) were left out.
- Known, not changed: the alias-push callback is global, so images mapped
  after a child registered its emulator go to that child's emulator, also
  when the main process maps them (they then take the exec-fault redirect in
  the main process).
- Not compiled for iOS here; a differential `clang -fsyntax-only` of
  virtual_ios.c against upstream shows no new diagnostics.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189oLHghpaYKLk4f786a6bc
Signed-off-by: bahacan16 <190844990+bahacan16@users.noreply.github.com>
willfaust pushed a commit that referenced this pull request Oct 4, 2026
…141)

Squashed from #141.

Signed-off-by: bahacan16 <190844990+bahacan16@users.noreply.github.com>
willfaust added a commit that referenced this pull request Oct 4, 2026
…ARM64EC child

- A child's exit can now clear ios_jit_alias_pushback_cb (#141). Both
  callers tested it and then read it again, so a parent mapping an image
  while a child exited could call NULL; they load it once with acquire
  semantics, and the register and drop paths store it with release.
- The RtlPcToFileHeader pool-alias patch (#135) runs for ARM64EC children
  only; an aarch64/WoW64 child logged a "not patched" line per process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@willfaust willfaust closed this Oct 4, 2026
@willfaust willfaust reopened this Oct 4, 2026
@willfaust
willfaust merged commit a70caf9 into willfaust:main Oct 4, 2026
spitefulowl added a commit to spitefulowl/Madeira that referenced this pull request Oct 4, 2026
A 64-bit PE whose fixed base is below 4 GB and whose relocations are
stripped (the default output of Delphi Win64 and older MinGW toolchains,
typically at 0x400000) cannot be mapped at its base on iOS, where nothing
is mapped below 4 GB. It is mapped high and its low addresses are served by
the ml938 sub-floor window (fault emulation, FEX's inline translation).
Such a program failed at every step; each fix below showed its own log line
in a device run on 2026-10-01 and the failure it targeted did not recur:

- ml1195, load: without a dynamic base the server hands out no map
  address, so the image was placed high with its header still naming the
  low base, and the loader refused it ("failed to create main module",
  c0000018). The header now names the real base; nothing is relocated.
  (run 6: "ml1195: ... ImageBase rewritten", the program started)
- ml1201, VirtualProtect/VirtualQuery on the image's own low addresses
  found no view (STATUS_INVALID_PARAMETER); a program patching 5 bytes of
  its own code died with a Delphi exception. A span inside one of this
  process's image windows is translated to the real mapping, as ml966 does
  for low allocations. (run 11: "ml1201: PROTECT ... -> real ...", status 0)
- ml1202, FlushInstructionCache on a low address ran `ic ivau` on unmapped
  memory and SEGV'd; it now flushes the real mapping of an image window or
  low allocation and skips other low addresses. (run 12)
- ml1203, unwind data: the PE-side RtlLookupFunctionTable (wine fork,
  pinned at the end of this branch) asks for the window of a low pc
  through the private NtQueryVirtualMemory class 1006 added here, so
  exceptions raised at low RIPs are no longer all unhandled. (run 13)
- ml1204, a relocatable DLL with the same preferred base as a running
  fixed-base image no longer re-points that image's window; the program had
  gone on running the DLL's bytes. (run 14)
- ml1205, windows per process: all pseudo-processes share one address
  space, and two processes with images at the same low base replaced each
  other's window and ran each other's code. Windows now carry the owner's
  PEB, lookups use the faulting or calling thread's PEB, each process's FEX
  gets only its own windows, they are released at process exit, and the
  table grows from 8 to 32 slots with free-slot reuse. Which process's FEX
  holds the callback is ios_jit_alias_pushback_peb, which upstream added
  for the same reason (willfaust#141); a push compares it after its single acquire
  load of the callback (19cbce3), and the catch-up loop of a registering
  emulator pushes that process's own windows and the shared ones. The
  window functions change arity (ios_subfloor_enum,
  ios_push_subfloor_window, ios_register_subfloor_image), and every caller
  declares them with a block-scope extern, which is why virtual_ios.c,
  signal_arm64_ios.c and server_ios.c change in this one commit, and the
  stubs of tests/host/check-child-ntdll-alias.py with them. (run 15: two
  windows with two owners, "released with its process")
- ml1206, a stripped fixed-base exe starts at the LOW address of its entry
  point: its absolute pointers are low, and code that combines a
  RIP-relative base with an absolute pointer (MinGW's pseudo-relocator)
  computed wrong targets. (run 15: "starts at its low entry")
- ml1208, the store emulator handles CASB/CASH in all acquire/release
  forms, which FEX emits for a byte or word `lock cmpxchg`; one on such an
  image's .bss was an unhandled encoding (c0000005). A misaligned halfword
  is emulated without atomicity (x86 keeps a split-lock word CAS atomic, so
  a concurrent writer could be lost there; rare). (run 17)

Kill switches (one cached getenv each, default on, catalog regenerated):
MADEIRA_SUBFLOOR_VM, MADEIRA_SUBFLOOR_SEH, MADEIRA_SUBFLOOR_LOWENTRY.

Fixes found in review, built only, not yet run on the device:
- ios_subfloor_image_translate skipped only other processes' windows and so
  also translated the shared KUSER_SHARED_DATA window (owner NULL):
  VirtualQuery(0x7ffe0000) answered with the real page and
  VirtualProtect(0x7ffe0000, PAGE_READWRITE) made it writable, which
  Windows refuses. It now skips that window (an image window registered
  without an owner is still translated, as before).
- ios_subfloor_window_held read the holder image's DOS/NT headers directly
  under virtual_mutex; an image that made its header page PAGE_NOACCESS
  would fault in unix code. The reads go through mach_vm_read_overwrite.
- The doc comment of ios_lowalloc_translate is back above that function.
- The ml1205 comment in ios_push_subfloor_window now states the known gap:
  a window registered after another process replaced the single FEX
  callback is not pushed to its owner's FEX (not a regression).

Tagged ml1195, ml1201, ml1202, ml1203, ml1204, ml1205, ml1206, ml1208.

Needs the wine and FEX branches of the same name (pinned in the last
commit): wine dlls/ntdll/unwind.c for ml1203, FEX's ml1207 invalidation of
both names of a window's code. clang -fsyntax-only with the build.sh flags:
no new warnings in the three files.

Rebuild: libntdll_unix.a (build/ntdll-unix/build.sh); nothing committed.

Signed-off-by: spitefulowl <spitefulowll@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

3 participants