kernel: jb: Fix iOS 27 guest display via IOUC sandbox gate bypass

iOS 27 userland on the 26.4 vphone600 kernel had SpringBoard crash-looping
with no display. Root cause: the IOKit user-client open path runs TWO
independent MAC gates -- a MACF-aggregator check (already patched by
patchIoucFailedMacf) and a separate Sandbox check -- and the Sandbox gate
spuriously DENIES the render server (backboardd) its opens of
IOMobileFramebufferUserClient / IOSurfaceRootUserClient / IOHIDEventService.
27-specific: absent on a native 26.4 userland. Denied the framebuffer,
backboardd can't present (no Apple logo) and vends no main display, so
FBSDisplayMonitor asserts on a nil mainDisplay -> crash-loop.

Add patchIoucFailedSandbox (KernelJBPatchIoucSandbox.swift), mirroring
patchIoucFailedMacf: anchor on the "IOUC %s failed sandbox in process %s"
string, find the deny block (the CBNZ target enclosing the fail-log ADRP),
and rewrite its first instruction with an unconditional B to the
NotPermitted allow-proceed target. Structural anchors only, no hardcoded
offsets. No-op where the gate already allows (native 26.x userlands).

Verified: backboardd opens the framebuffer, SpringBoard runs (0 crashes),
display enumerates (LCD/primary), [CADisplay mainDisplay] resolves, and the
guest GUI renders (confirmed visible over VNC).

