Repository navigation
Give a pseudo-process child's emulator and pool copies its own ntdll - #141
Merged
Merged
Conversation
## 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
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 hitsNoExec instruction in entry blockand 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:unixcall_ios_push_jit_aliasesskipped every owner-tagged mapping, so the child's emulator mapped ntdll to the PARENT's copy. ntdll'sdispatch_syscall(EC code running from the child's copy) sets the x64 Pc toinvoke_arm64ec_syscallin 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.ios_jit_patch_stale_pointertranslated each copy by itsowner_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'sKiUserExceptionDispatcheron its next syscall (NoExec at the parent'sinvoke_arm64ec_syscall).Change
build/ntdll-unix/virtual_ios.conly:struct ios_jit_mappinggetsmap_peb, the process whose thread registered the copy (ios_jit_add_mapping:ios_jit_current_peb(); child ntdll copy: the child).ios_jit_reclaim_processdrops 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.owner_peb, elsemap_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 ofios_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 redirectwhen 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 existingcheck-image-retire,check-lazy-windows,check-small-va-band,check-unixlib-binder.Evidence
iPhone 17 Pro Max, iOS 27, GTA V Enhanced:
[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).Notes / risks
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.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.clang -fsyntax-onlyof virtual_ios.c against upstream shows no new diagnostics.🤖 Generated with Claude Code
Claude-Session: https://claude.ai/code/session_0189oLHghpaYKLk4f786a6bc