The three restore-fatal DT root properties (root `model`, root
`target-type`, root `compatible[0]`) cannot be edited at fw_patch
time because `restored_external` / iBoot's restore mode cross-checks
them against the BuildManifest's signed `SupportedProductTypes`.
Editing them in the IPSW's `devicetree.im4p` causes the restore to
fail partway through.
But the cross-check fires ONLY during installation. After restore
completes, subsequent boots validate IM4P contents against the IM4M
only, which the existing iBSS/iBEC/LLB `image4_validate_property_callback`
bypass patches accept regardless. So an additional DT edit applied
AFTER restore but BEFORE the device reboots into the rootfs is
in-policy with the project's existing trust-chain bypass.
EXP-JB-6 exploits this. While the install pipeline still has `/mnt5`
(the preboot volume) mounted on the ramdisk, `cfw_install_exp.sh`:
1. scp_from's `/mnt5/<boot-hash>/usr/standalone/firmware/devicetree.img4`
to the host.
2. Runs `scripts/patchers/cfw_patch_post_restore_dt.py`, which:
- Unwraps IMG4 -> IM4P -> LZFSE-decompresses -> DT flat-binary
blob (via pyimg4).
- Rewrites three root properties:
root `model`: iPhone99,11 -> iPhone17,3
root `target-type`: VPHONE600 -> D47
root `compatible`: reorder so D47AP is first, VPHONE600AP
second (IOKit's AppleVMApple1IO platform
bind still resolves via the second
entry; userland reads only the first
for `hw.model`).
- Re-compresses LZFSE -> repacks IM4P -> repacks IMG4 with the
ORIGINAL IM4M (the per-board ticket survives — the image4
bypass already accepts any payload contents).
3. scp_to's the modified img4 back to the same path.
4. The device reboots out of ramdisk; iBoot loads the modified DT;
kernel populates `machine_info` from the new property values.
Idempotent: the patcher detects target-state-already-met and exits
without rewriting.
Userland effects on next boot:
- `sysctl hw.machine` -> "iPhone17,3" (was "iPhone99,11")
- `sysctl hw.product` -> "iPhone17,3" (was "iPhone99,11")
- `sysctl hw.model` -> "D47AP" (was "VPHONE600AP")
- Settings -> General -> About -> Model Identifier picks up the new
ProductType after the gestalt cache rebuilds.
- `scripts/patchers/cfw_patch_post_restore_dt.py` — host-side
img4 <-> IM4P <-> DT flat-binary round-trip via pyimg4. Mirrors the
DT format parser/serializer from `DeviceTreePatcher.swift`.
- `scripts/cfw_install_exp.sh` — EXP-JB-6 phase: discovers the
boot-manifest-hash via the same `get_boot_manifest_hash` helper used
by earlier install steps; tolerates a missing devicetree.img4 with
warn-and-skip. JB and DEV install scripts do NOT carry this step.
Splits `DeviceTreePatcher`'s property-patch list into two arrays:
- `basePropertyPatches` (4 entries: `serial-number`,
`home-button-type`, `artwork-device-subtype`,
`island-notch-location`) — applied for every variant. Behaviour
identical to pre-split.
- `identityPropertyPatches` (8 entries — Tier 1b + 1c userland-facing
identity surfaces) — applied only when `includeIdentityPatches` is
true, which `FirmwarePipeline` sets exactly when `variant == .exp`.
The 8 EXP-only identity properties flip userland-visible identity toward
D47AP / iPhone17,3:
- Tier 1b (5 properties, slot-length-preserving rewrites):
device-tree.target-sub-type: VPHONE600AP -> D47AP
device-tree.compatible[1]: iPhone99,11 -> iPhone17,3
(reorder, VPHONE600AP kept first
so IOKit's AppleVMApple1IO bind
still resolves)
device-tree/product.fdr-product-type: iPhone99,11 -> iPhone17,3
device-tree/product.sub-product-type: iPhone99,11 -> iPhone17,3
device-tree/product.unique-model: VPHONE600AP -> D47AP
- Tier 1c (3 properties — IOKit secondary matchers + Gestalt subtree
rename, matched against the real D47AP DT):
device-tree/arm-io.device_type: vresearch1-io -> t8140-io
device-tree/arm-io.soc-generation: VResearch1 -> H17
device-tree/product/vphone600-gestalt-variants.name (node rename):
vphone600-gestalt-variants
-> d47-gestalt-variants
Root `model` and root `target-type` are deliberately NOT in this list —
both have been empirically shown to break restore (signed-identity
cross-check in `restored_external`). Those edits run post-restore in a
later commit as EXP-JB-6.
- `sources/FirmwarePatcher/DeviceTree/DeviceTreePatcher.swift` — adds
`includeIdentityPatches: Bool = false` to init (backwards-compatible
default), stores it, splits `propertyPatches` into two static lists,
iterates base first and then optionally identity in `applyPatches`.
- `sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift` — passes
`includeIdentityPatches: variant == .exp` into the DT factory.
JB and other variants (regular, dev, less) leave the device's identity
properties untouched.
After the kernel-side OID rename (`KernelEXPPatchHvVmmRename`),
`sysctlbyname("kern.hv_vmm_present", ...)` returns ENOENT on this image.
`/usr/libexec/watchdogd` caches that answer at startup. On ENOENT the
cached byte stays at its BSS-zero default (`0`) and the downstream
`cbz w0, ...` at the IOWatchdog-lookup site takes a branch into
`_os_crash` -> `brk #1`; launchd's `_PanicOnCrash =
PanicOnConsecutiveCrash = true` flag in `com.apple.watchdogd.plist`
escalates the SIGTRAP to a kernel panic.
The cstring-mangle approach used for DSC dylibs doesn't apply here: we
want this binary to behave as if the sysctl returned 1, not as if it
returned ENOENT. Solution is a surgical 2-instruction patch that forces
the cached "am I a VM?" byte to 1 regardless of the sysctl result.
- `scripts/patchers/cfw_patch_watchdogd.py` — capstone-anchored pattern
matcher + Keystone-assembled 2-insn patch. Two functions match the
canonical caching shape on iPhone17,3 / iOS 26.1; both are patched.
Net effect: `cbnz w0, skip` -> NOP and `cset wN, ne` -> `mov wN, #1`,
forcing the cached byte to 1. watchdogd's pre-existing "detected
virtual machine environment, exiting..." clean-exit branch runs
instead of the trap path. Idempotent.
- `scripts/patchers/cfw_macho_codesign.py` — generic standalone-Mach-O
page-hash re-attestation. Parses `LC_CODE_SIGNATURE` directly, reads
page size from each `CS_CodeDirectory` header (4 KiB on watchdogd —
not the DSC's 16 KiB), handles short tail slot length
(`codeLimit - (n-1)*pageSize`), and updates every present CD. The
resulting cdHash change is accepted by the JB `patch_amfi_cdhash_in_trustcache`
kernel patch which accepts any cdHash; the patcher does NOT re-sign
with ldid (preserving the original Apple-issued code-signing
identifier is required for launchd's boot-task identity validation).
- `scripts/patchers/cfw.py` — adds `patch-watchdogd` subcommand.
- `scripts/patch_hv_vmm_userland.sh` — adds `watchdogd <binary>` op.
- `scripts/cfw_install_exp.sh` — invokes the patcher at step
`[EXP-JB-3.5]` on the live `/mnt1/usr/libexec/watchdogd` (scp-down,
patch, scp-up, chmod 0755). JB and DEV install scripts do NOT run
this step.
Companion user-mode patches to the kernel-side OID rename. Mangles
byte 5 of every `kern.hv_vmm_present` cstring inside DSC dylibs EXCEPT
those in `DONT_PATCH_INSTALL_NAMES` (sign-in / device-likeness consumers,
~15 entries). Patched dylibs query the renamed OID and get the truthful
1 (graphics + accel passthrough); blacklisted dylibs keep the original
cstring, hit ENOENT on the renamed kernel, defensively cache 0 ("not
running on a VM") for sign-in / device-attestation surfaces.
- `scripts/patchers/cfw_patch_hv_vmm_dsc.py` — DSC orchestrator. Walks
every `kern.hv_vmm_present\\0` cstring in any executable mapping,
resolves the containing dylib via Mach-O-header walk-back +
LC_ID_DYLIB, applies the byte-5 mangle to non-blacklisted dylibs, and
drives slot-hash re-attestation in the chunk's `CS_CodeDirectory`.
- `scripts/patchers/cfw_dsc_chunks.py` — pure-Python helper for the
chunked DSC layout: vmaddr<->chunk-fileoff mapping, install-name
walk-back, byte-level read/write at a vmaddr.
- `scripts/patchers/cfw_dsc_codesign.py` — per-page SHA-256 slot-hash
re-attestation for DSC chunks (16 KiB pages). Required on
`codeSigningMonitor == 2` (TXM) hardware where per-page hash checks
would otherwise SIGKILL the patched dylibs at first demand-page-in.
- `scripts/patchers/cfw_patch_hv_vmm.py` — standalone Mach-O variant of
the cstring mangle (kept for completeness; the historical
standalone-binary loop step was removed in favor of the
blacklist-flip-via-kernel-rename design).
- `scripts/patchers/cfw_patch_hv_vmm_rootfs.py` — rootfs-path inventory
shared with the install scripts (former JB-3.5 / 6.5/7 loop input).
- `scripts/patch_hv_vmm_userland.sh` — thin wrapper used by the install
script (`dsc` and `standalone` operations; `watchdogd` is added by
the next commit).
- `scripts/patchers/cfw.py` — new subcommands: `patch-hv-vmm`,
`patch-hv-vmm-dsc`, `list-hv-vmm-rootfs-paths`.
- `scripts/cfw_install_exp.sh` — adds a pre-step that decrypts the
SystemOS Cryptex AEA into the cache location `cfw_install.sh` already
uses, mounts it, applies the DSC patch, and unmounts. The unmodified
base `cfw_install.sh` then picks up the cached (patched) DMG.
Research docs in this commit describe the full EXP variant comprehensively
(`research/0_binary_patch_comparison.md` top-of-doc note, EXP-Only
Kernel Methods section, DSC userland subsection, plus forward references
to EXP-JB-3.5 / EXP-JB-6 / EXP-JB-7 subsections wired up by the following
commits; `research/firmware_manifest_and_origins.md` sections 9-11
similarly forward-describe the DT and build-version pieces).
JB and DEV variants are NOT affected: their install scripts don't invoke
any of this.
Introduces a new `exp` firmware variant on top of JB and ports the
`hv_vmm_present` sysctl rename to it.
Variant infrastructure
----------------------
- Adds `case exp` to `FirmwarePipeline.Variant`,
`VPhoneCLI.PatchFirmwareCLI.VariantOption`, and
`VPhoneVirtualMachine.Variant`. Every `switch variant` block in
`FirmwarePipeline.buildComponentList` includes the new case.
- Adds `make fw_patch_exp` + `make cfw_install_exp` Makefile targets
and an `EXP=1` flag for `make setup_machine`, mutually exclusive with
`JB=1` / `DEV=1` / `LESS=1`. `SPOOF_BUILD` env var is threaded
through for later use by the build-version step.
- Adds `--exp` to `scripts/setup_machine.sh` alongside `--jb` /
`--dev` / `--less`. The post-install JB-Finalize block also fires
for EXP (since EXP inherits the JB rootfs deployment).
- Adds `scripts/cfw_install_exp.sh` as the EXP install script
starting point: identical phase set to `cfw_install_jb.sh` (JB-1..JB-5)
with banner/header/footer updated for EXP. Subsequent commits in this
branch add the EXP-only experimental phases on top.
- Updates the variants table in `AGENTS.md` / `README.md` and the
three translated READMEs to include the new `Experimental (EXP)`
row, plus a paragraph describing what EXP adds on top of JB.
Kernel patch
------------
- New `KernelEXPPatcher` orchestrator in
`sources/FirmwarePatcher/Kernel/`, chained after `KernelPatcher` +
`KernelJBPatcher` for the `.exp` variant only. Inherits
`KernelJBPatcherBase` to reuse the JB symbol-table / ADRP-BL index /
branch-encoder infrastructure.
- New `KernelEXPPatchHvVmmRename` patch in
`sources/FirmwarePatcher/Kernel/EXPPatches/`. Part A flips byte 0 of
the NUL-delimited `\\0hv_vmm_present\\0` cstring (the sysctl OID's
`oid_name` value) — `'h'` -> `'X'` — so the kernel resolves
`sysctlbyname("kern.hv_vmm_present")` as ENOENT and
`sysctlbyname("kern.Xv_vmm_present")` to the OID's real int value.
Part B mangles byte 5 of every kernel-internal occurrence of
`kern.hv_vmm_present` so callers keep hitting the renamed OID; two
byte-aligned forms are covered (NUL-delimited cstring + sandbox-profile
name-token with trailing `\\x0f`).
- Patch IDs are `kernelcache_exp.hv_vmm_oid_rename` and
`kernelcache_exp.hv_vmm_internal_caller_mangle`. Idempotent.
- `KernelJBPatcher` is unchanged at the call-site level (no
`patchHvVmmRename` call); its docstring is updated to point at
`KernelEXPPatcher` for the EXP-only patch.
JB and other variants are NOT affected: `cfw_install_jb.sh` and
`cfw_install_dev.sh` are untouched in this commit.
Add rpc-project rpcserver_ios LaunchDaemon to CFW install, update
cfw_input.tar.zst with the plist, and add iproxy 5910 + rpcclient
to README.
Co-authored-by: zqxwce <[email protected]>
Update vphoned build step to compile all split source files instead of
just vphoned.m. Tracks all .m files for staleness checking and passes
them to clang with the correct -I include path.
Co-authored-by: zqxwce <[email protected]>