Disable three earlier wrong-theory display patches (kept in-tree, off in
findAll, for the record):
 - patchParavirtDisplayPrimary: setting primary=1 is actively HARMFUL on 27
   -- it becomes the display NAME suffix ("primary-1") and breaks the render
   server's exact-name match.
 - patchIomfbSwapEnd{VariableSize,HandlerSize}: iOS 27 never uses IOMFB
   method 5 (SwapEnd) for present -- confirmed by kernel trace of the real
   handler (cmp w2,#0x588) with SpringBoard actively presenting -- so these
   are irrelevant on 27 and would break 26.x's native 0x588 SwapEnd.

SwapEnd userland DSC size patch made per-base (cfw_install*, cfw.py,
cfw_patch_iomfb_swapend.py): 26.x validated, harmless on 27.

research/0_binary_patch_comparison.md updated (JB-10b added for the sandbox
gate; JB-02c corrected to disabled/wrong-theory).

Still open: the normal (VZ) render path stays black even though the guest
presents (VNC works) -- 27 uses a present mechanism the 26.4 paravirt-GPU
path doesn't receive. Tracked separately for follow-up.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
zqxwce
2026-07-20 16:20:34 +03:00
committed by zqxwce
co-authored by Claude Opus 4.8
parent 679d3d0476
commit c22da90929
9 changed files with 505 additions and 47 deletions
+4 -2
View File
@@ -127,6 +127,7 @@
| JB-01 | A | `patch_amfi_cdhash_in_trustcache` | `AMFIIsCDHashInTrustCache` | Always return true + store hash | Y |
| JB-02 | A | `patch_amfi_execve_kill_path` | AMFI execve kill return site | Convert shared kill return from deny to allow (superseded by C21; standalone only) | N |
| JB-02b| C | `patch_exec_security_policy_kill` | XNU exec `imgp->ip_mac_return` gate (kern_exec, `os_reason_create(OS_REASON_EXEC, EXEC_EXIT_REASON_SECURITY_POLICY)` site) | Flip `cbz wN, <skip>` → unconditional `b <skip>` so the exec-time MAC-verdict `SECURITY_POLICY` kill is unreachable. **Needed to run a userland NEWER than the kernel (iOS 27.0 on the 26.4 kernel):** AMFI's exec hooks reject the newer binaries' code-sign validation category, setting `ip_mac_return != 0` → core daemons (backboardd/cfprefsd/containermanagerd/…) die at exec (`namespace 9 / code 0x8`) → boot deadlock. Validated on 27.0/26.4: 0 SECURITY_POLICY kills, daemons launch, networking + SSH come up. No-op-in-effect for version-matched userlands (ip_mac_return == 0 there, so the original cbz already skips). | Y |
| JB-02c| B | `patch_paravirt_display_primary` | `AppleParavirtDisplay` IOKit `setProperty("primary", <flag>, 32)` value load (`ldr w2,[xN,#imm]`) | Rewrite the value load to `mov w2,#1` so the VM's single paravirt display is published `primary=1`. **Needed for the display on iOS 27:** 27 refactored FrontBoard (`FBSDisplayMonitor`) to REQUIRE a designated main display (`_initWithDisplays:mainDisplay:` hard-asserts non-nil; new `isMainDisplay:` threading — foldable/multi-display groundwork). The paravirt display was published `primary=0` / `Transport=Other`, so CoreDisplay/FrontBoard saw no built-in main display → `mainDisplay=nil` → SpringBoard crash-loops (`failed to initialize mainDisplay source`) → black screen. Anchor (structural): exact `"primary\0"` cstring xref whose site is the `setProperty(key,value,32)` form (`add …,#<primary>``mov w3,#0x20``blraa`), then the nearest preceding `ldr w2,[…]`. **DISABLED — WRONG THEORY, HARMFUL on iOS 27 (2026-07-15).** primary=1 was based on the mistaken belief that iOS 27 keys mainDisplay off the IOMFB `primary` property. It does not; instead 27 reads the `primary` property VALUE and appends it as the display-name suffix → the display is named `"primary-1"`, which then FAILS the render server's exact-name match → still no mainDisplay. The real cause was the IOUC sandbox gate (see JB-10b), which is the actual fix. Keep this OFF (primary must stay 0 so the name is `"primary"`). | N |
| JB-03 | C | `patch_cred_label_update_execve` | `_cred_label_update_execve` | Reworked C21-v3: C21-v1 already boots; v3 keeps split late exits and additionally ORs success-only helper bits `0xC` after clearing `0x3F00`; still disabled pending boot validation | N |
| JB-04 | C | `patch_hook_cred_label_update_execve` | sandbox `mpo_cred_label_update_execve` wrapper (`ops[18]` -> `sub_FFFFFE00093BDB64`) | Faithful upstream C23 trampoline: copy `VSUID`/`VSGID` owner state into pending cred, set `P_SUGID`, then branch back to wrapper | Y |
| JB-05 | C | `patch_kcall10` | `sysent[439]` (`SYS_kas_info` replacement) | Rebuilt ABI-correct kcall cave: `target + 7 args -> uint64 x0`; re-enabled after focused dry-run validation | Y |
@@ -135,6 +136,7 @@
| JB-08 | A | `patch_task_conversion_eval_internal` | `_task_conversion_eval_internal` | Allow task conversion | Y |
| JB-09 | A | `patch_sandbox_hooks_extended` | Sandbox MACF ops (extended) | Stub remaining 30+ sandbox hooks (incl. IOKit 201..210)| Y |
| JB-10 | A | `patch_iouc_failed_macf` | IOUC MACF shared gate | A5-v2: patch only the post-`mac_iokit_check_open` deny gate (`CBZ W0, allow` -> `B allow`) and keep the rest of the IOUserClient open path intact | Y |
| JB-10b| A | `patch_iouc_failed_sandbox` | IOUC *sandbox* shared gate (string `"IOUC %s failed sandbox in process %s"`) | **THE iOS-27 display fix (CONFIRMED 2026-07-15).** Sibling to JB-10: the IOUserClient open path has a SEPARATE Sandbox gate beyond the MACF one. On 27 userland / 26.4 kernel it spuriously DENIES the render server (backboardd) its IOMobileFramebuffer/IOSurface/HID userclient opens (27-specific: ABSENT on native 26.4; backboardd absent from every `IOUserClientCreator`) → no present (no Apple logo) + `mainDisplay=nil` → SpringBoard FBSDisplayMonitor crash-loop. Gate shape (same fn as JB-10): `blraa` sandbox check (PAC-indirect) → `cmp w0,#0xe00002c7` (kIOReturnNotPermitted) `b.eq <ALLOW>`; `ldr w8,[sp,#x]; cbnz w8,<DENY>` (other error → deny block w/ fail-log ADRP → returns error); w0==0 path → `b <ALLOW>`. Patch rewrites `<DENY>`'s first insn → `b <ALLOW>` (deny → allow-proceed), leaving the w0==0 path intact. Anchor is structural (fail-log string xref → the CBNZ whose target encloses it → the preceding `b.eq` allow target). Verified: backboardd then holds an IOMFB userclient, `[CADisplay mainDisplay]` resolves to `LCD/primary`, SpringBoard runs with 0 crashes. | Y |
| JB-11 | B | `patch_proc_security_policy` | `_proc_security_policy` | Bypass security policy | Y |
| JB-12 | B | `patch_proc_pidinfo` | `_proc_pidinfo` | Allow pid 0 info | Y |
| JB-13 | B | `patch_convert_port_to_map` | `_convert_port_to_map_with_flavor` | Skip kernel map panic | Y |
@@ -175,7 +177,7 @@ do NOT execute these).
| 6 | Weak dylib load injection | `launchd` | Load short alias `/b` (copy of `launchdhook.dylib`) at launch. On by default; set `DISABLE_LAUNCHD_HOOK=1` to skip because this pid-1 hook path is boot-critical and has produced boot-analysis failures | - | Y | Y |
| 7 | cstring byte 5 mangle `'h' → 'X'` (`"kern.hv_vmm_present"``"kern.Xv_vmm_present"`) + per-page slot-hash re-attestation, BLACKLIST semantics — **EXP only** | DSC dylibs | Companion to EXP kernel rename (`KernelEXPPatcher.patchHvVmmRename`). The mangle is applied to every DSC dylib EXCEPT those in `DONT_PATCH_INSTALL_NAMES` (sign-in / device-likeness consumers, ~15 entries). Patched dylibs query `kern.Xv_vmm_present` and get the truthful 1 (graphics / accel passthrough). Blacklisted dylibs keep the original cstring, hit ENOENT on the renamed kernel, cache 0, lie about VM presence. On `codeSigningMonitor == 2` hardware the byte-mangle alone causes `CODESIGNING/Invalid Page` SIGKILL because TXM enforces per-page hashes; the re-attestation pass recomputes the SHA-256 slot in the chunk's `CS_CodeDirectory` for every modified 16 KiB page. See `scripts/patchers/cfw_dsc_codesign.py` and `cfw_patch_hv_vmm_dsc.py`. | - | - | - |
| 8 | (removed — was: standalone-binary mangle in 6 rootfs Mach-Os via SSH) | n/a | Removed in the blacklist-flip redesign. With the EXP kernel rename in place, the 6 rootfs binaries (MobileActivationMigrator, CheckerBoard, StoreKitUISceneService, storekitd, appstored, CorePrescriptionService) get the desired "cache 0 / not in a VM" behavior for free: they keep their original cstring, hit ENOENT on the renamed kernel sysctl, defensive `cbnz w0, skip` leaves the cached byte at BSS-zero. No SSH-time standalone patch needed. | - | - | - |
| 9 | `mov w3,#<size>` -> `mov w3,#0x560` in `_kern_SwapEnd`**26.0/26.0.1 and 18.x** | DSC `IOMobileFramebuffer` | Fixes host VZ GUI black-screen with the available PCC vphone600 userclient: userland sends a smaller external-method-5 SwapEnd state than the 26.1-era 0x560 the userclient expects, so SwapEnd returns `kIOReturnBadArgument` and the host display stays black (guest still renders — the Apple logo is visible over VNC, just not in the vphone-cli view). Source sizes observed: 26.0/26.0.1 = 0x548, 18.6.2 = 0x514. The patcher is semantic (anchors on `mov w1,#5` -> `mov w3,#imm` -> `mov x4,#0`/`mov x5,#0` -> `bl` inside `_kern_SwapEnd`) and idempotent — rewrites the size to 0x560 regardless of source and re-attests the modified DSC page. Install gate fires when `ProductVersion` starts with `26.0` or `18.`. Validated after host install on `17,3_26.0_23A341`, `17,3_26.0.1_23A355`, and `17,3_18.6.2_22G100` (Apple logo now renders in the vphone-cli view); 26.1 remains unmodified. | Y | Y | Y |
| 9 | `mov w3,#<size>` -> `mov w3,#<base-size>` in `_kern_SwapEnd`**26.0/26.0.1, 18.x, and 27.0** | DSC `IOMobileFramebuffer` | Fixes host VZ GUI black-screen with the available PCC vphone600 userclient: the userclient does an exact `checkStructureInputSize` check on external-method-5 (SwapEnd) input, so a userland whose `_kern_SwapEnd` sends a different-sized state gets `kIOReturnBadArgument` and the host display stays black (guest still renders — the Apple logo is visible over VNC, just not in the vphone-cli view). **The accepted size is a property of the base kernel, not the userland**: 26.1 base -> **0x560**, 26.4 base (xnu-12377) -> **0x588**. The 0x588 value is confirmed two ways: the sole dispatch-shaped entry in `kernelcache.*.vphone600` with `checkStructureInputSize==0x588` (scalarIn=0, scalarOut=0, structOut=0, preceded by a ptrauth code ptr, at decompressed file offset 0x9c7228), and empirically — native 26.5 userland sends 0x588 and displays correctly on this stack. Source (userland-sent) sizes observed: 18.6.2 = 0x514, 26.0/26.0.1 = 0x548, 27.0 (24A5380h) = 0x6e0. The patcher is semantic (anchors on `mov w1,#5` -> `mov w3,#imm` -> `mov x4,#0`/`mov x5,#0` -> `bl` inside `_kern_SwapEnd`) and idempotent — rewrites the size to `--target-size` regardless of source and re-attests the modified DSC page. **Target is chosen per base**: because reliably reading it from the kernelcache needs the IOMFB userclient dispatch table (a blind shape-scan is ambiguous — 8 candidates), the install keys the target off the userland version as an interim proxy for the validated base pairing — `27.*` -> 0x588 (26.4 base), `26.0*`/`18.*` -> 0x560 (26.1 base). Validated after host install on `17,3_26.0_23A341`, `17,3_26.0.1_23A355`, and `17,3_18.6.2_22G100` (Apple logo renders) against the 26.1 base; 26.4-base + 27.0 (-> 0x588) patched + re-attested clean on the 24A5380h DSC, pending on-device display confirmation. TODO: replace the version proxy with real per-kernel `checkStructureInputSize` detection. | Y | Y | Y |
| 10 | Zero `maxSlide` in `dyld_cache_header` (`@0xF0`) — **iOS 27.0 / any userland whose cache overflows the 6 GiB region** | DSC `dyld_shared_cache_arm64e` header | Fixes pid-1 `launchd` panic at boot on the vphone600 26.x kernel. The kernel reserves `SHARED_REGION_SIZE_ARM64 = 0x180000000` (6 GiB) and, at map time, needs room for the cache's mapped span **plus** the header `maxSlide` (ASLR range). iOS 27.0's cache (span `0x17c830000` ≈ 5.95 GiB) + `maxSlide 0x20000000` = `0x19c830000` > 6 GiB, so `_shared_region_map_and_slide` returns `ENOMEM`, dyld cannot map `libSystem.B.dylib`, and `launchd` panics (`initproc failed to start`). Zeroing `maxSlide` (LE u64) in the main chunk maps the cache at slide 0 (fits with ~58 MiB spare). Self-gating (`patch-dsc-maxslide`): no-op unless span + maxSlide > `0x180000000`, so 26.x / 18.x are untouched. **No** page re-attestation (header metadata, not a `cs_validate`'d code page — confirmed empirically). Validated on `17,3_27.0_24A5380h` + cloudOS 26.4 (`c0ecdb4b…`): `dyld cache mapped system-wide`, launchd reaches first unlock, vphoned connects as iOS 27.0.0, 0 panics. See `scripts/patchers/cfw_patch_dsc_maxslide.py`. | Y | Y | Y |
### Installed Components
@@ -657,7 +659,7 @@ cache rebuild.
| Procursus bootstrap deployment | - | - | Y (JB-2) | Y (JB-2) |
| BaseBin hook deployment (`*.dylib` -> `/mnt1/cores`) | - | - | Y (JB-3) | Y (JB-3) |
| First-boot JB finalization (`vphone_jb_setup.sh`) | - | - | Y (post-boot) | Y (post-boot) |
| IOMobileFramebuffer SwapEnd payload-size patch (`26.0 and 26.0.1` only) | Y | Y | Y (inherited from base run) | Y (inherited from base run) |
| IOMobileFramebuffer SwapEnd payload-size patch (`26.0/26.0.1`,`18.x` -> 0x560 / 26.1 base; `27.0` -> 0x588 / 26.4 base) | Y | Y | Y (inherited from base run) | Y (inherited from base run) |
| dyld cache `maxSlide` zero (`patch-dsc-maxslide`; only when cache overflows the 6 GiB region, e.g. iOS 27.0) | Y | Y | Y (inherited from base run) | Y (inherited from base run) |
| DSC pre-patch (`kern.hv_vmm_present` byte-5 mangle + slot reattest) | - | - | - | Y (pre-step, before base CFW) |
| DSC camera patches (12 patches across CMCapture / CoreMediaIO / AVFCapture / libMobileGestalt) | - | - | - | Y (pre-step, same cryptex mount as hv_vmm) |
+29 -10
View File
@@ -283,19 +283,38 @@ else
echo " [+] Cryptex installed"
fi
# Some userland versions send an IOMobileFramebuffer SwapEnd state smaller than
# the 26.1-era 0x560 the PCC vphone600 userclient expects, so SwapEnd returns
# kIOReturnBadArgument and the host VZ display stays black (guest still renders;
# visible over VNC). Known: 26.0/26.0.1 send 0x548, 18.x sends 0x514 (18.6.2).
# Patch only that immediate in the installed DSC; do not replace frameworks or
# normalize GPU metadata. The patcher is semantic + idempotent (rewrites the
# SwapEnd size to 0x560, no-op if already 0x560).
# Some userland versions send an IOMobileFramebuffer SwapEnd state whose size
# differs from what the PCC vphone600 userclient expects (an exact
# checkStructureInputSize check), so SwapEnd returns kIOReturnBadArgument and
# the host VZ display stays black (guest still renders; visible over VNC).
#
# The accepted size is a property of the BASE KERNEL, not the userland:
# - 26.1 base: userclient expects 0x560
# - 26.4 base (xnu-12377, current): userclient expects 0x588
# Reliably reading it from the kernelcache needs the IOMFB userclient dispatch
# table (a blind shape-scan is ambiguous — 8 candidates), so until that dynamic
# detection lands we key the target off the userland version as a proxy for the
# validated base pairing:
# - 27.x runs on the 26.4 base -> 0x588
# - 26.0/26.0.1 and 18.x validated on 26.1 base -> 0x560
# Known userland-sent sizes: 18.x -> 0x514, 26.0/26.0.1 -> 0x548, 27.0 -> 0x6e0.
# Patch only that immediate in the installed DSC; the patcher is semantic +
# idempotent (rewrites the SwapEnd size to the target, no-op if already there).
# NOTE: iOS 27 is intentionally NOT truncated here — its swap struct (0x6e0) has
# a new layout, so size-truncation to 0x588 feeds the kernel misaligned data.
# Instead the JB kernel patch `patchIomfbSwapEndVariableSize` makes the userclient
# accept iOS 27's native 0x6e0 struct (variable-size dispatch), so 27 must send
# its native size — leave it unpatched here.
IOS_VERSION=$(/usr/bin/plutil -extract ProductVersion raw -o - "$MNT1/System/Library/CoreServices/SystemVersion.plist" 2>/dev/null || true)
if [[ "$IOS_VERSION" == 26.0* || "$IOS_VERSION" == 18.* ]]; then
echo " [*] Patching IOMobileFramebuffer SwapEnd payload size (iOS $IOS_VERSION -> 0x560)..."
IOMFB_TARGET=""
case "$IOS_VERSION" in
26.0*|18.*) IOMFB_TARGET=0x560 ;;
esac
if [[ -n "$IOMFB_TARGET" ]]; then
echo " [*] Patching IOMobileFramebuffer SwapEnd payload size (iOS $IOS_VERSION -> $IOMFB_TARGET)..."
DSC_DIR="$MNT1/System/Cryptexes/OS/System/Library/Caches/com.apple.dyld"
[[ -d "$DSC_DIR" ]] || die "dyld cache dir missing: $DSC_DIR"
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-iomfb-swapend "$DSC_DIR"
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-iomfb-swapend "$DSC_DIR" --target-size "$IOMFB_TARGET"
fi
# Newer userlands ship a dyld shared cache that nearly fills the vphone600 26.x
+29 -10
View File
@@ -300,19 +300,38 @@ safe_detach "$MNT_APPOS"
echo " [+] Cryptex installed"
# Some userland versions send an IOMobileFramebuffer SwapEnd state smaller than
# the 26.1-era 0x560 the PCC vphone600 userclient expects, so SwapEnd returns
# kIOReturnBadArgument and the host VZ display stays black (guest still renders;
# visible over VNC). Known: 26.0/26.0.1 send 0x548, 18.x sends 0x514 (18.6.2).
# Patch only that immediate in the installed DSC; do not replace frameworks or
# normalize GPU metadata. The patcher is semantic + idempotent (rewrites the
# SwapEnd size to 0x560, no-op if already 0x560).
# Some userland versions send an IOMobileFramebuffer SwapEnd state whose size
# differs from what the PCC vphone600 userclient expects (an exact
# checkStructureInputSize check), so SwapEnd returns kIOReturnBadArgument and
# the host VZ display stays black (guest still renders; visible over VNC).
#
# The accepted size is a property of the BASE KERNEL, not the userland:
# - 26.1 base: userclient expects 0x560
# - 26.4 base (xnu-12377, current): userclient expects 0x588
# Reliably reading it from the kernelcache needs the IOMFB userclient dispatch
# table (a blind shape-scan is ambiguous — 8 candidates), so until that dynamic
# detection lands we key the target off the userland version as a proxy for the
# validated base pairing:
# - 27.x runs on the 26.4 base -> 0x588
# - 26.0/26.0.1 and 18.x validated on 26.1 base -> 0x560
# Known userland-sent sizes: 18.x -> 0x514, 26.0/26.0.1 -> 0x548, 27.0 -> 0x6e0.
# Patch only that immediate in the installed DSC; the patcher is semantic +
# idempotent (rewrites the SwapEnd size to the target, no-op if already there).
# NOTE: iOS 27 is intentionally NOT truncated here — its swap struct (0x6e0) has
# a new layout, so size-truncation to 0x588 feeds the kernel misaligned data.
# Instead the JB kernel patch `patchIomfbSwapEndVariableSize` makes the userclient
# accept iOS 27's native 0x6e0 struct (variable-size dispatch), so 27 must send
# its native size — leave it unpatched here.
IOS_VERSION=$(/usr/bin/plutil -extract ProductVersion raw -o - "$MNT1/System/Library/CoreServices/SystemVersion.plist" 2>/dev/null || true)
if [[ "$IOS_VERSION" == 26.0* || "$IOS_VERSION" == 18.* ]]; then
echo " [*] Patching IOMobileFramebuffer SwapEnd payload size (iOS $IOS_VERSION -> 0x560)..."
IOMFB_TARGET=""
case "$IOS_VERSION" in
26.0*|18.*) IOMFB_TARGET=0x560 ;;
esac
if [[ -n "$IOMFB_TARGET" ]]; then
echo " [*] Patching IOMobileFramebuffer SwapEnd payload size (iOS $IOS_VERSION -> $IOMFB_TARGET)..."
DSC_DIR="$MNT1/System/Cryptexes/OS/System/Library/Caches/com.apple.dyld"
[[ -d "$DSC_DIR" ]] || die "dyld cache dir missing: $DSC_DIR"
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-iomfb-swapend "$DSC_DIR"
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-iomfb-swapend "$DSC_DIR" --target-size "$IOMFB_TARGET"
fi
# ═══════════ 2/7 PATCH SEPUTIL ════════════════════════════════
+7 -2
View File
@@ -160,11 +160,16 @@ def main():
elif cmd == "patch-iomfb-swapend":
if len(sys.argv) < 3:
print("Usage: patch_cfw.py patch-iomfb-swapend <chunks_dir> [--dry-run]")
print("Usage: patch_cfw.py patch-iomfb-swapend <chunks_dir> "
"[--target-size <hex|int>] [--dry-run]")
sys.exit(1)
dry_run = "--dry-run" in sys.argv[3:]
kwargs = {}
if "--target-size" in sys.argv:
i = sys.argv.index("--target-size")
kwargs["target_size"] = int(sys.argv[i + 1], 0)
try:
patch_iomfb_swapend(sys.argv[2], dry_run=dry_run)
patch_iomfb_swapend(sys.argv[2], dry_run=dry_run, **kwargs)
except ValueError as e:
print(f"[-] {e}")
sys.exit(1)
+41 -23
View File
@@ -1,27 +1,41 @@
"""Patch iOS 26.0 and 26.0.1 IOMobileFramebuffer SwapEnd payload size.
"""Patch IOMobileFramebuffer SwapEnd payload size to match the base kernel.
The PCC vphone600 26.1 kernel-side IOMobileFramebuffer external method 5
expects the newer 0x560-byte swap state. iOS 26.0 and 26.0.1 userland send a
smaller state (0x548), so SwapEnd returns kIOReturnBadArgument and the VM
display stays black.
The SwapEnd input-state size is enforced kernel-side: the PCC vphone600
kernel's IOMobileFramebufferUserClient external method 5 (SwapEnd /
swap_submit) does an exact `checkStructureInputSize` check. A userland whose
`_kern_SwapEnd` sends a different size gets kIOReturnBadArgument, so no frame
is presented and the host VZ display stays black (the guest still renders
the Apple logo is visible over VNC, just not in the vphone-cli view).
The accepted size is a property of the BASE KERNEL, not the userland:
- 26.1 base (older): userclient expects 0x560.
- 26.4 base (xnu-12377, current): userclient expects 0x588. Confirmed two
ways: the sole dispatch-shaped entry in kernelcache.*.vphone600 with
checkStructureInputSize==0x588 (scalarIn=0, scalarOut=0, structOut=0,
preceded by a ptrauth code ptr), and empirically native 26.5 userland
sends 0x588 and displays correctly on this stack.
Known userland-sent sizes: 18.6.2 -> 0x514, 26.0/26.0.1 -> 0x548,
26.5 -> 0x588 (native match on 26.4), 27.0 (24A5380h) -> 0x6e0.
`_kern_SwapEnd` sets up an external-method-5 call:
ldr w0, [x0,#0x14]
add x2, x19,#0x18
mov w1,#5 <- external method selector 5
mov w3,#<size> <- input-state size (0x548 on 26.0; 0x588 on 26.5)
mov w3,#<size> <- input-state size (source; version-specific)
mov x4,#0
mov x5,#0
bl _io_connect_method
The `mov w3,#<size>` immediate is what this patcher rewrites to 0x560, the
size the 26.1 userclient accepts. The site is located dynamically: resolve
`_kern_SwapEnd`, disassemble it with Capstone, and anchor on the semantic
call-setup shape (selector `mov w1,#5` then `mov w3,#imm` then the zeroed
`mov x4,#0`/`mov x5,#0` and the `bl`). Nothing about the source size is
hardcoded, so this fires on 26.0 and 26.0.1 alike; the replacement immediate
comes from the Keystone-backed `asm()` helper.
The `mov w3,#<size>` immediate is what this patcher rewrites to the target
size (default 0x588 for the 26.4 base; override via `target_size`). The site
is located dynamically: resolve `_kern_SwapEnd`, disassemble it with Capstone,
and anchor on the semantic call-setup shape (selector `mov w1,#5` then
`mov w3,#imm` then the zeroed `mov x4,#0`/`mov x5,#0` and the `bl`). Nothing
about the source size is hardcoded it is discovered, never matched so this
fires on any userland; the replacement immediate comes from the
Keystone-backed `asm()` helper.
"""
import os
@@ -44,11 +58,13 @@ except ImportError: # direct self-test execution
IOMFB = "/System/Library/PrivateFrameworks/IOMobileFramebuffer.framework/IOMobileFramebuffer"
SWAPEND_SYMBOL = "_kern_SwapEnd"
# External-method selector for SwapEnd, and the input-state size the 26.1
# vphone600 userclient accepts. TARGET_SIZE is the semantic goal, not an
# anchor — the source immediate (0x548 on 26.0) is discovered, never matched.
# External-method selector for SwapEnd, and the input-state size the 26.4
# vphone600 userclient accepts (checkStructureInputSize). TARGET_SIZE is the
# semantic goal, not an anchor — the source immediate (e.g. 0x6e0 on 27.0) is
# discovered, never matched. Override via patch_iomfb_swapend(target_size=...)
# when building against a different base kernel (26.1 base wants 0x560).
SWAPEND_SELECTOR = 5
TARGET_SIZE = 0x560
TARGET_SIZE = 0x588
def _resolve_symbol(dsc_path, image, symbol):
@@ -114,7 +130,8 @@ def _find_swap_size_insn(insns):
return None
def patch_iomfb_swapend(chunks_dir, *, dsc_path=None, dry_run=False):
def patch_iomfb_swapend(chunks_dir, *, dsc_path=None, target_size=TARGET_SIZE,
dry_run=False):
chunks = DSCChunks(chunks_dir)
print(f" [.] {chunks!r}")
@@ -134,17 +151,17 @@ def patch_iomfb_swapend(chunks_dir, *, dsc_path=None, dry_run=False):
_reg, cur_size = _mov_reg_imm(target)
insn_vma = target.address
new_bytes = asm(f"mov w3, #{TARGET_SIZE}")
new_bytes = asm(f"mov w3, #{target_size}")
if len(new_bytes) != 4:
raise RuntimeError(f"expected 4 bytes, got {len(new_bytes)}")
if cur_size == TARGET_SIZE:
print(f" [=] already 0x{TARGET_SIZE:X} at 0x{insn_vma:X}; "
if cur_size == target_size:
print(f" [=] already 0x{target_size:X} at 0x{insn_vma:X}; "
f"re-attesting page only")
else:
action = "would patch" if dry_run else "patched"
print(f" [+] {action} {IOMFB} {SWAPEND_SYMBOL} size "
f"0x{cur_size:X} -> 0x{TARGET_SIZE:X} at 0x{insn_vma:X}")
f"0x{cur_size:X} -> 0x{target_size:X} at 0x{insn_vma:X}")
if not dry_run:
chunks.write_at_vma(insn_vma, new_bytes)
@@ -178,7 +195,8 @@ def _self_test():
reg, imm = _mov_reg_imm(target)
assert (reg, imm) == ("w3", 0x548), (reg, hex(imm))
assert target.address == 0x1000 + 12, hex(target.address)
assert asm(f"mov w3, #{TARGET_SIZE}") == bytes.fromhex("03ac8052")
assert TARGET_SIZE == 0x588, hex(TARGET_SIZE)
assert asm(f"mov w3, #{TARGET_SIZE}") == bytes.fromhex("03b18052")
print("self-test OK")
@@ -0,0 +1,144 @@
// KernelJBPatchIomfbSwap.swift JB kernel patch: make the IOMobileFramebuffer
// SwapEnd userclient accept the caller's native swap-struct size.
//
// Background: iOS 27's `_kern_SwapEnd` sends a 0x6e0-byte IOMFBSwapRec, but the
// 26.4 kernel's IOMobileFramebufferUserClient external-method-5 dispatch does an
// EXACT `checkStructureInputSize == 0x588` check 27's call is rejected
// (kIOReturnBadArgument), no frame presented. The userland size patch (truncate
// 0x6e0 -> 0x588) makes the check pass but feeds the 26.4 handler a truncated /
// misaligned struct (iOS 27's layout 26.4's) still no valid frame.
//
// This patch flips that dispatch entry's `checkStructureInputSize` to
// kIOUCVariableStructureSize (0xffffffff) so the kernel accepts the caller's
// native size and passes the full, correctly-laid-out struct to the handler.
// Combined with LEAVING iOS 27's userland at its native 0x6e0 (no truncation),
// the handler then reads its fields from an authentic iOS-27 IOMFBSwapRec which
// works iff iOS 27's layout is a superset of what the 26.4 handler reads.
//
// Anchor (structural, no hardcoded offsets): the sole IOExternalMethodDispatch
// entry whose shape matches the SwapEnd selector an 8-byte ptrauth-signed code
// pointer followed by checkScalarInputCount==0, checkStructureInputSize==0x588,
// checkScalarOutputCount==0, checkStructureOutputSize==0. Verified unique in the
// vphone600 26.4 kernelcache. Scanned in __DATA_CONST (where the dispatch table
// lives), 8-byte aligned.
//
// NOTE: only meaningful for an iOS-27 build; on a 26.x userland the native size
// is 0x588 anyway. Variable-size is safe for callers that send >= the fields the
// handler reads (26.5=0x588, 27=0x6e0 both do).
import Foundation
extension KernelJBPatcher {
private static let swapEndExpectedSize: UInt32 = 0x588 // 26.4 kernel's native SwapEnd struct size
private static let swapEndIOS27Size: UInt32 = 0x6e0 // iOS 27's native IOMFBSwapRec size
private static let kIOUCVariableStructureSize: UInt32 = 0xFFFF_FFFF
/// The swap_submit handler has a SECOND, internal exact-size gate beyond the
/// dispatch table's checkStructureInputSize:
/// cmp w2, #0x588 ; b.ne <error> (w2 = structureInputSize)
/// With iOS 27 sending 0x6e0 this branches to the error path (kIOReturnBadArgument,
/// swap aborted) even after the dispatch check is relaxed so no frame is ever
/// presented (no Apple logo, no UI). Retarget the compare to iOS 27's size so the
/// handler takes the success path and processes the native struct. Anchor is the
/// unique `cmp w2, #0x588` immediately followed by `b.ne` (semantic; the 0x588 is
/// the SwapEnd struct size the handler gates on the value being changed). Only
/// the imm12 field is rewritten, preserving the rest of the instruction.
@discardableResult
func patchIomfbSwapEndHandlerSize() -> Bool {
log("\n[JB] IOMFB swap_submit handler size gate cmp w2,#0x588 -> #0x6e0 (accept iOS 27 native struct)")
guard let (ks, ke) = kernTextRange else {
log(" [-] no kernel text range")
return false
}
// cmp w2,#imm == SUBS wzr,w2,#imm : 0x71000000 | imm12<<10 | Rn(2)<<5 | Rd(31)
let cmpW2Old: UInt32 = 0x7100_0000 | (Self.swapEndExpectedSize << 10) | (2 << 5) | 31
var hits: [Int] = []
var off = ks
while off + 8 <= ke {
if buffer.readU32(at: off) == cmpW2Old {
// Confirm the following instruction is a conditional b.ne (the gate).
if let nxt = disasAt(off + 4), nxt.mnemonic == "b.ne" {
hits.append(off)
}
}
off += 4
}
guard hits.count == 1 else {
log(" [-] swap_submit handler size gate (cmp w2,#0x588 -> b.ne) not found uniquely (found \(hits.count))")
return false
}
let cmpOff = hits[0]
// Rewrite only the imm12 field [21:10] to the iOS 27 size.
var word = buffer.readU32(at: cmpOff)
word = (word & ~(UInt32(0xFFF) << 10)) | (Self.swapEndIOS27Size << 10)
var le = word.littleEndian
var newBytes = Data(count: 4)
withUnsafeBytes(of: &le) { newBytes.replaceSubrange(0..<4, with: $0) }
let va = fileOffsetToVA(cmpOff)
emit(
cmpOff,
newBytes,
patchID: "iomfb_swapend_handler_size",
virtualAddress: va,
description: "swap_submit cmp w2,#0x588 -> #0x6e0 [accept iOS 27 native SwapEnd struct]"
)
return true
}
@discardableResult
func patchIomfbSwapEndVariableSize() -> Bool {
log("\n[JB] IOMFB SwapEnd dispatch checkStructureInputSize -> variable (accept iOS 27 native struct)")
guard let seg = segments.first(where: { $0.name == "__DATA_CONST" }), seg.fileSize > 0 else {
log(" [-] no __DATA_CONST segment")
return false
}
let start = Int(seg.fileOffset)
let end = start + Int(seg.fileSize)
var hits: [Int] = []
var off = start
while off + 24 <= end {
// entry: ptr(8) scalarIn(4) structIn(4) scalarOut(4) structOut(4)
let structIn = buffer.readU32(at: off + 12)
if structIn == Self.swapEndExpectedSize {
let scalarIn = buffer.readU32(at: off + 8)
let scalarOut = buffer.readU32(at: off + 16)
let structOut = buffer.readU32(at: off + 20)
let ptrHi = buffer.readU32(at: off + 4) // top word of the 8-byte fn ptr
let topByte = ptrHi >> 24
if scalarIn == 0, scalarOut == 0, structOut == 0, topByte >= 0x80 {
hits.append(off)
}
}
off += 8 // pointer-aligned dispatch entries
}
guard hits.count == 1 else {
log(" [-] SwapEnd dispatch entry not found uniquely (found \(hits.count))")
return false
}
let entryOff = hits[0]
let sizeFieldOff = entryOff + 12
var newBytes = Data(count: 4)
var v = Self.kIOUCVariableStructureSize.littleEndian
withUnsafeBytes(of: &v) { newBytes.replaceSubrange(0..<4, with: $0) }
let va = fileOffsetToVA(sizeFieldOff)
emit(
sizeFieldOff,
newBytes,
patchID: "iomfb_swapend_variable_size",
virtualAddress: va,
description: "SwapEnd checkStructureInputSize 0x588 -> variable [accept iOS 27 native IOMFBSwapRec]"
)
return true
}
}
@@ -0,0 +1,110 @@
// KernelJBPatchIoucSandbox.swift JB kernel patch: IOUC *sandbox* gate bypass.
//
// Sibling to patchIoucFailedMacf. The IOKit user-client open path runs TWO
// independent MAC gates: a MACF-aggregator check ("IOUC %s failed MACF in
// process %s", handled by patchIoucFailedMacf) and a Sandbox check ("IOUC %s
// failed sandbox in process %s"). Only the MACF one was patched.
//
// On iOS 27 userland atop the 26.4 vphone600 kernel, the Sandbox gate
// spuriously DENIES the render server (backboardd) its opens of
// IOMobileFramebufferUserClient / IOSurfaceRootUserClient / IOHIDEventService
// (confirmed via serial "IOUC IOMobileFramebufferUserClient failed sandbox in
// process pid <N>, backboardd" ABSENT on a native 26.4 userland, which
// displays fine, and backboardd is absent from every IOUserClientCreator). With
// the render server denied the framebuffer there is no present (no Apple logo)
// and no vended main display (SpringBoard's FBSDisplayMonitor asserts on a nil
// mainDisplay crash-loop).
//
// Real shape of the gate (offsets illustrative):
// blraa x8, x17 ; sandbox check (PAC-indirect, NOT a plain BL)
// mov w8, #0x2c7 ; movk w8,#0xe000,lsl#16 ; w8 = kIOReturnNotPermitted
// str w0, [sp,#X] ; cmp w0,w8 ; b.eq <ALLOW> ; NotPermitted allow
// ldr w8, [sp,#X] ; cbnz w8, <DENY> ; other error deny
// ... (w0==0 path) ... b <ALLOW>
// <DENY>: ...pac cleanup... ADRP "failed sandbox" ...log... return error
// <ALLOW>: str wzr,[sp,#X]; ...; bl <proceed-to-open>
//
// Fix: rewrite the FIRST instruction of the deny block (<DENY>, the CBNZ
// target that encloses the fail-log ADRP) with an unconditional B to <ALLOW>
// (the B.EQ / NotPermitted allow-proceed target). This turns the denied open
// into an allowed one while leaving the w0==0 (already-allowed) path untouched.
// Anchor is structural (fail-log string xref the CBNZ whose target encloses
// it the immediately-preceding B.EQ allow target); no hardcoded offsets.
import Foundation
extension KernelJBPatcher {
@discardableResult
func patchIoucFailedSandbox() -> Bool {
log("\n[JB] IOUC sandbox gate: deny-block → allow redirect")
guard let failStrOff = buffer.findString("IOUC %s failed sandbox in process %s") else {
log(" [-] IOUC failed-sandbox format string not found")
return false
}
let refs = findStringRefs(failStrOff)
guard !refs.isEmpty else {
log(" [-] no xrefs for IOUC failed-sandbox format string")
return false
}
for (adrpOff, _) in refs {
guard let funcStart = findFunctionStart(adrpOff) else { continue }
let funcEnd = findFuncEnd(funcStart, maxSize: 0x2000)
// Find the CBNZ Wn, <DENY> whose target encloses the fail-log ADRP.
var off = funcStart
while off < adrpOff {
defer { off += 4 }
let insn = buffer.readU32(at: off)
guard isCbnzW(insn) else { continue }
guard let denyEntry = cbTarget(insn, at: off) else { continue }
// The fail-log ADRP must sit inside the deny block.
guard denyEntry <= adrpOff, adrpOff < denyEntry + 0x60,
denyEntry > funcStart, denyEntry < funcEnd else { continue }
// The allow target is the NotPermitted B.EQ, a couple insns before
// the CBNZ (cmp ; b.eq <ALLOW> ; ldr ; cbnz). Search a small window.
var allowTarget = -1
for back in stride(from: off - 4, through: off - 0x14, by: -4) where back > funcStart {
let bi = buffer.readU32(at: back)
if let t = bCondEqTarget(bi, at: back), t > funcStart, t < funcEnd {
allowTarget = t
break
}
}
guard allowTarget >= 0 else { continue }
guard let patchBytes = ARM64Encoder.encodeB(from: denyEntry, to: allowTarget) else { continue }
let delta = allowTarget - denyEntry
let va = fileOffsetToVA(denyEntry)
log(" [+] IOUC sandbox gate fn=0x\(String(format: "%X", funcStart)), cbnz=0x\(String(format: "%X", off)), deny=0x\(String(format: "%X", denyEntry)) → allow=0x\(String(format: "%X", allowTarget))")
emit(denyEntry, patchBytes,
patchID: "iouc_sandbox_gate",
virtualAddress: va,
description: "b #\(delta >= 0 ? "" : "-")0x\(String(format: "%X", abs(delta))) [IOUC sandbox deny → allow]")
return true
}
}
log(" [-] narrow IOUC sandbox deny branch not found")
return false
}
/// CBNZ Wt, <label> (32-bit): high byte 0x35.
private func isCbnzW(_ insn: UInt32) -> Bool { ((insn >> 24) & 0xFF) == 0x35 }
/// Decode CBZ/CBNZ target (imm19, sign-extended, scaled by 4).
private func cbTarget(_ insn: UInt32, at pc: Int) -> Int? {
let imm19 = (insn >> 5) & 0x7FFFF
return pc + Int(Int32(bitPattern: imm19 << 13) >> 13) * 4
}
/// If `insn` is B.EQ <label>, return its target; else nil.
/// B.cond: [31:24]=0x54, [4]=0, cond=[3:0]; EQ cond = 0.
private func bCondEqTarget(_ insn: UInt32, at pc: Int) -> Int? {
guard (insn & 0xFF00_0010) == 0x5400_0000, (insn & 0xF) == 0x0 else { return nil }
let imm19 = (insn >> 5) & 0x7FFFF
return pc + Int(Int32(bitPattern: imm19 << 13) >> 13) * 4
}
}
@@ -0,0 +1,110 @@
// KernelJBPatchParavirtDisplay.swift JB kernel patch: publish the VM's single
// AppleParavirtDisplay as the primary/main display.
//
// iOS 27 refactored FrontBoard (FBSDisplayMonitor) to REQUIRE a designated main
// display: `-[FBSDisplayMonitor _initWithDisplays:mainDisplay:bookendObserver:transformer:]`
// hard-asserts `mainDisplay != nil`, and the new `isMainDisplay:` flag is threaded
// through display construction. (Groundwork for multi-display/foldable hardware.)
//
// The VZ paravirt display is published generic: `AppleParavirtDisplay` sets its IOKit
// "primary" property from a per-display field that is 0, so CoreDisplay/FrontBoard
// never sees a built-in/primary display mainDisplay stays nil SpringBoard
// crash-loops ("failed to initialize mainDisplay source -> mainDisplay=(null)")
// black screen. iOS 26.x had no main-display requirement, so the same VM booted fine.
//
// Force the published value to 1. In AppleParavirtDisplay the property is set via
// IOKit `setProperty("primary", value, 32)`; the value is loaded into w2 by a
// `ldr w2,[xN,#imm]` immediately before the call. Rewrite that load to `mov w2,#1`
// so the sole display is always published primary=1.
//
// Anchor (structural, no hardcoded offsets): the exact "primary\0" cstring xref
// whose call site is the `setProperty(key, value, numberOfBits=32)` form an
// `add xN,xN,#<primary>` followed within a few insns by `mov w3,#0x20` then a
// `blraa` distinguishing it from the sibling getProperty("primary") ref. From
// that ADRP, the nearest preceding `ldr w2,[]` is the value load to rewrite.
//
// Safe for iOS 26.x too: a single display legitimately IS the primary display, so
// forcing primary=1 matches reality; 26.x simply didn't require it.
import Foundation
extension KernelJBPatcher {
@discardableResult
func patchParavirtDisplayPrimary() -> Bool {
log("\n[JB] AppleParavirtDisplay 'primary' -> 1 (publish VM display as main; iOS 27 FBSDisplayMonitor)")
guard let (ks, ke) = kernTextRange else {
log(" [-] no kernel text range")
return false
}
// ADRP+ADD refs to the exact "primary\0" cstring within kext __TEXT_EXEC.
let refs = findStringRefs(in: (start: ks, end: ke), string: "primary")
guard !refs.isEmpty else {
log(" [-] no refs to \"primary\" cstring")
return false
}
var hits: [Int] = []
for (adrpOff, addOff) in refs {
// Confirm the setProperty(key, value, numberOfBits=32) shape: after the
// ADD that completes the "primary" pointer, a `mov w3,#0x20` then a
// `blraa`. Skips the sibling getProperty("primary") reference.
var sawBits = false
var sawCall = false
var o = addOff + 4
var steps = 0
while steps < 8, o + 4 <= ke {
guard let ins = disasAt(o) else { break }
if ins.mnemonic == "mov",
ins.operandString.contains("w3"),
ins.operandString.contains("0x20") {
sawBits = true
}
if ins.mnemonic == "blraa" {
sawCall = true
break
}
o += 4
steps += 1
}
guard sawBits, sawCall else { continue }
// The setProperty value lives in w2, loaded just before the ADRP via
// `ldr w2,[xN,#imm]` (the per-display primary flag == 0). Find it.
var l = adrpOff - 4
var back = 0
while back < 12, l >= ks {
if let ins = disasAt(l),
ins.mnemonic == "ldr",
ins.operandString.hasPrefix("w2,") {
hits.append(l)
break
}
l -= 4
back += 1
}
}
guard hits.count == 1 else {
log(" [-] paravirt-display primary value-load not found uniquely (found \(hits.count))")
return false
}
let ldrOff = hits[0]
guard let movBytes = ARM64Encoder.encodeMovzW(rd: 2, imm16: 1) else {
log(" [-] failed to encode mov w2,#1")
return false
}
let va = fileOffsetToVA(ldrOff)
emit(
ldrOff,
movBytes,
patchID: "paravirt_display_primary",
virtualAddress: va,
description: "ldr w2,[primary flag] -> mov w2,#1 [publish VM display as primary/main for iOS 27]"
)
return true
}
}
@@ -33,6 +33,13 @@ public final class KernelJBPatcher: KernelJBPatcherBase, Patcher {
patchTaskConversionEvalInternal()
patchSandboxHooksExtended()
patchIoucFailedMacf()
// iOS 27 userland on the 26.4 kernel: the IOKit user-client open path's
// Sandbox gate (separate from the MACF gate above) spuriously denies the
// render server (backboardd) its IOMobileFramebuffer/IOSurface/HID user
// clients no present (no Apple logo) + nil main display (SpringBoard
// crash-loop). Bypass it, mirroring the MACF gate. No-op where the gate
// already allows (native 26.x userlands).
patchIoucFailedSandbox()
// Group B
patchPostValidationAdditional()
@@ -65,6 +72,30 @@ public final class KernelJBPatcher: KernelJBPatcherBase, Patcher {
// No-op-in-effect for version-matched userlands (ip_mac_return == 0 there).
patchExecSecurityPolicyKill()
// DISABLED (kept off): these three were an earlier, WRONG theory that the
// iOS-27 black screen was an IOMFB SwapEnd size/struct ABI issue. Root
// cause is actually the IOUC *sandbox* gate denying backboardd the
// framebuffer fixed by patchIoucFailedSandbox() above. Leave these off:
// - patchParavirtDisplayPrimary sets the display's "primary" property=1,
// which iOS 27 turns into the device NAME suffix ("primary-1"), which
// then fails the render server's exact name match HARMFUL.
// - the SwapEnd patches target a present path iOS 27 never takes (method
// 5 is never called) and would break 26.x's native 0x588 SwapEnd.
//
// Publish the VM's single paravirt display as primary=1 so iOS 27's
// FBSDisplayMonitor finds a main display (else SpringBoard crash-loops on a
// nil mainDisplay assertion black screen). Harmless for iOS 26.x bases.
// patchParavirtDisplayPrimary()
// Accept iOS 27's native 0x6e0 IOMFBSwapRec (variable-size dispatch)
// instead of truncating userland to 0x588 (which misaligns 27's layout).
// Paired with leaving iOS 27 userland at native size (cfw_install gate).
// patchIomfbSwapEndVariableSize()
// The handler has a SECOND internal exact-size gate (cmp w2,#0x588) beyond
// the dispatch check; retarget it to iOS 27's 0x6e0 so the native struct
// reaches the real swap processing.
// patchIomfbSwapEndHandlerSize()
return patches
}