mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-10-03 08:28:16 +08:00
feat(cfw): port the six Mach-O patchers, and take fw_prepare.sh off Python
Six CFW patchers move to Swift under FirmwarePatcher/CFW/Patches:
seputil, launchd_cache_loader, mobileactivationd, launchd jetsam,
watchdogd and diskimagesiod. Each was run against its Python on two
`cp -c` clones of the real 24A435 binary, with re-attestation off (what
the Python emits) and on (against cfw_macho_codesign's re-attester).
Every one is byte-identical both ways, and each was re-checked by a
second agent that rebuilt the evidence with its own driver.
seputil %s -> AA @0x1BDD2, slot 27
cache_loader cbz x0 @0xC7C -> nop
mobileactivationd should_hactivate @0x2EC368, slot 748
jetsam cbz w0 @0xFA98 -> b
watchdogd 2 sites x 2 insns, slots 4 and 10
diskimagesiod isMountComplete @0x320C0, slot 50
Every port is idempotent, and three of the Pythons are not: a second run
over their own output walks past the first patch and changes something
else (cache_loader NOPs the log-file guard, jetsam rewrites a second
branch, seputil exits 1). The shell never saw it because it copies from
the .bak first. The in-place call site in
CryptexFilesystemPatcherGuestPayload does not, and it now calls
CFWCacheLoaderPatcher and CFWMobileactivationd in process instead of
spawning cfw.py.
Review found one critical defect, fixed here: CFWCacheLoaderPatcher
trapped on every patch with logging on, which is the default. Its
before/after window starts two instructions ahead of the gate, and the
negative delta went through `UInt64(Int)`, which traps even under -O.
Every test passed `log: nil`. `loggingPathDoesNotTrap` now covers it.
fw_prepare.sh runs no Python any more. Its two heredocs, the firmware
support matrix and the version/build selector, are VPhoneFirmwareMatrix
behind `vphone-cli fw list` and `fw resolve`; output was compared with
the Python over the real README and a live `ipsw --urls` capture, in
pipe, pty, NO_COLOR and CLICOLOR_FORCE modes. The one divergence is a
mixed int/str sort the Python crashed on. The third block, a SHA-256
fallback for hosts with neither shasum nor sha256sum, could not run on
macOS and is now a `die`. `fw prepare` and `vm create` stop provisioning
a venv for it.
Behaviour change: `fw_prepare.sh --list` and the version selectors now
need a built vphone-cli, as the manifest step already did.
The shell call sites in cfw_install*.sh and cfw-kit still run cfw.py
until a `vphone-cli cfw` verb replaces them. Five lower-severity review
findings are recorded in research/0_binary_patch_comparison.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -262,6 +262,58 @@ addresses (49 gates each, zero verdict differences).
|
||||
cache-shaped file in the chunks directory turns a working patch into a hard
|
||||
abort. Fail-closed, and left alone deliberately.
|
||||
|
||||
### Swift port status — the six independent Mach-O patchers (2026-09-23)
|
||||
|
||||
Rows 1, 2, 3, 5 and 13 above, plus the EXP `watchdogd` patch, now have Swift
|
||||
implementations under `sources/FirmwarePatcher/CFW/Patches/`. The patches did not
|
||||
change. Each was run against its Python on two `cp -c` clones of the real 24A435
|
||||
binary in `ipsws/ref_extract/macho_pristine/`, both with re-attestation off (what
|
||||
the Python emits) and on (against `cfw_macho_codesign.reattest_modified_offsets`).
|
||||
|
||||
| Python | Swift | Sites | Parity on the real binary |
|
||||
| --- | --- | --- | --- |
|
||||
| `cfw_patch_seputil.py` | `CFWSeputil` | 1 (+1 slot) | `%s`→`AA` at `0x1BDD2`; sha256 `75dc86f8…` both sides. Re-attested: slot 27 identical. Anchored on the whole `__cstring` literal plus the `adrp`+`add` that builds it, not the Python's file-wide `find`. |
|
||||
| `cfw_patch_cache_loader.py` | `CFWCacheLoaderPatcher` | 1 (+1 slot, opt-in) | `cbz x0 @0xC7C` → `nop`; sha256 `17c5b00c…` both sides, 4 bytes differ from pristine. |
|
||||
| `cfw_patch_mobileactivationd.py` | `CFWMobileactivationd` | 1 (+1 slot) | `-[DeviceType should_hactivate]` at `0x2EC368`; sha256 `9f26bf92…` both sides. Re-attested: slot 748 identical. The Python's symbol match is a substring search that sees four candidates on this image; the Swift matches the exact selector and cross-checks ObjC metadata. |
|
||||
| `cfw_patch_jetsam.py` | `CFWJetsamPatcher` | 1 | `cbz w0 @0xFA98` → `b` (`launchd`); sha256 `cae806f5…` both sides. |
|
||||
| `cfw_patch_watchdogd.py` | `CFWWatchdogd` | 2 × 2 insns + 2 slots | Sites at `0x100004754` and `0x10000AB30`, slots 4 and 10; sha256 `963cd445…` both sides, `codesign -v` passes. |
|
||||
| `cfw_patch_diskimagesiod.py` | `CFWDiskimagesiod` | 1 (+1 slot) | `isMountComplete…` IMP at `0x320C0`, found through the relative method list; sha256 `41daf01d…` both sides. Re-attested: slot 50 identical. |
|
||||
|
||||
**Every port is idempotent, and three of the Pythons are not.** A second Python
|
||||
run over its own output walks past the first patch and changes something else:
|
||||
`cache_loader` NOPs the `cbnz x0 @0xC84` log-file guard, `jetsam` rewrites the
|
||||
`b.ne @0xFAB0` in the same return block, and `seputil` exits 1. The shell never
|
||||
hit this because it always copies from the `.bak` first. The Swift reports
|
||||
`already patched` and writes nothing, which is what the in-place call site in
|
||||
`CryptexFilesystemPatcherGuestPayload` needs.
|
||||
|
||||
**Wired in.** `CryptexFilesystemPatcherGuestPayload` now calls
|
||||
`CFWCacheLoaderPatcher` and `CFWMobileactivationd` in process instead of
|
||||
spawning `cfw.py`. The shell call sites (`cfw_install*.sh`, `cfw-kit`,
|
||||
`patch_hv_vmm_userland.sh`) still run the Python until a `vphone-cli cfw` verb
|
||||
replaces them.
|
||||
|
||||
**A defect that review found and this branch fixed:** `CFWCacheLoaderPatcher`
|
||||
trapped on every patch with logging on, which is the default. The before/after
|
||||
window starts two instructions ahead of the gate, and it converted that negative
|
||||
delta with `UInt64(Int)`, which traps even under `-O`. Every test passed
|
||||
`log: nil`, so none reached it. Now covered by `loggingPathDoesNotTrap`.
|
||||
|
||||
**Known, not fixed** (all LOW, none changes a byte on these binaries):
|
||||
|
||||
* A Mach-O truncated to about 64 bytes makes `diskimagesiod` and `watchdogd`
|
||||
trap in the shared `MachOParser` (`BinaryBuffer.swift:9` precondition) instead
|
||||
of throwing.
|
||||
* `CFWCacheLoaderPatcher.flagSetterOnResult` accepts a flag-setting instruction
|
||||
with `x0`/`w0` in any operand, including as the destination.
|
||||
* `CFWMobileactivationd`'s ObjC cross-check is scoped to the selector, not the
|
||||
class: it takes the first relative-method-list entry with that name.
|
||||
* `CFWJetsamPatcher`'s `.alreadyPatched` is silent. If a later firmware emits an
|
||||
unconditional `b` into the return block ahead of the real gate, it would read
|
||||
as already patched.
|
||||
* `CFWWatchdogdTests.swift` converts a VA to a file offset by subtracting the
|
||||
arm64 image base as a constant, in a test only.
|
||||
|
||||
### Installed Components
|
||||
|
||||
| # | Component | Description | Regular | Dev | JB |
|
||||
|
||||
+29
-177
@@ -87,6 +87,9 @@ resolve_vphone_binary() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Only ever used to name a cache file, so a short hash is enough. There used to
|
||||
# be a Python third branch here for hosts with neither tool; shasum ships with
|
||||
# macOS system Perl and this project is macOS-only, so it could not run.
|
||||
source_hash_suffix() {
|
||||
local src="$1"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
@@ -94,12 +97,7 @@ source_hash_suffix() {
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s' "$src" | sha256sum | awk '{print substr($1, 1, 12)}'
|
||||
else
|
||||
"$PYTHON3" - "$src" <<'PY'
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
print(hashlib.sha256(sys.argv[1].encode("utf-8")).hexdigest()[:12])
|
||||
PY
|
||||
die "neither 'shasum' nor 'sha256sum' found — cannot derive a cache name"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -159,182 +157,37 @@ style_status() {
|
||||
esac
|
||||
}
|
||||
|
||||
# The firmware support matrix — the README's "Tested Environments" table joined
|
||||
# against what Apple still serves — lives in VPhoneCore/VPhoneFirmwareMatrix.swift
|
||||
# now. The shell keeps the half it is good at: running `ipsw` and handing the
|
||||
# output over in DOWNLOADABLE_IPSW_URLS, the same variable the Python read, so
|
||||
# the contract between the two halves did not change.
|
||||
#
|
||||
# Colour is decided per stream inside the callee, from NO_COLOR, CLICOLOR_FORCE
|
||||
# and isatty: `fw list` styles stdout, `fw resolve` styles stderr (its stdout is
|
||||
# the $( ) capture below). That only stays right because the binary inherits
|
||||
# this script's descriptors — do not add a pipe or a tee.
|
||||
firmware_matrix_cli() {
|
||||
resolve_vphone_binary vphone-cli \
|
||||
|| die "cannot find vphone-cli for the firmware matrix — run 'make build'"
|
||||
}
|
||||
|
||||
list_firmwares() {
|
||||
local device="$1" readme_path="$2"
|
||||
local downloadable_urls
|
||||
local device="$1" readme_path="$2" cli downloadable_urls
|
||||
# Separate from `local`, which would swallow the substitution's status.
|
||||
cli="$(firmware_matrix_cli)"
|
||||
downloadable_urls="$(downloadable_ipsw_urls "$device")"
|
||||
DOWNLOADABLE_IPSW_URLS="$downloadable_urls" "$PYTHON3" - "$device" "$readme_path" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
device = sys.argv[1]
|
||||
readme_path = sys.argv[2]
|
||||
|
||||
def supports_color(stream):
|
||||
return not os.environ.get("NO_COLOR") and (stream.isatty() or os.environ.get("CLICOLOR_FORCE") == "1")
|
||||
|
||||
def styled_status(status, stream):
|
||||
text = f"{status:<11}"
|
||||
if not supports_color(stream):
|
||||
return text
|
||||
colors = {
|
||||
"Supported": "\033[32m",
|
||||
"Not Tested": "\033[33m",
|
||||
"Unsupported": "\033[31m",
|
||||
}
|
||||
color = colors.get(status)
|
||||
return f"{color}{text}\033[0m" if color else text
|
||||
|
||||
def load_supported_pairs(readme_path, device):
|
||||
supported = set()
|
||||
device_suffix = device.removeprefix("iPhone")
|
||||
in_section = False
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("## Tested Environments"):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and line.startswith("## "):
|
||||
break
|
||||
if not in_section:
|
||||
continue
|
||||
for match in re.finditer(r"`(?P<device>\d+,\d+)_(?P<version>[^_`]+)_(?P<build>[A-Za-z0-9]+)`", line):
|
||||
if match.group("device") == device_suffix:
|
||||
supported.add((match.group("version"), match.group("build")))
|
||||
except FileNotFoundError:
|
||||
return supported
|
||||
return supported
|
||||
|
||||
supported_pairs = load_supported_pairs(readme_path, device)
|
||||
rows = []
|
||||
for line in os.environ.get("DOWNLOADABLE_IPSW_URLS", "").splitlines():
|
||||
match = re.search(
|
||||
rf"/({re.escape(device)}_(?P<version>[^_]+)_(?P<build>[A-Za-z0-9]+)_Restore\.ipsw)$",
|
||||
line.strip(),
|
||||
)
|
||||
if match:
|
||||
rows.append((match.group("version"), match.group("build"), line.strip()))
|
||||
|
||||
if not rows:
|
||||
print(f"No downloadable IPSWs found for {device}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def version_key(version):
|
||||
parts = []
|
||||
for item in version.split("."):
|
||||
try:
|
||||
parts.append(int(item))
|
||||
except ValueError:
|
||||
parts.append(item)
|
||||
return tuple(parts)
|
||||
|
||||
rows = sorted(set(rows), key=lambda row: (version_key(row[0]), row[1]), reverse=True)
|
||||
print(f"Available downloadable IPSWs for {device}:")
|
||||
print("")
|
||||
print(
|
||||
"Status:",
|
||||
styled_status("Supported", sys.stdout),
|
||||
styled_status("Not Tested", sys.stdout),
|
||||
styled_status("Unsupported", sys.stdout),
|
||||
)
|
||||
print("")
|
||||
print(f"{'VERSION':<12} {'BUILD':<10} STATUS")
|
||||
for version, build, url in rows:
|
||||
status = "Supported" if (version, build) in supported_pairs else "Not Tested"
|
||||
print(f"{version:<12} {build:<10} {styled_status(status, sys.stdout)}")
|
||||
PY
|
||||
DOWNLOADABLE_IPSW_URLS="$downloadable_urls" \
|
||||
"$cli" fw list --device "$device" --readme "$readme_path"
|
||||
}
|
||||
|
||||
resolve_selector_from_downloads() {
|
||||
local device="$1" version="$2" build="$3" readme_path="$4"
|
||||
local downloadable_urls
|
||||
local device="$1" version="$2" build="$3" readme_path="$4" cli downloadable_urls
|
||||
cli="$(firmware_matrix_cli)"
|
||||
downloadable_urls="$(downloadable_ipsw_urls "$device")"
|
||||
DOWNLOADABLE_IPSW_URLS="$downloadable_urls" "$PYTHON3" - "$device" "$version" "$build" "$readme_path" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
device, version, build, readme_path = sys.argv[1:5]
|
||||
|
||||
def supports_color(stream):
|
||||
return not os.environ.get("NO_COLOR") and (stream.isatty() or os.environ.get("CLICOLOR_FORCE") == "1")
|
||||
|
||||
def styled_status(status, stream):
|
||||
text = status
|
||||
if not supports_color(stream):
|
||||
return text
|
||||
colors = {
|
||||
"Supported": "\033[32m",
|
||||
"Not Tested": "\033[33m",
|
||||
"Unsupported": "\033[31m",
|
||||
}
|
||||
color = colors.get(status)
|
||||
return f"{color}{text}\033[0m" if color else text
|
||||
|
||||
def load_supported_pairs(readme_path, device):
|
||||
supported = set()
|
||||
device_suffix = device.removeprefix("iPhone")
|
||||
in_section = False
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("## Tested Environments"):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and line.startswith("## "):
|
||||
break
|
||||
if not in_section:
|
||||
continue
|
||||
for match in re.finditer(r"`(?P<device>\d+,\d+)_(?P<version>[^_`]+)_(?P<build>[A-Za-z0-9]+)`", line):
|
||||
if match.group("device") == device_suffix:
|
||||
supported.add((match.group("version"), match.group("build")))
|
||||
except FileNotFoundError:
|
||||
return supported
|
||||
return supported
|
||||
|
||||
supported_pairs = load_supported_pairs(readme_path, device)
|
||||
matches = []
|
||||
for line in os.environ.get("DOWNLOADABLE_IPSW_URLS", "").splitlines():
|
||||
match = re.search(
|
||||
rf"/({re.escape(device)}_(?P<version>[^_]+)_(?P<build>[A-Za-z0-9]+)_Restore\.ipsw)$",
|
||||
line.strip(),
|
||||
)
|
||||
if not match:
|
||||
continue
|
||||
entry_version = match.group("version")
|
||||
entry_build = match.group("build")
|
||||
if version and entry_version != version:
|
||||
continue
|
||||
if build and entry_build != build:
|
||||
continue
|
||||
matches.append((entry_version, entry_build, line.strip()))
|
||||
|
||||
if not matches:
|
||||
prefix = styled_status("Unsupported", sys.stderr)
|
||||
if version and build:
|
||||
print(f"{prefix}: no downloadable IPSW matched device={device} version={version} build={build}", file=sys.stderr)
|
||||
elif build:
|
||||
print(f"{prefix}: no downloadable IPSW matched device={device} build={build}", file=sys.stderr)
|
||||
else:
|
||||
print(f"{prefix}: no downloadable IPSW matched device={device} version={version}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if version and not build:
|
||||
builds = sorted({item[1] for item in matches})
|
||||
if len(builds) > 1:
|
||||
print(f"Version {version} is ambiguous for {device}; specify one of these builds:", file=sys.stderr)
|
||||
print(f"{'BUILD':<10} STATUS", file=sys.stderr)
|
||||
for item in sorted(set(matches), key=lambda row: row[1], reverse=True):
|
||||
status = "Supported" if (item[0], item[1]) in supported_pairs else "Not Tested"
|
||||
print(f"{item[1]:<10} {styled_status(status, sys.stderr)}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
selected = sorted(set(matches), key=lambda row: row[1], reverse=True)[0]
|
||||
status = "Supported" if (selected[0], selected[1]) in supported_pairs else "Not Tested"
|
||||
print("\t".join(selected + (status,)))
|
||||
PY
|
||||
DOWNLOADABLE_IPSW_URLS="$downloadable_urls" \
|
||||
"$cli" fw resolve --device "$device" --version "$version" \
|
||||
--build "$build" --readme "$readme_path"
|
||||
}
|
||||
|
||||
download_file() {
|
||||
@@ -548,7 +401,6 @@ IPHONE_BUILD="${IPHONE_BUILD:-}"
|
||||
IPHONE_SOURCE="${IPHONE_SOURCE:-}"
|
||||
CLOUDOS_SOURCE="${CLOUDOS_SOURCE:-}"
|
||||
IPSW_DIR="${IPSW_DIR:-${SCRIPT_DIR}/../ipsws}"
|
||||
PYTHON3="${VPHONE_PYTHON:-python3}"
|
||||
|
||||
POSITIONAL=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
// CFWCacheloader.swift — open launchd_cache_loader's unsecure-cache gate.
|
||||
//
|
||||
// Swift port of `scripts/patchers/cfw_patch_cache_loader.py`, driven today by
|
||||
// `cfw.py patch-launchd-cache-loader <binary>`.
|
||||
//
|
||||
// What the binary does. `/usr/libexec/launchd_cache_loader` builds the XPC
|
||||
// service cache launchd loads at boot. Before it will accept a cache that did
|
||||
// not come from the sealed system volume it looks for the boot-arg
|
||||
// `launchd_unsecure_cache=` in `kern.bootargs`:
|
||||
//
|
||||
// adrp x0, "kern.bootargs" ; add x0, x0, #… ; add x1, sp, #…
|
||||
// bl <sysctl-by-name helper>
|
||||
// cbz x0, no_bootargs
|
||||
// ldr x0, [sp, #…] ; cbz x0, no_bootargs
|
||||
// adrp x1, "launchd_unsecure_cache=" ; add x1, x1, #… <- the string xref
|
||||
// mov x2, #0
|
||||
// bl <boot-arg lookup> <- the call
|
||||
// cbz x0, skip_unsecure_cache <- THE GATE
|
||||
// … <- "Using unsecure cache: %s"
|
||||
//
|
||||
// On a VM there is no way to set that boot-arg, so the gate always takes the
|
||||
// skip and the loader refuses anything but the stock cache. NOP'ing the one
|
||||
// `cbz` makes the unsecure path unconditional, which is what lets a MODIFIED
|
||||
// `/System/Library/xpc/launchd.plist` be loaded — the only reason this patch
|
||||
// exists (`research/0_binary_patch_comparison.md`, "Allow modified
|
||||
// launchd.plist"). A flavour that does not rewrite `launchd.plist` does not
|
||||
// need it; see `cfw-kit/lib/base_stages.sh:stage_launchd_cache_loader`.
|
||||
//
|
||||
// Nothing is hardcoded. The boot-arg string is found by content in the Mach-O's
|
||||
// own string sections, its ADRP+ADD xref is recovered from Capstone-decoded
|
||||
// operands, the call after it is found by control flow, and the gate is the
|
||||
// conditional branch that consumes that call's return register. The replacement
|
||||
// is `ARM64.nop`, which ARM64Constants generated with keystone.
|
||||
//
|
||||
// Two deliberate divergences from the Python, both documented at the site:
|
||||
//
|
||||
// 1. IDEMPOTENCE. The Python is not idempotent. On a second run it walks past
|
||||
// the NOP it wrote and takes the *next* conditional branch — `cbnz x0,`
|
||||
// over the log-file `fopen` — so it silently re-opens and truncates the log
|
||||
// on every cache build. This port recognises its own output and stops.
|
||||
// `CryptexFilesystemPatcher.patchLaunchdCacheLoader` patches in place with
|
||||
// no `.bak` restore, so that second run is reachable today.
|
||||
// 2. REFUSAL OVER GUESSING. The Python takes the first conditional branch it
|
||||
// sees after the call, whatever it tests. This port checks that the branch
|
||||
// really consumes the call's result and jumps forward out of the unsecure
|
||||
// path, and throws when it does not, rather than NOP'ing an unrelated
|
||||
// branch on a firmware whose shape has moved.
|
||||
//
|
||||
// Re-signing is OFF by default, which is what keeps this byte-identical to the
|
||||
// Python: every shipped caller re-signs the whole binary afterwards (`ldid_sign`
|
||||
// in `cfw_install*.sh`, `VPhoneSigner.sign` in the Swift call site), so slot
|
||||
// hashes written here would be thrown away. A caller that does NOT re-sign must
|
||||
// pass `reattestsCodeSignature: true` or ship a binary TXM will SIGKILL on the
|
||||
// first page-in of the patched page.
|
||||
|
||||
import Capstone
|
||||
import Foundation
|
||||
|
||||
/// NOPs the `launchd_unsecure_cache=` gate in `/usr/libexec/launchd_cache_loader`.
|
||||
public enum CFWCacheLoaderPatcher {
|
||||
// MARK: - Identity
|
||||
|
||||
/// Component name, matching the Python's `records.set_group`.
|
||||
public static let component = "launchd_cache_loader"
|
||||
|
||||
/// Record identity, matching the Python's `records.site` label so a captured
|
||||
/// reference and this port sort together.
|
||||
public static let patchID = "launchd_cache_loader.unsecure_cache_gate"
|
||||
|
||||
/// Substrings that name the gate's boot-arg, most specific first. The same
|
||||
/// list the Python carries: the later three are there for a firmware that
|
||||
/// renames the boot-arg, and have never matched on a shipping build.
|
||||
public static let anchorTokens = ["unsecure_cache", "unsecure", "cache_valid", "validation"]
|
||||
|
||||
// MARK: - Search windows
|
||||
|
||||
/// How far an ADD may sit from the ADRP it completes. Both are emitted by
|
||||
/// the same relocation pair, so the compiler keeps them close; 8 is the
|
||||
/// Python's window.
|
||||
static let maxADRPToADDInstructions = 8
|
||||
|
||||
/// How far past the string xref to look for the call it is an argument to.
|
||||
static let maxInstructionsToCall = 16
|
||||
|
||||
/// How far past that call the branch on its result may sit.
|
||||
static let maxInstructionsAfterCall = 8
|
||||
|
||||
/// The no-call fallback window, used only when no call follows the xref.
|
||||
static let maxFallbackInstructions = 32
|
||||
|
||||
// MARK: - Results
|
||||
|
||||
/// The boot-arg string the gate is built around, and the code that loads it.
|
||||
public struct Anchor: Sendable, Equatable {
|
||||
/// Which of ``anchorTokens`` matched.
|
||||
public let token: String
|
||||
/// The whole null-terminated string the token sits inside.
|
||||
public let text: String
|
||||
/// `"__TEXT,__cstring"` and the like — where the string was found.
|
||||
public let sectionName: String
|
||||
/// File offset of the string's FIRST byte, which is what code addresses.
|
||||
public let stringFileOffset: Int
|
||||
public let stringVMA: UInt64
|
||||
/// Address of the matched substring, which differs from ``stringVMA``
|
||||
/// whenever the token is not itself the start of the string.
|
||||
public let matchVMA: UInt64
|
||||
/// File offset of the ADRP that forms the string's address.
|
||||
public let referenceFileOffset: Int
|
||||
public let referenceVMA: UInt64
|
||||
}
|
||||
|
||||
/// The conditional branch that skips the unsecure-cache path — or the NOP a
|
||||
/// previous run already left in its place.
|
||||
public struct Gate: Sendable, Equatable {
|
||||
public let fileOffset: Int
|
||||
public let vma: UInt64
|
||||
/// Capstone's mnemonic: `cbz`/`cbnz`/`tbz`/`tbnz`/`b.<cond>`, or `nop`
|
||||
/// when this binary has already been patched.
|
||||
public let mnemonic: String
|
||||
public let operandString: String
|
||||
/// The call whose return value the gate tests, when one was found. The
|
||||
/// fallback path leaves this `nil`.
|
||||
public let callFileOffset: Int?
|
||||
public let callVMA: UInt64?
|
||||
/// Where the branch jumps when it is taken, i.e. past the unsecure path.
|
||||
/// `nil` once the gate is a NOP and there is no target left to read.
|
||||
public let targetVMA: UInt64?
|
||||
|
||||
/// True when the site already holds this patch's own output.
|
||||
public var wasAlreadyNOP: Bool { mnemonic == "nop" }
|
||||
|
||||
/// How the gate reads in disassembly.
|
||||
public var text: String {
|
||||
operandString.isEmpty ? mnemonic : "\(mnemonic) \(operandString)"
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run did.
|
||||
public enum Outcome: String, Sendable, Equatable {
|
||||
/// The gate already held a NOP. Nothing was written.
|
||||
case alreadyPatched
|
||||
/// `dryRun` was set, so the site was located and reported only.
|
||||
case wouldPatch
|
||||
/// The branch was replaced with a NOP.
|
||||
case patched
|
||||
}
|
||||
|
||||
/// The outcome of one run, and the site it acted on.
|
||||
public struct Report: Sendable {
|
||||
public let outcome: Outcome
|
||||
public let anchor: Anchor
|
||||
public let gate: Gate
|
||||
/// The write, in the shape the Python's reference capture records it.
|
||||
/// `nil` unless bytes actually changed.
|
||||
public let record: PatchRecord?
|
||||
/// Slot hashes recomputed, empty unless `reattestsCodeSignature` was set.
|
||||
public let reattestedSlots: [CFWSlotRehash]
|
||||
|
||||
/// Sites whose bytes this run changed. The parity number: the Python
|
||||
/// writes exactly one, and so must this.
|
||||
public var sitesWritten: Int { record == nil ? 0 : 1 }
|
||||
}
|
||||
|
||||
/// Where progress goes when the caller does not say. The Python prints to
|
||||
/// stdout and `cfw_install*.sh` captures that, so this does too.
|
||||
public static let stdoutLog: @Sendable (String) -> Void = { print($0) }
|
||||
|
||||
// MARK: - Patching
|
||||
|
||||
/// Open the gate in the `launchd_cache_loader` at `url`.
|
||||
///
|
||||
/// Idempotent: a binary this has already patched is reported
|
||||
/// `.alreadyPatched` and left untouched, byte for byte.
|
||||
///
|
||||
/// - Parameter reattestsCodeSignature: recompute the code-directory slot
|
||||
/// hash of the page the NOP lands in. Off by default because every
|
||||
/// shipped caller re-signs the binary wholesale afterwards; a caller that
|
||||
/// does not MUST set it, or TXM SIGKILLs the guest process.
|
||||
/// - Throws: ``PatcherError/patchSiteNotFound(_:)`` when the boot-arg
|
||||
/// string, its xref, or a branch with the gate's shape is missing — each
|
||||
/// of which means the firmware moved and has to stop the install rather
|
||||
/// than be guessed at.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
fileAt url: URL,
|
||||
dryRun: Bool = false,
|
||||
reattestsCodeSignature: Bool = false,
|
||||
log: ((String) -> Void)? = stdoutLog
|
||||
) throws -> Report {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw PatcherError.fileNotFound(url.path)
|
||||
}
|
||||
var data = try Data(contentsOf: url)
|
||||
let report = try patch(
|
||||
&data,
|
||||
dryRun: dryRun,
|
||||
reattestsCodeSignature: reattestsCodeSignature,
|
||||
log: log
|
||||
)
|
||||
if !dryRun, report.sitesWritten > 0 || !report.reattestedSlots.isEmpty {
|
||||
try data.write(to: url)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
/// In-memory form, for callers that already hold the binary.
|
||||
///
|
||||
/// `data` is left untouched unless the run writes, so an `.alreadyPatched`
|
||||
/// or `.wouldPatch` result cannot change a byte.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
_ data: inout Data,
|
||||
dryRun: Bool = false,
|
||||
reattestsCodeSignature: Bool = false,
|
||||
log: ((String) -> Void)? = stdoutLog
|
||||
) throws -> Report {
|
||||
// Every offset below is an absolute file offset, so the buffer has to be
|
||||
// zero-based — a slice handed in by a caller is not.
|
||||
if data.startIndex != 0 { data = Data(data) }
|
||||
|
||||
let located = try locateGate(in: data)
|
||||
let anchor = located.anchor
|
||||
let gate = located.gate
|
||||
|
||||
if anchor.text == anchor.token {
|
||||
log?(" Found anchor '\(anchor.token)' at va:0x\(hex(anchor.stringVMA))")
|
||||
} else {
|
||||
log?(" Found anchor '\(anchor.token)' inside \"\(anchor.text)\"")
|
||||
log?(" String start: va:0x\(hex(anchor.stringVMA)) "
|
||||
+ "(match at va:0x\(hex(anchor.matchVMA)))")
|
||||
}
|
||||
log?(" Found string ref at 0x\(hex(UInt64(anchor.referenceFileOffset)))")
|
||||
if let callVMA = gate.callVMA {
|
||||
log?(" Call at va:0x\(hex(callVMA)); gate tests its result")
|
||||
}
|
||||
|
||||
guard !gate.wasAlreadyNOP else {
|
||||
// The Python has no such branch: it walks past its own NOP and takes
|
||||
// the next conditional branch, which on this binary is the `cbnz`
|
||||
// guarding the log-file `fopen`. Stopping here is the whole
|
||||
// difference between running the installer twice and corrupting the
|
||||
// binary on the second pass.
|
||||
log?(" [=] already NOP at 0x\(hex(UInt64(gate.fileOffset))); nothing to do")
|
||||
// Re-attestation still runs when asked for: a binary the Python
|
||||
// patched carries a stale slot hash, and this is the one call that
|
||||
// can repair it without rewriting an instruction. It returns nothing
|
||||
// when the stored hashes already match, so the idempotent case stays
|
||||
// byte-stable.
|
||||
var slots: [CFWSlotRehash] = []
|
||||
if reattestsCodeSignature, !dryRun {
|
||||
slots = try CFWMachOCodeSignature.reattest(&data, modifiedOffsets: [gate.fileOffset])
|
||||
for slot in slots { log?(" re-attested \(slot)") }
|
||||
}
|
||||
return Report(
|
||||
outcome: .alreadyPatched,
|
||||
anchor: anchor,
|
||||
gate: gate,
|
||||
record: nil,
|
||||
reattestedSlots: slots
|
||||
)
|
||||
}
|
||||
|
||||
let nop = ARM64.nop
|
||||
let original = Data(data[gate.fileOffset ..< gate.fileOffset + nop.count])
|
||||
|
||||
log?(" Before:")
|
||||
log?(context(in: data, around: gate, marking: gate.fileOffset))
|
||||
|
||||
guard !dryRun else {
|
||||
log?(" [.] dry-run: would NOP \(gate.text) at 0x\(hex(UInt64(gate.fileOffset)))")
|
||||
return Report(
|
||||
outcome: .wouldPatch,
|
||||
anchor: anchor,
|
||||
gate: gate,
|
||||
record: nil,
|
||||
reattestedSlots: []
|
||||
)
|
||||
}
|
||||
|
||||
data.replaceSubrange(gate.fileOffset ..< gate.fileOffset + nop.count, with: nop)
|
||||
|
||||
log?(" After:")
|
||||
log?(context(in: data, around: gate, marking: gate.fileOffset))
|
||||
|
||||
let written = Data(data[gate.fileOffset ..< gate.fileOffset + nop.count])
|
||||
guard written == nop else {
|
||||
throw PatcherError.patchVerificationFailed(
|
||||
"\(component): gate at 0x\(hex(UInt64(gate.fileOffset))) reads \(written.hex) after write"
|
||||
)
|
||||
}
|
||||
|
||||
var slots: [CFWSlotRehash] = []
|
||||
if reattestsCodeSignature {
|
||||
slots = try CFWMachOCodeSignature.reattest(&data, modifiedOffsets: [gate.fileOffset])
|
||||
for slot in slots { log?(" re-attested \(slot)") }
|
||||
}
|
||||
|
||||
log?(" [+] NOPped at 0x\(hex(UInt64(gate.fileOffset)))")
|
||||
|
||||
return Report(
|
||||
outcome: .patched,
|
||||
anchor: anchor,
|
||||
gate: gate,
|
||||
record: record(anchor: anchor, gate: gate, original: original, patched: nop),
|
||||
reattestedSlots: slots
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Locating the gate
|
||||
|
||||
/// Find the gate without touching the binary.
|
||||
///
|
||||
/// The three failure modes are reported apart, because they mean different
|
||||
/// things: no boot-arg string at all (not this binary), a string with no
|
||||
/// xref (the check was compiled out), and an xref whose branch no longer has
|
||||
/// the gate's shape (the function was rewritten).
|
||||
public static func locateGate(in data: Data) throws -> (anchor: Anchor, gate: Gate) {
|
||||
let data = data.startIndex == 0 ? data : Data(data)
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
guard !sections.isEmpty else {
|
||||
throw PatcherError.invalidFormat("not a 64-bit Mach-O, or it carries no sections")
|
||||
}
|
||||
guard let text = sections["__TEXT,__text"] else {
|
||||
throw PatcherError.invalidFormat("__TEXT,__text not found")
|
||||
}
|
||||
|
||||
var firstFound: StringHit?
|
||||
for hit in stringHits(in: data, sections: sections) {
|
||||
if firstFound == nil { firstFound = hit }
|
||||
// The code addresses the string's first byte, so that VA is tried
|
||||
// first; the substring's own VA is the Python's fallback, for a
|
||||
// compiler that split the literal.
|
||||
let candidates = hit.stringVMA == hit.matchVMA
|
||||
? [hit.stringVMA]
|
||||
: [hit.stringVMA, hit.matchVMA]
|
||||
guard let reference = candidates.lazy.compactMap({ target in
|
||||
findStringReference(in: data, text: text, targetVMA: target)
|
||||
}).first else { continue }
|
||||
|
||||
let anchor = Anchor(
|
||||
token: hit.token,
|
||||
text: hit.text,
|
||||
sectionName: hit.sectionName,
|
||||
stringFileOffset: hit.stringFileOffset,
|
||||
stringVMA: hit.stringVMA,
|
||||
matchVMA: hit.matchVMA,
|
||||
referenceFileOffset: reference.fileOffset,
|
||||
referenceVMA: reference.vma
|
||||
)
|
||||
return (anchor, try findGate(in: data, text: text, anchor: anchor))
|
||||
}
|
||||
|
||||
guard let found = firstFound else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(component): none of \(anchorTokens) appears in this binary's string sections"
|
||||
)
|
||||
}
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(component): \"\(found.text)\" is present but nothing in __TEXT,__text "
|
||||
+ "forms its address (ADRP+ADD) — the boot-arg check looks compiled out"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The boot-arg string
|
||||
|
||||
/// One `anchorTokens` match, resolved to the whole C string around it.
|
||||
struct StringHit: Sendable, Equatable {
|
||||
let token: String
|
||||
let text: String
|
||||
let sectionName: String
|
||||
let stringFileOffset: Int
|
||||
let stringVMA: UInt64
|
||||
let matchVMA: UInt64
|
||||
}
|
||||
|
||||
/// Every token match, most specific token first and, within a token,
|
||||
/// `__TEXT,__cstring` before the rest.
|
||||
///
|
||||
/// Searching sections rather than the raw file — which is what the Python
|
||||
/// does — is what keeps a byte sequence inside the code signature or the
|
||||
/// link-edit tables from being mistaken for a string literal.
|
||||
static func stringHits(in data: Data, sections: [String: MachOSectionInfo]) -> [StringHit] {
|
||||
// A zerofill section has no file bytes, and its `offset` field is 0;
|
||||
// searching it would walk the Mach-O header instead.
|
||||
let searchable = sections.values
|
||||
.filter { $0.fileOffset > 0 && $0.size > 0 }
|
||||
.filter { Int($0.fileOffset) + Int($0.size) <= data.count }
|
||||
.sorted {
|
||||
let cstring = "__cstring"
|
||||
if ($0.sectionName == cstring) != ($1.sectionName == cstring) {
|
||||
return $0.sectionName == cstring
|
||||
}
|
||||
return $0.fileOffset < $1.fileOffset
|
||||
}
|
||||
|
||||
var hits: [StringHit] = []
|
||||
for token in anchorTokens {
|
||||
let needle = Array(token.utf8)
|
||||
for section in searchable {
|
||||
let start = Int(section.fileOffset)
|
||||
let end = start + Int(section.size)
|
||||
guard let match = firstOccurrence(of: needle, in: data, from: start, to: end) else {
|
||||
continue
|
||||
}
|
||||
let stringStart = cStringStart(in: data, containing: match, notBefore: start)
|
||||
let stringEnd = cStringEnd(in: data, from: stringStart, notAfter: end)
|
||||
hits.append(StringHit(
|
||||
token: token,
|
||||
text: String(decoding: data[stringStart ..< stringEnd], as: UTF8.self),
|
||||
sectionName: "\(section.segmentName),\(section.sectionName)",
|
||||
stringFileOffset: stringStart,
|
||||
stringVMA: section.address + UInt64(stringStart - start),
|
||||
matchVMA: section.address + UInt64(match - start)
|
||||
))
|
||||
break
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
static func firstOccurrence(of needle: [UInt8], in data: Data, from: Int, to: Int) -> Int? {
|
||||
guard !needle.isEmpty, to - from >= needle.count else { return nil }
|
||||
for offset in from ... (to - needle.count) {
|
||||
var matched = true
|
||||
for (index, byte) in needle.enumerated() where data[offset + index] != byte {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
if matched { return offset }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The first byte of the null-terminated string containing `offset`.
|
||||
///
|
||||
/// Code forms the address of a literal's START, never of a substring inside
|
||||
/// it, so a token that matched mid-string has to be walked back before its
|
||||
/// address can be looked for.
|
||||
static func cStringStart(in data: Data, containing offset: Int, notBefore floor: Int) -> Int {
|
||||
var position = offset - 1
|
||||
while position >= floor, data[position] != 0 { position -= 1 }
|
||||
return position + 1
|
||||
}
|
||||
|
||||
static func cStringEnd(in data: Data, from start: Int, notAfter ceiling: Int) -> Int {
|
||||
var position = start
|
||||
while position < ceiling, data[position] != 0 { position += 1 }
|
||||
return position
|
||||
}
|
||||
|
||||
// MARK: - The string xref
|
||||
|
||||
/// The ADRP of the first ADRP+ADD pair in `__TEXT,__text` that forms
|
||||
/// `targetVMA`.
|
||||
///
|
||||
/// The pair need not be adjacent — the compiler interleaves other setup
|
||||
/// between them — so an ADRP is remembered per destination register and
|
||||
/// matched against a later ADD that reads it. Everything is read from
|
||||
/// Capstone's decoded operands: the ADRP's immediate is already the absolute
|
||||
/// page, and the ADD's is the page offset.
|
||||
static func findStringReference(
|
||||
in data: Data,
|
||||
text: MachOSectionInfo,
|
||||
targetVMA: UInt64
|
||||
) -> (fileOffset: Int, vma: UInt64)? {
|
||||
let targetPage = Int64(targetVMA & ~0xFFF)
|
||||
let targetPageOffset = Int64(targetVMA & 0xFFF)
|
||||
let disassembler = ARM64Disassembler()
|
||||
|
||||
/// ADRP destination register -> (instruction index, address, page).
|
||||
var pendingADRP: [UInt32: (index: Int, fileOffset: Int, vma: UInt64, page: Int64)] = [:]
|
||||
|
||||
for (index, offset) in wordOffsets(of: text).enumerated() {
|
||||
let vma = text.address + UInt64(offset - Int(text.fileOffset))
|
||||
guard let instruction = disassembler.disassembleOne(in: data, at: offset, address: vma),
|
||||
let operands = instruction.aarch64?.operands
|
||||
else { continue }
|
||||
|
||||
switch instruction.mnemonic {
|
||||
case "adrp":
|
||||
guard operands.count >= 2,
|
||||
operands[0].type == AARCH64_OP_REG,
|
||||
operands[1].type == AARCH64_OP_IMM
|
||||
else { continue }
|
||||
pendingADRP[UInt32(operands[0].reg.rawValue)] = (index, offset, vma, operands[1].imm)
|
||||
|
||||
case "add":
|
||||
guard operands.count >= 3,
|
||||
operands[1].type == AARCH64_OP_REG,
|
||||
operands[2].type == AARCH64_OP_IMM,
|
||||
let adrp = pendingADRP[UInt32(operands[1].reg.rawValue)],
|
||||
adrp.page == targetPage,
|
||||
operands[2].imm == targetPageOffset,
|
||||
index - adrp.index <= maxADRPToADDInstructions
|
||||
else { continue }
|
||||
return (adrp.fileOffset, adrp.vma)
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - The gate
|
||||
|
||||
/// The conditional branch that consumes the boot-arg lookup's result.
|
||||
///
|
||||
/// Shape, in order: the first `bl` at or after the string xref, then the
|
||||
/// first conditional branch after that call. A `nop` reached before that
|
||||
/// branch is this patch's own output and ends the search — see the file
|
||||
/// header for why that matters.
|
||||
static func findGate(
|
||||
in data: Data,
|
||||
text: MachOSectionInfo,
|
||||
anchor: Anchor
|
||||
) throws -> Gate {
|
||||
let disassembler = ARM64Disassembler()
|
||||
let textEnd = Int(text.fileOffset) + Int(text.size)
|
||||
func decode(_ offset: Int) -> Instruction? {
|
||||
guard offset >= Int(text.fileOffset), offset + 4 <= textEnd else { return nil }
|
||||
return disassembler.disassembleOne(
|
||||
in: data,
|
||||
at: offset,
|
||||
address: text.address + UInt64(offset - Int(text.fileOffset))
|
||||
)
|
||||
}
|
||||
|
||||
var call: Instruction?
|
||||
for step in 0 ..< maxInstructionsToCall {
|
||||
guard let instruction = decode(anchor.referenceFileOffset + step * 4) else { break }
|
||||
// A direct `bl` only — the string is an argument to a call, and an
|
||||
// indirect `blr`/`blraa` through a register is not one this pass can
|
||||
// attribute a return value to.
|
||||
if instruction.mnemonic == "bl", branchTarget(of: instruction) != nil {
|
||||
call = instruction
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let searchBase = call.map { Int($0.address - text.address) + Int(text.fileOffset) }
|
||||
?? anchor.referenceFileOffset
|
||||
let window = call == nil ? maxFallbackInstructions : maxInstructionsAfterCall
|
||||
|
||||
for step in 1 ... window {
|
||||
let offset = searchBase + step * 4
|
||||
guard let instruction = decode(offset) else { break }
|
||||
|
||||
if instruction.mnemonic == "nop" {
|
||||
return Gate(
|
||||
fileOffset: offset,
|
||||
vma: instruction.address,
|
||||
mnemonic: instruction.mnemonic,
|
||||
operandString: instruction.operandString,
|
||||
callFileOffset: call.map { Int($0.address - text.address) + Int(text.fileOffset) },
|
||||
callVMA: call?.address,
|
||||
targetVMA: nil
|
||||
)
|
||||
}
|
||||
|
||||
guard isConditionalBranch(instruction) else { continue }
|
||||
try validate(gate: instruction, in: data, text: text, after: call, disassembler)
|
||||
return Gate(
|
||||
fileOffset: offset,
|
||||
vma: instruction.address,
|
||||
mnemonic: instruction.mnemonic,
|
||||
operandString: instruction.operandString,
|
||||
callFileOffset: call.map { Int($0.address - text.address) + Int(text.fileOffset) },
|
||||
callVMA: call?.address,
|
||||
targetVMA: branchTarget(of: instruction)
|
||||
)
|
||||
}
|
||||
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(component): no conditional branch within \(window) instructions of "
|
||||
+ (call.map { "the call at 0x\(hex($0.address))" }
|
||||
?? "the \"\(anchor.text)\" xref at 0x\(hex(anchor.referenceVMA))")
|
||||
)
|
||||
}
|
||||
|
||||
/// Conditional branches, decided on the mnemonic Capstone produced.
|
||||
static func isConditionalBranch(_ instruction: Instruction) -> Bool {
|
||||
switch instruction.mnemonic {
|
||||
case "cbz", "cbnz", "tbz", "tbnz": true
|
||||
default: instruction.mnemonic.hasPrefix("b.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject a branch that is not the gate.
|
||||
///
|
||||
/// Two properties have to hold, and the Python checks neither: the branch
|
||||
/// tests the register the call returned in, and it jumps FORWARD, past the
|
||||
/// unsecure-cache path it guards. A branch that fails either is some other
|
||||
/// branch, and NOP'ing it would be a silent miscompile of a boot-critical
|
||||
/// binary — so this throws instead.
|
||||
static func validate(
|
||||
gate: Instruction,
|
||||
in data: Data,
|
||||
text: MachOSectionInfo,
|
||||
after call: Instruction?,
|
||||
_ disassembler: ARM64Disassembler
|
||||
) throws {
|
||||
if let target = branchTarget(of: gate) {
|
||||
let textEnd = text.address + text.size
|
||||
guard target > gate.address, target < textEnd else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(component): branch at 0x\(hex(gate.address)) jumps to 0x\(hex(target)), "
|
||||
+ "which is not forward inside __TEXT,__text — not the unsecure-cache gate"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
guard call != nil else { return } // Fallback path: nothing to attribute.
|
||||
|
||||
switch gate.mnemonic {
|
||||
case "cbz", "cbnz", "tbz", "tbnz":
|
||||
let register = disassembler.firstRegisterName(gate)
|
||||
guard register == "x0" || register == "w0" else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(component): branch at 0x\(hex(gate.address)) tests "
|
||||
+ "\(register ?? "an unknown register"), not the call's result in x0/w0"
|
||||
)
|
||||
}
|
||||
default:
|
||||
// `b.<cond>` reads the flags, so the call's result has to reach it
|
||||
// through a flag-setting instruction on x0/w0 in between.
|
||||
guard flagSetterOnResult(between: call!, and: gate, in: data, text: text, disassembler)
|
||||
else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(component): \(gate.mnemonic) at 0x\(hex(gate.address)) is not preceded by a "
|
||||
+ "compare of the call's result — nothing ties it to the boot-arg lookup"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether some instruction between the call and the branch sets the flags
|
||||
/// from x0/w0 — a `cmp`/`subs`/`ands`/`tst` reading register 0.
|
||||
static func flagSetterOnResult(
|
||||
between call: Instruction,
|
||||
and gate: Instruction,
|
||||
in data: Data,
|
||||
text: MachOSectionInfo,
|
||||
_ disassembler: ARM64Disassembler
|
||||
) -> Bool {
|
||||
var address = call.address + 4
|
||||
while address < gate.address {
|
||||
defer { address += 4 }
|
||||
let offset = Int(address - text.address) + Int(text.fileOffset)
|
||||
guard let instruction = disassembler.disassembleOne(in: data, at: offset, address: address),
|
||||
instruction.aarch64?.updatesFlags == true,
|
||||
let operands = instruction.aarch64?.operands
|
||||
else { continue }
|
||||
for operand in operands where operand.type == AARCH64_OP_REG {
|
||||
let name = disassembler.registerName(UInt32(operand.reg.rawValue))
|
||||
if name == "x0" || name == "w0" { return true }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// The absolute address a branch jumps to, read from its last immediate
|
||||
/// operand, or `nil` when the instruction carries none.
|
||||
static func branchTarget(of instruction: Instruction) -> UInt64? {
|
||||
guard let operands = instruction.aarch64?.operands,
|
||||
let last = operands.last,
|
||||
last.type == AARCH64_OP_IMM,
|
||||
last.imm >= 0
|
||||
else { return nil }
|
||||
return UInt64(last.imm)
|
||||
}
|
||||
|
||||
// MARK: - Recording
|
||||
|
||||
private static func record(
|
||||
anchor: Anchor,
|
||||
gate: Gate,
|
||||
original: Data,
|
||||
patched: Data
|
||||
) -> PatchRecord {
|
||||
PatchRecord(
|
||||
patchID: patchID,
|
||||
component: component,
|
||||
fileOffset: gate.fileOffset,
|
||||
virtualAddress: gate.vma,
|
||||
originalBytes: original,
|
||||
patchedBytes: patched,
|
||||
beforeDisasm: disassemblyText(of: original, at: gate.vma),
|
||||
afterDisasm: disassemblyText(of: patched, at: gate.vma),
|
||||
// Worded as the Python words it, so a captured reference compares.
|
||||
description: "NOP the cache-validation branch gated on '\(anchor.token)'"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Logging
|
||||
|
||||
/// Five instructions around the gate, with the gate itself marked — the
|
||||
/// Python's `_log_asm` before/after block.
|
||||
private static func context(in data: Data, around gate: Gate, marking marker: Int) -> String {
|
||||
let disassembler = ARM64Disassembler()
|
||||
let start = max(gate.fileOffset - 8, 0)
|
||||
return (0 ..< 5).compactMap { step -> String? in
|
||||
let offset = start + step * 4
|
||||
guard offset + 4 <= data.count else { return nil }
|
||||
// The window starts two instructions BEFORE the gate, so the delta is
|
||||
// negative for the first steps. `UInt64(Int)` traps on a negative
|
||||
// value even under -O; wrap through the bit pattern instead.
|
||||
let vma = gate.vma &+ UInt64(bitPattern: Int64(offset - gate.fileOffset))
|
||||
guard let instruction = disassembler.disassembleOne(in: data, at: offset, address: vma)
|
||||
else { return nil }
|
||||
let tag = offset == marker ? " >>>" : " "
|
||||
let mnemonic = instruction.mnemonic.count < 8
|
||||
? instruction.mnemonic.padding(toLength: 8, withPad: " ", startingAt: 0)
|
||||
: instruction.mnemonic
|
||||
// Laid out by hand rather than with `String(format:)`: `%X` consumes
|
||||
// 32 bits, and every offset here is an `Int`.
|
||||
return " \(tag) 0x\(hex(UInt64(offset), padTo: 8)): \(mnemonic) \(instruction.operandString)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func disassemblyText(of bytes: Data, at vma: UInt64) -> String {
|
||||
ARM64Disassembler()
|
||||
.disassemble(bytes, at: vma)
|
||||
.map { $0.operandString.isEmpty ? $0.mnemonic : "\($0.mnemonic) \($0.operandString)" }
|
||||
.joined(separator: "; ")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Every 4-byte instruction offset in a section, in order.
|
||||
static func wordOffsets(of section: MachOSectionInfo) -> StrideTo<Int> {
|
||||
let start = Int(section.fileOffset)
|
||||
return stride(from: start, to: start + (Int(section.size) & ~3), by: 4)
|
||||
}
|
||||
|
||||
private static func hex(_ value: UInt64, padTo width: Int = 0) -> String {
|
||||
let digits = String(value, radix: 16, uppercase: true)
|
||||
guard digits.count < width else { return digits }
|
||||
return String(repeating: "0", count: width - digits.count) + digits
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
// CFWDiskimagesiod.swift — force diskimagesiod's DDI mount-completion gate open.
|
||||
//
|
||||
// Swift port of `scripts/patchers/cfw_patch_diskimagesiod.py`, driven by
|
||||
// `cfw.py patch-diskimagesiod <binary>` and, in the shipped installers, by
|
||||
// `scripts/cfw_install.sh:415` / `cfw-kit/lib/base_stages.sh:188`.
|
||||
//
|
||||
// Why the patch exists
|
||||
// ────────────────────
|
||||
// `pymobiledevice3 mounter auto-mount` attaches the personalized DDI, then
|
||||
// MobileStorageMounter waits on diskimagesiod's
|
||||
// `-[DIDiskArb waitForDAMountWithExpectedCount:diskTracker:]` before it
|
||||
// performs the real (nobrowse) mount of the DDI volume at /System/Developer.
|
||||
// That wait spins until `isMountComplete` returns YES, which is
|
||||
// `callbackReached || (appearedDiskCount >= expectedCount &&
|
||||
// mountedDiskCount >= mountableDiskCount)`. On the iOS-27-userland /
|
||||
// 26.4-vphone600-kernel hybrid it never becomes true: only some of the DMG's
|
||||
// IOMedia ever "appear" to diskimagesiod's DiskArbitration session, and
|
||||
// diskarbitrationd never auto-mounts the volume, so the wait hangs forever and
|
||||
// pmd3 times out. diskimagesiod itself does NOT mount the DDI — its
|
||||
// `-[DIDiskArb mountWithDeviceName:…]` is dead code; it only gates
|
||||
// MobileStorageMounter. Forcing `isMountComplete` to YES lets the wait return
|
||||
// immediately so MobileStorageMounter proceeds and mounts the DDI.
|
||||
//
|
||||
// Pairs with the JB kernel patches that make the DDI attachable and mountable
|
||||
// (the DiskImages2 ABI pokes, and the Sandbox `mpo_proc_check_syscall_unix`
|
||||
// stub that lets MobileStorageMounter's `mount_apfs` issue `mount(2)`).
|
||||
// No-op-in-effect on version-matched userlands, where the wait completes on its
|
||||
// own and returning YES early changes nothing observable.
|
||||
//
|
||||
// How the site is found
|
||||
// ─────────────────────
|
||||
// Nothing here is a hardcoded offset. Two source-backed anchors, in order:
|
||||
//
|
||||
// 1. `LC_SYMTAB`: a symbol whose name contains `isMountCompleteWithExpectedCount`.
|
||||
// Shipped diskimagesiod is stripped, so this normally misses — it is kept
|
||||
// because it is the cheapest and most direct anchor when it does hit.
|
||||
// 2. ObjC runtime metadata, which survives stripping:
|
||||
// selector cstring in `__TEXT,__objc_methname`
|
||||
// → its `__objc_selrefs` entry (chained-fixup aware)
|
||||
// → the relative method-list entry naming it
|
||||
// → that entry's `imp` field, relative-addressed.
|
||||
// `__TEXT,__objc_methlist` is walked *structurally* first — a packed run
|
||||
// of `{entsizeAndFlags, count}` headers followed by `count` 12-byte
|
||||
// `{name, types, imp}` entries, each list 8-byte aligned. The structural
|
||||
// walk cannot land mid-entry and cannot mistake a `__const` word for a
|
||||
// method. The Python's 4-byte-strided scan is kept behind it, over the
|
||||
// same sections the Python tries plus `__objc_methlist` itself, so this
|
||||
// port is never less capable than the one it replaces. Either way the
|
||||
// result is required to be the *only* entry in the image naming that
|
||||
// selector.
|
||||
//
|
||||
// The resolved IMP is then checked to land inside `__TEXT,__text` and to decode
|
||||
// as real instructions before a single byte is written.
|
||||
//
|
||||
// What is written
|
||||
// ───────────────
|
||||
// `mov x0, #1 ; ret` over the method prologue. Safe: the function returns to the
|
||||
// caller's (unsigned) LR without ever having pushed a frame, so overwriting
|
||||
// `pacibsp; stp …` loses nothing that the new epilogue needs. Both words come
|
||||
// from the encoder — `ARM64Encoder.encodeMovzX` builds MOVZ from its ISA fields
|
||||
// and `ARM64.ret` is the keystone-derived constant; `CFWDiskimagesiodTests`
|
||||
// asserts the two agree with `ARM64.movX0_1`.
|
||||
//
|
||||
// Re-signing
|
||||
// ──────────
|
||||
// Off by default, matching the Python and the call site: `cfw_install.sh`
|
||||
// re-signs the patched binary with `ldid` under the extracted
|
||||
// `com.apple.diskimagesiod` entitlements, so a slot re-attest here would be
|
||||
// overwritten moments later. `reattest: true` recomputes the CodeDirectory slot
|
||||
// hashes through `CFWMachOCodeSignature` instead, which is what a caller that
|
||||
// drops the `ldid` step needs — and what makes `codesign -v` pass on the patched
|
||||
// file on its own.
|
||||
|
||||
import Capstone
|
||||
import Foundation
|
||||
|
||||
/// Forces `-[DIDiskArb isMountCompleteWithExpectedCount:diskTracker:]` to
|
||||
/// return YES so MobileStorageMounter stops waiting on a mount that will never
|
||||
/// be reported.
|
||||
public enum CFWDiskimagesiod {
|
||||
// MARK: - Identity
|
||||
|
||||
/// The component name the Python records this write under.
|
||||
public static let component = "diskimagesiod"
|
||||
|
||||
/// The selector whose implementation is stubbed.
|
||||
public static let selector = "isMountCompleteWithExpectedCount:diskTracker:"
|
||||
|
||||
/// The `LC_SYMTAB` fragment strategy 1 looks for. Deliberately shorter than
|
||||
/// the selector: a symbol name is `-[DIDiskArb isMountComplete…]`, and the
|
||||
/// colon-bearing tail differs between symbol spellings.
|
||||
public static let symbolFragment = "isMountCompleteWithExpectedCount"
|
||||
|
||||
/// The method, as it reads in a disassembler.
|
||||
public static let method = "-[DIDiskArb \(selector)]"
|
||||
|
||||
/// Record identity, matching the Python's `records.site` label so a captured
|
||||
/// reference and this port sort together.
|
||||
public static let patchID = "diskimagesiod.is_mount_complete"
|
||||
|
||||
/// `mov x0, #1 ; ret`.
|
||||
///
|
||||
/// MOVZ is built from its ISA fields by ``ARM64Encoder/encodeMovzX(rd:imm16:shift:)``;
|
||||
/// the `?? ARM64.movX0_1` arm is unreachable (that encoder returns `nil`
|
||||
/// only for a shift above 48) and exists so this stays a plain `let` with
|
||||
/// no trap in it. `CFWDiskimagesiodTests` asserts the two spellings are the
|
||||
/// same four bytes.
|
||||
public static let replacement: Data =
|
||||
(ARM64Encoder.encodeMovzX(rd: 0, imm16: 1) ?? ARM64.movX0_1) + ARM64.ret
|
||||
|
||||
// MARK: - Results
|
||||
|
||||
/// Which anchor found the implementation.
|
||||
public enum Anchor: String, Sendable, Equatable {
|
||||
/// `LC_SYMTAB` carried the symbol. Only on an unstripped build.
|
||||
case symbolTable
|
||||
/// Structural walk of `__TEXT,__objc_methlist`.
|
||||
case relativeMethodList
|
||||
/// The Python's strategy: a 4-byte-strided scan, over `__objc_methlist`
|
||||
/// when the structural walk could not follow it, then over the
|
||||
/// `__objc_const` sections of the pre-`__objc_methlist` layout.
|
||||
case methodListScan
|
||||
}
|
||||
|
||||
/// The implementation this patch overwrites.
|
||||
public struct Site: Sendable, Equatable {
|
||||
/// File offset of the method's first instruction.
|
||||
public let fileOffset: Int
|
||||
/// Its virtual address, when an anchor produced one.
|
||||
public let virtualAddress: UInt64?
|
||||
/// How it was found.
|
||||
public let anchor: Anchor
|
||||
/// The bytes the patch replaces, as found — ``replacement``-many.
|
||||
public let original: Data
|
||||
|
||||
/// True when the site already holds this patch's own output.
|
||||
public var isAlreadyPatched: Bool { original == CFWDiskimagesiod.replacement }
|
||||
}
|
||||
|
||||
/// What a run did.
|
||||
public enum Outcome: String, Sendable, Equatable {
|
||||
/// The prologue already read `mov x0, #1 ; ret`; nothing was written
|
||||
/// there. (A stale slot hash may still have been re-attested.)
|
||||
case alreadyPatched
|
||||
/// `dryRun` was set, so the site was located and reported only.
|
||||
case wouldPatch
|
||||
/// The prologue was replaced.
|
||||
case patched
|
||||
}
|
||||
|
||||
/// The outcome of one run, and the site it acted on.
|
||||
public struct Report: Sendable {
|
||||
public let outcome: Outcome
|
||||
public let site: Site
|
||||
/// The write, in the shape the Python's reference capture records it.
|
||||
/// `nil` unless the prologue bytes actually changed.
|
||||
public let record: PatchRecord?
|
||||
/// CodeDirectory slots re-attested. Empty unless `reattest` was set,
|
||||
/// and empty on a second run because the stored hashes already match.
|
||||
public let rehashes: [CFWSlotRehash]
|
||||
|
||||
/// The parity number: the Python writes exactly one site, and so must this.
|
||||
public var sitesWritten: Int { record == nil ? 0 : 1 }
|
||||
}
|
||||
|
||||
/// Where progress goes when the caller does not say. The Python prints to
|
||||
/// stdout and `cfw_install*.sh` captures that, so this does too.
|
||||
public static let stdoutLog: @Sendable (String) -> Void = { print($0) }
|
||||
|
||||
// MARK: - Patching
|
||||
|
||||
/// Stub the mount-completion gate in `data`.
|
||||
///
|
||||
/// Idempotent: a buffer that already reads `mov x0, #1 ; ret` at the site
|
||||
/// reports ``Outcome/alreadyPatched`` and writes nothing, rather than
|
||||
/// failing to recognise a prologue that is no longer there. Re-running is
|
||||
/// the normal case — `cfw install` is re-run against an already-installed
|
||||
/// volume all the time — and commit `8eb6c8b` exists because a sibling
|
||||
/// patcher got this wrong.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reattest: recompute the CodeDirectory slot hashes for the pages the
|
||||
/// write touched. Off by default; see the file header for why.
|
||||
/// - dryRun: locate and report without writing.
|
||||
/// - Throws: ``PatcherError/invalidFormat(_:)`` when the buffer is not a
|
||||
/// 64-bit Mach-O or its ObjC metadata is ambiguous, and
|
||||
/// ``PatcherError/patchSiteNotFound(_:)`` when no anchor resolves — both
|
||||
/// have to stop the install rather than be guessed at.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
_ data: inout Data,
|
||||
reattest: Bool = false,
|
||||
dryRun: Bool = false,
|
||||
log: ((String) -> Void)? = stdoutLog
|
||||
) throws -> Report {
|
||||
if data.startIndex != 0 { data = Data(data) }
|
||||
|
||||
let site = try locate(in: data)
|
||||
let patched = replacement
|
||||
log?(" \(method) @ \(describe(site))")
|
||||
log?(" Before: \(disassemblyText(of: site.original, at: site.virtualAddress))")
|
||||
|
||||
guard !dryRun else {
|
||||
log?(" [.] dry-run: would write \(patched.hex) at 0x\(hex(UInt64(site.fileOffset)))")
|
||||
return Report(outcome: .wouldPatch, site: site, record: nil, rehashes: [])
|
||||
}
|
||||
|
||||
var record: PatchRecord?
|
||||
if site.isAlreadyPatched {
|
||||
log?(" [=] already `mov x0, #1 ; ret` at 0x\(hex(UInt64(site.fileOffset))); nothing to write")
|
||||
} else {
|
||||
data.replaceSubrange(site.fileOffset ..< site.fileOffset + patched.count, with: patched)
|
||||
record = makeRecord(site: site, patched: patched)
|
||||
log?(" After: \(disassemblyText(of: patched, at: site.virtualAddress))")
|
||||
}
|
||||
|
||||
var rehashes: [CFWSlotRehash] = []
|
||||
if reattest {
|
||||
// First and last byte of the write: an 8-byte span sits inside one
|
||||
// 4 KiB page here, but a page boundary between them would otherwise
|
||||
// leave the second page's slot stale.
|
||||
rehashes = try CFWMachOCodeSignature.reattest(
|
||||
&data,
|
||||
modifiedOffsets: [site.fileOffset, site.fileOffset + patched.count - 1]
|
||||
)
|
||||
for rehash in rehashes { log?(" [~] \(rehash)") }
|
||||
if rehashes.isEmpty {
|
||||
log?(" [=] code directory slots already current; no re-attest needed")
|
||||
}
|
||||
}
|
||||
|
||||
let written = data[site.fileOffset ..< site.fileOffset + patched.count]
|
||||
guard written == patched else {
|
||||
throw PatcherError.patchVerificationFailed(
|
||||
"\(method): site at 0x\(hex(UInt64(site.fileOffset))) reads \(Data(written).hex) after write"
|
||||
)
|
||||
}
|
||||
|
||||
log?(" [+] \(method) forced to YES at 0x\(hex(UInt64(site.fileOffset)))")
|
||||
return Report(
|
||||
outcome: site.isAlreadyPatched ? .alreadyPatched : .patched,
|
||||
site: site,
|
||||
record: record,
|
||||
rehashes: rehashes
|
||||
)
|
||||
}
|
||||
|
||||
/// File-backed form of ``patch(_:reattest:dryRun:log:)``.
|
||||
///
|
||||
/// The file is rewritten only when its bytes actually changed, so a
|
||||
/// re-run leaves even the modification time alone.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
fileAt url: URL,
|
||||
reattest: Bool = false,
|
||||
dryRun: Bool = false,
|
||||
log: ((String) -> Void)? = stdoutLog
|
||||
) throws -> Report {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw PatcherError.fileNotFound(url.path)
|
||||
}
|
||||
var data = try Data(contentsOf: url)
|
||||
let report = try patch(&data, reattest: reattest, dryRun: dryRun, log: log)
|
||||
if !dryRun, report.record != nil || !report.rehashes.isEmpty {
|
||||
try data.write(to: url)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// MARK: - Locating the implementation
|
||||
|
||||
/// Resolve the method's first instruction without touching the buffer.
|
||||
///
|
||||
/// - Throws: ``PatcherError/patchSiteNotFound(_:)`` when every anchor is
|
||||
/// exhausted, ``PatcherError/invalidFormat(_:)`` when the image is not a
|
||||
/// 64-bit Mach-O or names the selector from more than one implementation.
|
||||
public static func locate(in data: Data) throws -> Site {
|
||||
let data = rebased(data)
|
||||
guard data.count > 32, data.loadLE(UInt32.self, at: 0) == machMagic64 else {
|
||||
throw PatcherError.invalidFormat("\(component): not a 64-bit Mach-O")
|
||||
}
|
||||
let segments = MachOParser.parseSegments(from: data)
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
let textRange = sections[textSectionKey].map {
|
||||
Int($0.fileOffset) ..< Int($0.fileOffset) + Int($0.size)
|
||||
}
|
||||
|
||||
// Strategy 1 — the symbol table, when the image kept one.
|
||||
if let va = MachOParser.findSymbol(containing: symbolFragment, in: data),
|
||||
let offset = MachOParser.vaToFileOffset(va, segments: segments),
|
||||
let site = makeSite(
|
||||
in: data,
|
||||
fileOffset: offset,
|
||||
virtualAddress: va,
|
||||
anchor: .symbolTable,
|
||||
textRange: textRange
|
||||
)
|
||||
{
|
||||
return site
|
||||
}
|
||||
|
||||
// Strategy 2 — ObjC metadata, which survives stripping.
|
||||
let (impVA, anchor) = try resolveIMPViaObjCMetadata(in: data, sections: sections)
|
||||
guard let offset = MachOParser.vaToFileOffset(impVA, segments: segments) else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"\(method): IMP va 0x\(hex(impVA)) is in no mapped segment"
|
||||
)
|
||||
}
|
||||
guard let site = makeSite(
|
||||
in: data,
|
||||
fileOffset: offset,
|
||||
virtualAddress: impVA,
|
||||
anchor: anchor,
|
||||
textRange: textRange
|
||||
) else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(method): IMP at 0x\(hex(impVA)) (foff 0x\(hex(UInt64(offset)))) "
|
||||
+ "is outside __TEXT,__text or does not decode as instructions"
|
||||
)
|
||||
}
|
||||
return site
|
||||
}
|
||||
|
||||
/// Build a ``Site`` when the candidate offset survives validation, else nil.
|
||||
///
|
||||
/// Two checks, both semantic rather than positional: the offset must lie in
|
||||
/// the image's executable section, and the words there must decode — unless
|
||||
/// they are already this patch's own output, which is the idempotent case.
|
||||
static func makeSite(
|
||||
in data: Data,
|
||||
fileOffset: Int,
|
||||
virtualAddress: UInt64?,
|
||||
anchor: Anchor,
|
||||
textRange: Range<Int>?
|
||||
) -> Site? {
|
||||
let length = replacement.count
|
||||
guard fileOffset >= 0, fileOffset + length <= data.count else { return nil }
|
||||
if let textRange, !(textRange.contains(fileOffset) && textRange.contains(fileOffset + length - 1)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
let original = Data(data[fileOffset ..< fileOffset + length])
|
||||
let site = Site(
|
||||
fileOffset: fileOffset,
|
||||
virtualAddress: virtualAddress,
|
||||
anchor: anchor,
|
||||
original: original
|
||||
)
|
||||
if site.isAlreadyPatched { return site }
|
||||
|
||||
// `skipData` is on in the shared disassembler, so an undecodable word
|
||||
// arrives as a data pseudo-instruction (id 0) instead of ending the
|
||||
// stream — which is what makes this a usable "is this code?" test.
|
||||
let decoded = ARM64Disassembler().disassemble(
|
||||
original,
|
||||
at: virtualAddress ?? UInt64(fileOffset)
|
||||
)
|
||||
guard decoded.count == length / 4, decoded.allSatisfy({ $0.id != 0 }) else { return nil }
|
||||
return site
|
||||
}
|
||||
|
||||
// MARK: - ObjC metadata
|
||||
|
||||
/// selector cstring → selref → method-list entry → IMP.
|
||||
static func resolveIMPViaObjCMetadata(
|
||||
in data: Data,
|
||||
sections: [String: MachOSectionInfo]
|
||||
) throws -> (impVA: UInt64, anchor: Anchor) {
|
||||
guard let selectorVA = selectorStringVA(in: data, sections: sections) else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(component): selector '\(selector)' not present in the image"
|
||||
)
|
||||
}
|
||||
|
||||
// A method-list `name` field points at the uniqued `SEL *` (the selref)
|
||||
// on every toolchain that emits `__objc_selrefs`, and straight at the
|
||||
// cstring in a "direct selector" list. Both are accepted, exactly as
|
||||
// the Python does, so a missing selref is not fatal on its own.
|
||||
var targets: Set<UInt64> = [selectorVA]
|
||||
if let selrefsSection = section(sections, "__DATA_CONST,__objc_selrefs", "__DATA,__objc_selrefs", "__AUTH_CONST,__objc_selrefs"),
|
||||
let selrefVA = selectorReferenceVA(
|
||||
in: data,
|
||||
selrefs: selrefsSection,
|
||||
selectorVA: selectorVA,
|
||||
imageBase: imageBase(sections)
|
||||
)
|
||||
{
|
||||
targets.insert(selrefVA)
|
||||
}
|
||||
|
||||
// Preferred: a structural walk of the packed relative method lists.
|
||||
if let methlist = sections[methodListSectionKey] {
|
||||
let imps = relativeMethodListIMPs(in: data, section: methlist, naming: targets)
|
||||
if let impVA = try single(imps, strategy: methodListSectionKey) {
|
||||
return (impVA, .relativeMethodList)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: the Python's own strategy, a 4-byte-strided scan, over the
|
||||
// same sections it tries. It covers two cases the structural walk does
|
||||
// not — a `__TEXT,__objc_methlist` whose list chain this parser cannot
|
||||
// follow to the end, and the older layout where method lists are
|
||||
// embedded in `class_ro_t` records inside an `__objc_const` section and
|
||||
// so are not packed back to back. Running it over the method-list
|
||||
// section too is what keeps this port from ever being *less* capable
|
||||
// than the Python it replaces. Every candidate still has to resolve to
|
||||
// a single agreed-upon IMP, and that IMP still has to pass ``makeSite``.
|
||||
for key in scannedSectionKeys {
|
||||
guard let scanned = sections[key] else { continue }
|
||||
let imps = scanRelativeMethodEntryIMPs(in: data, section: scanned, naming: targets)
|
||||
if let impVA = try single(imps, strategy: key) {
|
||||
return (impVA, .methodListScan)
|
||||
}
|
||||
}
|
||||
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(method): no ObjC method-list entry names '\(selector)'"
|
||||
)
|
||||
}
|
||||
|
||||
/// The single distinct IMP in `candidates`, `nil` when there are none.
|
||||
///
|
||||
/// More than one distinct IMP means the image names this selector from
|
||||
/// several implementations and the patch has no unambiguous target — which
|
||||
/// stops the install rather than picking one.
|
||||
static func single(_ candidates: [UInt64], strategy: String) throws -> UInt64? {
|
||||
let distinct = Set(candidates)
|
||||
guard let first = distinct.first else { return nil }
|
||||
guard distinct.count == 1 else {
|
||||
let list = distinct.sorted().map { "0x\(hex($0))" }.joined(separator: ", ")
|
||||
throw PatcherError.invalidFormat(
|
||||
"\(method): \(strategy) names '\(selector)' from \(distinct.count) "
|
||||
+ "implementations (\(list)) — no unambiguous target"
|
||||
)
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
/// Virtual address of the selector cstring.
|
||||
///
|
||||
/// Looked for in `__TEXT,__objc_methname` first — the section that exists
|
||||
/// for exactly this — then `__TEXT,__cstring`, then anywhere in the file,
|
||||
/// which is the Python's only search. A hit has to start a string (the
|
||||
/// preceding byte is NUL, or it is the first byte of its section) so a
|
||||
/// selector that is the tail of a longer one cannot match.
|
||||
static func selectorStringVA(in data: Data, sections: [String: MachOSectionInfo]) -> UInt64? {
|
||||
let needle = Data(selector.utf8) + [0]
|
||||
|
||||
for key in stringSectionKeys {
|
||||
guard let section = sections[key] else { continue }
|
||||
let start = Int(section.fileOffset)
|
||||
let end = start + Int(section.size)
|
||||
guard start >= 0, end <= data.count, start < end else { continue }
|
||||
guard let found = firstStringStart(of: needle, in: data, range: start ..< end) else { continue }
|
||||
return section.address + UInt64(found - start)
|
||||
}
|
||||
|
||||
guard let found = firstStringStart(of: needle, in: data, range: 0 ..< data.count) else { return nil }
|
||||
return virtualAddress(ofFileOffset: found, sections: sections)
|
||||
}
|
||||
|
||||
/// First occurrence of `needle` in `range` that begins a C string.
|
||||
static func firstStringStart(of needle: Data, in data: Data, range: Range<Int>) -> Int? {
|
||||
var searchFrom = range.lowerBound
|
||||
while searchFrom < range.upperBound,
|
||||
let found = data.range(of: needle, in: searchFrom ..< range.upperBound)
|
||||
{
|
||||
if found.lowerBound == range.lowerBound || data[found.lowerBound - 1] == 0 {
|
||||
return found.lowerBound
|
||||
}
|
||||
searchFrom = found.lowerBound + 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The `__objc_selrefs` slot pointing at `selectorVA`.
|
||||
///
|
||||
/// The slot holds a *chained fixup*, not a linked address, so the raw
|
||||
/// quadword rarely equals the target. Four interpretations are tried in
|
||||
/// decreasing strictness, and the first that matches anywhere in the
|
||||
/// section wins:
|
||||
///
|
||||
/// 1. the value itself — an already-linked or non-chained image;
|
||||
/// 2. `DYLD_CHAINED_PTR_64` rebase: the low 36 bits are an offset from the
|
||||
/// image's preferred base, the rest are `high8` / `next` / `bind`;
|
||||
/// 3. the low 48 bits, for the older 8-byte fixup spellings;
|
||||
/// 4. the low 32 bits, which is the Python's catch-all.
|
||||
static func selectorReferenceVA(
|
||||
in data: Data,
|
||||
selrefs: MachOSectionInfo,
|
||||
selectorVA: UInt64,
|
||||
imageBase: UInt64
|
||||
) -> UInt64? {
|
||||
let start = Int(selrefs.fileOffset)
|
||||
let count = Int(selrefs.size)
|
||||
guard start >= 0, start + count <= data.count else { return nil }
|
||||
|
||||
let matchers: [(UInt64) -> Bool] = [
|
||||
{ $0 == selectorVA },
|
||||
{ imageBase &+ ($0 & chainedRebaseTargetMask) == selectorVA },
|
||||
{ ($0 & 0x0000_FFFF_FFFF_FFFF) == selectorVA },
|
||||
{ ($0 & 0xFFFF_FFFF) == (selectorVA & 0xFFFF_FFFF) },
|
||||
]
|
||||
for matches in matchers {
|
||||
var offset = 0
|
||||
while offset + 8 <= count {
|
||||
if matches(data.loadLE(UInt64.self, at: start + offset)) {
|
||||
return selrefs.address + UInt64(offset)
|
||||
}
|
||||
offset += 8
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Method lists
|
||||
|
||||
/// Walk `__TEXT,__objc_methlist` as what it is: relative method lists laid
|
||||
/// end to end, each one 8-byte aligned.
|
||||
///
|
||||
/// A list is `{uint32 entsizeAndFlags, uint32 count}` followed by `count`
|
||||
/// entries of `entsizeAndFlags & 0xFFFC` bytes. Bit 31 marks the relative
|
||||
/// form, whose entry is three `int32` fields — `name`, `types`, `imp` —
|
||||
/// each relative to *its own* address.
|
||||
///
|
||||
/// Returns the IMP virtual address of every entry whose `name` field
|
||||
/// resolves into `targets`.
|
||||
static func relativeMethodListIMPs(
|
||||
in data: Data,
|
||||
section: MachOSectionInfo,
|
||||
naming targets: Set<UInt64>
|
||||
) -> [UInt64] {
|
||||
let base = Int(section.fileOffset)
|
||||
let size = Int(section.size)
|
||||
guard base >= 0, size > 0, base + size <= data.count else { return [] }
|
||||
|
||||
var found: [UInt64] = []
|
||||
var offset = 0
|
||||
while offset + methodListHeaderSize <= size {
|
||||
let header = data.loadLE(UInt32.self, at: base + offset)
|
||||
let count = Int(data.loadLE(UInt32.self, at: base + offset + 4))
|
||||
let entrySize = Int(header & methodListEntrySizeMask)
|
||||
guard header & relativeMethodListFlag != 0,
|
||||
entrySize == relativeMethodEntrySize,
|
||||
count > 0,
|
||||
methodListHeaderSize + entrySize * count <= size - offset
|
||||
else { break }
|
||||
|
||||
let entriesStart = offset + methodListHeaderSize
|
||||
for index in 0 ..< count {
|
||||
let entryOffset = entriesStart + index * entrySize
|
||||
if let impVA = relativeMethodEntryIMP(
|
||||
in: data,
|
||||
fileOffset: base + entryOffset,
|
||||
virtualAddress: section.address + UInt64(entryOffset),
|
||||
naming: targets
|
||||
) {
|
||||
found.append(impVA)
|
||||
}
|
||||
}
|
||||
|
||||
offset = alignUp(entriesStart + entrySize * count, to: methodListAlignment)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// The Python's strategy, kept for the layouts the structural walk cannot
|
||||
/// parse: treat every 4-byte-aligned word in `section` as the `name` field
|
||||
/// of a relative method entry and keep the ones that resolve into `targets`.
|
||||
static func scanRelativeMethodEntryIMPs(
|
||||
in data: Data,
|
||||
section: MachOSectionInfo,
|
||||
naming targets: Set<UInt64>
|
||||
) -> [UInt64] {
|
||||
let base = Int(section.fileOffset)
|
||||
let size = Int(section.size)
|
||||
guard base >= 0, size >= relativeMethodEntrySize, base + size <= data.count else { return [] }
|
||||
|
||||
var found: [UInt64] = []
|
||||
var offset = 0
|
||||
while offset + relativeMethodEntrySize <= size {
|
||||
if let impVA = relativeMethodEntryIMP(
|
||||
in: data,
|
||||
fileOffset: base + offset,
|
||||
virtualAddress: section.address + UInt64(offset),
|
||||
naming: targets
|
||||
) {
|
||||
found.append(impVA)
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// `imp` of the relative method entry at `fileOffset`, when its `name`
|
||||
/// field resolves into `targets`.
|
||||
static func relativeMethodEntryIMP(
|
||||
in data: Data,
|
||||
fileOffset: Int,
|
||||
virtualAddress: UInt64,
|
||||
naming targets: Set<UInt64>
|
||||
) -> UInt64? {
|
||||
guard fileOffset >= 0, fileOffset + relativeMethodEntrySize <= data.count else { return nil }
|
||||
let nameRelative = Int32(bitPattern: data.loadLE(UInt32.self, at: fileOffset))
|
||||
let nameVA = UInt64(bitPattern: Int64(bitPattern: virtualAddress) &+ Int64(nameRelative))
|
||||
guard targets.contains(nameVA) else { return nil }
|
||||
|
||||
let impFieldOffset = fileOffset + 8
|
||||
let impFieldVA = virtualAddress &+ 8
|
||||
let impRelative = Int32(bitPattern: data.loadLE(UInt32.self, at: impFieldOffset))
|
||||
return UInt64(bitPattern: Int64(bitPattern: impFieldVA) &+ Int64(impRelative))
|
||||
}
|
||||
|
||||
// MARK: - Recording
|
||||
|
||||
private static func makeRecord(site: Site, patched: Data) -> PatchRecord {
|
||||
PatchRecord(
|
||||
patchID: patchID,
|
||||
component: component,
|
||||
fileOffset: site.fileOffset,
|
||||
virtualAddress: site.virtualAddress,
|
||||
originalBytes: site.original,
|
||||
patchedBytes: patched,
|
||||
beforeDisasm: disassemblyText(of: site.original, at: site.virtualAddress),
|
||||
afterDisasm: disassemblyText(of: patched, at: site.virtualAddress),
|
||||
description: "\(method) -> mov x0, #1; ret"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Section helpers
|
||||
|
||||
static let machMagic64: UInt32 = 0xFEED_FACF
|
||||
|
||||
/// Executable section every anchor's answer has to land in.
|
||||
static let textSectionKey = "__TEXT,__text"
|
||||
|
||||
/// Where modern toolchains put packed relative method lists.
|
||||
static let methodListSectionKey = "__TEXT,__objc_methlist"
|
||||
|
||||
/// Sections the strided fallback scan walks, in order: the packed method
|
||||
/// lists again, then the older layouts where method lists sit inside
|
||||
/// `class_ro_t` records. The Python's list, plus `__objc_methlist`.
|
||||
static let scannedSectionKeys = [
|
||||
methodListSectionKey,
|
||||
"__DATA_CONST,__objc_const",
|
||||
"__DATA,__objc_const",
|
||||
"__AUTH_CONST,__objc_const",
|
||||
]
|
||||
|
||||
/// Selector cstrings live in the first of these that the image has.
|
||||
static let stringSectionKeys = ["__TEXT,__objc_methname", "__TEXT,__cstring"]
|
||||
|
||||
/// `{uint32 entsizeAndFlags, uint32 count}`.
|
||||
static let methodListHeaderSize = 8
|
||||
/// Lists are laid out back to back on an 8-byte boundary.
|
||||
static let methodListAlignment = 8
|
||||
/// `entsizeAndFlags` bit 31 — entries are relative, not pointers.
|
||||
static let relativeMethodListFlag: UInt32 = 0x8000_0000
|
||||
/// The entry-size field, with the flag bits masked off.
|
||||
static let methodListEntrySizeMask: UInt32 = 0xFFFC
|
||||
/// `{int32 name, int32 types, int32 imp}`.
|
||||
static let relativeMethodEntrySize = 12
|
||||
/// `DYLD_CHAINED_PTR_64` rebase: `target` is the low 36 bits.
|
||||
static let chainedRebaseTargetMask: UInt64 = 0x0000_000F_FFFF_FFFF
|
||||
|
||||
static func section(_ sections: [String: MachOSectionInfo], _ keys: String...) -> MachOSectionInfo? {
|
||||
for key in keys {
|
||||
if let section = sections[key] { return section }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The image's preferred load address: the `__TEXT` segment's `vmaddr`,
|
||||
/// read off the section table so no extra segment walk is needed.
|
||||
static func imageBase(_ sections: [String: MachOSectionInfo]) -> UInt64 {
|
||||
sections[textSectionKey].map { $0.address - UInt64($0.fileOffset) } ?? 0
|
||||
}
|
||||
|
||||
/// Map a file offset back to a virtual address through the section table.
|
||||
///
|
||||
/// Zero-fill sections (`__bss`, `__common`) carry a file offset of 0 and
|
||||
/// would otherwise claim the Mach-O header, so they are skipped. The
|
||||
/// candidates are walked in file order rather than in the dictionary's,
|
||||
/// which has no defined order — two runs over the same bytes must resolve
|
||||
/// the same address.
|
||||
static func virtualAddress(ofFileOffset offset: Int, sections: [String: MachOSectionInfo]) -> UInt64? {
|
||||
for section in sections.values.sorted(by: { $0.fileOffset < $1.fileOffset }) {
|
||||
let start = Int(section.fileOffset)
|
||||
guard start > 0 else { continue }
|
||||
if offset >= start, offset < start + Int(section.size) {
|
||||
return section.address + UInt64(offset - start)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
static func describe(_ site: Site) -> String {
|
||||
let va = site.virtualAddress.map { " va 0x\(hex($0))" } ?? ""
|
||||
return "foff 0x\(hex(UInt64(site.fileOffset)))\(va) [\(site.anchor.rawValue)]"
|
||||
}
|
||||
|
||||
static func disassemblyText(of bytes: Data, at virtualAddress: UInt64?) -> String {
|
||||
ARM64Disassembler()
|
||||
.disassemble(bytes, at: virtualAddress ?? 0)
|
||||
.map { $0.operandString.isEmpty ? $0.mnemonic : "\($0.mnemonic) \($0.operandString)" }
|
||||
.joined(separator: "; ")
|
||||
}
|
||||
|
||||
static func hex(_ value: UInt64) -> String {
|
||||
String(value, radix: 16, uppercase: true)
|
||||
}
|
||||
|
||||
static func alignUp(_ value: Int, to alignment: Int) -> Int {
|
||||
(value + alignment - 1) & ~(alignment - 1)
|
||||
}
|
||||
|
||||
/// Zero-base a `Data` so the integer subscripts used throughout are valid.
|
||||
static func rebased(_ data: Data) -> Data {
|
||||
data.startIndex == 0 ? data : Data(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
// CFWJetsam.swift — Defuse the launchd jetsam panic guard in /sbin/launchd.
|
||||
//
|
||||
// WHY
|
||||
// ---
|
||||
// `/sbin/launchd` is pid 1. Under the vphone kernel the jetsam property
|
||||
// category for a Daemon job is never initialized, so the guard that checks it
|
||||
// takes its failure path, logs "jetsam property category (%s) is not
|
||||
// initialized" and tears the process down. initproc dying is a panic, and the
|
||||
// panic is a loop: the guest never reaches userspace. Forcing the guard's
|
||||
// success return is what lets the boot continue.
|
||||
//
|
||||
// The blast radius here is the largest of the six standalone Mach-O patchers:
|
||||
// a wrong four bytes in pid 1 is a guest that never boots, with no shell to
|
||||
// debug it from. Everything below is therefore anchored on what the compiler
|
||||
// had to emit, never on where it happened to land.
|
||||
//
|
||||
// REVEAL PROCEDURE (no file offset, virtual address or instruction byte in
|
||||
// this file is written down — all four steps derive their address):
|
||||
//
|
||||
// 1. String anchor — find the jetsam-not-initialized format string in the
|
||||
// image, then walk back to the start of the enclosing NUL-terminated C
|
||||
// string, because code references a string's start, never a substring.
|
||||
// 2. Cross-reference — find the ADRP+ADD pair in `__TEXT,__text` that
|
||||
// computes that string's VA. That is the guard's failure path: the
|
||||
// instruction that loads the message it is about to log.
|
||||
// 3. Enclosing function — walk back from the xref to the function's
|
||||
// `PACIBSP` prologue. This is the one place this port deliberately
|
||||
// diverges from `scripts/patchers/cfw_patch_jetsam.py`, which instead
|
||||
// scans a blind 0x300-byte window that can start inside the *previous*
|
||||
// function. Both pick the same instruction on iOS 27.0 / 24A435 (proven
|
||||
// byte for byte in `CFWJetsamTests`); the function bound is what keeps
|
||||
// that true when the code around it moves. The blind window survives as
|
||||
// the fallback for a function with no PAC prologue.
|
||||
// 4. Gate — inside that function, take the earliest conditional branch whose
|
||||
// target is a *return block*: a basic block reaching `ret`/`retab`/`retaa`
|
||||
// without leaving through a branch first. Earliest, because that one skips
|
||||
// the most of the jetsam path. Rewrite it to an unconditional `b` to the
|
||||
// same target, so every path that reaches the gate returns through its
|
||||
// success path. Not every path reaches it: on 24A435 a `cbz x1` four
|
||||
// instructions earlier branches past the gate, as it did before the patch.
|
||||
//
|
||||
// The replacement comes from `ARM64Encoder.encodeB(from:to:)`, and the branch
|
||||
// classification from Capstone's typed operands — never from operand text.
|
||||
// Capstone 6 prints a branch target as `0x237ef22bc` where the Capstone 5 the
|
||||
// Python links prints `#0x237ef22bc`; that string reaches a log line and a
|
||||
// `PatchRecord` description, never a patched byte.
|
||||
//
|
||||
// IDEMPOTENCE
|
||||
// -----------
|
||||
// Running twice is a clean no-op. That is not free here, and getting it wrong
|
||||
// is a live bug in the reference: rewriting the gate drops it out of the
|
||||
// conditional-branch set, so the Python's backward scan walks past it and
|
||||
// patches the *next* branch into the same return block — a second, wrong site
|
||||
// on a binary that was already correct (`scripts/patchers/README_reference_capture.md`,
|
||||
// "The non-idempotency itself is a separate, pre-existing bug"). The fix has to
|
||||
// live inside the scan, because on a re-run neither implementation picks the
|
||||
// site it patched before. So the scan collects unconditional `b`s into a return
|
||||
// block as well, and an earlier one of those means a previous run already did
|
||||
// the work. See `Verdict.alreadyPatched`.
|
||||
//
|
||||
// SIGNING
|
||||
// -------
|
||||
// `reattest` defaults to false, matching the reference: every call site
|
||||
// (`scripts/cfw_install_{dev,jb,exp}.sh`, `cfw-kit/jb/install.sh`) runs `ldid`
|
||||
// over the result immediately afterwards, which rebuilds the signature whole.
|
||||
// Pass `reattest: true` when nothing downstream re-signs — it recomputes the
|
||||
// slot hash of the one page this patch dirties through
|
||||
// `CFWMachOCodeSignature`, short tail slot included, and the result passes
|
||||
// `codesign -v`.
|
||||
|
||||
import Capstone
|
||||
import Foundation
|
||||
|
||||
public enum CFWJetsamPatcher {
|
||||
// MARK: - Anchors
|
||||
|
||||
/// The jetsam guard's failure message, most specific first, exactly as the
|
||||
/// reference orders them. The first anchor that resolves all the way to a
|
||||
/// patch site wins; one that resolves partway is abandoned for the next.
|
||||
///
|
||||
/// The middle entry is the substring that actually matches on iOS 27.0 —
|
||||
/// the full sentence is a format string (`(%s)`) in the image, not the
|
||||
/// rendered text.
|
||||
public static let panicStringAnchors = [
|
||||
"jetsam property category (Daemon) is not initialized",
|
||||
"jetsam property category",
|
||||
"initproc exited -- exit reason namespace 7 subcode 0x1",
|
||||
]
|
||||
|
||||
/// Conditional branches that can gate the jetsam failure path. Matched
|
||||
/// against Capstone's mnemonic, which is the instruction's identity; the
|
||||
/// target comes from its typed immediate operand.
|
||||
static let conditionalBranchMnemonics: Set<String> = [
|
||||
"b.eq", "b.ne", "b.cs", "b.hs", "b.cc", "b.lo", "b.mi", "b.pl",
|
||||
"b.vs", "b.vc", "b.hi", "b.ls", "b.ge", "b.lt", "b.gt", "b.le",
|
||||
"cbz", "cbnz", "tbz", "tbnz",
|
||||
]
|
||||
|
||||
/// How far back to look for the enclosing function's `PACIBSP` prologue.
|
||||
/// A quarter of a page of instructions is well past any launchd function
|
||||
/// that references a log string.
|
||||
static let maxFunctionPrologueScan = 0x400
|
||||
|
||||
/// The reference's blind backward window, kept as the fallback for a
|
||||
/// function whose prologue does not sign the link register.
|
||||
static let fallbackScanWindow = 0x300
|
||||
|
||||
/// Instructions to decode from a branch target while deciding whether it is
|
||||
/// a return block.
|
||||
static let returnBlockProbeInstructions = 8
|
||||
|
||||
/// `ARM64Disassembler` is stateless across calls and `Sendable`.
|
||||
private static let disassembler = ARM64Disassembler()
|
||||
|
||||
// MARK: - Outcome
|
||||
|
||||
/// What one run did.
|
||||
public struct Outcome: Sendable {
|
||||
public enum Verdict: Sendable, Equatable, CustomStringConvertible {
|
||||
/// The gate was live and has been rewritten.
|
||||
case patched
|
||||
/// An unconditional branch into the function's return block already
|
||||
/// sits ahead of every conditional one. A previous run wrote it;
|
||||
/// nothing was written and nothing needed re-attesting.
|
||||
case alreadyPatched
|
||||
/// A dry run that located a live gate and stopped short of writing.
|
||||
case wouldPatch
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .patched: "patched"
|
||||
case .alreadyPatched: "already patched"
|
||||
case .wouldPatch: "would patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public let verdict: Verdict
|
||||
/// Which of `panicStringAnchors` resolved.
|
||||
public let anchor: String
|
||||
/// File offset of the branch that was (or would be) rewritten.
|
||||
public let gateOffset: Int
|
||||
/// Virtual address of the same.
|
||||
public let gateVMA: UInt64
|
||||
/// File offset the gate branches to — the function's return block.
|
||||
public let returnBlockOffset: Int
|
||||
/// File offset of the enclosing function's first instruction.
|
||||
public let functionOffset: Int
|
||||
/// The record of the single write, on a run that wrote or would write.
|
||||
public let record: PatchRecord?
|
||||
/// Slot hashes re-attestation replaced, on a run that wrote with
|
||||
/// `reattest: true`.
|
||||
public let rehashes: [CFWSlotRehash]
|
||||
|
||||
public init(
|
||||
verdict: Verdict,
|
||||
anchor: String,
|
||||
gateOffset: Int,
|
||||
gateVMA: UInt64,
|
||||
returnBlockOffset: Int,
|
||||
functionOffset: Int,
|
||||
record: PatchRecord? = nil,
|
||||
rehashes: [CFWSlotRehash] = []
|
||||
) {
|
||||
self.verdict = verdict
|
||||
self.anchor = anchor
|
||||
self.gateOffset = gateOffset
|
||||
self.gateVMA = gateVMA
|
||||
self.returnBlockOffset = returnBlockOffset
|
||||
self.functionOffset = functionOffset
|
||||
self.record = record
|
||||
self.rehashes = rehashes
|
||||
}
|
||||
|
||||
/// Sites this run put on disk — 1 on a live patch, 0 otherwise.
|
||||
public var sitesWritten: Int { verdict == .patched ? 1 : 0 }
|
||||
}
|
||||
|
||||
// MARK: - Entry points
|
||||
|
||||
/// Patch `/sbin/launchd` in place.
|
||||
///
|
||||
/// `reattest: true` recomputes the slot hash of the page the patch dirties
|
||||
/// so the binary still verifies on its own; leave it false when the caller
|
||||
/// re-signs (every current one does).
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
fileAt url: URL,
|
||||
dryRun: Bool = false,
|
||||
reattest: Bool = false,
|
||||
log: ((String) -> Void)? = { FileHandle.standardError.write(Data(($0 + "\n").utf8)) }
|
||||
) throws -> Outcome {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw PatcherError.fileNotFound(url.path)
|
||||
}
|
||||
var data = try Data(contentsOf: url)
|
||||
let outcome = try patch(&data, dryRun: dryRun, reattest: reattest, log: log)
|
||||
if !dryRun, outcome.verdict == .patched {
|
||||
try data.write(to: url)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
/// Patch a `/sbin/launchd` image held in memory.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
_ data: inout Data,
|
||||
dryRun: Bool = false,
|
||||
reattest: Bool = false,
|
||||
log: ((String) -> Void)? = nil
|
||||
) throws -> Outcome {
|
||||
if data.startIndex != 0 { data = Data(data) }
|
||||
|
||||
let image = try Image(data: data)
|
||||
guard let site = try locate(in: image, log: log) else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"launchd jetsam: no anchor string resolved to a conditional branch "
|
||||
+ "into its function's return block"
|
||||
)
|
||||
}
|
||||
|
||||
let gateVMA = image.virtualAddress(ofTextOffset: site.gateOffset)
|
||||
log?(" Found jetsam anchor '\(site.anchor)'")
|
||||
log?(String(format: " string start: va:0x%llX", site.stringVMA))
|
||||
log?(String(format: " xref at foff:0x%X", site.xrefOffset))
|
||||
log?(String(format: " function at foff:0x%X", site.functionOffset))
|
||||
|
||||
if site.isAlreadyPatched {
|
||||
log?(String(
|
||||
format: " [=] already patched at 0x%X: b 0x%X (jetsam panic guard bypass)",
|
||||
site.gateOffset,
|
||||
site.returnBlockOffset
|
||||
))
|
||||
return Outcome(
|
||||
verdict: .alreadyPatched,
|
||||
anchor: site.anchor,
|
||||
gateOffset: site.gateOffset,
|
||||
gateVMA: gateVMA,
|
||||
returnBlockOffset: site.returnBlockOffset,
|
||||
functionOffset: site.functionOffset
|
||||
)
|
||||
}
|
||||
|
||||
guard let branch = ARM64Encoder.encodeB(from: site.gateOffset, to: site.returnBlockOffset) else {
|
||||
throw PatcherError.invalidFormat(
|
||||
String(
|
||||
format: "launchd jetsam: b 0x%X is out of range from 0x%X",
|
||||
site.returnBlockOffset,
|
||||
site.gateOffset
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let original = Data(data[site.gateOffset ..< site.gateOffset + 4])
|
||||
let record = PatchRecord(
|
||||
patchID: "launchd_jetsam.panic_guard_bypass",
|
||||
component: "launchd_jetsam",
|
||||
fileOffset: site.gateOffset,
|
||||
virtualAddress: gateVMA,
|
||||
originalBytes: original,
|
||||
patchedBytes: branch,
|
||||
beforeDisasm: describe(original, at: site.gateOffset),
|
||||
afterDisasm: describe(branch, at: site.gateOffset),
|
||||
description: String(
|
||||
format: "conditional branch -> unconditional b 0x%X (jetsam panic guard bypass)",
|
||||
site.returnBlockOffset
|
||||
)
|
||||
)
|
||||
|
||||
log?(String(
|
||||
format: " %@ at 0x%X: %@ -> %@",
|
||||
dryRun ? "[.] would patch" : "[+] patching",
|
||||
site.gateOffset,
|
||||
record.beforeDisasm,
|
||||
record.afterDisasm
|
||||
))
|
||||
|
||||
guard !dryRun else {
|
||||
return Outcome(
|
||||
verdict: .wouldPatch,
|
||||
anchor: site.anchor,
|
||||
gateOffset: site.gateOffset,
|
||||
gateVMA: gateVMA,
|
||||
returnBlockOffset: site.returnBlockOffset,
|
||||
functionOffset: site.functionOffset,
|
||||
record: record
|
||||
)
|
||||
}
|
||||
|
||||
data.replaceSubrange(site.gateOffset ..< site.gateOffset + 4, with: branch)
|
||||
guard Data(data[site.gateOffset ..< site.gateOffset + 4]) == branch else {
|
||||
throw PatcherError.patchVerificationFailed(
|
||||
String(format: "launchd jetsam: post-write verify failed at 0x%X", site.gateOffset)
|
||||
)
|
||||
}
|
||||
|
||||
var rehashes: [CFWSlotRehash] = []
|
||||
if reattest {
|
||||
rehashes = try CFWMachOCodeSignature.reattest(&data, modifiedOffsets: [site.gateOffset])
|
||||
for rehash in rehashes { log?(" [.] re-attest \(rehash)") }
|
||||
}
|
||||
|
||||
log?(String(format: " [+] Patched at 0x%X: jetsam panic guard bypass", site.gateOffset))
|
||||
return Outcome(
|
||||
verdict: .patched,
|
||||
anchor: site.anchor,
|
||||
gateOffset: site.gateOffset,
|
||||
gateVMA: gateVMA,
|
||||
returnBlockOffset: site.returnBlockOffset,
|
||||
functionOffset: site.functionOffset,
|
||||
record: record,
|
||||
rehashes: rehashes
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Image
|
||||
|
||||
/// The parts of the Mach-O this patch reads: `__TEXT,__text`, and every
|
||||
/// file-backed section, so a string hit can be turned into a VA.
|
||||
struct Image {
|
||||
let data: Data
|
||||
let textOffset: Int
|
||||
let textSize: Int
|
||||
let textVMA: UInt64
|
||||
/// File-backed sections, in file order. Zero-fill sections (`__bss`,
|
||||
/// `__common`) are dropped: their `fileOffset` is 0, so leaving them in
|
||||
/// lets one claim the range `[0, size)` and mislocate a hit in the
|
||||
/// Mach-O header.
|
||||
let sections: [MachOSectionInfo]
|
||||
|
||||
init(data rawData: Data) throws {
|
||||
// Zero-base so the integer subscripts used throughout are valid.
|
||||
let data = rawData.startIndex == 0 ? rawData : Data(rawData)
|
||||
guard data.count > 32, data.loadLE(UInt32.self, at: 0) == 0xFEED_FACF else {
|
||||
throw PatcherError.invalidFormat("launchd jetsam: not a 64-bit Mach-O")
|
||||
}
|
||||
let parsed = MachOParser.parseSections(from: data)
|
||||
guard let text = parsed["__TEXT,__text"] else {
|
||||
throw PatcherError.invalidFormat("launchd jetsam: __TEXT,__text not found")
|
||||
}
|
||||
guard Int(text.fileOffset) + Int(text.size) <= data.count else {
|
||||
throw PatcherError.invalidFormat("launchd jetsam: __TEXT,__text runs past the file")
|
||||
}
|
||||
self.data = data
|
||||
textOffset = Int(text.fileOffset)
|
||||
textSize = Int(text.size)
|
||||
textVMA = text.address
|
||||
sections = parsed.values
|
||||
.filter { $0.fileOffset != 0 && $0.size != 0 }
|
||||
.sorted { $0.fileOffset < $1.fileOffset }
|
||||
}
|
||||
|
||||
var textEnd: Int { textOffset + textSize }
|
||||
|
||||
func isInText(_ offset: Int) -> Bool { offset >= textOffset && offset < textEnd }
|
||||
|
||||
/// VA of a `__TEXT,__text` file offset. `__text` is one contiguous
|
||||
/// mapping, so the two differ by a constant.
|
||||
func virtualAddress(ofTextOffset offset: Int) -> UInt64 {
|
||||
textVMA &+ UInt64(offset - textOffset)
|
||||
}
|
||||
|
||||
/// The file-backed section containing `offset`, if any.
|
||||
func section(containing offset: Int) -> MachOSectionInfo? {
|
||||
sections.first {
|
||||
offset >= Int($0.fileOffset) && offset < Int($0.fileOffset) + Int($0.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reveal
|
||||
|
||||
/// Everything step 4 needs, plus what the log prints about how it got there.
|
||||
struct Site {
|
||||
let anchor: String
|
||||
let stringVMA: UInt64
|
||||
let xrefOffset: Int
|
||||
let functionOffset: Int
|
||||
let gateOffset: Int
|
||||
let returnBlockOffset: Int
|
||||
/// True when `gateOffset` already holds the unconditional branch.
|
||||
let isAlreadyPatched: Bool
|
||||
}
|
||||
|
||||
/// Walk the anchors in order, taking the first that resolves all the way to
|
||||
/// a gate. An anchor that resolves partway — present but with no xref, or
|
||||
/// an xref with no qualifying branch — is abandoned for the next one, which
|
||||
/// is what the reference does.
|
||||
static func locate(in image: Image, log: ((String) -> Void)? = nil) throws -> Site? {
|
||||
for anchor in panicStringAnchors {
|
||||
guard let hit = image.data.range(of: Data(anchor.utf8))?.lowerBound else { continue }
|
||||
guard let section = image.section(containing: hit) else { continue }
|
||||
|
||||
let stringOffset = cStringStart(in: image.data, containing: hit, sectionStart: Int(section.fileOffset))
|
||||
let stringVMA = section.address &+ UInt64(stringOffset - Int(section.fileOffset))
|
||||
|
||||
guard let xrefOffset = findADRPADDReference(to: stringVMA, in: image) else {
|
||||
log?(" [.] anchor '\(anchor)' has no ADRP+ADD xref in __TEXT,__text")
|
||||
continue
|
||||
}
|
||||
|
||||
let functionOffset = functionStart(before: xrefOffset, in: image)
|
||||
guard let gate = findReturnGate(from: functionOffset, to: xrefOffset, in: image) else {
|
||||
log?(String(format: " [.] anchor '%@' has no return-block gate in [0x%X, 0x%X)",
|
||||
anchor, functionOffset, xrefOffset))
|
||||
continue
|
||||
}
|
||||
|
||||
return Site(
|
||||
anchor: anchor,
|
||||
stringVMA: stringVMA,
|
||||
xrefOffset: xrefOffset,
|
||||
functionOffset: functionOffset,
|
||||
gateOffset: gate.offset,
|
||||
returnBlockOffset: gate.target,
|
||||
isAlreadyPatched: gate.isUnconditional
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Start of the NUL-terminated C string containing `offset`.
|
||||
///
|
||||
/// Code references a string's first byte, so a substring anchor has to be
|
||||
/// widened to the whole string before its address means anything.
|
||||
static func cStringStart(in data: Data, containing offset: Int, sectionStart: Int) -> Int {
|
||||
var position = offset - 1
|
||||
while position >= sectionStart, data[position] != 0 {
|
||||
position -= 1
|
||||
}
|
||||
return position + 1
|
||||
}
|
||||
|
||||
/// File offset of the ADRP in the first `ADRP Rd, page` / `ADD Rd, Rd, #off`
|
||||
/// pair in `__TEXT,__text` that computes `targetVMA`.
|
||||
///
|
||||
/// The pair need not be adjacent — the compiler interleaves other setup
|
||||
/// between them — so the most recent ADRP per destination register is kept
|
||||
/// and matched against a later ADD that reads it, within eight instructions.
|
||||
///
|
||||
/// Decoded from the instruction words rather than through Capstone: this is
|
||||
/// the one scan that covers all of `__text` (93k instructions in launchd),
|
||||
/// and the two opcode predicates below are exact. `ARM64Inst` documents the
|
||||
/// same split — raw predicates for the hot loops, Capstone once a specific
|
||||
/// instruction is in hand, which is what every semantic test in this file
|
||||
/// uses.
|
||||
static func findADRPADDReference(to targetVMA: UInt64, in image: Image) -> Int? {
|
||||
let targetPage = targetVMA & ~0xFFF
|
||||
let targetPageOffset = UInt32(targetVMA & 0xFFF)
|
||||
|
||||
// Rd -> (instruction index, page the ADRP produced)
|
||||
var pending: [UInt32: (index: Int, page: UInt64)] = [:]
|
||||
|
||||
var offset = image.textOffset
|
||||
var index = 0
|
||||
while offset + 4 <= image.textEnd {
|
||||
let word = image.data.loadLE(UInt32.self, at: offset)
|
||||
|
||||
if ARM64Inst.isADRP(word) {
|
||||
pending[ARM64Inst.rd(word)] = (index, adrpPage(word, at: image.virtualAddress(ofTextOffset: offset)))
|
||||
} else if isAddImm64(word) {
|
||||
let rn = ARM64Inst.rn(word)
|
||||
if let adrp = pending[rn],
|
||||
adrp.page == targetPage,
|
||||
ARM64Inst.addSubImm12(word) == targetPageOffset,
|
||||
index - adrp.index <= 8
|
||||
{
|
||||
return image.textOffset + (adrp.index * 4)
|
||||
}
|
||||
}
|
||||
|
||||
offset += 4
|
||||
index += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Page address an ADRP at `pc` produces.
|
||||
static func adrpPage(_ word: UInt32, at pc: UInt64) -> UInt64 {
|
||||
let immhi = (word >> 5) & 0x7FFFF
|
||||
let immlo = (word >> 29) & 0x3
|
||||
let imm21 = (immhi << 2) | immlo
|
||||
// Sign-extend the 21-bit immediate, then scale by the 4 KiB page.
|
||||
let signed = Int64(Int32(bitPattern: imm21 << 11) >> 11)
|
||||
return (pc & ~0xFFF) &+ UInt64(bitPattern: signed << 12)
|
||||
}
|
||||
|
||||
/// `ADD Xd, Xn, #imm12` with `LSL #0` — `[31:22] = 1001000100`.
|
||||
///
|
||||
/// Requiring `sh == 0` is what keeps `add xd, xn, #imm, lsl #12` out; a
|
||||
/// shifted-register `add` has a different `[28:24]` and never reaches here.
|
||||
static func isAddImm64(_ word: UInt32) -> Bool { (word & 0xFFC0_0000) == 0x9100_0000 }
|
||||
|
||||
/// First instruction of the function containing `offset`.
|
||||
///
|
||||
/// `PACIBSP` is the prologue of every non-leaf arm64e function, and it is
|
||||
/// the only instruction that can only appear at a function's entry, which
|
||||
/// makes it the one reliable boundary here. A `ret` is not: this function's
|
||||
/// own success epilogue returns *before* the failure path the xref sits in,
|
||||
/// so a backward scan for `ret` stops inside the function it is trying to
|
||||
/// delimit.
|
||||
///
|
||||
/// With no prologue in range this falls back to the reference's blind
|
||||
/// window, so a function that does not sign its link register still gets
|
||||
/// the reference's behaviour rather than none.
|
||||
static func functionStart(before offset: Int, in image: Image) -> Int {
|
||||
let floor = max(image.textOffset, offset - maxFunctionPrologueScan)
|
||||
var scan = offset - 4
|
||||
while scan >= floor {
|
||||
if image.data.loadLE(UInt32.self, at: scan) == ARM64.pacibspU32 { return scan }
|
||||
scan -= 4
|
||||
}
|
||||
return max(image.textOffset, offset - fallbackScanWindow)
|
||||
}
|
||||
|
||||
/// The gate: the earliest branch in `[start, end)` whose target is a return
|
||||
/// block of the same function.
|
||||
///
|
||||
/// Unconditional `b`s are collected alongside the conditional ones so that
|
||||
/// the shape this patch *writes* is recognised on a second run. An
|
||||
/// unconditional one earlier than every conditional candidate is a previous
|
||||
/// run's work — there is nothing left to do, and rewriting the next
|
||||
/// conditional branch instead (which is what the reference does) would put
|
||||
/// a second, unasked-for patch into pid 1.
|
||||
///
|
||||
/// The limit of that signal, stated so nobody has to rediscover it: a
|
||||
/// compiler-emitted `b` to the epilogue, earlier in this window than any
|
||||
/// conditional gate, would read as already-patched on a *pristine* image and
|
||||
/// the patch would never be applied. There is none on iOS 27.0 / 24A435 —
|
||||
/// `gateIsTheEarliestQualifyingBranch` walks the window and proves it — and
|
||||
/// `revealStepsAreSelfConsistent` asserts `!isAlreadyPatched` on the pristine
|
||||
/// binary, so a firmware that grows one fails the suite rather than quietly
|
||||
/// shipping an unpatched pid 1.
|
||||
static func findReturnGate(
|
||||
from start: Int,
|
||||
to end: Int,
|
||||
in image: Image
|
||||
) -> (offset: Int, target: Int, isUnconditional: Bool)? {
|
||||
var liveGate: (offset: Int, target: Int)?
|
||||
var patchedGate: (offset: Int, target: Int)?
|
||||
|
||||
var offset = start
|
||||
while offset + 4 <= end {
|
||||
defer { offset += 4 }
|
||||
guard let insn = disassembler.disassembleOne(in: image.data, at: offset) else { continue }
|
||||
|
||||
let unconditional = insn.mnemonic == "b"
|
||||
guard unconditional || conditionalBranchMnemonics.contains(insn.mnemonic) else { continue }
|
||||
guard let target = branchTarget(insn), image.isInText(target) else { continue }
|
||||
guard isReturnBlock(target, in: image) else { continue }
|
||||
|
||||
if unconditional {
|
||||
if patchedGate == nil { patchedGate = (offset, target) }
|
||||
} else if liveGate == nil {
|
||||
liveGate = (offset, target)
|
||||
}
|
||||
if liveGate != nil, patchedGate != nil { break }
|
||||
}
|
||||
|
||||
switch (liveGate, patchedGate) {
|
||||
case let (live?, patched?):
|
||||
return patched.offset < live.offset
|
||||
? (patched.offset, patched.target, true)
|
||||
: (live.offset, live.target, false)
|
||||
case let (live?, nil):
|
||||
return (live.offset, live.target, false)
|
||||
case let (nil, patched?):
|
||||
return (patched.offset, patched.target, true)
|
||||
case (nil, nil):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Branch destination as a file offset, from Capstone's typed operands.
|
||||
///
|
||||
/// Every branch form here carries its destination as its last immediate:
|
||||
/// `b`/`b.<cond>` have only one operand, `cbz`/`cbnz` a register then the
|
||||
/// target, `tbz`/`tbnz` a register, a bit number, then the target. Reading
|
||||
/// the last immediate covers all three without parsing operand text. The
|
||||
/// instruction was decoded at its own file offset, so the immediate is a
|
||||
/// file offset too.
|
||||
static func branchTarget(_ insn: Instruction) -> Int? {
|
||||
guard let detail = insn.aarch64 else { return nil }
|
||||
for operand in detail.operands.reversed() where operand.type == AARCH64_OP_IMM {
|
||||
return Int(operand.imm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// True when the instruction returns from the function — `ret`, `retaa`,
|
||||
/// `retab`, by Capstone's own classification rather than a mnemonic prefix.
|
||||
///
|
||||
/// The prefix test this replaces (`hasPrefix("ret")`) is close enough on
|
||||
/// this image and wrong in principle; the group is what Capstone decoded
|
||||
/// the instruction to mean.
|
||||
static func isReturn(_ insn: Instruction) -> Bool {
|
||||
insn.groups.contains(UInt8(CS_GRP_RET.rawValue))
|
||||
}
|
||||
|
||||
/// True when control leaves the block here without falling through: an
|
||||
/// unconditional jump (`b`, `br`, `braa`, …) or a call (`bl`, `blr`,
|
||||
/// `blraa`, …).
|
||||
///
|
||||
/// A conditional branch is deliberately not one of these. It falls through,
|
||||
/// so the return can still be the instruction after it, which is what makes
|
||||
/// a compare-and-return epilogue a return block.
|
||||
///
|
||||
/// Groups again, not prefixes: `hasPrefix("br")` also swallows `brk`, which
|
||||
/// is a breakpoint (`CS_GRP_INT`) and ends nothing, and `hasPrefix("bl")`
|
||||
/// would only reach the authenticated calls by accident.
|
||||
static func leavesBlock(_ insn: Instruction) -> Bool {
|
||||
if insn.groups.contains(UInt8(CS_GRP_CALL.rawValue)) { return true }
|
||||
return insn.groups.contains(UInt8(CS_GRP_JUMP.rawValue))
|
||||
&& !conditionalBranchMnemonics.contains(insn.mnemonic)
|
||||
}
|
||||
|
||||
/// True when the block at `offset` returns from the function.
|
||||
///
|
||||
/// Decodes forward until the block returns, until control leaves it some
|
||||
/// other way (so it is not a return block), or until the end of `__text` or
|
||||
/// the probe limit.
|
||||
static func isReturnBlock(_ offset: Int, in image: Image) -> Bool {
|
||||
for step in 0 ..< returnBlockProbeInstructions {
|
||||
let probe = offset + step * 4
|
||||
guard probe + 4 <= image.textEnd else { return false }
|
||||
guard let insn = disassembler.disassembleOne(in: image.data, at: probe) else { continue }
|
||||
if isReturn(insn) { return true }
|
||||
if leavesBlock(insn) { return false }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Logging
|
||||
|
||||
/// `mnemonic operands` for a single instruction, for the record's
|
||||
/// before/after fields. Text only — nothing matches on it.
|
||||
static func describe(_ bytes: Data, at offset: Int) -> String {
|
||||
guard let insn = disassembler.disassembleOne(bytes, at: UInt64(offset)) else { return bytes.hex }
|
||||
return insn.operandString.isEmpty ? insn.mnemonic : "\(insn.mnemonic) \(insn.operandString)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
// CFWMobileactivationd.swift — force `-[DeviceType should_hactivate]` to YES.
|
||||
//
|
||||
// Swift port of `scripts/patchers/cfw_patch_mobileactivationd.py`, driven by
|
||||
// `cfw.py patch-mobileactivationd <binary>` from `cfw_install{,_dev}.sh` and
|
||||
// `cfw-kit/lib/base_stages.sh`'s `stage_mobileactivationd`.
|
||||
//
|
||||
// `mobileactivationd` asks `-[DeviceType should_hactivate]` whether the device
|
||||
// may activate itself without talking to albert.apple.com. On a real iPhone the
|
||||
// answer is NO and Setup.app sits on the activation screen forever, which is
|
||||
// where an unpatched guest ends up. The method is a synthesised `_BOOL` ivar
|
||||
// getter — two instructions, `ldrb w0, [x0, #<ivar>]` then `ret` — so forcing
|
||||
// YES is `mov x0, #1 ; ret` over the same eight bytes. No cave, no shifting.
|
||||
//
|
||||
// Anchoring, and why it is not the Python's
|
||||
// ----------------------------------------
|
||||
// The Python takes two shortcuts this does not copy.
|
||||
//
|
||||
// 1. It resolves the IMP with a *substring* search over LC_SYMTAB
|
||||
// (`find_symbol_va(data, "should_hactivate")`) and patches the first hit.
|
||||
// On the iOS 27.0 / 24A435 iPhone17,3 binary four symbols contain that
|
||||
// substring — the method, `_objc_msgSend$should_hactivate` (a selector
|
||||
// stub in `__TEXT,__objc_stubs`), `_OBJC_IVAR_$_DeviceType._should_hactivate`
|
||||
// (a *data* offset in `__DATA`) and a duplicate N_STAB debug entry. It
|
||||
// works today only because the method happens to sort first. Patching the
|
||||
// ivar-offset word instead would corrupt every access to the ivar.
|
||||
// Here the symbol is matched by its exact ObjC name, STAB debug entries
|
||||
// are skipped, and the symbol must be N_SECT-defined.
|
||||
//
|
||||
// 2. Its ObjC-metadata fallback is dead code on this binary, twice over: it
|
||||
// takes the first `memmem` hit for `should_hactivate\0`, which lands at
|
||||
// the tail of the property-attribute string `TB,R,N,V_should_hactivate`
|
||||
// — the `V` field naming the backing ivar — 0x2F4D bytes before the real
|
||||
// selector; and it looks for relative method lists in `__objc_const`,
|
||||
// where iOS 16+ no longer puts them (they live in
|
||||
// `__TEXT,__objc_methlist`). Called directly on the pristine binary it
|
||||
// prints "Selref not found (chained fixups may obscure pointers)" and
|
||||
// returns -1. The fallback here finds the NUL-preceded selector,
|
||||
// unpacks chained-fixup rebase targets, and walks real relative method
|
||||
// lists — so it resolves the same IMP the symbol table does, which is how
|
||||
// the anchor is cross-checked rather than trusted.
|
||||
//
|
||||
// Both routes run. When both resolve they must agree, or the binary is not the
|
||||
// shape we were told it was and the run stops. Nothing is a literal address.
|
||||
//
|
||||
// Re-attestation
|
||||
// --------------
|
||||
// `codeSigningMonitor == 2` on this stack, so TXM holds the original per-page
|
||||
// slot hashes and any byte changed inside an executable mapping is a SIGKILL on
|
||||
// first page-in. The touched page is re-hashed through `CFWMachOCodeSignature`.
|
||||
// The Python leaves that to the `ldid_sign` that follows it in the shell; doing
|
||||
// it here means the binary is runnable the moment it is written, and the later
|
||||
// `ldid_sign` stays a no-op-in-effect re-sign. Pass `resign: false` to get the
|
||||
// Python's exact bytes.
|
||||
|
||||
import Capstone
|
||||
import Foundation
|
||||
|
||||
/// Forces `-[DeviceType should_hactivate]` to return YES, so the guest
|
||||
/// self-activates instead of waiting on Apple's activation service.
|
||||
public enum CFWMobileactivationd {
|
||||
// MARK: - Identity
|
||||
|
||||
/// The ObjC method whose result is forced.
|
||||
public static let method = "-[DeviceType should_hactivate]"
|
||||
|
||||
/// The selector, as it appears in `__TEXT,__objc_methname`.
|
||||
public static let selector = "should_hactivate"
|
||||
|
||||
/// Component name, matching `records.set_group("mobileactivationd")`.
|
||||
public static let component = "mobileactivationd"
|
||||
|
||||
/// Record identity, matching the Python's `records.site` label so a captured
|
||||
/// reference and this port sort together.
|
||||
public static let patchID = "mobileactivationd.should_hactivate"
|
||||
|
||||
/// Where progress goes when the caller does not say. The Python prints to
|
||||
/// stdout and `cfw_install*.sh` captures that, so this does too.
|
||||
public static let stdoutLog: @Sendable (String) -> Void = { print($0) }
|
||||
|
||||
// MARK: - Results
|
||||
|
||||
/// Which anchor produced the IMP address.
|
||||
public enum AnchorSource: String, Sendable, Equatable {
|
||||
/// Both the symbol table and the ObjC metadata chain resolved, and agreed.
|
||||
case symbolTableAndObjCMetadata
|
||||
/// Only `LC_SYMTAB` carried the method — a binary whose ObjC metadata
|
||||
/// this port cannot walk (a layout change), but whose symbol is exact.
|
||||
case symbolTable
|
||||
/// Only the ObjC metadata chain resolved — a stripped binary.
|
||||
case objcMetadata
|
||||
}
|
||||
|
||||
/// The located IMP.
|
||||
public struct Anchor: Sendable, Equatable {
|
||||
public let virtualAddress: UInt64
|
||||
public let fileOffset: Int
|
||||
public let source: AnchorSource
|
||||
/// `segment,section` the IMP lands in. Always an executable one.
|
||||
public let section: String
|
||||
}
|
||||
|
||||
/// What a run did.
|
||||
public enum Outcome: String, Sendable, Equatable {
|
||||
/// The eight bytes already read `mov x0, #1 ; ret`. Nothing was written.
|
||||
case alreadyPatched
|
||||
/// `dryRun` was set, so the site was located and reported only.
|
||||
case wouldPatch
|
||||
/// The getter was rewritten and its page re-attested.
|
||||
case patched
|
||||
}
|
||||
|
||||
/// The outcome of one run, and the site it acted on.
|
||||
public struct Report: Sendable {
|
||||
public let outcome: Outcome
|
||||
public let anchor: Anchor
|
||||
/// The write, in the shape the Python's reference capture records it.
|
||||
/// `nil` unless bytes actually changed.
|
||||
public let record: PatchRecord?
|
||||
/// Code-directory slots recomputed for the page the write landed in.
|
||||
/// Empty on a dry run, and on a re-run whose slots already match.
|
||||
public let slotRehashes: [CFWSlotRehash]
|
||||
|
||||
/// Sites whose bytes this run changed. The parity number: the Python
|
||||
/// writes exactly one, and so must this.
|
||||
public var sitesWritten: Int { record == nil ? 0 : 1 }
|
||||
}
|
||||
|
||||
// MARK: - Patching
|
||||
|
||||
/// Patch the `mobileactivationd` at `url` in place.
|
||||
///
|
||||
/// Idempotent: a second run finds `mov x0, #1 ; ret` already in place,
|
||||
/// reports ``Outcome/alreadyPatched`` and leaves the file untouched, byte
|
||||
/// for byte. It does not error and it does not write the getter twice.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The `mobileactivationd` Mach-O to patch.
|
||||
/// - resign: Recompute the code-directory slot hash of the touched page.
|
||||
/// `false` reproduces the Python's output exactly, for byte comparison.
|
||||
/// - dryRun: Locate and report, write nothing.
|
||||
/// - Throws: ``PatcherError/patchSiteNotFound(_:)`` when neither anchor
|
||||
/// resolves, ``PatcherError/invalidFormat(_:)`` when the two anchors
|
||||
/// disagree or the site is not executable code.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
fileAt url: URL,
|
||||
resign: Bool = true,
|
||||
dryRun: Bool = false,
|
||||
log: ((String) -> Void)? = stdoutLog
|
||||
) throws -> Report {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw PatcherError.fileNotFound(url.path)
|
||||
}
|
||||
var data = try Data(contentsOf: url)
|
||||
let before = data
|
||||
let report = try patch(&data, resign: resign, dryRun: dryRun, log: log)
|
||||
|
||||
// Written only when something changed, so a no-op run does not even
|
||||
// touch the file's mtime — and so `sha256` before and after a re-run
|
||||
// is trivially the same number.
|
||||
if data != before {
|
||||
try data.write(to: url)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
/// In-memory form of ``patch(fileAt:resign:dryRun:log:)``.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
_ data: inout Data,
|
||||
resign: Bool = true,
|
||||
dryRun: Bool = false,
|
||||
log: ((String) -> Void)? = stdoutLog
|
||||
) throws -> Report {
|
||||
if data.startIndex != 0 { data = Data(data) }
|
||||
|
||||
let anchor = try locateIMP(in: data)
|
||||
log?(" [.] \(method) @ 0x\(hex(anchor.virtualAddress)) "
|
||||
+ "-> foff 0x\(hex(UInt64(anchor.fileOffset))) "
|
||||
+ "in \(anchor.section) (via \(anchor.source.rawValue))")
|
||||
|
||||
let patched = try replacementBytes()
|
||||
guard data.count >= anchor.fileOffset + patched.count else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"\(method): IMP at 0x\(hex(UInt64(anchor.fileOffset))) is past the end of the file"
|
||||
)
|
||||
}
|
||||
let original = Data(data[anchor.fileOffset ..< anchor.fileOffset + patched.count])
|
||||
let body = try decodeBody(original, at: anchor.virtualAddress)
|
||||
|
||||
log?(" [.] before: \(text(of: body))")
|
||||
|
||||
if original == patched {
|
||||
// The already-patched shape, recognised rather than re-applied.
|
||||
// `8eb6c8b` fixed this exact class of bug for the DSC gates.
|
||||
log?(" [=] already `mov x0, #1 ; ret` at 0x\(hex(anchor.virtualAddress)); "
|
||||
+ "nothing to write")
|
||||
let rehashes = dryRun || !resign
|
||||
? []
|
||||
: try CFWMachOCodeSignature.reattest(&data, modifiedOffsets: touchedOffsets(anchor, patched))
|
||||
if !rehashes.isEmpty {
|
||||
log?(" [+] re-attested \(rehashes.count) stale slot(s): "
|
||||
+ rehashes.map(\.description).joined(separator: ", "))
|
||||
}
|
||||
return Report(
|
||||
outcome: .alreadyPatched,
|
||||
anchor: anchor,
|
||||
record: nil,
|
||||
slotRehashes: rehashes
|
||||
)
|
||||
}
|
||||
|
||||
guard isPlausibleGetterBody(body) else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"\(method): body at 0x\(hex(anchor.virtualAddress)) reads `\(text(of: body))`, "
|
||||
+ "which is not a two-instruction body this patch can replace"
|
||||
)
|
||||
}
|
||||
|
||||
guard !dryRun else {
|
||||
log?(" [.] dry-run: would write \(original.hex) -> \(patched.hex) "
|
||||
+ "at foff 0x\(hex(UInt64(anchor.fileOffset)))")
|
||||
return Report(outcome: .wouldPatch, anchor: anchor, record: nil, slotRehashes: [])
|
||||
}
|
||||
|
||||
data.replaceSubrange(anchor.fileOffset ..< anchor.fileOffset + patched.count, with: patched)
|
||||
log?(" [+] after: \(text(of: try decodeBody(patched, at: anchor.virtualAddress)))")
|
||||
|
||||
let written = Data(data[anchor.fileOffset ..< anchor.fileOffset + patched.count])
|
||||
guard written == patched else {
|
||||
throw PatcherError.patchVerificationFailed(
|
||||
"\(method): site at 0x\(hex(UInt64(anchor.fileOffset))) reads \(written.hex) after write"
|
||||
)
|
||||
}
|
||||
|
||||
var rehashes: [CFWSlotRehash] = []
|
||||
if resign {
|
||||
rehashes = try CFWMachOCodeSignature.reattest(
|
||||
&data,
|
||||
modifiedOffsets: touchedOffsets(anchor, patched)
|
||||
)
|
||||
log?(" [.] re-attested \(rehashes.count) slot(s): "
|
||||
+ rehashes.map(\.description).joined(separator: ", "))
|
||||
let unsupported = CFWMachOCodeSignature.unsupportedCodeDirectories(in: data)
|
||||
if !unsupported.isEmpty {
|
||||
log?(" [-] \(unsupported.count) non-SHA256 CodeDirectory(ies) left alone")
|
||||
}
|
||||
}
|
||||
|
||||
log?(" [+] Patched at 0x\(hex(UInt64(anchor.fileOffset))): mov x0, #1; ret")
|
||||
return Report(
|
||||
outcome: .patched,
|
||||
anchor: anchor,
|
||||
record: PatchRecord(
|
||||
patchID: patchID,
|
||||
component: component,
|
||||
fileOffset: anchor.fileOffset,
|
||||
virtualAddress: anchor.virtualAddress,
|
||||
originalBytes: original,
|
||||
patchedBytes: patched,
|
||||
beforeDisasm: text(of: body),
|
||||
afterDisasm: text(of: try decodeBody(patched, at: anchor.virtualAddress)),
|
||||
description: "\(method) -> mov x0, #1; ret"
|
||||
),
|
||||
slotRehashes: rehashes
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Replacement
|
||||
|
||||
/// `mov x0, #1 ; ret`, assembled rather than written down.
|
||||
///
|
||||
/// The MOVZ comes out of ``ARM64Encoder``, whose every encoder is asserted
|
||||
/// against keystone; `ret` has no operands to encode, so it is the shared
|
||||
/// keystone-generated constant — the same split the Python makes between
|
||||
/// `asm("mov x0, #1")` and its `RET`.
|
||||
static func replacementBytes() throws -> Data {
|
||||
guard let mov = ARM64Encoder.encodeMovzX(rd: 0, imm16: 1) else {
|
||||
throw PatcherError.invalidFormat("could not encode `mov x0, #1`")
|
||||
}
|
||||
return mov + ARM64.ret
|
||||
}
|
||||
|
||||
/// The file offsets whose pages need re-hashing. Both words are listed, not
|
||||
/// just the first: a getter whose second instruction begins a new 4 KiB page
|
||||
/// dirties two slots, and hashing only the first would leave the tail slot
|
||||
/// stale — a SIGKILL the first time that page is demand-paged in.
|
||||
static func touchedOffsets(_ anchor: Anchor, _ patched: Data) -> [Int] {
|
||||
stride(from: anchor.fileOffset, to: anchor.fileOffset + patched.count, by: 4).map { $0 }
|
||||
}
|
||||
|
||||
// MARK: - Anchoring
|
||||
|
||||
/// Resolve the IMP of ``method``, by symbol and by ObjC metadata.
|
||||
///
|
||||
/// Both routes are independent: one reads `LC_SYMTAB`, the other walks
|
||||
/// `__objc_methname` -> `__objc_selrefs` -> relative method list. When both
|
||||
/// answer they must give the same address.
|
||||
public static func locateIMP(in data: Data) throws -> Anchor {
|
||||
let data = data.startIndex == 0 ? data : Data(data)
|
||||
let segments = MachOParser.parseSegments(from: data)
|
||||
guard !segments.isEmpty else {
|
||||
throw PatcherError.invalidFormat("not a 64-bit Mach-O, or it carries no LC_SEGMENT_64")
|
||||
}
|
||||
|
||||
let bySymbol = symbolVirtualAddress(in: data)
|
||||
let byMetadata = objcMetadataVirtualAddress(in: data, segments: segments)
|
||||
|
||||
let source: AnchorSource
|
||||
let virtualAddress: UInt64
|
||||
switch (bySymbol, byMetadata) {
|
||||
case let (symbol?, metadata?):
|
||||
guard symbol == metadata else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"\(method): symbol table says 0x\(hex(symbol)) but the ObjC method list "
|
||||
+ "says 0x\(hex(metadata)) — refusing to guess which is the IMP"
|
||||
)
|
||||
}
|
||||
source = .symbolTableAndObjCMetadata
|
||||
virtualAddress = symbol
|
||||
case let (symbol?, nil):
|
||||
source = .symbolTable
|
||||
virtualAddress = symbol
|
||||
case let (nil, metadata?):
|
||||
source = .objcMetadata
|
||||
virtualAddress = metadata
|
||||
case (nil, nil):
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"\(method): neither LC_SYMTAB nor the ObjC method lists carry it"
|
||||
)
|
||||
}
|
||||
|
||||
guard let fileOffset = MachOParser.vaToFileOffset(virtualAddress, segments: segments) else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"\(method): VA 0x\(hex(virtualAddress)) maps to no segment"
|
||||
)
|
||||
}
|
||||
guard let section = executableSection(containing: virtualAddress, in: data) else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"\(method): VA 0x\(hex(virtualAddress)) is not inside an executable section — "
|
||||
+ "the anchor resolved to data, not code"
|
||||
)
|
||||
}
|
||||
|
||||
return Anchor(
|
||||
virtualAddress: virtualAddress,
|
||||
fileOffset: fileOffset,
|
||||
source: source,
|
||||
section: section
|
||||
)
|
||||
}
|
||||
|
||||
/// The VA of the `LC_SYMTAB` entry whose name is exactly ``method``.
|
||||
///
|
||||
/// Exact, not a substring: `_objc_msgSend$should_hactivate` and
|
||||
/// `_OBJC_IVAR_$_DeviceType._should_hactivate` both contain the selector and
|
||||
/// neither is the IMP. N_STAB debug entries are skipped — the same address
|
||||
/// arrives twice on this binary, once as N_SECT and once as N_FUN — and the
|
||||
/// symbol must be section-defined with a non-zero value.
|
||||
static func symbolVirtualAddress(in data: Data) -> UInt64? {
|
||||
guard let symtab = MachOParser.parseSymtab(from: data) else { return nil }
|
||||
// <mach-o/nlist.h>: N_STAB masks off the debug entries, N_TYPE selects
|
||||
// the kind, and N_SECT is the one kind that means "defined in a section".
|
||||
let nStab: UInt8 = 0xE0
|
||||
let nTypeMask: UInt8 = 0x0E
|
||||
let nSect: UInt8 = 0x0E
|
||||
|
||||
for index in 0 ..< symtab.nsyms {
|
||||
let entry = symtab.symoff + index * 16 // sizeof(nlist_64)
|
||||
guard entry + 16 <= data.count else { break }
|
||||
|
||||
let typeByte = data[entry + 4]
|
||||
guard typeByte & nStab == 0, typeByte & nTypeMask == nSect else { continue }
|
||||
|
||||
let strx = Int(data.loadLE(UInt32.self, at: entry))
|
||||
let value = data.loadLE(UInt64.self, at: entry + 8)
|
||||
guard value != 0, strx < symtab.strsize else { continue }
|
||||
|
||||
guard let name = cString(in: data, at: symtab.stroff + strx,
|
||||
limit: symtab.stroff + symtab.strsize) else { continue }
|
||||
if name == method { return value }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The VA of the IMP, walked out of the ObjC metadata:
|
||||
/// `__objc_methname` selector -> `__objc_selrefs` entry -> the relative
|
||||
/// method-list entry whose `name` field points at that selref -> its `imp`.
|
||||
static func objcMetadataVirtualAddress(
|
||||
in data: Data,
|
||||
segments: [MachOSegmentInfo]
|
||||
) -> UInt64? {
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
guard let imageBase = segments.first(where: { $0.name == "__TEXT" })?.vmAddr else {
|
||||
return nil
|
||||
}
|
||||
guard let selectorVA = selectorVirtualAddress(in: data, sections: sections) else {
|
||||
return nil
|
||||
}
|
||||
guard let selrefVA = selectorReferenceVirtualAddress(
|
||||
to: selectorVA, in: data, sections: sections, imageBase: imageBase
|
||||
) else { return nil }
|
||||
|
||||
return methodImplementation(
|
||||
forSelectorReference: selrefVA, in: data, sections: sections
|
||||
)
|
||||
}
|
||||
|
||||
/// The selector string's VA — the whole string ``selector``, not a suffix of
|
||||
/// a longer one.
|
||||
///
|
||||
/// `DeviceType`'s property-attribute string `TB,R,N,V_should_hactivate`
|
||||
/// names the backing ivar and sits earlier in the very same section, so a
|
||||
/// plain `memmem` for `should_hactivate\0` — what the Python does — hits its
|
||||
/// tail first. Requiring the preceding byte to be the previous string's NUL
|
||||
/// is what separates a whole selector from a suffix of something longer.
|
||||
static func selectorVirtualAddress(
|
||||
in data: Data,
|
||||
sections: [String: MachOSectionInfo]
|
||||
) -> UInt64? {
|
||||
let candidates = ["__TEXT,__objc_methname", "__DATA,__objc_methname"]
|
||||
guard let section = candidates.compactMap({ sections[$0] }).first else { return nil }
|
||||
|
||||
let start = Int(section.fileOffset)
|
||||
let end = start + Int(section.size)
|
||||
guard start >= 0, end <= data.count, start < end else { return nil }
|
||||
|
||||
let needle = Data(selector.utf8) + Data([0])
|
||||
var cursor = start
|
||||
while cursor + needle.count <= end {
|
||||
guard let found = data[cursor ..< end].range(of: needle) else { return nil }
|
||||
let offset = found.lowerBound
|
||||
// The first string in the section needs no separator before it.
|
||||
if offset == start || data[offset - 1] == 0 {
|
||||
return section.address + UInt64(offset - start)
|
||||
}
|
||||
cursor = offset + 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The `__objc_selrefs` slot that points at `selectorVA`.
|
||||
///
|
||||
/// The slots are chained-fixup rebases on this stack, not plain pointers, so
|
||||
/// the raw word is matched three ways: as-is (an already-bound pointer), as a
|
||||
/// 36-bit rebase target relative to the image base, and as an absolute 36-bit
|
||||
/// target. Whichever form the binary uses, the resolved address has to be the
|
||||
/// selector's.
|
||||
static func selectorReferenceVirtualAddress(
|
||||
to selectorVA: UInt64,
|
||||
in data: Data,
|
||||
sections: [String: MachOSectionInfo],
|
||||
imageBase: UInt64
|
||||
) -> UInt64? {
|
||||
let candidates = [
|
||||
"__DATA,__objc_selrefs",
|
||||
"__DATA_CONST,__objc_selrefs",
|
||||
"__AUTH_CONST,__objc_selrefs",
|
||||
]
|
||||
guard let section = candidates.compactMap({ sections[$0] }).first else { return nil }
|
||||
|
||||
let start = Int(section.fileOffset)
|
||||
let count = Int(section.size)
|
||||
guard start >= 0, start + count <= data.count else { return nil }
|
||||
|
||||
// dyld_chained_ptr_64_rebase.target is 36 bits wide.
|
||||
let targetMask: UInt64 = (1 << 36) - 1
|
||||
|
||||
for slot in stride(from: 0, to: count - 7, by: 8) {
|
||||
let raw = data.loadLE(UInt64.self, at: start + slot)
|
||||
let target = raw & targetMask
|
||||
if raw == selectorVA || target == selectorVA || imageBase &+ target == selectorVA {
|
||||
return section.address + UInt64(slot)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The IMP of the relative-method-list entry whose `name` field resolves to
|
||||
/// `selrefVA`.
|
||||
///
|
||||
/// iOS 16+ stores "small" method lists — three `int32`s per entry, each
|
||||
/// relative to its own field's address — in `__TEXT,__objc_methlist`. The
|
||||
/// Python looks in `__objc_const`, which is why its fallback never fires.
|
||||
/// Both are searched here, so an older layout still resolves.
|
||||
static func methodImplementation(
|
||||
forSelectorReference selrefVA: UInt64,
|
||||
in data: Data,
|
||||
sections: [String: MachOSectionInfo]
|
||||
) -> UInt64? {
|
||||
let candidates = [
|
||||
"__TEXT,__objc_methlist",
|
||||
"__DATA,__objc_const",
|
||||
"__DATA_CONST,__objc_const",
|
||||
"__AUTH_CONST,__objc_const",
|
||||
]
|
||||
for name in candidates {
|
||||
guard let section = sections[name] else { continue }
|
||||
if let imp = methodImplementation(
|
||||
forSelectorReference: selrefVA, in: data, section: section
|
||||
) { return imp }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Walk one section as a run of relative method lists.
|
||||
///
|
||||
/// Each list is `{ uint32 entsizeAndFlags; uint32 count; }` followed by
|
||||
/// `count` 12-byte entries, and the next list starts at the following 8-byte
|
||||
/// boundary. A header that is not a 12-byte-entry small list ends the walk:
|
||||
/// past it the bytes are no longer method lists, and matching an "entry" in
|
||||
/// them would be matching noise.
|
||||
static func methodImplementation(
|
||||
forSelectorReference selrefVA: UInt64,
|
||||
in data: Data,
|
||||
section: MachOSectionInfo
|
||||
) -> UInt64? {
|
||||
let smallMethodListFlag: UInt32 = 0x8000_0000
|
||||
let entrySizeMask: UInt32 = 0x0000_FFFC
|
||||
let entrySize = 12
|
||||
|
||||
let base = Int(section.fileOffset)
|
||||
let size = Int(section.size)
|
||||
guard base >= 0, base + size <= data.count else { return nil }
|
||||
|
||||
var cursor = 0
|
||||
while cursor + 8 <= size {
|
||||
let header = data.loadLE(UInt32.self, at: base + cursor)
|
||||
let count = Int(data.loadLE(UInt32.self, at: base + cursor + 4))
|
||||
guard header & smallMethodListFlag != 0,
|
||||
Int(header & entrySizeMask) == entrySize,
|
||||
count > 0,
|
||||
cursor + 8 + count * entrySize <= size
|
||||
else { return nil }
|
||||
|
||||
for index in 0 ..< count {
|
||||
let entry = cursor + 8 + index * entrySize
|
||||
let entryVA = section.address + UInt64(entry)
|
||||
let nameDelta = Int(data.loadLE(Int32.self, at: base + entry))
|
||||
guard UInt64(bitPattern: Int64(entryVA) + Int64(nameDelta)) == selrefVA else {
|
||||
continue
|
||||
}
|
||||
// { name, types, imp } — the imp field is 8 bytes in, and its
|
||||
// delta is relative to the imp field's own address.
|
||||
let impField = entryVA + 8
|
||||
let impDelta = Int(data.loadLE(Int32.self, at: base + entry + 8))
|
||||
return UInt64(bitPattern: Int64(impField) + Int64(impDelta))
|
||||
}
|
||||
cursor += 8 + count * entrySize
|
||||
cursor = (cursor + 7) & ~7
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// `segment,section` of the executable section containing `va`, or nil when
|
||||
/// the address is not in one.
|
||||
///
|
||||
/// Executability is read off the segment's `initprot`, not off the segment's
|
||||
/// name, so this keeps working if the IMP ever lives somewhere other than
|
||||
/// `__TEXT,__text`.
|
||||
static func executableSection(containing va: UInt64, in data: Data) -> String? {
|
||||
let vmProtExecute: UInt32 = 0x4
|
||||
var executableSegments: Set<String> = []
|
||||
|
||||
let ncmds = data.loadLE(UInt32.self, at: 16)
|
||||
var offset = 32 // sizeof(mach_header_64)
|
||||
for _ in 0 ..< ncmds {
|
||||
guard offset + 8 <= data.count else { break }
|
||||
let cmd = data.loadLE(UInt32.self, at: offset)
|
||||
let cmdsize = Int(data.loadLE(UInt32.self, at: offset + 4))
|
||||
guard cmdsize > 0 else { break }
|
||||
if cmd == 0x19, offset + 64 <= data.count { // LC_SEGMENT_64
|
||||
let initprot = data.loadLE(UInt32.self, at: offset + 60)
|
||||
if initprot & vmProtExecute != 0 {
|
||||
let raw = data[offset + 8 ..< offset + 24]
|
||||
executableSegments.insert(
|
||||
String(decoding: raw.prefix { $0 != 0 }, as: UTF8.self)
|
||||
)
|
||||
}
|
||||
}
|
||||
offset += cmdsize
|
||||
}
|
||||
|
||||
for (key, section) in MachOParser.parseSections(from: data)
|
||||
where executableSegments.contains(section.segmentName)
|
||||
&& va >= section.address && va < section.address + section.size
|
||||
{
|
||||
return key
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Body Shape
|
||||
|
||||
/// Decode the eight bytes the patch replaces.
|
||||
static func decodeBody(_ bytes: Data, at va: UInt64) throws -> [Instruction] {
|
||||
let decoded = ARM64Disassembler().disassemble(bytes, at: va, count: 2)
|
||||
guard decoded.count == 2, decoded.allSatisfy({ $0.id != 0 }) else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"\(method): the eight bytes at 0x\(hex(va)) (\(bytes.hex)) are not two "
|
||||
+ "decodable instructions"
|
||||
)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
/// Whether the decoded body is something this patch may overwrite.
|
||||
///
|
||||
/// The method is a synthesised BOOL getter, so the shape to expect is a
|
||||
/// single-register load followed by `ret`. The check is deliberately on the
|
||||
/// *return* — a two-word body ending in `ret`, or a first instruction that
|
||||
/// is a plain function entry — rather than on `ldrb` specifically: a future
|
||||
/// build may spell the getter differently, but overwriting eight bytes that
|
||||
/// are *not* a function's first two words would land mid-function.
|
||||
static func isPlausibleGetterBody(_ body: [Instruction]) -> Bool {
|
||||
guard body.count == 2 else { return false }
|
||||
// A getter: `ldr…/mov… ; ret`.
|
||||
if body[1].mnemonic == "ret" || body[1].mnemonic.hasPrefix("reta") { return true }
|
||||
// A real function: a recognisable prologue in the first word, so the
|
||||
// eight bytes are the head of a function and an early return is safe.
|
||||
let prologue: Set<String> = ["pacibsp", "paciasp", "stp", "sub"]
|
||||
return prologue.contains(body[0].mnemonic)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func text(of instructions: [Instruction]) -> String {
|
||||
instructions
|
||||
.map { $0.operandString.isEmpty ? $0.mnemonic : "\($0.mnemonic) \($0.operandString)" }
|
||||
.joined(separator: "; ")
|
||||
}
|
||||
|
||||
/// Read a NUL-terminated ASCII string, refusing to run past `limit`.
|
||||
static func cString(in data: Data, at offset: Int, limit: Int) -> String? {
|
||||
guard offset >= 0, offset < min(limit, data.count) else { return nil }
|
||||
var end = offset
|
||||
let stop = min(limit, data.count)
|
||||
while end < stop, data[end] != 0 { end += 1 }
|
||||
return String(data: data[offset ..< end], encoding: .ascii)
|
||||
}
|
||||
|
||||
static func hex(_ value: UInt64) -> String {
|
||||
String(value, radix: 16, uppercase: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
// CFWSeputil.swift — pin seputil's gigalocker file name to "AA".
|
||||
//
|
||||
// `seputil` keeps the SEP's "gigalocker" blob in a file named after the
|
||||
// device's own UUID, built with a two-field format string:
|
||||
//
|
||||
// snprintf(path, n, "%s/%s.gl", mountpoint, uuid); // -> /mnt7/<uuid>.gl
|
||||
//
|
||||
// A vphone guest does not carry the UUID the gigalocker on the restored
|
||||
// filesystem was written for, so seputil looks for a file that is not there.
|
||||
// Rewriting the *uuid* field of that one literal to the constant `AA` makes
|
||||
// every lookup resolve to `<mountpoint>/AA.gl`, which is the name the install
|
||||
// gives the blob it ships. The mountpoint field is deliberately left as `%s`:
|
||||
// it is chosen at runtime between `/mnt7` and `/private/xarts` and both must
|
||||
// keep working.
|
||||
//
|
||||
// This is a data patch, not a code patch — the two bytes rewritten are ASCII
|
||||
// inside `__TEXT,__cstring`, so no instruction is assembled here and the ARM64
|
||||
// encoder has nothing to contribute. Capstone is used for the *anchor*: the
|
||||
// literal has to be the one seputil actually formats with, which is proven by
|
||||
// finding the `adrp`/`add` pair in `__TEXT,__text` that materialises its
|
||||
// address.
|
||||
//
|
||||
// Anchoring, in order, none of it positional:
|
||||
//
|
||||
// 1. `__TEXT,__cstring` from the load commands — a literal anywhere else is
|
||||
// not a C string this binary formats with.
|
||||
// 2. The one whole NUL-terminated literal in that section shaped
|
||||
// `<dir>/<2-byte field>.gl`, where the field reads `%s` (pristine) or `AA`
|
||||
// (already patched). Whole-literal equality, so the tail of a longer
|
||||
// string cannot match.
|
||||
// 3. An `adrp`+`add` in `__TEXT,__text` computing that literal's VA, matched
|
||||
// on decoded operands.
|
||||
// 4. The field to rewrite is the one after the literal's own last `/`.
|
||||
//
|
||||
// Port of `scripts/patchers/cfw_patch_seputil.py`, which stays the independent
|
||||
// reference: `CFWSeputilTests` runs the Python on one clone of a real seputil
|
||||
// and this on another and compares the files byte for byte.
|
||||
//
|
||||
// Two deliberate differences from the reference, both of them narrowing:
|
||||
//
|
||||
// * The Python searches the whole file for the *substring* `"/%s.gl\0"` and
|
||||
// patches the two bytes after the `/`. On this binary that substring is the
|
||||
// tail of `"%s/%s.gl"` and the result is the same byte, but the search is
|
||||
// not bounded to `__cstring`, does not require the match to be a whole
|
||||
// literal, and takes the first hit without checking that anything refers to
|
||||
// it. All three are tightened here.
|
||||
// * The Python leaves the code signature stale, because `cfw_install*.sh`
|
||||
// runs `ldid_sign` over the binary immediately afterwards. This
|
||||
// re-attests the page it dirtied through `CFWMachOCodeSignature`, so the
|
||||
// binary that leaves this function verifies on its own. Pass
|
||||
// `reattest: false` to reproduce the reference's bytes exactly.
|
||||
|
||||
import Capstone
|
||||
import Foundation
|
||||
|
||||
public enum CFWSeputil {
|
||||
// MARK: - Anchors
|
||||
|
||||
/// Where the format string lives. A literal outside this section is not one
|
||||
/// the binary formats with, so the search never leaves it.
|
||||
public static let cstringSection = (segment: "__TEXT", section: "__cstring")
|
||||
|
||||
/// Where a reference to the literal has to come from.
|
||||
public static let textSection = (segment: "__TEXT", section: "__text")
|
||||
|
||||
/// The conversion the pristine literal uses for the gigalocker's name.
|
||||
public static let uuidConversion = "%s"
|
||||
|
||||
/// What this patch pins that field to.
|
||||
public static let uuidReplacement = "AA"
|
||||
|
||||
/// The suffix that makes a two-field format string a gigalocker path
|
||||
/// rather than any other path-shaped literal in the binary.
|
||||
public static let gigalockerSuffix = ".gl"
|
||||
|
||||
/// How far past an `adrp` its `add` may sit. Eight instructions — the same
|
||||
/// window `KernelPatcherBase.findStringRefs` uses for the same job.
|
||||
static let addSearchWindow = 8
|
||||
|
||||
/// One disassembler for the whole patcher; `ARM64Disassembler` is `Sendable`
|
||||
/// and stateless across calls.
|
||||
private static let disassembler = ARM64Disassembler()
|
||||
|
||||
/// Default sink for the progress lines, matching the other patchers.
|
||||
public static let stderrLog: @Sendable (String) -> Void = { line in
|
||||
FileHandle.standardError.write(Data((line + "\n").utf8))
|
||||
}
|
||||
|
||||
// MARK: - Site
|
||||
|
||||
/// The literal this patch rewrites, and the field inside it.
|
||||
public struct Site: Sendable, Equatable {
|
||||
/// File offset of the literal's first byte.
|
||||
public let literalOffset: Int
|
||||
/// The literal's virtual address.
|
||||
public let literalVMA: UInt64
|
||||
/// The literal as it currently reads.
|
||||
public let literal: String
|
||||
/// File offset of the two-byte field after the literal's last `/`.
|
||||
public let fieldOffset: Int
|
||||
/// That field's virtual address.
|
||||
public let fieldVMA: UInt64
|
||||
/// True while the field still holds `%s`; false once it holds `AA`.
|
||||
public let isPristine: Bool
|
||||
|
||||
/// The offsets the write dirties. Two, not one: a literal can straddle
|
||||
/// a page boundary, and then two slots need re-attesting.
|
||||
public var modifiedOffsets: [Int] {
|
||||
Array(fieldOffset ..< fieldOffset + CFWSeputil.uuidReplacement.utf8.count)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Outcome
|
||||
|
||||
/// What one run did.
|
||||
public struct Outcome: Sendable {
|
||||
public enum Verdict: Sendable, Equatable, CustomStringConvertible {
|
||||
/// The field already reads `AA`. Nothing was written, nothing was
|
||||
/// re-attested — a second run over an installed binary lands here.
|
||||
case alreadyPatched
|
||||
/// A dry run that found a pristine field and stopped before writing.
|
||||
case wouldPatch
|
||||
/// The field was rewritten, and the page it sits in re-attested
|
||||
/// unless the caller asked otherwise.
|
||||
case patched
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .alreadyPatched: "already patched"
|
||||
case .wouldPatch: "would patch"
|
||||
case .patched: "patched"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public let verdict: Verdict
|
||||
public let site: Site
|
||||
/// Addresses of the `add` instructions that materialise the literal.
|
||||
public let references: [UInt64]
|
||||
/// The record of the single write, on a run that wrote or would write.
|
||||
public let record: PatchRecord?
|
||||
/// Slot hashes re-attestation replaced. Empty when `reattest` was off.
|
||||
public let rehashes: [CFWSlotRehash]
|
||||
|
||||
public init(
|
||||
verdict: Verdict,
|
||||
site: Site,
|
||||
references: [UInt64],
|
||||
record: PatchRecord? = nil,
|
||||
rehashes: [CFWSlotRehash] = []
|
||||
) {
|
||||
self.verdict = verdict
|
||||
self.site = site
|
||||
self.references = references
|
||||
self.record = record
|
||||
self.rehashes = rehashes
|
||||
}
|
||||
|
||||
/// Sites this run put on disk — 1 on a live patch, 0 otherwise. Mirrors
|
||||
/// what `patch_seputil()` reports.
|
||||
public var sitesWritten: Int { verdict == .patched ? 1 : 0 }
|
||||
}
|
||||
|
||||
// MARK: - Entry points
|
||||
|
||||
/// Patch the seputil binary at `url`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - dryRun: locate and report, write nothing.
|
||||
/// - reattest: recompute the slot hash of the page the write dirties.
|
||||
/// On by default, so the binary verifies without an external re-sign.
|
||||
/// Off reproduces `cfw_patch_seputil.py`'s bytes exactly.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
fileAt url: URL,
|
||||
dryRun: Bool = false,
|
||||
reattest: Bool = true,
|
||||
log: ((String) -> Void)? = stderrLog
|
||||
) throws -> Outcome {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw PatcherError.fileNotFound(url.path)
|
||||
}
|
||||
log?(" [.] \(url.path)")
|
||||
var data = try Data(contentsOf: url)
|
||||
let outcome = try patch(&data, dryRun: dryRun, reattest: reattest, log: log)
|
||||
if outcome.verdict == .patched {
|
||||
try data.write(to: url)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
/// Patch an in-memory seputil image.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
_ data: inout Data,
|
||||
dryRun: Bool = false,
|
||||
reattest: Bool = true,
|
||||
log: ((String) -> Void)? = stderrLog
|
||||
) throws -> Outcome {
|
||||
if data.startIndex != 0 { data = Data(data) }
|
||||
|
||||
let (cstring, text) = try sections(in: data)
|
||||
let site = try findSite(in: data, cstring: cstring)
|
||||
log?(
|
||||
" [.] literal \"\(site.literal)\" @ 0x\(hex(site.literalVMA)) "
|
||||
+ "(file 0x\(hex(site.literalOffset)))"
|
||||
)
|
||||
|
||||
// The literal has to be one the code actually formats with. Without
|
||||
// this, a build that moved the gigalocker path into a different string
|
||||
// would still offer some `<dir>/%s.gl` to rewrite and the patch would
|
||||
// report success while changing nothing that runs.
|
||||
let references = references(to: site.literalVMA, in: data, text: text)
|
||||
guard !references.isEmpty else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"seputil: \"\(site.literal)\" @ 0x\(hex(site.literalVMA)) has no adrp+add "
|
||||
+ "reference in \(textSection.segment),\(textSection.section); "
|
||||
+ "it is not the literal the gigalocker path is built from"
|
||||
)
|
||||
}
|
||||
log?(" [.] referenced from \(references.map { "0x" + hex($0) }.joined(separator: ", "))")
|
||||
|
||||
guard site.isPristine else {
|
||||
log?(
|
||||
" [=] field at 0x\(hex(site.fieldOffset)) already reads "
|
||||
+ "\"\(uuidReplacement)\"; nothing to patch/re-attest"
|
||||
)
|
||||
return Outcome(verdict: .alreadyPatched, site: site, references: references)
|
||||
}
|
||||
|
||||
let replacement = Data(uuidReplacement.utf8)
|
||||
let range = site.fieldOffset ..< site.fieldOffset + replacement.count
|
||||
let original = Data(data[range])
|
||||
let record = record(for: site, original: original, replacement: replacement)
|
||||
|
||||
guard !dryRun else {
|
||||
log?(" [+] would write \"\(uuidReplacement)\" at 0x\(hex(site.fieldOffset))")
|
||||
return Outcome(
|
||||
verdict: .wouldPatch,
|
||||
site: site,
|
||||
references: references,
|
||||
record: record
|
||||
)
|
||||
}
|
||||
|
||||
data.replaceSubrange(range, with: replacement)
|
||||
log?(
|
||||
" [+] 0x\(hex(site.fieldOffset)): \(original.hex) -> \(replacement.hex) "
|
||||
+ "(\"\(site.literal)\" -> \"\(patchedLiteral(of: site))\")"
|
||||
)
|
||||
|
||||
var rehashes: [CFWSlotRehash] = []
|
||||
if reattest {
|
||||
for directory in CFWMachOCodeSignature.unsupportedCodeDirectories(in: data) {
|
||||
log?(
|
||||
" [-] CodeDirectory @ 0x\(hex(directory.offset)) is hashType "
|
||||
+ "\(directory.hashType), not SHA-256 — left untouched"
|
||||
)
|
||||
}
|
||||
rehashes = try CFWMachOCodeSignature.reattest(&data, modifiedOffsets: site.modifiedOffsets)
|
||||
for rehash in rehashes { log?(" [+] \(rehash)") }
|
||||
}
|
||||
|
||||
// Re-read the site the same way it was found, rather than trusting the
|
||||
// write: anything that moved underneath it shows up here.
|
||||
let after = try findSite(in: data, cstring: cstring)
|
||||
guard after.fieldOffset == site.fieldOffset, !after.isPristine else {
|
||||
throw PatcherError.patchVerificationFailed(
|
||||
"seputil: post-write read back \"\(after.literal)\" at 0x\(hex(after.fieldOffset))"
|
||||
)
|
||||
}
|
||||
log?(" [+] seputil gigalocker name pinned to \"\(uuidReplacement)\(gigalockerSuffix)\"")
|
||||
|
||||
return Outcome(
|
||||
verdict: .patched,
|
||||
site: site,
|
||||
references: references,
|
||||
record: record,
|
||||
rehashes: rehashes
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Reveal
|
||||
|
||||
/// The two sections this patcher reads, from the load commands.
|
||||
static func sections(in data: Data) throws -> (cstring: MachOSectionInfo, text: MachOSectionInfo) {
|
||||
let all = MachOParser.parseSections(from: data)
|
||||
guard let cstring = all["\(cstringSection.segment),\(cstringSection.section)"] else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"seputil: no \(cstringSection.segment),\(cstringSection.section) section "
|
||||
+ "(not a 64-bit Mach-O, or not the binary we were handed)"
|
||||
)
|
||||
}
|
||||
guard let text = all["\(textSection.segment),\(textSection.section)"] else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"seputil: no \(textSection.segment),\(textSection.section) section"
|
||||
)
|
||||
}
|
||||
return (cstring, text)
|
||||
}
|
||||
|
||||
/// The single gigalocker path literal in `__TEXT,__cstring`.
|
||||
///
|
||||
/// Matching is on whole NUL-terminated literals, so `"%s.gl"` — the very
|
||||
/// next literal after the one we want on the reference binary — cannot be
|
||||
/// mistaken for it, and neither can the tail of any longer string.
|
||||
///
|
||||
/// Throws when there is no candidate or more than one: with two, there is
|
||||
/// no evidence which one seputil formats with, and picking either is a
|
||||
/// coin flip that boots or does not.
|
||||
static func findSite(in data: Data, cstring: MachOSectionInfo) throws -> Site {
|
||||
let start = Int(cstring.fileOffset)
|
||||
let end = start + Int(cstring.size)
|
||||
guard start >= 0, end <= data.count, start <= end else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"seputil: \(cstringSection.section) runs to 0x\(hex(end)), past the end of a "
|
||||
+ "0x\(hex(data.count))-byte file"
|
||||
)
|
||||
}
|
||||
|
||||
var sites: [Site] = []
|
||||
var cursor = start
|
||||
while cursor < end {
|
||||
var terminator = cursor
|
||||
while terminator < end, data[terminator] != 0 { terminator += 1 }
|
||||
guard terminator < end else { break } // unterminated tail, not a literal
|
||||
let literal = Array(data[cursor ..< terminator])
|
||||
if let field = fileField(of: literal), let pristine = pristineness(of: literal[field]) {
|
||||
sites.append(Site(
|
||||
literalOffset: cursor,
|
||||
literalVMA: cstring.address + UInt64(cursor - start),
|
||||
literal: String(decoding: literal, as: UTF8.self),
|
||||
fieldOffset: cursor + field.lowerBound,
|
||||
fieldVMA: cstring.address + UInt64(cursor - start + field.lowerBound),
|
||||
isPristine: pristine
|
||||
))
|
||||
}
|
||||
cursor = terminator + 1
|
||||
}
|
||||
|
||||
guard let site = sites.first else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"seputil: no \"<dir>/\(uuidConversion)\(gigalockerSuffix)\" literal in "
|
||||
+ "\(cstringSection.segment),\(cstringSection.section)"
|
||||
)
|
||||
}
|
||||
guard sites.count == 1 else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"seputil: \(sites.count) gigalocker path literals "
|
||||
+ "(\(sites.map { "\"\($0.literal)\" @ 0x" + hex($0.literalOffset) }.joined(separator: ", "))); "
|
||||
+ "refusing to guess which one builds the path"
|
||||
)
|
||||
}
|
||||
return site
|
||||
}
|
||||
|
||||
/// The range, within `literal`, of the field after its last `/`.
|
||||
///
|
||||
/// `"%s/%s.gl"` names a directory and a file. The field this patch pins is
|
||||
/// the file's, so it is read off the literal's own last separator instead
|
||||
/// of being counted in from either end.
|
||||
static func fileField(of literal: [UInt8]) -> Range<Int>? {
|
||||
let suffix = Array(gigalockerSuffix.utf8)
|
||||
let width = uuidReplacement.utf8.count
|
||||
guard literal.count > suffix.count,
|
||||
literal.suffix(suffix.count).elementsEqual(suffix),
|
||||
let slash = literal.lastIndex(of: UInt8(ascii: "/"))
|
||||
else { return nil }
|
||||
let fieldStart = slash + 1
|
||||
let fieldEnd = literal.count - suffix.count
|
||||
guard fieldEnd - fieldStart == width else { return nil }
|
||||
return fieldStart ..< fieldEnd
|
||||
}
|
||||
|
||||
/// `true` for a field that still reads `%s`, `false` for one already
|
||||
/// reading `AA`, `nil` for anything else — which is not this patch's site.
|
||||
static func pristineness(of field: ArraySlice<UInt8>) -> Bool? {
|
||||
if field.elementsEqual(uuidConversion.utf8) { return true }
|
||||
if field.elementsEqual(uuidReplacement.utf8) { return false }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Addresses of the `add` instructions that, with their `adrp`, materialise
|
||||
/// `vma` somewhere in `__TEXT,__text`.
|
||||
///
|
||||
/// Pairing is the same shape `KernelPatcherBase.findStringRefs` uses, but
|
||||
/// read off Capstone's decoded operands rather than raw encodings: an
|
||||
/// `adrp` whose immediate is the literal's page, then an `add` within the
|
||||
/// window whose source register is that `adrp`'s destination and whose
|
||||
/// immediate is the literal's page offset.
|
||||
static func references(to vma: UInt64, in data: Data, text: MachOSectionInfo) -> [UInt64] {
|
||||
let start = Int(text.fileOffset)
|
||||
let size = Int(text.size)
|
||||
guard start >= 0, size > 0, start + size <= data.count else { return [] }
|
||||
let instructions = disassembler.disassemble(Data(data[start ..< start + size]), at: text.address)
|
||||
|
||||
let page = vma & ~0xFFF
|
||||
let pageOffset = Int64(vma & 0xFFF)
|
||||
|
||||
var sites: [UInt64] = []
|
||||
for (index, insn) in instructions.enumerated() {
|
||||
guard insn.mnemonic == "adrp",
|
||||
let operands = insn.aarch64?.operands,
|
||||
operands.count == 2,
|
||||
operands[0].type == AARCH64_OP_REG,
|
||||
operands[1].type == AARCH64_OP_IMM,
|
||||
UInt64(bitPattern: operands[1].imm) == page
|
||||
else { continue }
|
||||
|
||||
let base = operands[0].reg.rawValue
|
||||
let limit = Swift.min(index + addSearchWindow, instructions.count - 1)
|
||||
var cursor = index + 1
|
||||
while cursor <= limit {
|
||||
let candidate = instructions[cursor]
|
||||
cursor += 1
|
||||
guard candidate.mnemonic == "add",
|
||||
let addOperands = candidate.aarch64?.operands,
|
||||
addOperands.count == 3,
|
||||
addOperands[0].type == AARCH64_OP_REG,
|
||||
addOperands[1].type == AARCH64_OP_REG,
|
||||
addOperands[1].reg.rawValue == base,
|
||||
addOperands[2].type == AARCH64_OP_IMM,
|
||||
addOperands[2].imm == pageOffset,
|
||||
!isShiftedAddImmediate(candidate)
|
||||
else { continue }
|
||||
sites.append(candidate.address)
|
||||
break
|
||||
}
|
||||
}
|
||||
return sites
|
||||
}
|
||||
|
||||
/// True when an `add` immediate carries `lsl #12`.
|
||||
///
|
||||
/// The Swift Capstone wrapper does not surface an operand's shift, so the
|
||||
/// `sh` field is read off the instruction's own 32-bit encoding — still a
|
||||
/// property of the decode, never of the printed operand text. Without it an
|
||||
/// `add xD, xN, #imm, lsl #12` could be paired as if it computed
|
||||
/// `page + imm`, which is a different address than it really forms.
|
||||
static func isShiftedAddImmediate(_ insn: Instruction) -> Bool {
|
||||
guard insn.bytes.count == 4 else { return false }
|
||||
let word = UInt32(insn.bytes[0])
|
||||
| UInt32(insn.bytes[1]) << 8
|
||||
| UInt32(insn.bytes[2]) << 16
|
||||
| UInt32(insn.bytes[3]) << 24
|
||||
return (word >> 22) & 1 == 1
|
||||
}
|
||||
|
||||
// MARK: - Reporting
|
||||
|
||||
/// How the literal reads once the field is rewritten.
|
||||
static func patchedLiteral(of site: Site) -> String {
|
||||
var literal = Array(site.literal.utf8)
|
||||
guard let field = fileField(of: literal) else { return site.literal }
|
||||
literal.replaceSubrange(field, with: Array(uuidReplacement.utf8))
|
||||
return String(decoding: literal, as: UTF8.self)
|
||||
}
|
||||
|
||||
/// The record for the one write, with the reference's own `patchID`,
|
||||
/// `component` and wording so a captured reference compares field for
|
||||
/// field.
|
||||
private static func record(for site: Site, original: Data, replacement: Data) -> PatchRecord {
|
||||
PatchRecord(
|
||||
patchID: "seputil.gigalocker_uuid",
|
||||
component: "seputil",
|
||||
fileOffset: site.fieldOffset,
|
||||
virtualAddress: site.fieldVMA,
|
||||
originalBytes: original,
|
||||
patchedBytes: replacement,
|
||||
description: "gigalocker path format '/\(uuidConversion)\(gigalockerSuffix)' "
|
||||
+ "-> '/\(uuidReplacement)\(gigalockerSuffix)'"
|
||||
)
|
||||
}
|
||||
|
||||
private static func hex(_ value: UInt64) -> String {
|
||||
String(value, radix: 16, uppercase: true)
|
||||
}
|
||||
|
||||
private static func hex(_ value: Int) -> String {
|
||||
String(value, radix: 16, uppercase: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
// CFWWatchdogd.swift — force watchdogd's cached "am I a VM?" byte to 1.
|
||||
//
|
||||
// Port of `scripts/patchers/cfw_patch_watchdogd.py` (453 lines). EXP only.
|
||||
//
|
||||
// Why the patch exists
|
||||
// --------------------
|
||||
// The EXP variant renames the kernel's `kern.hv_vmm_present` sysctl OID
|
||||
// (`KernelEXPPatchHvVmmRename`), so every userland caller that still asks for
|
||||
// that name gets ENOENT. `/usr/libexec/watchdogd` caches the answer at startup:
|
||||
//
|
||||
// adrp x0, <page>
|
||||
// add x0, x0, #<off> ; "kern.hv_vmm_present"
|
||||
// sub x1, x29, #4 ; &oldval
|
||||
// mov x2, sp ; &oldlen
|
||||
// mov x3, #0
|
||||
// mov x4, #0
|
||||
// bl _sysctlbyname ; via __auth_stubs
|
||||
// cbnz w0, <skip> ; ENOENT -> skip the store
|
||||
// ldur w8, [x29, #-4]
|
||||
// cmp w8, #0
|
||||
// cset w8, ne ; w8 = (oldval != 0)
|
||||
// adrp x9, <page>
|
||||
// strb w8, [x9, #<off>] ; the cached byte, a __DATA zero-fill global
|
||||
//
|
||||
// With the OID renamed the `cbnz` is taken, the store never runs, the cached
|
||||
// byte keeps its BSS zero, and a downstream `cbz` on that byte falls into an
|
||||
// `_os_crash` wrapper that executes `brk #1`. launchd's `_PanicOnCrash` turns
|
||||
// the resulting SIGTRAP into a kernel panic, so this is a boot blocker rather
|
||||
// than a cosmetic detection problem.
|
||||
//
|
||||
// What is changed
|
||||
// ---------------
|
||||
// Two instructions per site, and nothing else:
|
||||
//
|
||||
// cbnz w0, <skip> -> nop (never skip the store)
|
||||
// cset wN, ne -> mov wN, #1 (store 1, not the sysctl's answer)
|
||||
//
|
||||
// The cstring is deliberately NOT touched: the EXP design keeps every
|
||||
// `kern.hv_vmm_present` consumer on the now-ENOENT name and opts individual
|
||||
// consumers out by rewriting their logic, which is what this does. watchdogd
|
||||
// then takes its clean-exit branch ("detected virtual machine environment and
|
||||
// no watchdog KEXT found, exiting...") instead of the trap.
|
||||
//
|
||||
// How the site is anchored — no offsets, no byte patterns
|
||||
// ------------------------------------------------------
|
||||
// Five layers, each read off a Capstone decode or the Mach-O's own tables:
|
||||
//
|
||||
// 1. The `"kern.hv_vmm_present\0"` literal is found in a cstring section at a
|
||||
// NUL boundary, giving its VA.
|
||||
// 2. In `__TEXT,__text`, an ADRP+ADD pair whose resolved address is that VA,
|
||||
// and whose result reaches x0 before the call — directly, or through a
|
||||
// `mov x0, xN`. That is the literal being passed as `sysctlbyname`'s
|
||||
// `name` argument rather than merely mentioned.
|
||||
// 3. The following `bl`'s target is resolved through the indirect symbol
|
||||
// table to the imported function `_sysctlbyname`. This is an in-image
|
||||
// symbol lookup, which the Python does not do: it accepts any `bl`.
|
||||
// 4. The instruction right after the call gates the store on the call's
|
||||
// return value (`cbnz w0`), and the value stored is `cset wN, ne` — the
|
||||
// truthiness of the sysctl's out-parameter. The condition is read from
|
||||
// Capstone's decoded condition code, not from operand text.
|
||||
// 5. The `strb` stores that same wN into an ADRP-relative address inside a
|
||||
// `__DATA*` segment — a cached global, not a stack or heap field.
|
||||
//
|
||||
// Layers 2, 3 and 5 are additions over the Python, which stops at "some `bl`
|
||||
// with a `cbnz w0` behind it". On `iPhone17,3` / iOS 27.0 (24A435) both
|
||||
// implementations select exactly the same two sites; the extra layers are what
|
||||
// keeps that true when the next firmware moves the code.
|
||||
//
|
||||
// Idempotence
|
||||
// -----------
|
||||
// A second run must be a no-op, not an error and not a double-apply (commit
|
||||
// 8eb6c8b fixed exactly that class of bug elsewhere in this tree). The site
|
||||
// matcher therefore recognises both shapes: the pristine one above and the one
|
||||
// this patch leaves behind (`nop` … `mov wN, #1` … `strb wN`). A binary whose
|
||||
// sites are all in the patched shape is reported as `.alreadyPatched`, nothing
|
||||
// is written, and — importantly — re-attestation is skipped too, so the file
|
||||
// on disk is byte-for-byte unchanged.
|
||||
//
|
||||
// Code signing
|
||||
// ------------
|
||||
// Editing bytes inside `__TEXT,__text` invalidates the SHA-256 slot hash of
|
||||
// each containing 4 KiB page. On `codeSigningMonitor == 2` hardware TXM holds
|
||||
// those hashes and kills the process on the first demand page-in, so every
|
||||
// written offset is handed to `CFWMachOCodeSignature` to re-hash its page.
|
||||
//
|
||||
// The binary is NOT re-signed with an identity. Re-signing would reset the
|
||||
// code-signing identifier to the local filename, which trips launchd's
|
||||
// boot-task identity check — the failure mode observed on mobile_obliterator
|
||||
// before an earlier attempt was reverted. Mutating the CD does change the
|
||||
// binary's cdHash; the JB kernel patch `patch_amfi_cdhash_in_trustcache`
|
||||
// short-circuits AMFI's trust-cache check, and that precondition still holds.
|
||||
|
||||
import Capstone
|
||||
import Foundation
|
||||
|
||||
public enum CFWWatchdogd {
|
||||
// MARK: - Anchors
|
||||
|
||||
/// Component name carried by every ``PatchRecord`` this patcher emits.
|
||||
public static let component = "watchdogd"
|
||||
|
||||
/// The sysctl whose cached answer this patch overrides.
|
||||
public static let sysctlName = "kern.hv_vmm_present"
|
||||
|
||||
/// The imported function the call site must resolve to. Layer 3 of the
|
||||
/// anchor: a `bl` that goes anywhere else is not this call.
|
||||
public static let sysctlFunction = "_sysctlbyname"
|
||||
|
||||
/// `"kern.hv_vmm_present\0"` — the terminator is part of the match, so a
|
||||
/// longer name that merely starts with this one cannot pass.
|
||||
static let needle = Data((sysctlName + "\0").utf8)
|
||||
|
||||
/// Sections a C string literal can land in. `__cstring` is where the linker
|
||||
/// puts them; the ObjC name pools could hold the same bytes, and the
|
||||
/// reference scans those too.
|
||||
static let literalSectionNames: Set<String> = [
|
||||
"__cstring", "__objc_methname", "__objc_classname",
|
||||
]
|
||||
|
||||
static let textSectionKey = "__TEXT,__text"
|
||||
|
||||
/// Segment prefix a cached global must live under. `__bss` and `__common`
|
||||
/// are both `__DATA` sections; the prefix also covers `__DATA_DIRTY`.
|
||||
static let globalSegmentPrefix = "__DATA"
|
||||
|
||||
/// Where the AArch64 C ABI puts `sysctlbyname`'s first argument.
|
||||
static let argumentRegister = "x0"
|
||||
|
||||
// MARK: - Scan windows
|
||||
//
|
||||
// Instruction counts, not byte counts. Same values as the Python, which
|
||||
// measured them against the shipped binary.
|
||||
|
||||
/// ADRP and the ADD that completes it may be separated by argument setup.
|
||||
static let pageToOffsetWindow = 8
|
||||
/// From the ADD that forms the string pointer forward to the call.
|
||||
static let argumentSetupWindow = 20
|
||||
/// From the gate forward to the instruction that produces the stored value.
|
||||
static let gateToValueWindow = 12
|
||||
/// From that instruction forward to the store.
|
||||
static let valueToStoreWindow = 8
|
||||
|
||||
// MARK: - Sites
|
||||
|
||||
/// One "cache the VM-presence answer" site, pristine or already patched.
|
||||
public struct Site: Sendable, Equatable {
|
||||
/// Which shape the site is in.
|
||||
public enum State: String, Sendable {
|
||||
/// The stock shape: `cbnz w0` gating a `cset wN, ne`.
|
||||
case pristine
|
||||
/// This patch's own output: `nop` and `mov wN, #1`.
|
||||
case patched
|
||||
}
|
||||
|
||||
public let state: State
|
||||
/// VA of the `"kern.hv_vmm_present\0"` literal this site loads.
|
||||
public let literalVMA: UInt64
|
||||
/// VA of the ADD that completes the literal's address in x0.
|
||||
public let addVMA: UInt64
|
||||
/// VA of the `bl _sysctlbyname`.
|
||||
public let callVMA: UInt64
|
||||
/// VA of the gate — `cbnz w0` when pristine, `nop` when patched.
|
||||
public let gateVMA: UInt64
|
||||
public let gateFileOffset: Int
|
||||
/// VA of the instruction producing the cached value — `cset wN, ne`
|
||||
/// when pristine, `mov wN, #1` when patched.
|
||||
public let valueVMA: UInt64
|
||||
public let valueFileOffset: Int
|
||||
/// The `w` register carrying the cached value, e.g. `"w8"`.
|
||||
public let valueRegister: String
|
||||
/// That register's number, for re-encoding it as `mov wN, #1`.
|
||||
public let valueRegisterNumber: UInt32
|
||||
/// VA of the `strb` that writes the cached byte.
|
||||
public let storeVMA: UInt64
|
||||
/// VA of the cached byte itself — the `__DATA` global.
|
||||
public let cachedByteVMA: UInt64
|
||||
}
|
||||
|
||||
// MARK: - Report
|
||||
|
||||
/// What a run did, as a whole.
|
||||
public enum Outcome: String, Sendable, Equatable {
|
||||
/// Every site was already in the patched shape; nothing was written.
|
||||
case alreadyPatched
|
||||
/// `dryRun` was set: sites were located and reported only.
|
||||
case wouldPatch
|
||||
/// Bytes were written and the affected pages re-attested.
|
||||
case patched
|
||||
}
|
||||
|
||||
/// The outcome of one run, and what it acted on.
|
||||
public struct Report: Sendable {
|
||||
public let outcome: Outcome
|
||||
/// Every site the matcher recognised, in address order.
|
||||
public let sites: [Site]
|
||||
/// One record per instruction actually rewritten — two per patched
|
||||
/// site. Empty for `.alreadyPatched`.
|
||||
public let records: [PatchRecord]
|
||||
/// Code-directory slots re-hashed as a result.
|
||||
public let rehashedSlots: [CFWSlotRehash]
|
||||
|
||||
/// Sites whose bytes this run changed. The parity number against the
|
||||
/// Python, whose `patch_watchdogd()` returns exactly this.
|
||||
public var sitesWritten: Int { records.count / 2 }
|
||||
}
|
||||
|
||||
/// Where progress goes when the caller does not say. The Python prints to
|
||||
/// stdout and `cfw_install_exp.sh` captures that, so this does too.
|
||||
public static let stdoutLog: @Sendable (String) -> Void = { print($0) }
|
||||
|
||||
// MARK: - Patching
|
||||
|
||||
/// Patch the watchdogd Mach-O at `url` in place.
|
||||
///
|
||||
/// - Returns: a ``Report``. `.alreadyPatched` leaves the file untouched,
|
||||
/// which is what makes a second install run a clean no-op.
|
||||
/// - Throws: ``PatcherError/invalidFormat(_:)`` when the file is not the
|
||||
/// kind of Mach-O this patch understands, and
|
||||
/// ``PatcherError/patchSiteNotFound(_:)`` when it is but holds no site in
|
||||
/// either shape — a watchdogd that no longer caches the sysctl, which has
|
||||
/// to stop the install rather than be silently skipped.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
at url: URL,
|
||||
dryRun: Bool = false,
|
||||
log: ((String) -> Void)? = stdoutLog
|
||||
) throws -> Report {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw PatcherError.fileNotFound(url.path)
|
||||
}
|
||||
var data = try Data(contentsOf: url)
|
||||
let report = try patch(&data, dryRun: dryRun, log: log)
|
||||
if !dryRun, report.outcome == .patched {
|
||||
try data.write(to: url)
|
||||
log?(" [+] \(url.path): wrote \(report.sitesWritten) site(s)")
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
/// In-memory form of ``patch(at:dryRun:log:)``.
|
||||
@discardableResult
|
||||
public static func patch(
|
||||
_ data: inout Data,
|
||||
dryRun: Bool = false,
|
||||
log: ((String) -> Void)? = stdoutLog
|
||||
) throws -> Report {
|
||||
if data.startIndex != 0 { data = Data(data) }
|
||||
|
||||
let sites = try locateSites(in: data, log: log)
|
||||
let pending = sites.filter { $0.state == .pristine }
|
||||
|
||||
guard !pending.isEmpty else {
|
||||
log?(" [.] all \(sites.count) matching site(s) already patched — nothing to do")
|
||||
return Report(outcome: .alreadyPatched, sites: sites, records: [], rehashedSlots: [])
|
||||
}
|
||||
log?(" [+] found \(sites.count) '\(sysctlName)' cache site(s), \(pending.count) to patch")
|
||||
|
||||
let disassembler = ARM64Disassembler()
|
||||
var records: [PatchRecord] = []
|
||||
var modifiedOffsets: [Int] = []
|
||||
|
||||
for site in pending {
|
||||
guard let value = ARM64Encoder.encodeMovzW(rd: site.valueRegisterNumber, imm16: 1) else {
|
||||
throw PatcherError.patchVerificationFailed(
|
||||
"could not encode `mov \(site.valueRegister), #1`"
|
||||
)
|
||||
}
|
||||
let gate = ARM64.nop
|
||||
|
||||
log?(" site @ add 0x\(hex(site.addVMA)) (bl 0x\(hex(site.callVMA)), "
|
||||
+ "gate 0x\(hex(site.gateVMA)), value \(site.valueRegister) 0x\(hex(site.valueVMA)), "
|
||||
+ "strb 0x\(hex(site.storeVMA)) -> cached byte 0x\(hex(site.cachedByteVMA)))")
|
||||
|
||||
records.append(record(
|
||||
in: data,
|
||||
at: site.gateFileOffset,
|
||||
virtualAddress: site.gateVMA,
|
||||
patched: gate,
|
||||
id: "\(component).hv_vmm_cache.cbnz@0x\(hex(site.gateVMA))",
|
||||
description: "NOP the cbnz w0 that skips the cached hv_vmm_present store",
|
||||
disassembler: disassembler
|
||||
))
|
||||
records.append(record(
|
||||
in: data,
|
||||
at: site.valueFileOffset,
|
||||
virtualAddress: site.valueVMA,
|
||||
patched: value,
|
||||
id: "\(component).hv_vmm_cache.cset@0x\(hex(site.valueVMA))",
|
||||
description: "cset \(site.valueRegister) -> mov \(site.valueRegister), #1 "
|
||||
+ "(cached 'am I a VM?' byte forced to 1)",
|
||||
disassembler: disassembler
|
||||
))
|
||||
|
||||
if !dryRun {
|
||||
data.replaceSubrange(site.gateFileOffset ..< site.gateFileOffset + 4, with: gate)
|
||||
data.replaceSubrange(site.valueFileOffset ..< site.valueFileOffset + 4, with: value)
|
||||
}
|
||||
modifiedOffsets.append(site.gateFileOffset)
|
||||
modifiedOffsets.append(site.valueFileOffset)
|
||||
}
|
||||
|
||||
for record in records {
|
||||
log?(" [+] 0x\(hex(UInt64(record.fileOffset))): \(record.beforeDisasm) -> \(record.afterDisasm)")
|
||||
}
|
||||
|
||||
guard !dryRun else {
|
||||
log?(" [.] dry-run — nothing written, no page re-attested")
|
||||
return Report(outcome: .wouldPatch, sites: sites, records: records, rehashedSlots: [])
|
||||
}
|
||||
|
||||
for directory in CFWMachOCodeSignature.unsupportedCodeDirectories(in: data) {
|
||||
log?(" [!] CodeDirectory @0x\(hex(UInt64(directory.offset))) uses hashType "
|
||||
+ "\(directory.hashType); its slots are left stale")
|
||||
}
|
||||
|
||||
let rehashed = try CFWMachOCodeSignature.reattest(&data, modifiedOffsets: modifiedOffsets)
|
||||
for slot in rehashed { log?(" [+] re-attest: \(slot)") }
|
||||
log?(" [+] re-attest updated \(rehashed.count) slot(s)")
|
||||
|
||||
try verify(sites: pending, in: data)
|
||||
return Report(outcome: .patched, sites: sites, records: records, rehashedSlots: rehashed)
|
||||
}
|
||||
|
||||
// MARK: - Site discovery
|
||||
|
||||
/// Every VM-presence cache site in `data`, pristine or already patched, in
|
||||
/// address order.
|
||||
///
|
||||
/// Throws rather than returning an empty array when nothing matches: an
|
||||
/// empty result would be indistinguishable from "this binary does not need
|
||||
/// the patch", and for watchdogd it always does.
|
||||
public static func locateSites(in data: Data, log: ((String) -> Void)? = nil) throws -> [Site] {
|
||||
let data = data.startIndex == 0 ? data : Data(data)
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
guard let text = sections[textSectionKey] else {
|
||||
throw PatcherError.invalidFormat("no \(textSectionKey) section")
|
||||
}
|
||||
guard let literal = findLiteral(in: data, sections: sections) else {
|
||||
throw PatcherError.patchSiteNotFound("'\(sysctlName)' cstring not present")
|
||||
}
|
||||
log?(" [.] cstring at va:0x\(hex(literal.address)) "
|
||||
+ "(foff:0x\(hex(UInt64(literal.fileOffset))), sect=\(literal.section))")
|
||||
|
||||
guard let symbols = CFWWatchdogdSymbolTargets(data: data) else {
|
||||
throw PatcherError.invalidFormat(
|
||||
"no LC_SYMTAB/LC_DYSYMTAB — \(sysctlFunction) cannot be resolved"
|
||||
)
|
||||
}
|
||||
|
||||
let start = Int(text.fileOffset)
|
||||
let end = start + Int(text.size)
|
||||
guard start >= 0, end <= data.count else {
|
||||
throw PatcherError.invalidFormat("\(textSectionKey) falls outside the file")
|
||||
}
|
||||
let instructions = ARM64Disassembler().disassemble(data.subdata(in: start ..< end), at: text.address)
|
||||
|
||||
let segments = MachOParser.parseSegments(from: data)
|
||||
var pages: [UInt32: (page: UInt64, index: Int)] = [:]
|
||||
var sites: [Site] = []
|
||||
|
||||
for (index, instruction) in instructions.enumerated() {
|
||||
// Capstone runs with `skipData` on, so a word it cannot decode
|
||||
// arrives as a data pseudo-instruction rather than ending the
|
||||
// stream. Register state across such a word means nothing.
|
||||
guard instruction.id != 0 else {
|
||||
pages.removeAll()
|
||||
continue
|
||||
}
|
||||
|
||||
if instruction.mnemonic == "adrp" {
|
||||
if let destination = registerNumber(instruction, 0),
|
||||
let page = immediate(instruction, 1)
|
||||
{
|
||||
pages[destination] = (UInt64(bitPattern: page), index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Layer 2: an ADRP+ADD pair that resolves to the literal. The 64-bit
|
||||
// form only — a `w` destination is arithmetic, not an address.
|
||||
guard instruction.mnemonic == "add",
|
||||
let pointer = registerName(instruction, 0), pointer.hasPrefix("x"),
|
||||
let base = registerNumber(instruction, 1),
|
||||
let offset = immediate(instruction, 2),
|
||||
let page = pages[base],
|
||||
index - page.index <= pageToOffsetWindow,
|
||||
page.page &+ UInt64(bitPattern: offset) == literal.address
|
||||
else { continue }
|
||||
|
||||
guard let site = matchSite(
|
||||
instructions: instructions,
|
||||
addIndex: index,
|
||||
pointerRegister: pointer,
|
||||
literalVMA: literal.address,
|
||||
text: text,
|
||||
segments: segments,
|
||||
symbols: symbols
|
||||
) else { continue }
|
||||
|
||||
if !sites.contains(where: { $0.gateVMA == site.gateVMA }) { sites.append(site) }
|
||||
}
|
||||
|
||||
guard !sites.isEmpty else {
|
||||
throw PatcherError.patchSiteNotFound(
|
||||
"no '\(sysctlName)' cache site: expected an adrp+add for the cstring reaching "
|
||||
+ "\(argumentRegister), a bl \(sysctlFunction), a cbnz w0 gate, a cset wN, ne "
|
||||
+ "and a strb into a \(globalSegmentPrefix) global"
|
||||
)
|
||||
}
|
||||
return sites.sorted { $0.gateVMA < $1.gateVMA }
|
||||
}
|
||||
|
||||
/// Match the canonical shape forward from the ADD that formed the string
|
||||
/// pointer. Returns `nil` when any layer of the anchor fails, which is how
|
||||
/// the three unrelated `sysctlbyname` calls in watchdogd are rejected.
|
||||
static func matchSite(
|
||||
instructions: [Instruction],
|
||||
addIndex: Int,
|
||||
pointerRegister: String,
|
||||
literalVMA: UInt64,
|
||||
text: MachOSectionInfo,
|
||||
segments: [MachOSegmentInfo],
|
||||
symbols: CFWWatchdogdSymbolTargets
|
||||
) -> Site? {
|
||||
// Layer 3: the call, and the import it resolves to.
|
||||
guard let callIndex = firstIndex(
|
||||
in: instructions, from: addIndex + 1, within: argumentSetupWindow,
|
||||
where: { $0.mnemonic == "bl" }
|
||||
) else { return nil }
|
||||
let call = instructions[callIndex]
|
||||
guard let target = ARM64Encoder.decodeBranchTarget(
|
||||
insn: word(of: call), pc: call.address
|
||||
), symbols.name(forBranchTarget: target) == sysctlFunction else { return nil }
|
||||
|
||||
// Layer 2, concluded: the literal has to be the call's `name` argument,
|
||||
// not just something this stretch of code also mentions.
|
||||
guard passesLiteral(
|
||||
inRegister: pointerRegister, from: addIndex, toCallAt: callIndex, in: instructions
|
||||
) else { return nil }
|
||||
|
||||
// Layer 4a: the gate, which must be the very next instruction — the
|
||||
// defensive check on the call's return value.
|
||||
let gateIndex = callIndex + 1
|
||||
guard gateIndex < instructions.count else { return nil }
|
||||
let gate = instructions[gateIndex]
|
||||
let state: Site.State
|
||||
if gate.mnemonic == "cbnz", registerName(gate, 0) == "w0" {
|
||||
state = .pristine
|
||||
} else if gate.mnemonic == "nop" {
|
||||
state = .patched
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Layer 4b: the value the store writes. `cset wN, ne` is the stock
|
||||
// truthiness of the sysctl's out-parameter; `mov wN, #1` is what this
|
||||
// patch leaves in its place.
|
||||
guard let valueIndex = firstIndex(
|
||||
in: instructions, from: gateIndex + 1, within: gateToValueWindow,
|
||||
where: { instruction in
|
||||
switch state {
|
||||
case .pristine:
|
||||
instruction.mnemonic == "cset" && instruction.aarch64?.conditionCode == AArch64CC_NE
|
||||
case .patched:
|
||||
isMoveOfOne(instruction)
|
||||
}
|
||||
}
|
||||
) else { return nil }
|
||||
let value = instructions[valueIndex]
|
||||
guard let valueRegister = registerName(value, 0),
|
||||
let valueNumber = wRegisterNumber(valueRegister) else { return nil }
|
||||
|
||||
// Layer 5: the store of that same register into an ADRP-relative
|
||||
// __DATA address — the cached global.
|
||||
guard let storeIndex = firstIndex(
|
||||
in: instructions, from: valueIndex + 1, within: valueToStoreWindow,
|
||||
where: { $0.mnemonic == "strb" && registerName($0, 0) == valueRegister }
|
||||
) else { return nil }
|
||||
let store = instructions[storeIndex]
|
||||
guard let memory = memoryOperand(store) else { return nil }
|
||||
guard let basePage = pageAddress(
|
||||
ofRegister: UInt32(memory.base.rawValue),
|
||||
before: storeIndex, notBefore: addIndex, in: instructions
|
||||
) else { return nil }
|
||||
let cachedByte = basePage &+ UInt64(bitPattern: Int64(memory.disp))
|
||||
guard let segment = segments.first(where: {
|
||||
cachedByte >= $0.vmAddr && cachedByte < $0.vmAddr &+ $0.vmSize
|
||||
}), segment.name.hasPrefix(globalSegmentPrefix) else { return nil }
|
||||
|
||||
return Site(
|
||||
state: state,
|
||||
literalVMA: literalVMA,
|
||||
addVMA: instructions[addIndex].address,
|
||||
callVMA: call.address,
|
||||
gateVMA: gate.address,
|
||||
gateFileOffset: fileOffset(of: gate.address, in: text),
|
||||
valueVMA: value.address,
|
||||
valueFileOffset: fileOffset(of: value.address, in: text),
|
||||
valueRegister: valueRegister,
|
||||
valueRegisterNumber: valueNumber,
|
||||
storeVMA: store.address,
|
||||
cachedByteVMA: cachedByte
|
||||
)
|
||||
}
|
||||
|
||||
/// True when the literal the ADD at `addIndex` formed is in `x0` by the time
|
||||
/// the call at `callIndex` runs.
|
||||
///
|
||||
/// Two ways that happens, and both occur in Apple's own codegen: the ADD
|
||||
/// writes `x0` outright, or it writes a scratch register that a later
|
||||
/// `mov x0, xN` moves into place. Accepting only the first would make the
|
||||
/// patch miss the site on a build that schedules the argument differently —
|
||||
/// loudly, but still wrongly.
|
||||
static func passesLiteral(
|
||||
inRegister pointer: String,
|
||||
from addIndex: Int,
|
||||
toCallAt callIndex: Int,
|
||||
in instructions: [Instruction]
|
||||
) -> Bool {
|
||||
if pointer == argumentRegister { return true }
|
||||
for index in (addIndex + 1) ..< callIndex {
|
||||
let instruction = instructions[index]
|
||||
if instruction.mnemonic == "mov",
|
||||
registerName(instruction, 0) == argumentRegister,
|
||||
registerName(instruction, 1) == pointer
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// The literal's VA, file offset and section, matched only at a string
|
||||
/// boundary so a suffix of a longer literal cannot pass.
|
||||
static func findLiteral(
|
||||
in data: Data,
|
||||
sections: [String: MachOSectionInfo]
|
||||
) -> (address: UInt64, fileOffset: Int, section: String)? {
|
||||
for (key, section) in sections.sorted(by: { $0.key < $1.key })
|
||||
where literalSectionNames.contains(section.sectionName)
|
||||
{
|
||||
let start = Int(section.fileOffset)
|
||||
let end = start + Int(section.size)
|
||||
guard start >= 0, end <= data.count, start < end else { continue }
|
||||
let body = data.subdata(in: start ..< end)
|
||||
|
||||
var searchFrom = body.startIndex
|
||||
while let found = body.range(of: needle, in: searchFrom ..< body.endIndex) {
|
||||
let index = found.lowerBound
|
||||
if index == body.startIndex || body[index - 1] == 0 {
|
||||
return (section.address &+ UInt64(index), start + index, key)
|
||||
}
|
||||
searchFrom = index + 1
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
/// Read the patched words back out and confirm they are what was written.
|
||||
static func verify(sites: [Site], in data: Data) throws {
|
||||
for site in sites {
|
||||
let gate = data.subdata(in: site.gateFileOffset ..< site.gateFileOffset + 4)
|
||||
guard gate == ARM64.nop else {
|
||||
throw PatcherError.patchVerificationFailed(
|
||||
"gate at 0x\(hex(site.gateVMA)) reads \(gate.hex) after write"
|
||||
)
|
||||
}
|
||||
let value = data.subdata(in: site.valueFileOffset ..< site.valueFileOffset + 4)
|
||||
guard value == ARM64Encoder.encodeMovzW(rd: site.valueRegisterNumber, imm16: 1) else {
|
||||
throw PatcherError.patchVerificationFailed(
|
||||
"value at 0x\(hex(site.valueVMA)) reads \(value.hex) after write"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Records
|
||||
|
||||
static func record(
|
||||
in data: Data,
|
||||
at fileOffset: Int,
|
||||
virtualAddress: UInt64,
|
||||
patched: Data,
|
||||
id: String,
|
||||
description: String,
|
||||
disassembler: ARM64Disassembler
|
||||
) -> PatchRecord {
|
||||
let original = data.subdata(in: fileOffset ..< fileOffset + patched.count)
|
||||
return PatchRecord(
|
||||
patchID: id,
|
||||
component: component,
|
||||
fileOffset: fileOffset,
|
||||
virtualAddress: virtualAddress,
|
||||
originalBytes: original,
|
||||
patchedBytes: patched,
|
||||
beforeDisasm: text(of: original, at: virtualAddress, disassembler),
|
||||
afterDisasm: text(of: patched, at: virtualAddress, disassembler),
|
||||
description: description
|
||||
)
|
||||
}
|
||||
|
||||
static func text(of word: Data, at address: UInt64, _ disassembler: ARM64Disassembler) -> String {
|
||||
guard let instruction = disassembler.disassembleOne(word, at: address) else { return "???" }
|
||||
return instruction.operandString.isEmpty
|
||||
? instruction.mnemonic
|
||||
: "\(instruction.mnemonic) \(instruction.operandString)"
|
||||
}
|
||||
|
||||
// MARK: - Instruction helpers
|
||||
|
||||
/// Index of the first instruction at or after `from` that satisfies
|
||||
/// `predicate`, within `within` instructions. A word Capstone could not
|
||||
/// decode ends the window: past it the stream is no longer this function's
|
||||
/// instructions.
|
||||
static func firstIndex(
|
||||
in instructions: [Instruction],
|
||||
from: Int,
|
||||
within: Int,
|
||||
where predicate: (Instruction) -> Bool
|
||||
) -> Int? {
|
||||
guard from >= 0 else { return nil }
|
||||
let end = min(instructions.count, from + within)
|
||||
var index = from
|
||||
while index < end {
|
||||
guard instructions[index].id != 0 else { return nil }
|
||||
if predicate(instructions[index]) { return index }
|
||||
index += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Page address an ADRP put in `register`, searching back from `before`
|
||||
/// (exclusive) no further than `notBefore`.
|
||||
static func pageAddress(
|
||||
ofRegister register: UInt32,
|
||||
before: Int,
|
||||
notBefore: Int,
|
||||
in instructions: [Instruction]
|
||||
) -> UInt64? {
|
||||
var index = before - 1
|
||||
while index >= notBefore {
|
||||
let instruction = instructions[index]
|
||||
if instruction.mnemonic == "adrp", registerNumber(instruction, 0) == register,
|
||||
let page = immediate(instruction, 1)
|
||||
{
|
||||
return UInt64(bitPattern: page)
|
||||
}
|
||||
index -= 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// True for `mov wN, #1` in any encoding Capstone aliases to it (MOVZ, and
|
||||
/// the ORR-immediate form) — the shape this patch writes.
|
||||
static func isMoveOfOne(_ instruction: Instruction) -> Bool {
|
||||
guard instruction.mnemonic == "mov",
|
||||
let register = registerName(instruction, 0), register.hasPrefix("w"),
|
||||
let value = immediate(instruction, 1)
|
||||
else { return false }
|
||||
return value == 1
|
||||
}
|
||||
|
||||
/// The instruction's raw little-endian word, for the branch decoder.
|
||||
static func word(of instruction: Instruction) -> UInt32 {
|
||||
var value: UInt32 = 0
|
||||
for byte in instruction.bytes.prefix(4).reversed() { value = (value << 8) | UInt32(byte) }
|
||||
return value
|
||||
}
|
||||
|
||||
static func registerName(_ instruction: Instruction, _ index: Int) -> String? {
|
||||
guard let operands = instruction.aarch64?.operands, index < operands.count,
|
||||
operands[index].type == AARCH64_OP_REG
|
||||
else { return nil }
|
||||
return sharedDisassembler.registerName(UInt32(operands[index].reg.rawValue))
|
||||
}
|
||||
|
||||
static func registerNumber(_ instruction: Instruction, _ index: Int) -> UInt32? {
|
||||
guard let operands = instruction.aarch64?.operands, index < operands.count,
|
||||
operands[index].type == AARCH64_OP_REG
|
||||
else { return nil }
|
||||
return UInt32(operands[index].reg.rawValue)
|
||||
}
|
||||
|
||||
static func immediate(_ instruction: Instruction, _ index: Int) -> Int64? {
|
||||
guard let operands = instruction.aarch64?.operands, index < operands.count,
|
||||
operands[index].type == AARCH64_OP_IMM
|
||||
else { return nil }
|
||||
return operands[index].imm
|
||||
}
|
||||
|
||||
static func memoryOperand(_ instruction: Instruction) -> aarch64_op_mem? {
|
||||
guard let operands = instruction.aarch64?.operands,
|
||||
let operand = operands.first(where: { $0.type == AARCH64_OP_MEM })
|
||||
else { return nil }
|
||||
return operand.mem
|
||||
}
|
||||
|
||||
/// `"w8"` -> 8. `wzr` is rejected: the patch re-encodes this register as
|
||||
/// the destination of a `mov #1`, which is meaningless for the zero
|
||||
/// register and would mean the shape was misread.
|
||||
static func wRegisterNumber(_ name: String) -> UInt32? {
|
||||
guard name.hasPrefix("w"), let number = UInt32(name.dropFirst()), number <= 30 else { return nil }
|
||||
return number
|
||||
}
|
||||
|
||||
/// Capstone handle used only for register-name lookups, which need no
|
||||
/// per-call state.
|
||||
static let sharedDisassembler = ARM64Disassembler()
|
||||
|
||||
// MARK: - Addresses
|
||||
|
||||
static func fileOffset(of address: UInt64, in text: MachOSectionInfo) -> Int {
|
||||
Int(text.fileOffset) + Int(address &- text.address)
|
||||
}
|
||||
|
||||
static func hex(_ value: UInt64) -> String {
|
||||
String(value, radix: 16, uppercase: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Symbol targets
|
||||
|
||||
/// Resolves a branch target to the name of the function it calls.
|
||||
///
|
||||
/// Two paths, because a call can reach a function either way:
|
||||
///
|
||||
/// * through a stub section (`S_SYMBOL_STUBS`, which is what `__auth_stubs`
|
||||
/// is), whose entries map one-to-one onto a window of the indirect symbol
|
||||
/// table — the classic Mach-O import lookup, and the one watchdogd's
|
||||
/// `bl _sysctlbyname` takes;
|
||||
/// * directly to a defined symbol, for a statically linked build.
|
||||
///
|
||||
/// This is deliberately not in `MachOParser`: it needs each section's `flags`,
|
||||
/// `reserved1` and `reserved2` and the `LC_DYSYMTAB` indirect table, none of
|
||||
/// which that shared parser exposes. It carries this patcher's name because
|
||||
/// this patcher is its only caller — the first time a second one needs the same
|
||||
/// lookup, move it to `Binary/` under a neutral name rather than growing a copy.
|
||||
struct CFWWatchdogdSymbolTargets {
|
||||
/// A section of branch-island stubs, one per imported symbol.
|
||||
struct StubSection {
|
||||
let address: UInt64
|
||||
let size: UInt64
|
||||
/// `reserved2` — bytes per stub.
|
||||
let entrySize: UInt64
|
||||
/// `reserved1` — index of this section's first indirect symbol.
|
||||
let firstIndirectIndex: Int
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let symbolOffset: Int
|
||||
let symbolCount: Int
|
||||
let stringOffset: Int
|
||||
let stringSize: Int
|
||||
let indirectOffset: Int
|
||||
let indirectCount: Int
|
||||
let stubSections: [StubSection]
|
||||
|
||||
static let machMagic64: UInt32 = 0xFEED_FACF
|
||||
static let lcSegment64: UInt32 = 0x19
|
||||
static let lcDysymtab: UInt32 = 0x0B
|
||||
/// `SECTION_TYPE` of a stub section.
|
||||
static let sectionTypeSymbolStubs: UInt32 = 0x08
|
||||
/// Indirect entries that name no import.
|
||||
static let indirectSymbolLocal: UInt32 = 0x8000_0000
|
||||
static let indirectSymbolAbs: UInt32 = 0x4000_0000
|
||||
/// `N_STAB` — a debug entry, never a call target name.
|
||||
static let symbolIsDebug: UInt8 = 0xE0
|
||||
|
||||
init?(data: Data) {
|
||||
let data = data.startIndex == 0 ? data : Data(data)
|
||||
guard data.count > 32, data.loadLE(UInt32.self, at: 0) == Self.machMagic64 else { return nil }
|
||||
guard let symtab = MachOParser.parseSymtab(from: data) else { return nil }
|
||||
|
||||
var indirectOffset = 0
|
||||
var indirectCount = 0
|
||||
var stubs: [StubSection] = []
|
||||
|
||||
let commandCount = data.loadLE(UInt32.self, at: 16)
|
||||
var offset = 32
|
||||
for _ in 0 ..< commandCount {
|
||||
guard offset + 8 <= data.count else { return nil }
|
||||
let command = data.loadLE(UInt32.self, at: offset)
|
||||
let commandSize = Int(data.loadLE(UInt32.self, at: offset + 4))
|
||||
guard commandSize > 0 else { return nil }
|
||||
|
||||
if command == Self.lcDysymtab, offset + 64 <= data.count {
|
||||
indirectOffset = Int(data.loadLE(UInt32.self, at: offset + 56))
|
||||
indirectCount = Int(data.loadLE(UInt32.self, at: offset + 60))
|
||||
} else if command == Self.lcSegment64, offset + 72 <= data.count {
|
||||
let sectionCount = data.loadLE(UInt32.self, at: offset + 64)
|
||||
var section = offset + 72
|
||||
for _ in 0 ..< sectionCount {
|
||||
guard section + 80 <= data.count else { break }
|
||||
let flags = data.loadLE(UInt32.self, at: section + 64)
|
||||
let entrySize = UInt64(data.loadLE(UInt32.self, at: section + 72)) // reserved2
|
||||
if flags & 0xFF == Self.sectionTypeSymbolStubs, entrySize > 0 {
|
||||
stubs.append(StubSection(
|
||||
address: data.loadLE(UInt64.self, at: section + 32),
|
||||
size: data.loadLE(UInt64.self, at: section + 40),
|
||||
entrySize: entrySize,
|
||||
firstIndirectIndex: Int(data.loadLE(UInt32.self, at: section + 68)) // reserved1
|
||||
))
|
||||
}
|
||||
section += 80
|
||||
}
|
||||
}
|
||||
offset += commandSize
|
||||
}
|
||||
|
||||
self.data = data
|
||||
symbolOffset = symtab.symoff
|
||||
symbolCount = symtab.nsyms
|
||||
stringOffset = symtab.stroff
|
||||
stringSize = symtab.strsize
|
||||
self.indirectOffset = indirectOffset
|
||||
self.indirectCount = indirectCount
|
||||
stubSections = stubs
|
||||
}
|
||||
|
||||
/// Name of the function a `bl`/`b` to `address` ends up in, or `nil`.
|
||||
func name(forBranchTarget address: UInt64) -> String? {
|
||||
importName(atStub: address) ?? definedName(at: address)
|
||||
}
|
||||
|
||||
/// The imported symbol a stub at `address` stands for.
|
||||
func importName(atStub address: UInt64) -> String? {
|
||||
guard indirectCount > 0 else { return nil }
|
||||
for section in stubSections {
|
||||
guard address >= section.address, address < section.address &+ section.size else { continue }
|
||||
let index = section.firstIndirectIndex + Int((address - section.address) / section.entrySize)
|
||||
guard index >= 0, index < indirectCount else { return nil }
|
||||
let entryOffset = indirectOffset + index * 4
|
||||
guard entryOffset + 4 <= data.count else { return nil }
|
||||
let entry = data.loadLE(UInt32.self, at: entryOffset)
|
||||
guard entry & (Self.indirectSymbolLocal | Self.indirectSymbolAbs) == 0 else { return nil }
|
||||
return symbolName(at: Int(entry))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// A defined symbol whose value is exactly `address`.
|
||||
func definedName(at address: UInt64) -> String? {
|
||||
guard address != 0 else { return nil }
|
||||
for index in 0 ..< symbolCount {
|
||||
let entry = symbolOffset + index * 16
|
||||
guard entry + 16 <= data.count else { return nil }
|
||||
guard data[entry + 4] & Self.symbolIsDebug == 0 else { continue }
|
||||
guard data.loadLE(UInt64.self, at: entry + 8) == address else { continue }
|
||||
return symbolName(at: index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func symbolName(at index: Int) -> String? {
|
||||
guard index >= 0, index < symbolCount else { return nil }
|
||||
let entry = symbolOffset + index * 16
|
||||
guard entry + 4 <= data.count else { return nil }
|
||||
let stringIndex = Int(data.loadLE(UInt32.self, at: entry))
|
||||
guard stringIndex < stringSize else { return nil }
|
||||
let start = stringOffset + stringIndex
|
||||
guard start < data.count else { return nil }
|
||||
var end = start
|
||||
let limit = min(data.count, stringOffset + stringSize)
|
||||
while end < limit, data[end] != 0 { end += 1 }
|
||||
return String(data: data.subdata(in: start ..< end), encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,10 @@ extension CryptexFilesystemPatcher {
|
||||
func patchLaunchdCacheLoader(targetMount: String, cfwInput: URL) throws {
|
||||
let target = URL.init(filePath: targetMount)
|
||||
let launchdCacheLoaderPath = target.appending(path: "/usr/libexec/launchd_cache_loader")
|
||||
let pythonPath = try resources.pythonExecutable()
|
||||
let patcherPath = resources.cfwPy
|
||||
_ = try runProcess(pythonPath.path, [
|
||||
patcherPath.path, "patch-launchd-cache-loader",
|
||||
launchdCacheLoaderPath.path
|
||||
])
|
||||
// Patched in place with no `.bak` to restore from, so this is the call
|
||||
// site that needs the port's idempotence. No re-attestation: the sign
|
||||
// below replaces the whole signature anyway.
|
||||
try CFWCacheLoaderPatcher.patch(fileAt: launchdCacheLoaderPath)
|
||||
_ = try runProcess("/bin/chmod", ["0755", launchdCacheLoaderPath.path])
|
||||
|
||||
try VPhoneSigner.sign(
|
||||
@@ -207,11 +205,9 @@ extension CryptexFilesystemPatcher {
|
||||
func patchMobileActivation(targetMount: String, cfwInput: URL) throws {
|
||||
let target = URL.init(filePath: targetMount)
|
||||
let mobileActivationdPath = target.appending(path: "/usr/libexec/mobileactivationd")
|
||||
let pythonPath = try resources.pythonExecutable()
|
||||
_ = try runProcess(pythonPath.path, [
|
||||
resources.cfwPy.path, "patch-mobileactivationd",
|
||||
mobileActivationdPath.path
|
||||
])
|
||||
// `resign: false` because the sign below replaces the signature, and
|
||||
// re-attesting would refuse an unsigned input the Python accepted.
|
||||
try CFWMobileactivationd.patch(fileAt: mobileActivationdPath, resign: false)
|
||||
_ = try runProcess("/bin/chmod", ["0755", mobileActivationdPath.path])
|
||||
|
||||
try VPhoneSigner.sign(
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - VPhoneFirmwareSupport
|
||||
|
||||
/// How much confidence this project has in one downloadable iPhone firmware.
|
||||
///
|
||||
/// `supported` means the README's "Tested Environments" table records a boot on
|
||||
/// that exact version/build; `notTested` means Apple still serves it but nobody
|
||||
/// wrote a row for it. `unsupported` is never a table verdict — it is the
|
||||
/// prefix the selector puts on "nothing matched", and it exists here only
|
||||
/// because it shares the colour table with the other two.
|
||||
public enum VPhoneFirmwareSupport: String, Sendable, CaseIterable {
|
||||
case supported = "Supported"
|
||||
case notTested = "Not Tested"
|
||||
case unsupported = "Unsupported"
|
||||
|
||||
/// SGR introducer for this verdict. Green/amber/red, matching the palette
|
||||
/// the rest of the tool uses for status.
|
||||
var ansiColor: String {
|
||||
switch self {
|
||||
case .supported: "\u{1B}[32m"
|
||||
case .notTested: "\u{1B}[33m"
|
||||
case .unsupported: "\u{1B}[31m"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VPhoneStatusStyle
|
||||
|
||||
/// Whether ANSI colour may be written to one particular stream.
|
||||
///
|
||||
/// Three checks that have to stay together, and the order matters:
|
||||
///
|
||||
/// 1. `NO_COLOR` set to a non-empty value wins over everything.
|
||||
/// 2. Otherwise colour follows `isatty` **of this stream**, not of the process.
|
||||
/// 3. `CLICOLOR_FORCE=1` turns colour on even when the stream is a pipe.
|
||||
///
|
||||
/// The per-stream part is not a detail: the firmware listing styles stdout, the
|
||||
/// selector's failure paths style stderr, and a run that pipes one and not the
|
||||
/// other has to get a different answer for each. Getting this wrong means
|
||||
/// escape codes land in a pipe, or colour disappears in a terminal.
|
||||
public struct VPhoneStatusStyle: Sendable, Equatable {
|
||||
public let isColored: Bool
|
||||
|
||||
public init(isColored: Bool) {
|
||||
self.isColored = isColored
|
||||
}
|
||||
|
||||
/// The policy above, resolved for one file descriptor.
|
||||
public static func forStream(
|
||||
_ fileDescriptor: Int32,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> VPhoneStatusStyle {
|
||||
if let noColor = environment["NO_COLOR"], !noColor.isEmpty {
|
||||
return VPhoneStatusStyle(isColored: false)
|
||||
}
|
||||
if isatty(fileDescriptor) != 0 {
|
||||
return VPhoneStatusStyle(isColored: true)
|
||||
}
|
||||
return VPhoneStatusStyle(isColored: environment["CLICOLOR_FORCE"] == "1")
|
||||
}
|
||||
|
||||
/// `status`, left-justified to `width` and only then wrapped in colour.
|
||||
///
|
||||
/// The padding goes *inside* the escape sequence. That is deliberate: it is
|
||||
/// where the shell's Python put it, and it keeps the plain and coloured
|
||||
/// renderings the same width on screen, so the STATUS column lines up in
|
||||
/// both. Moving the padding outside would change every byte of the output.
|
||||
public func render(_ status: VPhoneFirmwareSupport, width: Int = 0) -> String {
|
||||
let text = VPhoneFirmwareMatrix.leftJustified(status.rawValue, width)
|
||||
guard isColored else { return text }
|
||||
return "\(status.ansiColor)\(text)\u{1B}[0m"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VPhoneFirmwareRelease
|
||||
|
||||
/// One downloadable restore image, as named by its own URL.
|
||||
public struct VPhoneFirmwareRelease: Sendable, Hashable {
|
||||
public let version: String
|
||||
public let build: String
|
||||
public let url: String
|
||||
|
||||
public init(version: String, build: String, url: String) {
|
||||
self.version = version
|
||||
self.build = build
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VPhoneFirmwareBuildID
|
||||
|
||||
/// The (version, build) pair the README table and the URL list are joined on.
|
||||
public struct VPhoneFirmwareBuildID: Sendable, Hashable {
|
||||
public let version: String
|
||||
public let build: String
|
||||
|
||||
public init(version: String, build: String) {
|
||||
self.version = version
|
||||
self.build = build
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VPhoneFirmwareListing
|
||||
|
||||
/// Result of the `--list` path.
|
||||
public enum VPhoneFirmwareListing: Sendable, Equatable {
|
||||
/// The rendered matrix. Goes to stdout; exit 0.
|
||||
case matrix(String)
|
||||
/// Nothing downloadable for the device. Goes to stderr; exit 1.
|
||||
case nothingDownloadable(String)
|
||||
}
|
||||
|
||||
// MARK: - VPhoneFirmwareSelection
|
||||
|
||||
/// Result of resolving a version/build selector against the download list.
|
||||
public enum VPhoneFirmwareSelection: Sendable, Equatable {
|
||||
/// One firmware and its verdict. Goes to stdout as `resolvedLine`; exit 0.
|
||||
case selected(release: VPhoneFirmwareRelease, support: VPhoneFirmwareSupport)
|
||||
/// A bare version that maps to several builds. Goes to stderr; exit 2 —
|
||||
/// `fw_prepare.sh` forwards that 2 so a caller can tell "ambiguous" from
|
||||
/// "no such firmware".
|
||||
case ambiguous(String)
|
||||
/// Nothing matched. Goes to stderr; exit 1.
|
||||
case unmatched(String)
|
||||
|
||||
/// `version\tbuild\turl\tstatus`, which `fw_prepare.sh` reads back with
|
||||
/// `IFS=$'\t' read -r`.
|
||||
///
|
||||
/// Plain even when the rest of the run is coloured. The shell echoes field
|
||||
/// 4 through its own `style_status`, so an escape sequence here would be
|
||||
/// wrapped in a second one.
|
||||
public var resolvedLine: String? {
|
||||
guard case let .selected(release, support) = self else { return nil }
|
||||
return "\(release.version)\t\(release.build)\t\(release.url)\t\(support.rawValue)\n"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VPhoneFirmwareMatrix
|
||||
|
||||
/// The firmware support matrix: the README's "Tested Environments" table joined
|
||||
/// against the restore images Apple still serves.
|
||||
///
|
||||
/// This replaces the two Python heredocs that used to live inside
|
||||
/// `scripts/fw_prepare.sh` (`list_firmwares` and `resolve_selector_from_downloads`).
|
||||
/// They were 166 lines that duplicated the same three parsers — the README
|
||||
/// section scan, the URL scan, and the version sort — once each, so this is one
|
||||
/// parser with two renderers on top of it.
|
||||
public enum VPhoneFirmwareMatrix {
|
||||
// MARK: Parsing
|
||||
|
||||
/// Every `(version, build)` the README's "Tested Environments" section
|
||||
/// records for `device`.
|
||||
///
|
||||
/// The section runs from the `## Tested Environments` heading to the next
|
||||
/// `## ` heading. Inside it, any backticked `17,3_26.1_23B85` cell whose
|
||||
/// device part equals `device` minus its `iPhone` prefix counts as tested;
|
||||
/// the cloudOS column uses `26.1-23B85`, which cannot match. A missing
|
||||
/// README is not an error — it just means nothing is known to be tested.
|
||||
public static func testedBuilds(readme: String?, device: String) -> Set<VPhoneFirmwareBuildID> {
|
||||
guard let readme else { return [] }
|
||||
let deviceSuffix = device.hasPrefix("iPhone")
|
||||
? String(device.dropFirst("iPhone".count))
|
||||
: device
|
||||
guard let cell = try? NSRegularExpression(
|
||||
pattern: "`(\\d+,\\d+)_([^_`]+)_([A-Za-z0-9]+)`"
|
||||
) else { return [] }
|
||||
|
||||
var tested: Set<VPhoneFirmwareBuildID> = []
|
||||
var inSection = false
|
||||
for line in normalizedLines(of: readme) {
|
||||
if line.hasPrefix("## Tested Environments") {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if inSection, line.hasPrefix("## ") { break }
|
||||
guard inSection else { continue }
|
||||
|
||||
for match in cell.matches(in: line, range: NSRange(line.startIndex..., in: line)) {
|
||||
guard let device = group(1, of: match, in: line),
|
||||
let version = group(2, of: match, in: line),
|
||||
let build = group(3, of: match, in: line),
|
||||
device == deviceSuffix
|
||||
else { continue }
|
||||
tested.insert(VPhoneFirmwareBuildID(version: version, build: build))
|
||||
}
|
||||
}
|
||||
return tested
|
||||
}
|
||||
|
||||
/// Every restore image for `device` named by the `ipsw download … --urls`
|
||||
/// output, in the order the lines arrive and with duplicates dropped.
|
||||
///
|
||||
/// A line counts when it *ends* with `/<device>_<version>_<build>_Restore.ipsw`,
|
||||
/// so the surrounding CDN path — which differs per release and is sometimes
|
||||
/// a bare UUID — is ignored.
|
||||
public static func releases(downloadURLs: String, device: String) -> [VPhoneFirmwareRelease] {
|
||||
let pattern = "/(" + NSRegularExpression.escapedPattern(for: device)
|
||||
+ "_([^_]+)_([A-Za-z0-9]+)_Restore\\.ipsw)$"
|
||||
guard let name = try? NSRegularExpression(pattern: pattern) else { return [] }
|
||||
|
||||
var seen: Set<VPhoneFirmwareRelease> = []
|
||||
var found: [VPhoneFirmwareRelease] = []
|
||||
for rawLine in normalizedLines(of: downloadURLs) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let match = name.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)),
|
||||
let version = group(2, of: match, in: line),
|
||||
let build = group(3, of: match, in: line)
|
||||
else { continue }
|
||||
let release = VPhoneFirmwareRelease(version: version, build: build, url: line)
|
||||
if seen.insert(release).inserted { found.append(release) }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// The verdict for one release, given what the README records.
|
||||
public static func support(
|
||||
of release: VPhoneFirmwareRelease,
|
||||
tested: Set<VPhoneFirmwareBuildID>
|
||||
) -> VPhoneFirmwareSupport {
|
||||
tested.contains(VPhoneFirmwareBuildID(version: release.version, build: release.build))
|
||||
? .supported
|
||||
: .notTested
|
||||
}
|
||||
|
||||
// MARK: Rendering — the listing
|
||||
|
||||
/// The full `--list` report, rendered for a stream with `style`'s colour policy.
|
||||
public static func listing(
|
||||
device: String,
|
||||
readme: String?,
|
||||
downloadURLs: String,
|
||||
style: VPhoneStatusStyle
|
||||
) -> VPhoneFirmwareListing {
|
||||
let releases = releases(downloadURLs: downloadURLs, device: device)
|
||||
guard !releases.isEmpty else {
|
||||
return .nothingDownloadable("No downloadable IPSWs found for \(device)\n")
|
||||
}
|
||||
let tested = testedBuilds(readme: readme, device: device)
|
||||
let rows = releases.sorted { isNewer($0, than: $1) }
|
||||
|
||||
var out = "Available downloadable IPSWs for \(device):\n"
|
||||
out += "\n"
|
||||
out += "Status: \(style.render(.supported, width: statusWidth))"
|
||||
+ " \(style.render(.notTested, width: statusWidth))"
|
||||
+ " \(style.render(.unsupported, width: statusWidth))\n"
|
||||
out += "\n"
|
||||
out += "\(leftJustified("VERSION", versionWidth)) \(leftJustified("BUILD", buildWidth)) STATUS\n"
|
||||
for row in rows {
|
||||
let status = support(of: row, tested: tested)
|
||||
out += "\(leftJustified(row.version, versionWidth))"
|
||||
+ " \(leftJustified(row.build, buildWidth))"
|
||||
+ " \(style.render(status, width: statusWidth))\n"
|
||||
}
|
||||
return .matrix(out)
|
||||
}
|
||||
|
||||
// MARK: Rendering — the selector
|
||||
|
||||
/// Resolve a version and/or build selector against the download list.
|
||||
///
|
||||
/// Empty strings mean "unconstrained", which is how the shell passes an
|
||||
/// unset `IPHONE_VERSION`/`IPHONE_BUILD`. A bare version that matches more
|
||||
/// than one build is reported as ambiguous rather than guessed at; anything
|
||||
/// else resolves to the highest build that matched.
|
||||
public static func selection(
|
||||
device: String,
|
||||
version: String,
|
||||
build: String,
|
||||
readme: String?,
|
||||
downloadURLs: String,
|
||||
style: VPhoneStatusStyle
|
||||
) -> VPhoneFirmwareSelection {
|
||||
let matches = releases(downloadURLs: downloadURLs, device: device).filter {
|
||||
(version.isEmpty || $0.version == version) && (build.isEmpty || $0.build == build)
|
||||
}
|
||||
guard !matches.isEmpty else {
|
||||
let prefix = style.render(.unsupported)
|
||||
let selector = if !version.isEmpty, !build.isEmpty {
|
||||
"device=\(device) version=\(version) build=\(build)"
|
||||
} else if !build.isEmpty {
|
||||
"device=\(device) build=\(build)"
|
||||
} else {
|
||||
"device=\(device) version=\(version)"
|
||||
}
|
||||
return .unmatched("\(prefix): no downloadable IPSW matched \(selector)\n")
|
||||
}
|
||||
|
||||
let tested = testedBuilds(readme: readme, device: device)
|
||||
let byBuild = matches.sorted { hasHigherBuild($0, than: $1) }
|
||||
|
||||
if !version.isEmpty, build.isEmpty, Set(matches.map(\.build)).count > 1 {
|
||||
var out = "Version \(version) is ambiguous for \(device); specify one of these builds:\n"
|
||||
out += "\(leftJustified("BUILD", buildWidth)) STATUS\n"
|
||||
for match in byBuild {
|
||||
out += "\(leftJustified(match.build, buildWidth)) \(style.render(support(of: match, tested: tested)))\n"
|
||||
}
|
||||
return .ambiguous(out)
|
||||
}
|
||||
|
||||
let selected = byBuild[0]
|
||||
return .selected(release: selected, support: support(of: selected, tested: tested))
|
||||
}
|
||||
|
||||
// MARK: Ordering
|
||||
|
||||
/// One dot-separated component of a version, compared the way the shell's
|
||||
/// Python compared it: numerically where it parses as a number, textually
|
||||
/// otherwise.
|
||||
///
|
||||
/// The Python built a tuple and let Python compare it, which *raises* on a
|
||||
/// number-against-text comparison (`26.1` vs `26.beta`). Nothing Apple
|
||||
/// serves produces that, and raising is not a behaviour worth porting, so
|
||||
/// numbers sort before text here.
|
||||
enum VersionPart: Comparable {
|
||||
case number(Int)
|
||||
case text(String)
|
||||
|
||||
static func < (lhs: VersionPart, rhs: VersionPart) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.number(l), .number(r)): l < r
|
||||
case let (.text(l), .text(r)): codePointsAscending(l, r)
|
||||
case (.number, .text): true
|
||||
case (.text, .number): false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `"26.4.1"` → `[26, 4, 1]`. Shorter sorts before longer on a common
|
||||
/// prefix, so `26.4` < `26.4.1`.
|
||||
static func versionKey(_ version: String) -> [VersionPart] {
|
||||
version.components(separatedBy: ".").map { part in
|
||||
if let number = Int(part) { .number(number) } else { .text(part) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Newest first: version descending, then build descending.
|
||||
///
|
||||
/// URL breaks the remaining ties. The Python had nothing there — it sorted
|
||||
/// a `set`, so two rows sharing a version *and* a build came out in hash
|
||||
/// order, which is to say a different order per run. That is not a
|
||||
/// behaviour to reproduce, so the order here is total and reproducible.
|
||||
static func isNewer(_ lhs: VPhoneFirmwareRelease, than rhs: VPhoneFirmwareRelease) -> Bool {
|
||||
let (l, r) = (versionKey(lhs.version), versionKey(rhs.version))
|
||||
for (lp, rp) in zip(l, r) where lp != rp { return rp < lp }
|
||||
if l.count != r.count { return r.count < l.count }
|
||||
return hasHigherBuild(lhs, than: rhs)
|
||||
}
|
||||
|
||||
/// Build descending, URL descending as the tie-break.
|
||||
static func hasHigherBuild(_ lhs: VPhoneFirmwareRelease, than rhs: VPhoneFirmwareRelease) -> Bool {
|
||||
if lhs.build != rhs.build { return codePointsAscending(rhs.build, lhs.build) }
|
||||
return codePointsAscending(rhs.url, lhs.url)
|
||||
}
|
||||
|
||||
// MARK: Text helpers
|
||||
|
||||
static let versionWidth = 12
|
||||
static let buildWidth = 10
|
||||
static let statusWidth = 11
|
||||
|
||||
/// `String.padding(toLength:)` truncates; this does not, which is what the
|
||||
/// `f"{value:<12}"` it replaces did. Width is counted in code points, again
|
||||
/// to match.
|
||||
static func leftJustified(_ text: String, _ width: Int) -> String {
|
||||
let count = text.unicodeScalars.count
|
||||
guard count < width else { return text }
|
||||
return text + String(repeating: " ", count: width - count)
|
||||
}
|
||||
|
||||
/// Code-point order, not Swift's canonical `String` ordering, because the
|
||||
/// comparison it stands in for was Python's.
|
||||
static func codePointsAscending(_ lhs: String, _ rhs: String) -> Bool {
|
||||
var left = lhs.unicodeScalars.makeIterator()
|
||||
var right = rhs.unicodeScalars.makeIterator()
|
||||
while true {
|
||||
switch (left.next(), right.next()) {
|
||||
case (nil, nil): return false
|
||||
case (nil, .some): return true
|
||||
case (.some, nil): return false
|
||||
case let (.some(l), .some(r)):
|
||||
if l.value != r.value { return l.value < r.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-mode line splitting: CRLF and CR collapse to LF, and a trailing
|
||||
/// newline does not produce a final empty line.
|
||||
static func normalizedLines(of text: String) -> [String] {
|
||||
var normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
normalized = normalized.replacingOccurrences(of: "\r", with: "\n")
|
||||
if normalized.hasSuffix("\n") { normalized.removeLast() }
|
||||
if normalized.isEmpty { return [] }
|
||||
return normalized.components(separatedBy: "\n")
|
||||
}
|
||||
|
||||
private static func group(_ index: Int, of match: NSTextCheckingResult, in text: String) -> String? {
|
||||
guard let range = Range(match.range(at: index), in: text) else { return nil }
|
||||
return String(text[range])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VPhoneFirmwareMatrixCommandLine
|
||||
|
||||
/// The two entry points `scripts/fw_prepare.sh` calls, including which stream
|
||||
/// each half writes to and what it exits with.
|
||||
///
|
||||
/// Stream choice is load-bearing twice over. The listing styles **stdout**, so
|
||||
/// `--list | less` gets plain text; every selector failure styles **stderr**,
|
||||
/// so an error stays coloured even when the resolved URL is being captured in a
|
||||
/// `$( … )`. Both are resolved independently, per stream.
|
||||
public enum VPhoneFirmwareMatrixCommandLine {
|
||||
/// Print the support matrix. 0 on success, 1 when the device has nothing
|
||||
/// downloadable.
|
||||
public static func list(
|
||||
device: String,
|
||||
readmePath: String,
|
||||
downloadURLs: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
stdout: FileHandle = .standardOutput,
|
||||
stderr: FileHandle = .standardError
|
||||
) -> Int32 {
|
||||
let readme = try? String(contentsOfFile: readmePath, encoding: .utf8)
|
||||
switch VPhoneFirmwareMatrix.listing(
|
||||
device: device,
|
||||
readme: readme,
|
||||
downloadURLs: downloadURLs,
|
||||
style: .forStream(stdout.fileDescriptor, environment: environment)
|
||||
) {
|
||||
case let .matrix(text):
|
||||
write(text, to: stdout)
|
||||
return 0
|
||||
case let .nothingDownloadable(text):
|
||||
write(text, to: stderr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a selector to `version\tbuild\turl\tstatus` on stdout. 0 on a
|
||||
/// hit, 2 when a bare version is ambiguous, 1 when nothing matched.
|
||||
public static func resolve(
|
||||
device: String,
|
||||
version: String,
|
||||
build: String,
|
||||
readmePath: String,
|
||||
downloadURLs: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
stdout: FileHandle = .standardOutput,
|
||||
stderr: FileHandle = .standardError
|
||||
) -> Int32 {
|
||||
let readme = try? String(contentsOfFile: readmePath, encoding: .utf8)
|
||||
let selection = VPhoneFirmwareMatrix.selection(
|
||||
device: device,
|
||||
version: version,
|
||||
build: build,
|
||||
readme: readme,
|
||||
downloadURLs: downloadURLs,
|
||||
style: .forStream(stderr.fileDescriptor, environment: environment)
|
||||
)
|
||||
switch selection {
|
||||
case .selected:
|
||||
write(selection.resolvedLine ?? "", to: stdout)
|
||||
return 0
|
||||
case let .ambiguous(text):
|
||||
write(text, to: stderr)
|
||||
return 2
|
||||
case let .unmatched(text):
|
||||
write(text, to: stderr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func write(_ text: String, to handle: FileHandle) {
|
||||
guard !text.isEmpty else { return }
|
||||
handle.write(Data(text.utf8))
|
||||
}
|
||||
}
|
||||
@@ -285,14 +285,14 @@ public struct VPhoneCreateOrchestrator {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
if let iphoneSource = options.iphoneSource { env["IPHONE_SOURCE"] = iphoneSource }
|
||||
if let cloudosSource = options.cloudosSource { env["CLOUDOS_SOURCE"] = cloudosSource }
|
||||
env["VPHONE_PYTHON"] = try resources.pythonExecutable().path
|
||||
// No VPHONE_PYTHON: fw_prepare.sh no longer runs any Python.
|
||||
env["IPSW_DIR"] = resources.ipswCacheDir.path
|
||||
env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path
|
||||
if isLess { env["VARIANT"] = "less" }
|
||||
if options.keepArtifacts { env["VPHONE_KEEP_ARTIFACTS"] = "1" }
|
||||
|
||||
trace(
|
||||
"spawn /bin/bash \(resources.fwPrepareScript.path) (env keys: VPHONE_PYTHON, IPSW_DIR, VPHONE_SEAL_DIR)",
|
||||
"spawn /bin/bash \(resources.fwPrepareScript.path) (env keys: IPSW_DIR, VPHONE_SEAL_DIR)",
|
||||
v
|
||||
)
|
||||
// Always streamed — silence during a multi-GB download reads as a hang.
|
||||
|
||||
@@ -12,9 +12,69 @@ struct VPhoneFWCommand: ParsableCommand {
|
||||
VPhoneFWPrepareCommand.self,
|
||||
VPhoneFWPatchCommand.self,
|
||||
VPhoneFWManifestCommand.self,
|
||||
VPhoneFWListCommand.self,
|
||||
VPhoneFWResolveCommand.self,
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - firmware support matrix
|
||||
|
||||
/// Replaces the two Python heredocs that used to live inside
|
||||
/// `scripts/fw_prepare.sh`. Both read the `DOWNLOADABLE_IPSW_URLS` the shell
|
||||
/// already sets, so only the language changed; the shell still runs `ipsw`.
|
||||
///
|
||||
/// Neither writes through `print`: `list` styles stdout and `resolve` styles
|
||||
/// stderr, and colour is only right if each descriptor is asked separately.
|
||||
struct VPhoneFWListCommand: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "Print the downloadable-firmware support matrix for a device"
|
||||
)
|
||||
|
||||
@Option(help: "Device identifier, e.g. iPhone17,3") var device: String
|
||||
@Option(help: "README.md holding the 'Tested Environments' table") var readme: String
|
||||
|
||||
func run() throws {
|
||||
let code = VPhoneFirmwareMatrixCommandLine.list(
|
||||
device: device,
|
||||
readmePath: readme,
|
||||
downloadURLs: ProcessInfo.processInfo.environment["DOWNLOADABLE_IPSW_URLS"] ?? ""
|
||||
)
|
||||
if code != 0 { throw ExitCode(code) }
|
||||
}
|
||||
}
|
||||
|
||||
struct VPhoneFWResolveCommand: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "resolve",
|
||||
abstract: "Resolve a version/build selector to a downloadable IPSW URL",
|
||||
discussion: """
|
||||
Prints version<TAB>build<TAB>url<TAB>status on stdout, which fw_prepare.sh
|
||||
reads back with `IFS=$'\\t' read -r`.
|
||||
|
||||
Exits 2 — not 1 — when a bare version matches more than one build, so a
|
||||
caller can tell "pick a build" from "there is no such firmware". An empty
|
||||
--version or --build means unconstrained.
|
||||
"""
|
||||
)
|
||||
|
||||
@Option(help: "Device identifier, e.g. iPhone17,3") var device: String
|
||||
@Option(help: "iOS version to match; empty matches any") var version: String = ""
|
||||
@Option(help: "Build to match; empty matches any") var build: String = ""
|
||||
@Option(help: "README.md holding the 'Tested Environments' table") var readme: String
|
||||
|
||||
func run() throws {
|
||||
let code = VPhoneFirmwareMatrixCommandLine.resolve(
|
||||
device: device,
|
||||
version: version,
|
||||
build: build,
|
||||
readmePath: readme,
|
||||
downloadURLs: ProcessInfo.processInfo.environment["DOWNLOADABLE_IPSW_URLS"] ?? ""
|
||||
)
|
||||
if code != 0 { throw ExitCode(code) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - manifest
|
||||
|
||||
/// Replaces `scripts/fw_manifest.py`, called from `fw_prepare.sh` once both
|
||||
@@ -117,16 +177,16 @@ struct VPhoneFWPrepareCommand: ParsableCommand {
|
||||
if let iphoneBuild { env["IPHONE_BUILD"] = iphoneBuild }
|
||||
if list { env["LIST_FIRMWARES"] = "1" }
|
||||
|
||||
// Redirect the three things a read-only bundle can't provide (python,
|
||||
// IPSW cache, extracted apfs_sealvolume) to the writable user cache.
|
||||
// Redirect the two things a read-only bundle can't provide (IPSW cache,
|
||||
// extracted apfs_sealvolume) to the writable user cache. No Python:
|
||||
// fw_prepare.sh's last heredocs moved into `fw list` / `fw resolve`.
|
||||
try FileManager.default.createDirectory(at: resources.ipswCacheDir, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true)
|
||||
env["VPHONE_PYTHON"] = try resources.pythonExecutable().path
|
||||
env["IPSW_DIR"] = resources.ipswCacheDir.path
|
||||
env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path
|
||||
|
||||
if v.tracesInternals {
|
||||
print("[trace] spawning: /bin/bash \(resources.fwPrepareScript.path) (env keys: VPHONE_PYTHON, IPSW_DIR, VPHONE_SEAL_DIR)")
|
||||
print("[trace] spawning: /bin/bash \(resources.fwPrepareScript.path) (env keys: IPSW_DIR, VPHONE_SEAL_DIR)")
|
||||
}
|
||||
let code = try VPhoneProcessRunner.runStreaming(
|
||||
URL(fileURLWithPath: "/bin/bash"),
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
// CFWCacheloaderTests.swift — parity for the launchd_cache_loader unsecure-cache gate.
|
||||
//
|
||||
// The patch is one instruction in a boot-critical binary, and a wrong one is a
|
||||
// guest that will not start rather than a failing assertion, so the reference
|
||||
// these tests grade against is the Python that has already shipped: `cfw.py
|
||||
// patch-launchd-cache-loader`. The centre of the suite runs both
|
||||
// implementations over two clones of the *real* iOS 27.0 / 24A435 binary and
|
||||
// compares every byte.
|
||||
//
|
||||
// The binary is required. Point `VPHONE_MACHO_PRISTINE` at a directory of
|
||||
// unpatched Mach-Os, or leave the default `ipsws/ref_extract/macho_pristine` in
|
||||
// place. Without it these tests FAIL — the suite never opens with a bare
|
||||
// `return`, which Swift Testing reports as a pass, so a green run cannot mean
|
||||
// the fixture was absent. A machine that genuinely cannot carry it sets
|
||||
// `VPHONE_MACHO_FIXTURE_OPTIONAL=1`, which turns the failure into a skip.
|
||||
//
|
||||
// Nothing here writes to the pristine tree. Clones are made with `clonefile`
|
||||
// (instant and near-free on APFS) under the system temporary directory, or
|
||||
// under `VPHONE_CACHELOADER_SCRATCH` when the caller names one;
|
||||
// `VPHONE_CACHELOADER_KEEP=1` leaves them behind for inspection.
|
||||
|
||||
@testable import FirmwarePatcher
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
// MARK: - Fixture discovery
|
||||
|
||||
private enum CacheLoaderFixture {
|
||||
static let repoRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
|
||||
/// The read-only reference binary.
|
||||
static var pristine: URL? {
|
||||
let directory = ProcessInfo.processInfo.environment["VPHONE_MACHO_PRISTINE"]
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
?? repoRoot.appendingPathComponent("ipsws/ref_extract/macho_pristine")
|
||||
let binary = directory.appendingPathComponent("launchd_cache_loader")
|
||||
return FileManager.default.fileExists(atPath: binary.path) ? binary : nil
|
||||
}
|
||||
|
||||
/// Opt-out for a machine that cannot carry the extracted IPSW.
|
||||
static var isOptional: Bool {
|
||||
ProcessInfo.processInfo.environment["VPHONE_MACHO_FIXTURE_OPTIONAL"] == "1"
|
||||
}
|
||||
|
||||
/// The suite runs unless the binary is absent *and* the caller opted out.
|
||||
static var runs: Bool { pristine != nil || !isOptional }
|
||||
|
||||
static let missing: Comment = """
|
||||
the real 24A435 launchd_cache_loader is required — put it at \
|
||||
ipsws/ref_extract/macho_pristine/, point VPHONE_MACHO_PRISTINE at that \
|
||||
directory, or set VPHONE_MACHO_FIXTURE_OPTIONAL=1 to skip these tests \
|
||||
instead of failing
|
||||
"""
|
||||
|
||||
/// Where clones go. Deliberately *not* inside `ipsws/ref_extract`: that tree
|
||||
/// is the pristine reference every parity test compares against, and it came
|
||||
/// out of a 12 GB IPSW nobody wants to re-extract.
|
||||
static var scratchRoot: URL {
|
||||
ProcessInfo.processInfo.environment["VPHONE_CACHELOADER_SCRATCH"]
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("vphone-cacheloader")
|
||||
}
|
||||
|
||||
/// Leave clones on disk after the run, for hand inspection.
|
||||
static var keepsArtifacts: Bool {
|
||||
ProcessInfo.processInfo.environment["VPHONE_CACHELOADER_KEEP"] == "1"
|
||||
}
|
||||
|
||||
/// The project venv, which is where the reference Python lives.
|
||||
static var python: URL? {
|
||||
let url = repoRoot.appendingPathComponent(".venv/bin/python3")
|
||||
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
||||
}
|
||||
|
||||
static var patchersDirectory: URL { repoRoot.appendingPathComponent("scripts/patchers") }
|
||||
static var cfwPy: URL { patchersDirectory.appendingPathComponent("cfw.py") }
|
||||
|
||||
/// Clone the pristine binary into a file the caller may write to.
|
||||
///
|
||||
/// `cp -c` asks for a `clonefile`, which costs no space and no time when the
|
||||
/// scratch root shares the fixture's APFS volume. When it does not — a
|
||||
/// caller who pointed `VPHONE_CACHELOADER_SCRATCH` at another disk — the
|
||||
/// clone is refused, and the fallback is an ordinary copy rather than a
|
||||
/// failed test.
|
||||
static func clone(named name: String) throws -> URL {
|
||||
let pristine = try #require(self.pristine, missing)
|
||||
try FileManager.default.createDirectory(
|
||||
at: scratchRoot,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let destination = scratchRoot.appendingPathComponent(name)
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
|
||||
for flags in [["-c"], []] {
|
||||
let result = try Shell.run(
|
||||
executable: URL(fileURLWithPath: "/bin/cp"),
|
||||
arguments: flags + [pristine.path, destination.path]
|
||||
)
|
||||
if result.status == 0 { return destination }
|
||||
}
|
||||
Issue.record("could not clone \(pristine.path) to \(destination.path)")
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
|
||||
/// Discard clones, and the scratch root with them once the last one is gone,
|
||||
/// so a test run leaves the filesystem as it found it.
|
||||
static func discard(_ clones: URL...) {
|
||||
guard !keepsArtifacts else { return }
|
||||
for clone in clones {
|
||||
try? FileManager.default.removeItem(at: clone)
|
||||
}
|
||||
let remaining = (try? FileManager.default
|
||||
.contentsOfDirectory(atPath: scratchRoot.path)) ?? []
|
||||
if remaining.isEmpty {
|
||||
try? FileManager.default.removeItem(at: scratchRoot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the shipped Python patcher over `binary`.
|
||||
@discardableResult
|
||||
static func runPython(on binary: URL) throws -> Shell.Result {
|
||||
let python = try #require(
|
||||
self.python,
|
||||
"the reference Python is required — run `make setup_venv`"
|
||||
)
|
||||
return try Shell.run(
|
||||
executable: python,
|
||||
arguments: [cfwPy.path, "patch-launchd-cache-loader", binary.path],
|
||||
currentDirectory: repoRoot
|
||||
)
|
||||
}
|
||||
|
||||
/// Recompute `binary`'s slot hashes with the Python's own re-signer — the
|
||||
/// independent reference for the half of this port `codesign -v` grades.
|
||||
@discardableResult
|
||||
static func runPythonReattest(on binary: URL, offsets: [Int]) throws -> Shell.Result {
|
||||
let python = try #require(
|
||||
self.python,
|
||||
"the reference Python is required — run `make setup_venv`"
|
||||
)
|
||||
let offsetList = offsets.map(String.init).joined(separator: ",")
|
||||
return try Shell.run(
|
||||
executable: python,
|
||||
arguments: [
|
||||
"-c",
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
import cfw_macho_codesign as codesign
|
||||
codesign.reattest_modified_offsets(
|
||||
sys.argv[2], [int(x) for x in sys.argv[3].split(",")], verbose=False
|
||||
)
|
||||
""",
|
||||
patchersDirectory.path,
|
||||
binary.path,
|
||||
offsetList,
|
||||
],
|
||||
currentDirectory: repoRoot
|
||||
)
|
||||
}
|
||||
|
||||
/// `codesign -v`, the second independent reference: it knows nothing about
|
||||
/// either implementation and recomputes the page hashes itself.
|
||||
static func codesignVerify(_ binary: URL) throws -> Shell.Result {
|
||||
try Shell.run(
|
||||
executable: URL(fileURLWithPath: "/usr/bin/codesign"),
|
||||
arguments: ["-v", "--verbose=2", binary.path]
|
||||
)
|
||||
}
|
||||
|
||||
static func bytes(of url: URL) throws -> Data { try Data(contentsOf: url) }
|
||||
}
|
||||
|
||||
// MARK: - Subprocess helper
|
||||
|
||||
private enum Shell {
|
||||
struct Result {
|
||||
let status: Int32
|
||||
let stdout: String
|
||||
let stderr: String
|
||||
var output: String { stdout + stderr }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func run(
|
||||
executable: URL,
|
||||
arguments: [String],
|
||||
currentDirectory: URL? = nil
|
||||
) throws -> Result {
|
||||
let process = Process()
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
if let currentDirectory { process.currentDirectoryURL = currentDirectory }
|
||||
let out = Pipe()
|
||||
let err = Pipe()
|
||||
process.standardOutput = out
|
||||
process.standardError = err
|
||||
try process.run()
|
||||
let outData = out.fileHandleForReading.readDataToEndOfFile()
|
||||
let errData = err.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return Result(
|
||||
status: process.terminationStatus,
|
||||
stdout: String(decoding: outData, as: UTF8.self),
|
||||
stderr: String(decoding: errData, as: UTF8.self)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Byte comparison
|
||||
|
||||
private enum ByteComparison {
|
||||
/// Every offset at which two equal-length buffers differ.
|
||||
static func differingOffsets(_ lhs: Data, _ rhs: Data) -> [Int] {
|
||||
guard lhs.count == rhs.count else { return [] }
|
||||
return (0 ..< lhs.count).filter { lhs[$0] != rhs[$0] }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gate discovery
|
||||
|
||||
@Suite(
|
||||
"launchd_cache_loader gate discovery",
|
||||
.enabled(if: CacheLoaderFixture.runs, CacheLoaderFixture.missing)
|
||||
)
|
||||
struct CFWCacheLoaderGateTests {
|
||||
@Test("the anchor is the whole launchd_unsecure_cache= literal, found in __cstring")
|
||||
func anchorIsTheBootArgLiteral() throws {
|
||||
let data = try CacheLoaderFixture.bytes(
|
||||
of: try #require(CacheLoaderFixture.pristine, CacheLoaderFixture.missing)
|
||||
)
|
||||
let located = try CFWCacheLoaderPatcher.locateGate(in: data)
|
||||
let anchor = located.anchor
|
||||
|
||||
#expect(anchor.token == "unsecure_cache")
|
||||
#expect(anchor.text == "launchd_unsecure_cache=")
|
||||
#expect(anchor.sectionName == "__TEXT,__cstring")
|
||||
// The token sits inside the literal, so the address code forms is the
|
||||
// literal's first byte — earlier than where the token matched.
|
||||
#expect(anchor.stringVMA < anchor.matchVMA)
|
||||
#expect(anchor.matchVMA - anchor.stringVMA == UInt64("launchd_".utf8.count))
|
||||
// …and that first byte really is where the literal starts on disk.
|
||||
let literal = data[anchor.stringFileOffset ..< anchor.stringFileOffset + anchor.text.utf8.count]
|
||||
#expect(String(decoding: literal, as: UTF8.self) == anchor.text)
|
||||
#expect(data[anchor.stringFileOffset - 1] == 0)
|
||||
}
|
||||
|
||||
@Test("the xref is an ADRP+ADD that really computes the literal's address")
|
||||
func referenceComputesTheString() throws {
|
||||
let data = try CacheLoaderFixture.bytes(
|
||||
of: try #require(CacheLoaderFixture.pristine, CacheLoaderFixture.missing)
|
||||
)
|
||||
let anchor = try CFWCacheLoaderPatcher.locateGate(in: data).anchor
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
let text = try #require(sections["__TEXT,__text"])
|
||||
let disassembler = ARM64Disassembler()
|
||||
|
||||
// Recomputed here from the two instructions, independently of how the
|
||||
// patcher found them: page(ADRP) + imm(ADD) has to be the literal.
|
||||
let adrp = try #require(disassembler.disassembleOne(
|
||||
in: data, at: anchor.referenceFileOffset, address: anchor.referenceVMA
|
||||
))
|
||||
let add = try #require(disassembler.disassembleOne(
|
||||
in: data, at: anchor.referenceFileOffset + 4, address: anchor.referenceVMA + 4
|
||||
))
|
||||
#expect(adrp.mnemonic == "adrp")
|
||||
#expect(add.mnemonic == "add")
|
||||
|
||||
let page = try #require(adrp.aarch64?.operands.last?.imm)
|
||||
let pageOffset = try #require(add.aarch64?.operands.last?.imm)
|
||||
#expect(UInt64(page + pageOffset) == anchor.stringVMA)
|
||||
|
||||
// And the xref is inside __TEXT,__text, which is the only place a gate
|
||||
// could be.
|
||||
#expect(anchor.referenceVMA >= text.address)
|
||||
#expect(anchor.referenceVMA < text.address + text.size)
|
||||
}
|
||||
|
||||
@Test("the gate is a forward cbz on the boot-arg lookup's result")
|
||||
func gateShape() throws {
|
||||
let data = try CacheLoaderFixture.bytes(
|
||||
of: try #require(CacheLoaderFixture.pristine, CacheLoaderFixture.missing)
|
||||
)
|
||||
let located = try CFWCacheLoaderPatcher.locateGate(in: data)
|
||||
let gate = located.gate
|
||||
|
||||
#expect(gate.mnemonic == "cbz")
|
||||
#expect(!gate.wasAlreadyNOP)
|
||||
#expect(gate.operandString.hasPrefix("x0,"))
|
||||
// It is the instruction immediately after the call it grades…
|
||||
let callVMA = try #require(gate.callVMA)
|
||||
#expect(gate.vma == callVMA + 4)
|
||||
#expect(callVMA > located.anchor.referenceVMA)
|
||||
// …and it jumps forward, over the unsecure-cache path it guards.
|
||||
let target = try #require(gate.targetVMA)
|
||||
#expect(target > gate.vma)
|
||||
}
|
||||
|
||||
@Test("the fixture's code directory is the SHA-256, short-tail shape this port assumes")
|
||||
func fixtureSignatureShape() throws {
|
||||
let data = try CacheLoaderFixture.bytes(
|
||||
of: try #require(CacheLoaderFixture.pristine, CacheLoaderFixture.missing)
|
||||
)
|
||||
let directories = try #require(CFWMachOCodeSignature.codeDirectories(in: data))
|
||||
let directory = try #require(directories.first)
|
||||
|
||||
#expect(directories.count == 1)
|
||||
#expect(directory.hashType == CFWMachOCodeSignature.hashTypeSHA256)
|
||||
#expect(directory.pageSize == 4096)
|
||||
// codeLimit is NOT page aligned here, so the last slot is short — the
|
||||
// case that regressed independent Mach-O re-signing once already.
|
||||
#expect(directory.codeLimit % directory.pageSize != 0)
|
||||
let tail = try #require(directory.slotRange(directory.codeSlotCount - 1))
|
||||
#expect(tail.count < directory.pageSize)
|
||||
|
||||
// The gate lands in slot 0, well inside the covered region.
|
||||
let gate = try CFWCacheLoaderPatcher.locateGate(in: data).gate
|
||||
let bounds = try #require(CFWMachOCodeSignature.pageBounds(
|
||||
fileOffset: gate.fileOffset,
|
||||
pageSize: directory.pageSize,
|
||||
codeLimit: directory.codeLimit
|
||||
))
|
||||
#expect(bounds.index == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parity against the Python
|
||||
|
||||
@Suite(
|
||||
"launchd_cache_loader parity",
|
||||
.enabled(if: CacheLoaderFixture.runs, CacheLoaderFixture.missing),
|
||||
.serialized
|
||||
)
|
||||
struct CFWCacheLoaderParityTests {
|
||||
@Test("Swift and Python produce byte-identical binaries")
|
||||
func byteForByteParity() throws {
|
||||
let swiftClone = try CacheLoaderFixture.clone(named: "swift")
|
||||
let pythonClone = try CacheLoaderFixture.clone(named: "python")
|
||||
defer { CacheLoaderFixture.discard(swiftClone, pythonClone) }
|
||||
|
||||
let report = try CFWCacheLoaderPatcher.patch(fileAt: swiftClone, log: nil)
|
||||
#expect(report.outcome == .patched)
|
||||
#expect(report.sitesWritten == 1)
|
||||
// Off by default, because every shipped caller re-signs afterwards.
|
||||
#expect(report.reattestedSlots.isEmpty)
|
||||
|
||||
let python = try CacheLoaderFixture.runPython(on: pythonClone)
|
||||
#expect(python.status == 0, "python failed: \(python.output)")
|
||||
#expect(python.stdout.contains("[+] NOPped at"))
|
||||
|
||||
let swiftBytes = try CacheLoaderFixture.bytes(of: swiftClone)
|
||||
let pythonBytes = try CacheLoaderFixture.bytes(of: pythonClone)
|
||||
#expect(swiftBytes == pythonBytes, "Swift and Python disagree")
|
||||
}
|
||||
|
||||
@Test("exactly one instruction changes, and it is the gate")
|
||||
func onlyTheGateChanges() throws {
|
||||
let clone = try CacheLoaderFixture.clone(named: "single-site")
|
||||
defer { CacheLoaderFixture.discard(clone) }
|
||||
|
||||
let pristine = try CacheLoaderFixture.bytes(
|
||||
of: try #require(CacheLoaderFixture.pristine, CacheLoaderFixture.missing)
|
||||
)
|
||||
let report = try CFWCacheLoaderPatcher.patch(fileAt: clone, log: nil)
|
||||
let patched = try CacheLoaderFixture.bytes(of: clone)
|
||||
|
||||
let differing = ByteComparison.differingOffsets(pristine, patched)
|
||||
#expect(differing == Array(report.gate.fileOffset ..< report.gate.fileOffset + 4))
|
||||
#expect(patched[report.gate.fileOffset ..< report.gate.fileOffset + 4] == ARM64.nop)
|
||||
}
|
||||
|
||||
@Test("the record names the byte that changed, its address and the anchor")
|
||||
func recordDescribesTheSite() throws {
|
||||
let clone = try CacheLoaderFixture.clone(named: "record")
|
||||
defer { CacheLoaderFixture.discard(clone) }
|
||||
|
||||
let report = try CFWCacheLoaderPatcher.patch(fileAt: clone, log: nil)
|
||||
let record = try #require(report.record)
|
||||
|
||||
#expect(record.patchID == "launchd_cache_loader.unsecure_cache_gate")
|
||||
#expect(record.component == "launchd_cache_loader")
|
||||
#expect(record.fileOffset == report.gate.fileOffset)
|
||||
#expect(record.virtualAddress == report.gate.vma)
|
||||
#expect(record.patchedBytes == ARM64.nop)
|
||||
#expect(record.originalBytes != ARM64.nop)
|
||||
#expect(record.afterDisasm == "nop")
|
||||
#expect(record.beforeDisasm.hasPrefix("cbz"))
|
||||
// Worded exactly as the Python words it, so a captured reference sorts
|
||||
// against this port.
|
||||
#expect(record.patchDescription
|
||||
== "NOP the cache-validation branch gated on 'unsecure_cache'")
|
||||
|
||||
let onDisk = try CacheLoaderFixture.bytes(of: clone)
|
||||
#expect(onDisk[record.fileOffset ..< record.fileOffset + 4] == ARM64.nop)
|
||||
}
|
||||
|
||||
/// Every other test here passes `log: nil`, and production does not. The
|
||||
/// before/after window starts two instructions ahead of the gate, and it
|
||||
/// once converted that negative delta with `UInt64(Int)` — a trap on every
|
||||
/// real patch that no `log: nil` test could see.
|
||||
@Test("patching with logging on succeeds and prints the marked window")
|
||||
func loggingPathDoesNotTrap() throws {
|
||||
let clone = try CacheLoaderFixture.clone(named: "logged")
|
||||
defer { CacheLoaderFixture.discard(clone) }
|
||||
|
||||
final class Lines: @unchecked Sendable { var all: [String] = [] }
|
||||
let lines = Lines()
|
||||
let report = try CFWCacheLoaderPatcher.patch(fileAt: clone, log: { lines.all.append($0) })
|
||||
|
||||
let text = lines.all.joined(separator: "\n")
|
||||
#expect(text.contains("Before:"))
|
||||
#expect(text.contains(">>>"))
|
||||
let onDisk = try CacheLoaderFixture.bytes(of: clone)
|
||||
#expect(onDisk[report.gate.fileOffset ..< report.gate.fileOffset + 4] == ARM64.nop)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Re-runs and dry runs
|
||||
|
||||
@Suite(
|
||||
"launchd_cache_loader re-runs",
|
||||
.enabled(if: CacheLoaderFixture.runs, CacheLoaderFixture.missing),
|
||||
.serialized
|
||||
)
|
||||
struct CFWCacheLoaderRerunTests {
|
||||
@Test("a second run is a byte-for-byte no-op")
|
||||
func secondRunChangesNothing() throws {
|
||||
let clone = try CacheLoaderFixture.clone(named: "twice")
|
||||
defer { CacheLoaderFixture.discard(clone) }
|
||||
|
||||
let first = try CFWCacheLoaderPatcher.patch(fileAt: clone, log: nil)
|
||||
let afterFirst = try CacheLoaderFixture.bytes(of: clone)
|
||||
|
||||
let second = try CFWCacheLoaderPatcher.patch(fileAt: clone, log: nil)
|
||||
let afterSecond = try CacheLoaderFixture.bytes(of: clone)
|
||||
|
||||
#expect(second.outcome == .alreadyPatched)
|
||||
#expect(second.sitesWritten == 0)
|
||||
#expect(second.record == nil)
|
||||
#expect(afterFirst == afterSecond)
|
||||
// The second run has to recognise the SAME site, not merely find some
|
||||
// other instruction it is willing to leave alone.
|
||||
#expect(second.gate.fileOffset == first.gate.fileOffset)
|
||||
#expect(second.gate.vma == first.gate.vma)
|
||||
#expect(second.gate.wasAlreadyNOP)
|
||||
#expect(second.anchor == first.anchor)
|
||||
|
||||
// A third run, for the same reason the second exists.
|
||||
let third = try CFWCacheLoaderPatcher.patch(fileAt: clone, log: nil)
|
||||
#expect(third.outcome == .alreadyPatched)
|
||||
#expect(try CacheLoaderFixture.bytes(of: clone) == afterFirst)
|
||||
}
|
||||
|
||||
@Test("the Python double-applies where this port stops — the reason they diverge")
|
||||
func pythonIsNotIdempotent() throws {
|
||||
let pythonClone = try CacheLoaderFixture.clone(named: "python-twice")
|
||||
let swiftClone = try CacheLoaderFixture.clone(named: "swift-twice")
|
||||
defer { CacheLoaderFixture.discard(pythonClone, swiftClone) }
|
||||
|
||||
try CacheLoaderFixture.runPython(on: pythonClone)
|
||||
let afterFirst = try CacheLoaderFixture.bytes(of: pythonClone)
|
||||
let secondRun = try CacheLoaderFixture.runPython(on: pythonClone)
|
||||
let afterSecond = try CacheLoaderFixture.bytes(of: pythonClone)
|
||||
|
||||
// Not an assertion about what the Python *should* do — a pin on what it
|
||||
// does, so this divergence is deliberate and stays visible. The Python
|
||||
// walks past its own NOP and takes the next conditional branch.
|
||||
#expect(secondRun.status == 0)
|
||||
#expect(afterFirst != afterSecond, "the Python became idempotent; revisit this port")
|
||||
let extra = ByteComparison.differingOffsets(afterFirst, afterSecond)
|
||||
#expect(extra.count == 4)
|
||||
|
||||
try CFWCacheLoaderPatcher.patch(fileAt: swiftClone, log: nil)
|
||||
try CFWCacheLoaderPatcher.patch(fileAt: swiftClone, log: nil)
|
||||
let swiftTwice = try CacheLoaderFixture.bytes(of: swiftClone)
|
||||
#expect(swiftTwice == afterFirst, "one Swift run twice must equal one Python run once")
|
||||
#expect(swiftTwice != afterSecond)
|
||||
}
|
||||
|
||||
@Test("a dry run locates the site and writes nothing")
|
||||
func dryRunChangesNothing() throws {
|
||||
let clone = try CacheLoaderFixture.clone(named: "dry-run")
|
||||
defer { CacheLoaderFixture.discard(clone) }
|
||||
|
||||
let pristine = try CacheLoaderFixture.bytes(
|
||||
of: try #require(CacheLoaderFixture.pristine, CacheLoaderFixture.missing)
|
||||
)
|
||||
let report = try CFWCacheLoaderPatcher.patch(fileAt: clone, dryRun: true, log: nil)
|
||||
|
||||
#expect(report.outcome == .wouldPatch)
|
||||
#expect(report.sitesWritten == 0)
|
||||
#expect(report.gate.mnemonic == "cbz")
|
||||
#expect(try CacheLoaderFixture.bytes(of: clone) == pristine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Code signature
|
||||
|
||||
@Suite(
|
||||
"launchd_cache_loader re-signing",
|
||||
.enabled(if: CacheLoaderFixture.runs, CacheLoaderFixture.missing),
|
||||
.serialized
|
||||
)
|
||||
struct CFWCacheLoaderSignatureTests {
|
||||
@Test("the default output fails codesign, exactly as the Python's does")
|
||||
func defaultOutputIsUnattested() throws {
|
||||
let swiftClone = try CacheLoaderFixture.clone(named: "unattested-swift")
|
||||
let pythonClone = try CacheLoaderFixture.clone(named: "unattested-python")
|
||||
defer { CacheLoaderFixture.discard(swiftClone, pythonClone) }
|
||||
|
||||
try CFWCacheLoaderPatcher.patch(fileAt: swiftClone, log: nil)
|
||||
try CacheLoaderFixture.runPython(on: pythonClone)
|
||||
|
||||
// Both leave a stale slot hash, because both rely on the caller
|
||||
// re-signing the binary wholesale afterwards (`ldid_sign` in
|
||||
// `cfw_install*.sh`, `VPhoneSigner.sign` in the Swift call site). This
|
||||
// test exists so that shared assumption is written down where it fails
|
||||
// loudly if a caller ever stops honouring it.
|
||||
#expect(try CacheLoaderFixture.codesignVerify(swiftClone).status != 0)
|
||||
#expect(try CacheLoaderFixture.codesignVerify(pythonClone).status != 0)
|
||||
}
|
||||
|
||||
@Test("re-attested output passes codesign and matches the Python's slot hashes")
|
||||
func reattestedOutputVerifies() throws {
|
||||
let swiftClone = try CacheLoaderFixture.clone(named: "attested-swift")
|
||||
let pythonClone = try CacheLoaderFixture.clone(named: "attested-python")
|
||||
defer { CacheLoaderFixture.discard(swiftClone, pythonClone) }
|
||||
|
||||
let report = try CFWCacheLoaderPatcher.patch(
|
||||
fileAt: swiftClone,
|
||||
reattestsCodeSignature: true,
|
||||
log: nil
|
||||
)
|
||||
#expect(report.outcome == .patched)
|
||||
let slot = try #require(report.reattestedSlots.first)
|
||||
#expect(report.reattestedSlots.count == 1)
|
||||
#expect(slot.pageIndex == 0)
|
||||
#expect(slot.before != slot.after)
|
||||
|
||||
// codesign knows nothing about either implementation — it recomputes the
|
||||
// page hashes from the file itself.
|
||||
let verified = try CacheLoaderFixture.codesignVerify(swiftClone)
|
||||
#expect(verified.status == 0, "codesign -v failed: \(verified.output)")
|
||||
|
||||
// …and the Python, patching and re-signing with its own code, lands on
|
||||
// the same bytes.
|
||||
try CacheLoaderFixture.runPython(on: pythonClone)
|
||||
let reattest = try CacheLoaderFixture.runPythonReattest(
|
||||
on: pythonClone,
|
||||
offsets: [report.gate.fileOffset]
|
||||
)
|
||||
#expect(reattest.status == 0, "python re-attest failed: \(reattest.output)")
|
||||
#expect(try CacheLoaderFixture.bytes(of: swiftClone)
|
||||
== (try CacheLoaderFixture.bytes(of: pythonClone)))
|
||||
}
|
||||
|
||||
@Test("re-attesting an already-patched binary repairs it without moving an instruction")
|
||||
func reattestRepairsPythonOutput() throws {
|
||||
let clone = try CacheLoaderFixture.clone(named: "repair")
|
||||
defer { CacheLoaderFixture.discard(clone) }
|
||||
|
||||
// The Python's output: patched, and carrying a stale slot hash.
|
||||
try CacheLoaderFixture.runPython(on: clone)
|
||||
let beforeRepair = try CacheLoaderFixture.bytes(of: clone)
|
||||
|
||||
let report = try CFWCacheLoaderPatcher.patch(
|
||||
fileAt: clone,
|
||||
reattestsCodeSignature: true,
|
||||
log: nil
|
||||
)
|
||||
#expect(report.outcome == .alreadyPatched)
|
||||
#expect(report.sitesWritten == 0)
|
||||
#expect(report.reattestedSlots.count == 1)
|
||||
#expect(try CacheLoaderFixture.codesignVerify(clone).status == 0)
|
||||
|
||||
// Only the slot hash moved; no instruction was rewritten.
|
||||
let afterRepair = try CacheLoaderFixture.bytes(of: clone)
|
||||
let differing = ByteComparison.differingOffsets(beforeRepair, afterRepair)
|
||||
let slot = try #require(report.reattestedSlots.first)
|
||||
#expect(differing == Array(slot.hashFileOffset ..< slot.hashFileOffset + slot.after.count))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
// CFWDiskimagesiodTests.swift — parity for the diskimagesiod DDI mount gate.
|
||||
//
|
||||
// The patch is eight bytes over an ObjC method prologue, and a wrong eight
|
||||
// bytes is a daemon that crashes on first call rather than a failing
|
||||
// assertion — so the reference these tests grade against is not this port's
|
||||
// own opinion. It is, in order of authority:
|
||||
//
|
||||
// 1. `cfw.py patch-diskimagesiod`, the Python that has already shipped, run
|
||||
// under the project venv over a clone of the same pristine binary;
|
||||
// 2. `cfw_macho_codesign.reattest_modified_offsets`, the Python's own
|
||||
// independent re-signing implementation, for the `reattest: true` path;
|
||||
// 3. `/usr/bin/codesign -v`, which is neither implementation.
|
||||
//
|
||||
// The fixture is the real `usr/libexec/diskimagesiod` from iOS 27.0 / 24A435 /
|
||||
// iPhone17,3: 2.8 MB, arm64e, ad-hoc signed, CodeDirectory v=20400, 710+7
|
||||
// hashes, and a codeLimit of 0x2C5840 that is NOT page-aligned — the short tail
|
||||
// slot that the last independent-Mach-O re-signing regression came from.
|
||||
//
|
||||
// Point `VPHONE_MACHO_PRISTINE` at a directory of those binaries, or leave the
|
||||
// default `ipsws/ref_extract/macho_pristine` in place. Without it these tests
|
||||
// FAIL — the suite never opens with a bare `return`, which Swift Testing
|
||||
// reports as a pass, so a green run cannot mean the fixture was absent. A
|
||||
// machine that genuinely cannot carry it sets `VPHONE_MACHO_FIXTURE_OPTIONAL=1`,
|
||||
// which turns the failure into a visible skip.
|
||||
//
|
||||
// Nothing here writes into the pristine tree. Clones are made with `cp -c`
|
||||
// (`clonefile`: instant, and free on APFS) under the system temporary
|
||||
// directory, or under `VPHONE_MACHO_SCRATCH` when the caller names one.
|
||||
|
||||
@testable import FirmwarePatcher
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
// MARK: - Fixture discovery
|
||||
|
||||
private enum DiskImagesFixture {
|
||||
static let repoRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
|
||||
/// The read-only reference tree of standalone Mach-O binaries.
|
||||
static var pristineDirectory: URL? {
|
||||
let url = ProcessInfo.processInfo.environment["VPHONE_MACHO_PRISTINE"]
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
?? repoRoot.appendingPathComponent("ipsws/ref_extract/macho_pristine")
|
||||
let main = url.appendingPathComponent("diskimagesiod")
|
||||
return FileManager.default.fileExists(atPath: main.path) ? url : nil
|
||||
}
|
||||
|
||||
static var pristine: URL? { pristineDirectory?.appendingPathComponent("diskimagesiod") }
|
||||
|
||||
/// A second real binary from the same firmware, used as the negative case:
|
||||
/// it has no `DIDiskArb`, so locating must fail rather than find something.
|
||||
static var unrelated: URL? { pristineDirectory?.appendingPathComponent("watchdogd") }
|
||||
|
||||
/// Opt-out for a machine that cannot carry the fixture.
|
||||
static var isOptional: Bool {
|
||||
ProcessInfo.processInfo.environment["VPHONE_MACHO_FIXTURE_OPTIONAL"] == "1"
|
||||
}
|
||||
|
||||
/// The suite runs unless the fixture is absent *and* the caller opted out.
|
||||
static var runs: Bool { pristine != nil || !isOptional }
|
||||
|
||||
static let missing: Comment = """
|
||||
the real 24A435 arm64e diskimagesiod is required — put it at \
|
||||
ipsws/ref_extract/macho_pristine/diskimagesiod, point VPHONE_MACHO_PRISTINE \
|
||||
at its directory, or set VPHONE_MACHO_FIXTURE_OPTIONAL=1 to skip these \
|
||||
tests instead of failing
|
||||
"""
|
||||
|
||||
/// Where clones go. Deliberately *not* inside `ipsws/ref_extract`: that tree
|
||||
/// is the pristine reference every parity test compares against, and a
|
||||
/// scratch directory next to it is one `rm -rf` typo away from destroying an
|
||||
/// extraction that costs a 12 GB IPSW to regenerate.
|
||||
static var scratchRoot: URL {
|
||||
ProcessInfo.processInfo.environment["VPHONE_MACHO_SCRATCH"]
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("vphone-macho-diskimagesiod")
|
||||
}
|
||||
|
||||
/// The project venv, which is where the reference Python lives.
|
||||
static var python: URL? {
|
||||
let url = repoRoot.appendingPathComponent(".venv/bin/python3")
|
||||
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
||||
}
|
||||
|
||||
static var cfwPy: URL { repoRoot.appendingPathComponent("scripts/patchers/cfw.py") }
|
||||
|
||||
/// Clone the pristine binary into a fresh file the caller may write to.
|
||||
///
|
||||
/// `cp -c` asks for a `clonefile`, which costs no space and no time when the
|
||||
/// scratch root shares the fixture's APFS volume. When it does not — a
|
||||
/// caller who pointed `VPHONE_MACHO_SCRATCH` at another disk — the clone is
|
||||
/// refused and the fallback is an ordinary copy rather than a failed test.
|
||||
static func clone(named name: String) throws -> URL {
|
||||
let pristine = try #require(self.pristine, missing)
|
||||
try FileManager.default.createDirectory(
|
||||
at: scratchRoot,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let destination = scratchRoot.appendingPathComponent(name)
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
|
||||
for flags in [["-c"], []] {
|
||||
let result = try DiskImagesShell.run(
|
||||
executable: URL(fileURLWithPath: "/bin/cp"),
|
||||
arguments: flags + [pristine.path, destination.path]
|
||||
)
|
||||
if result.status == 0 { return destination }
|
||||
}
|
||||
Issue.record("could not clone \(pristine.path) to \(destination.path)")
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
|
||||
/// Discard clones.
|
||||
///
|
||||
/// The scratch *root* is deliberately left behind. Suites run in parallel
|
||||
/// even when each one is `.serialized`, so a suite that removed the shared
|
||||
/// root on its way out would be deleting a directory another suite is
|
||||
/// mid-clone into. The root is an empty directory under the system
|
||||
/// temporary directory, which the OS reaps on its own schedule.
|
||||
static func discard(_ clones: URL...) {
|
||||
for clone in clones {
|
||||
try? FileManager.default.removeItem(at: clone)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the shipped Python patcher over `binary`.
|
||||
@discardableResult
|
||||
static func runPython(on binary: URL) throws -> DiskImagesShell.Result {
|
||||
let python = try #require(
|
||||
self.python,
|
||||
"the reference Python is required — run `make setup_venv`"
|
||||
)
|
||||
let result = try DiskImagesShell.run(
|
||||
executable: python,
|
||||
arguments: [cfwPy.path, "patch-diskimagesiod", binary.path],
|
||||
currentDirectory: repoRoot.appendingPathComponent("scripts")
|
||||
)
|
||||
#expect(result.status == 0, "cfw.py patch-diskimagesiod failed: \(result.stderr)")
|
||||
return result
|
||||
}
|
||||
|
||||
/// Re-attest `offsets` in `binary` using the Python's own independent
|
||||
/// implementation, `cfw_macho_codesign.reattest_modified_offsets`.
|
||||
@discardableResult
|
||||
static func runPythonReattest(on binary: URL, offsets: [Int]) throws -> DiskImagesShell.Result {
|
||||
let python = try #require(
|
||||
self.python,
|
||||
"the reference Python is required — run `make setup_venv`"
|
||||
)
|
||||
let list = offsets.map(String.init).joined(separator: ",")
|
||||
let program = """
|
||||
import sys
|
||||
sys.path.insert(0, "scripts")
|
||||
from patchers.cfw_macho_codesign import reattest_modified_offsets
|
||||
reattest_modified_offsets(sys.argv[1], [\(list)], verbose=False)
|
||||
"""
|
||||
let result = try DiskImagesShell.run(
|
||||
executable: python,
|
||||
arguments: ["-c", program, binary.path],
|
||||
currentDirectory: repoRoot
|
||||
)
|
||||
#expect(result.status == 0, "python reattest failed: \(result.stderr)")
|
||||
return result
|
||||
}
|
||||
|
||||
/// `codesign -v` on a file, which is a reference neither implementation wrote.
|
||||
static func codesignVerify(_ binary: URL) throws -> DiskImagesShell.Result {
|
||||
try DiskImagesShell.run(
|
||||
executable: URL(fileURLWithPath: "/usr/bin/codesign"),
|
||||
arguments: ["-v", "--verbose=2", binary.path]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subprocess helper
|
||||
|
||||
private enum DiskImagesShell {
|
||||
struct Result {
|
||||
let status: Int32
|
||||
let stdout: String
|
||||
let stderr: String
|
||||
}
|
||||
|
||||
/// Run `executable` to completion and collect both streams.
|
||||
///
|
||||
/// The two pipes are drained on separate queues rather than one after the
|
||||
/// other. A pipe holds about 64 KiB; draining stdout to EOF first would
|
||||
/// wedge any child that fills stderr in the meantime, and the child here is
|
||||
/// a Python patcher that logs freely to both.
|
||||
@discardableResult
|
||||
static func run(
|
||||
executable: URL,
|
||||
arguments: [String],
|
||||
currentDirectory: URL? = nil
|
||||
) throws -> Result {
|
||||
let process = Process()
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
if let currentDirectory { process.currentDirectoryURL = currentDirectory }
|
||||
let out = Pipe()
|
||||
let err = Pipe()
|
||||
process.standardOutput = out
|
||||
process.standardError = err
|
||||
try process.run()
|
||||
|
||||
let collected = Drain()
|
||||
let group = DispatchGroup()
|
||||
for (handle, isStandardOutput) in [
|
||||
(out.fileHandleForReading, true),
|
||||
(err.fileHandleForReading, false),
|
||||
] {
|
||||
DispatchQueue.global().async(group: group) {
|
||||
let data = handle.readDataToEndOfFile()
|
||||
collected.store(data, isStandardOutput: isStandardOutput)
|
||||
}
|
||||
}
|
||||
group.wait()
|
||||
process.waitUntilExit()
|
||||
|
||||
return Result(
|
||||
status: process.terminationStatus,
|
||||
stdout: String(decoding: collected.standardOutput, as: UTF8.self),
|
||||
stderr: String(decoding: collected.standardError, as: UTF8.self)
|
||||
)
|
||||
}
|
||||
|
||||
/// Somewhere for the two reader queues to put what they read.
|
||||
private final class Drain: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var out = Data()
|
||||
private var err = Data()
|
||||
|
||||
func store(_ data: Data, isStandardOutput: Bool) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if isStandardOutput { out = data } else { err = data }
|
||||
}
|
||||
|
||||
var standardOutput: Data { lock.withLock { out } }
|
||||
var standardError: Data { lock.withLock { err } }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Byte comparison
|
||||
|
||||
private enum DiskImagesComparison {
|
||||
/// Every offset at which two files differ, capped so a wholly wrong result
|
||||
/// reports a count instead of megabytes of noise.
|
||||
static func differences(between lhs: URL, and rhs: URL, limit: Int = 16) throws -> [Int] {
|
||||
let left = try Data(contentsOf: lhs)
|
||||
let right = try Data(contentsOf: rhs)
|
||||
guard left.count == right.count else { return [-1] }
|
||||
var offsets: [Int] = []
|
||||
for index in 0 ..< left.count where left[index] != right[index] {
|
||||
offsets.append(index)
|
||||
if offsets.count >= limit { break }
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
static func identical(_ lhs: URL, _ rhs: URL) throws -> Bool {
|
||||
try differences(between: lhs, and: rhs, limit: 1).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The replacement bytes
|
||||
|
||||
@Suite("diskimagesiod replacement encoding")
|
||||
struct CFWDiskimagesiodEncodingTests {
|
||||
@Test("`mov x0, #1` built from ISA fields is the keystone-verified constant")
|
||||
func movzAgreesWithKeystone() throws {
|
||||
let encoded = try #require(ARM64Encoder.encodeMovzX(rd: 0, imm16: 1))
|
||||
#expect(encoded == ARM64.movX0_1)
|
||||
// 0xD2800020, little-endian on disk.
|
||||
#expect(encoded.hex == "200080d2")
|
||||
}
|
||||
|
||||
@Test("the patch writes exactly `mov x0, #1 ; ret`")
|
||||
func replacementDisassembles() {
|
||||
#expect(CFWDiskimagesiod.replacement.count == 8)
|
||||
#expect(CFWDiskimagesiod.replacement == ARM64.movX0_1 + ARM64.ret)
|
||||
#expect(
|
||||
CFWDiskimagesiod.disassemblyText(of: CFWDiskimagesiod.replacement, at: nil)
|
||||
== "mov x0, #1; ret"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anchoring
|
||||
|
||||
@Suite(
|
||||
"diskimagesiod anchoring",
|
||||
.enabled(if: DiskImagesFixture.runs, DiskImagesFixture.missing),
|
||||
.serialized
|
||||
)
|
||||
struct CFWDiskimagesiodAnchorTests {
|
||||
@Test("the IMP resolves through the relative method list, into __TEXT,__text")
|
||||
func locatesImplementation() throws {
|
||||
let pristine = try #require(DiskImagesFixture.pristine, DiskImagesFixture.missing)
|
||||
let data = try Data(contentsOf: pristine)
|
||||
let site = try CFWDiskimagesiod.locate(in: data)
|
||||
|
||||
// Shipped diskimagesiod is stripped, so the symbol-table anchor misses
|
||||
// and the ObjC metadata walk is what answers.
|
||||
#expect(site.anchor == .relativeMethodList)
|
||||
#expect(MachOParser.findSymbol(containing: CFWDiskimagesiod.symbolFragment, in: data) == nil)
|
||||
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
let text = try #require(sections["__TEXT,__text"])
|
||||
#expect(site.fileOffset >= Int(text.fileOffset))
|
||||
#expect(site.fileOffset + 8 <= Int(text.fileOffset) + Int(text.size))
|
||||
|
||||
// The VA and the file offset have to agree through the segment table.
|
||||
let va = try #require(site.virtualAddress)
|
||||
let segments = MachOParser.parseSegments(from: data)
|
||||
#expect(MachOParser.vaToFileOffset(va, segments: segments) == site.fileOffset)
|
||||
|
||||
// A real ObjC method prologue, not yet patched.
|
||||
#expect(!site.isAlreadyPatched)
|
||||
#expect(CFWDiskimagesiod.disassemblyText(of: site.original, at: va)
|
||||
.hasPrefix("pacibsp; stp"))
|
||||
}
|
||||
|
||||
@Test("the Python's own anchor walk agrees on the same offset")
|
||||
func agreesWithPythonAnchor() throws {
|
||||
let pristine = try #require(DiskImagesFixture.pristine, DiskImagesFixture.missing)
|
||||
let site = try CFWDiskimagesiod.locate(in: try Data(contentsOf: pristine))
|
||||
|
||||
// The Python prints the offset it resolved; both walks must land on it.
|
||||
let clone = try DiskImagesFixture.clone(named: "anchor")
|
||||
defer { DiskImagesFixture.discard(clone) }
|
||||
let output = try DiskImagesFixture.runPython(on: clone).stdout
|
||||
let expected = "IMP va:0x\(String(site.virtualAddress ?? 0, radix: 16, uppercase: true)) "
|
||||
+ "foff:0x\(String(site.fileOffset, radix: 16, uppercase: true))"
|
||||
#expect(output.contains(expected), "python said:\n\(output)")
|
||||
}
|
||||
|
||||
@Test("the selector names exactly one implementation in the image")
|
||||
func selectorIsUnique() throws {
|
||||
let pristine = try #require(DiskImagesFixture.pristine, DiskImagesFixture.missing)
|
||||
let data = try Data(contentsOf: pristine)
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
|
||||
let selectorVA = try #require(
|
||||
CFWDiskimagesiod.selectorStringVA(in: data, sections: sections)
|
||||
)
|
||||
let selrefs = try #require(sections["__DATA,__objc_selrefs"])
|
||||
let selrefVA = try #require(CFWDiskimagesiod.selectorReferenceVA(
|
||||
in: data,
|
||||
selrefs: selrefs,
|
||||
selectorVA: selectorVA,
|
||||
imageBase: CFWDiskimagesiod.imageBase(sections)
|
||||
))
|
||||
|
||||
let methlist = try #require(sections["__TEXT,__objc_methlist"])
|
||||
let imps = CFWDiskimagesiod.relativeMethodListIMPs(
|
||||
in: data,
|
||||
section: methlist,
|
||||
naming: [selectorVA, selrefVA]
|
||||
)
|
||||
#expect(imps.count == 1)
|
||||
#expect(imps.first == (try CFWDiskimagesiod.locate(in: data)).virtualAddress)
|
||||
}
|
||||
|
||||
@Test("the strided fallback scan lands on the same IMP as the structural walk")
|
||||
func scanFallbackAgreesWithStructuralWalk() throws {
|
||||
let pristine = try #require(DiskImagesFixture.pristine, DiskImagesFixture.missing)
|
||||
let data = try Data(contentsOf: pristine)
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
|
||||
let selectorVA = try #require(
|
||||
CFWDiskimagesiod.selectorStringVA(in: data, sections: sections)
|
||||
)
|
||||
let selrefs = try #require(sections["__DATA,__objc_selrefs"])
|
||||
let selrefVA = try #require(CFWDiskimagesiod.selectorReferenceVA(
|
||||
in: data,
|
||||
selrefs: selrefs,
|
||||
selectorVA: selectorVA,
|
||||
imageBase: CFWDiskimagesiod.imageBase(sections)
|
||||
))
|
||||
let methlist = try #require(sections["__TEXT,__objc_methlist"])
|
||||
let targets: Set<UInt64> = [selectorVA, selrefVA]
|
||||
|
||||
// The Python only ever does the strided scan. Both walks over the same
|
||||
// section have to name the same single implementation, or the two
|
||||
// implementations would diverge on some other firmware even though they
|
||||
// agree on this one.
|
||||
let structural = CFWDiskimagesiod.relativeMethodListIMPs(
|
||||
in: data,
|
||||
section: methlist,
|
||||
naming: targets
|
||||
)
|
||||
let strided = CFWDiskimagesiod.scanRelativeMethodEntryIMPs(
|
||||
in: data,
|
||||
section: methlist,
|
||||
naming: targets
|
||||
)
|
||||
#expect(structural == strided)
|
||||
#expect(strided.count == 1)
|
||||
}
|
||||
|
||||
@Test("a binary without DIDiskArb is refused, not guessed at")
|
||||
func unrelatedBinaryIsRefused() throws {
|
||||
let unrelated = try #require(DiskImagesFixture.unrelated, DiskImagesFixture.missing)
|
||||
let data = try Data(contentsOf: unrelated)
|
||||
#expect(throws: PatcherError.self) {
|
||||
try CFWDiskimagesiod.locate(in: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parity against the Python
|
||||
|
||||
@Suite(
|
||||
"diskimagesiod parity",
|
||||
.enabled(if: DiskImagesFixture.runs, DiskImagesFixture.missing),
|
||||
.serialized
|
||||
)
|
||||
struct CFWDiskimagesiodParityTests {
|
||||
@Test("Swift and Python produce byte-identical binaries, one site each")
|
||||
func byteForByteParity() throws {
|
||||
let swiftClone = try DiskImagesFixture.clone(named: "swift")
|
||||
let pythonClone = try DiskImagesFixture.clone(named: "python")
|
||||
defer { DiskImagesFixture.discard(swiftClone, pythonClone) }
|
||||
|
||||
let report = try CFWDiskimagesiod.patch(fileAt: swiftClone, log: nil)
|
||||
#expect(report.outcome == .patched)
|
||||
#expect(report.sitesWritten == 1)
|
||||
// Off by default: `cfw_install.sh` re-signs with ldid straight after,
|
||||
// and the Python does not re-attest either.
|
||||
#expect(report.rehashes.isEmpty)
|
||||
|
||||
try DiskImagesFixture.runPython(on: pythonClone)
|
||||
|
||||
let differences = try DiskImagesComparison.differences(between: swiftClone, and: pythonClone)
|
||||
#expect(differences.isEmpty, "first differing offsets: \(differences.map { String($0, radix: 16) })")
|
||||
}
|
||||
|
||||
@Test("the recorded write names the site, the bytes and both disassemblies")
|
||||
func recordDescribesTheSite() throws {
|
||||
let clone = try DiskImagesFixture.clone(named: "record")
|
||||
defer { DiskImagesFixture.discard(clone) }
|
||||
|
||||
let report = try CFWDiskimagesiod.patch(fileAt: clone, log: nil)
|
||||
let record = try #require(report.record)
|
||||
|
||||
#expect(record.patchID == "diskimagesiod.is_mount_complete")
|
||||
#expect(record.component == "diskimagesiod")
|
||||
#expect(record.fileOffset == report.site.fileOffset)
|
||||
#expect(record.virtualAddress == report.site.virtualAddress)
|
||||
#expect(record.originalBytes == report.site.original)
|
||||
#expect(record.patchedBytes == CFWDiskimagesiod.replacement)
|
||||
#expect(record.afterDisasm == "mov x0, #1; ret")
|
||||
#expect(record.beforeDisasm.hasPrefix("pacibsp"))
|
||||
#expect(record.patchDescription.contains("isMountCompleteWithExpectedCount:diskTracker:"))
|
||||
|
||||
// What landed on disk is what the record claims.
|
||||
let patched = try Data(contentsOf: clone)
|
||||
let range = record.fileOffset ..< record.fileOffset + record.patchedBytes.count
|
||||
#expect(patched[range] == record.patchedBytes)
|
||||
}
|
||||
|
||||
@Test("only the eight patched bytes differ from the pristine binary")
|
||||
func onlyTheSiteChanges() throws {
|
||||
let clone = try DiskImagesFixture.clone(named: "minimal")
|
||||
defer { DiskImagesFixture.discard(clone) }
|
||||
let pristine = try #require(DiskImagesFixture.pristine, DiskImagesFixture.missing)
|
||||
|
||||
let report = try CFWDiskimagesiod.patch(fileAt: clone, log: nil)
|
||||
let differences = try DiskImagesComparison.differences(
|
||||
between: pristine,
|
||||
and: clone,
|
||||
limit: 64
|
||||
)
|
||||
#expect(differences.allSatisfy {
|
||||
(report.site.fileOffset ..< report.site.fileOffset + 8).contains($0)
|
||||
})
|
||||
#expect(!differences.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Re-signing
|
||||
|
||||
@Suite(
|
||||
"diskimagesiod re-attestation",
|
||||
.enabled(if: DiskImagesFixture.runs, DiskImagesFixture.missing),
|
||||
.serialized
|
||||
)
|
||||
struct CFWDiskimagesiodReattestTests {
|
||||
@Test("the fixture really does have the short tail slot this path regressed on")
|
||||
func fixtureHasShortTail() throws {
|
||||
let pristine = try #require(DiskImagesFixture.pristine, DiskImagesFixture.missing)
|
||||
let data = try Data(contentsOf: pristine)
|
||||
let directories = try #require(CFWMachOCodeSignature.codeDirectories(in: data))
|
||||
let directory = try #require(directories.first)
|
||||
|
||||
#expect(directory.hashType == CFWMachOCodeSignature.hashTypeSHA256)
|
||||
#expect(directory.pageSize == 4096)
|
||||
#expect(directory.codeLimit % directory.pageSize != 0, "expected a non-page-aligned codeLimit")
|
||||
|
||||
let tail = try #require(directory.slotRange(directory.codeSlotCount - 1))
|
||||
#expect(tail.count < directory.pageSize)
|
||||
#expect(tail.upperBound == directory.codeLimit)
|
||||
}
|
||||
|
||||
@Test("`codesign -v` rejects the un-attested patch and accepts the attested one")
|
||||
func codesignAgrees() throws {
|
||||
let bare = try DiskImagesFixture.clone(named: "bare")
|
||||
let attested = try DiskImagesFixture.clone(named: "attested")
|
||||
defer { DiskImagesFixture.discard(bare, attested) }
|
||||
|
||||
try CFWDiskimagesiod.patch(fileAt: bare, log: nil)
|
||||
#expect(try DiskImagesFixture.codesignVerify(bare).status != 0)
|
||||
|
||||
let report = try CFWDiskimagesiod.patch(fileAt: attested, reattest: true, log: nil)
|
||||
#expect(report.outcome == .patched)
|
||||
#expect(report.rehashes.count == 1)
|
||||
|
||||
let slot = try #require(report.rehashes.first)
|
||||
#expect(slot.pageIndex == report.site.fileOffset / slot.pageSize)
|
||||
#expect(!slot.isTailSlot)
|
||||
#expect(slot.before != slot.after)
|
||||
|
||||
let verification = try DiskImagesFixture.codesignVerify(attested)
|
||||
#expect(verification.status == 0, "codesign said: \(verification.stderr)")
|
||||
}
|
||||
|
||||
@Test("the re-attested bytes are the Python re-attest's bytes")
|
||||
func reattestMatchesPython() throws {
|
||||
let swiftClone = try DiskImagesFixture.clone(named: "swift-attested")
|
||||
let pythonClone = try DiskImagesFixture.clone(named: "python-attested")
|
||||
defer { DiskImagesFixture.discard(swiftClone, pythonClone) }
|
||||
|
||||
let report = try CFWDiskimagesiod.patch(fileAt: swiftClone, reattest: true, log: nil)
|
||||
|
||||
try DiskImagesFixture.runPython(on: pythonClone)
|
||||
try DiskImagesFixture.runPythonReattest(
|
||||
on: pythonClone,
|
||||
offsets: [report.site.fileOffset, report.site.fileOffset + 7]
|
||||
)
|
||||
|
||||
let differences = try DiskImagesComparison.differences(between: swiftClone, and: pythonClone)
|
||||
#expect(differences.isEmpty, "first differing offsets: \(differences.map { String($0, radix: 16) })")
|
||||
}
|
||||
|
||||
@Test("a short-tail slot is hashed to codeLimit, not to the end of its page")
|
||||
func tailSlotStopsAtCodeLimit() throws {
|
||||
let swiftClone = try DiskImagesFixture.clone(named: "swift-tail")
|
||||
let pythonClone = try DiskImagesFixture.clone(named: "python-tail")
|
||||
defer { DiskImagesFixture.discard(swiftClone, pythonClone) }
|
||||
|
||||
// Land a byte inside the last, short slot. This is synthetic — the real
|
||||
// patch site is nowhere near — and it is the only way to make both
|
||||
// implementations recompute the slot whose length is not a page.
|
||||
var data = try Data(contentsOf: swiftClone)
|
||||
let directory = try #require(CFWMachOCodeSignature.codeDirectories(in: data)?.first)
|
||||
let tail = try #require(directory.slotRange(directory.codeSlotCount - 1))
|
||||
let victim = tail.lowerBound + tail.count / 2
|
||||
data[victim] = data[victim] ^ 0xFF
|
||||
try data.write(to: swiftClone)
|
||||
try data.write(to: pythonClone)
|
||||
|
||||
let rehashes = try CFWMachOCodeSignature.reattest(fileAt: swiftClone, modifiedOffsets: [victim])
|
||||
let slot = try #require(rehashes.first)
|
||||
#expect(slot.isTailSlot)
|
||||
#expect(slot.hashedLength == tail.count)
|
||||
#expect(slot.pageEnd == directory.codeLimit)
|
||||
|
||||
try DiskImagesFixture.runPythonReattest(on: pythonClone, offsets: [victim])
|
||||
let differences = try DiskImagesComparison.differences(between: swiftClone, and: pythonClone)
|
||||
#expect(differences.isEmpty, "first differing offsets: \(differences.map { String($0, radix: 16) })")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idempotence
|
||||
|
||||
@Suite(
|
||||
"diskimagesiod idempotence",
|
||||
.enabled(if: DiskImagesFixture.runs, DiskImagesFixture.missing),
|
||||
.serialized
|
||||
)
|
||||
struct CFWDiskimagesiodIdempotenceTests {
|
||||
@Test("a second Swift run recognises its own output and writes nothing")
|
||||
func secondRunIsANoOp() throws {
|
||||
let clone = try DiskImagesFixture.clone(named: "twice")
|
||||
defer { DiskImagesFixture.discard(clone) }
|
||||
|
||||
try CFWDiskimagesiod.patch(fileAt: clone, log: nil)
|
||||
let afterFirst = try Data(contentsOf: clone)
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: clone.path)
|
||||
|
||||
let second = try CFWDiskimagesiod.patch(fileAt: clone, log: nil)
|
||||
#expect(second.outcome == .alreadyPatched)
|
||||
#expect(second.sitesWritten == 0)
|
||||
#expect(second.record == nil)
|
||||
#expect(second.site.isAlreadyPatched)
|
||||
#expect(try Data(contentsOf: clone) == afterFirst)
|
||||
|
||||
// Nothing was written at all, so even the modification time stands.
|
||||
let after = try FileManager.default.attributesOfItem(atPath: clone.path)
|
||||
#expect(after[.modificationDate] as? Date == attributes[.modificationDate] as? Date)
|
||||
}
|
||||
|
||||
@Test("a second run with re-attestation leaves the slot hashes alone")
|
||||
func secondAttestedRunIsANoOp() throws {
|
||||
let clone = try DiskImagesFixture.clone(named: "twice-attested")
|
||||
defer { DiskImagesFixture.discard(clone) }
|
||||
|
||||
try CFWDiskimagesiod.patch(fileAt: clone, reattest: true, log: nil)
|
||||
let afterFirst = try Data(contentsOf: clone)
|
||||
|
||||
let second = try CFWDiskimagesiod.patch(fileAt: clone, reattest: true, log: nil)
|
||||
#expect(second.outcome == .alreadyPatched)
|
||||
#expect(second.sitesWritten == 0)
|
||||
#expect(second.rehashes.isEmpty, "the stored slot hashes were already current")
|
||||
#expect(try Data(contentsOf: clone) == afterFirst)
|
||||
#expect(try DiskImagesFixture.codesignVerify(clone).status == 0)
|
||||
}
|
||||
|
||||
@Test("Python over a Swift-patched binary is a no-op, and the reverse too")
|
||||
func crossImplementationRerunsAgree() throws {
|
||||
let swiftFirst = try DiskImagesFixture.clone(named: "swift-then-python")
|
||||
let pythonFirst = try DiskImagesFixture.clone(named: "python-then-swift")
|
||||
defer { DiskImagesFixture.discard(swiftFirst, pythonFirst) }
|
||||
|
||||
try CFWDiskimagesiod.patch(fileAt: swiftFirst, log: nil)
|
||||
let afterSwift = try Data(contentsOf: swiftFirst)
|
||||
try DiskImagesFixture.runPython(on: swiftFirst)
|
||||
#expect(try Data(contentsOf: swiftFirst) == afterSwift)
|
||||
|
||||
try DiskImagesFixture.runPython(on: pythonFirst)
|
||||
let afterPython = try Data(contentsOf: pythonFirst)
|
||||
let report = try CFWDiskimagesiod.patch(fileAt: pythonFirst, log: nil)
|
||||
#expect(report.outcome == .alreadyPatched)
|
||||
#expect(try Data(contentsOf: pythonFirst) == afterPython)
|
||||
#expect(try DiskImagesComparison.identical(swiftFirst, pythonFirst))
|
||||
}
|
||||
|
||||
@Test("a dry run locates the site and writes nothing")
|
||||
func dryRunWritesNothing() throws {
|
||||
let clone = try DiskImagesFixture.clone(named: "dry")
|
||||
defer { DiskImagesFixture.discard(clone) }
|
||||
let pristine = try #require(DiskImagesFixture.pristine, DiskImagesFixture.missing)
|
||||
|
||||
let report = try CFWDiskimagesiod.patch(fileAt: clone, reattest: true, dryRun: true, log: nil)
|
||||
#expect(report.outcome == .wouldPatch)
|
||||
#expect(report.sitesWritten == 0)
|
||||
#expect(report.rehashes.isEmpty)
|
||||
#expect(report.site.fileOffset > 0)
|
||||
#expect(try DiskImagesComparison.identical(pristine, clone))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// CFWJetsamTests.swift — `CFWJetsamPatcher` against the reference it replaces.
|
||||
//
|
||||
// The bar for this port is not "the test passes". It is that the Swift patcher
|
||||
// and `scripts/patchers/cfw_patch_jetsam.py` produce the same bytes from the
|
||||
// same input, on the real `/sbin/launchd` out of iOS 27.0 / 24A435 — so the
|
||||
// comparison tests below run BOTH implementations, each over its own clone of
|
||||
// `ipsws/ref_extract/macho_pristine/launchd`, and diff the results.
|
||||
//
|
||||
// `codesign -v` is the second, fully independent reference: the patcher's
|
||||
// `reattest: true` mode has to leave a binary that verifies, and the slot hash
|
||||
// it writes has to equal the one the Python's own `cfw_macho_codesign.py`
|
||||
// computes over the same patched bytes.
|
||||
//
|
||||
// The reference is on its way out — plan P1.5 deletes `scripts/patchers/`, and
|
||||
// `ipsws/` is not in the repo — so every test that needs one is gated on it
|
||||
// still being there and skips rather than fails when it is not. The pure
|
||||
// decode/encode tests below have no such dependency and always run.
|
||||
//
|
||||
// Set `VPHONE_JETSAM_ARTIFACTS=<dir>` to keep each run's inputs and outputs for
|
||||
// inspection from a shell; without it they land in a temporary directory.
|
||||
|
||||
import Capstone
|
||||
@testable import FirmwarePatcher
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
enum JetsamFixture {
|
||||
/// The package root, derived from this file rather than the working
|
||||
/// directory, which `swift test` does not promise.
|
||||
static let repositoryRoot = URL(filePath: #filePath)
|
||||
.deletingLastPathComponent() // FirmwarePatcherTests
|
||||
.deletingLastPathComponent() // tests
|
||||
.deletingLastPathComponent() // <root>
|
||||
|
||||
/// The real, ad-hoc signed, thin arm64e `/sbin/launchd`.
|
||||
static let pristineLaunchd = repositoryRoot
|
||||
.appending(path: "ipsws/ref_extract/macho_pristine/launchd")
|
||||
static let python = repositoryRoot.appending(path: ".venv/bin/python3")
|
||||
static let pythonCFW = repositoryRoot.appending(path: "scripts/patchers/cfw.py")
|
||||
static let pythonCodeSign = repositoryRoot.appending(path: "scripts/patchers/cfw_macho_codesign.py")
|
||||
static let scriptsDirectory = repositoryRoot.appending(path: "scripts")
|
||||
static let codesign = URL(filePath: "/usr/bin/codesign")
|
||||
|
||||
static func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) }
|
||||
|
||||
static var hasLaunchd: Bool { exists(pristineLaunchd) }
|
||||
static var hasPythonReference: Bool { exists(python) && exists(pythonCFW) }
|
||||
static var hasLaunchdAndPython: Bool { hasLaunchd && hasPythonReference }
|
||||
static var hasLaunchdAndCodesign: Bool { hasLaunchd && exists(codesign) }
|
||||
|
||||
/// A directory for one test's artifacts. `VPHONE_JETSAM_ARTIFACTS` pins it
|
||||
/// so a shell can look at what a run produced.
|
||||
static func workDirectory(_ name: String) throws -> URL {
|
||||
let base = ProcessInfo.processInfo.environment["VPHONE_JETSAM_ARTIFACTS"]
|
||||
.map { URL(filePath: $0) } ?? URL(filePath: NSTemporaryDirectory())
|
||||
let directory = base.appending(path: "CFWJetsamTests-\(name)")
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// A private, writable clone of the pristine `launchd`.
|
||||
static func launchdCopy(named name: String, in directory: URL) throws -> URL {
|
||||
let destination = directory.appending(path: name)
|
||||
try FileManager.default.copyItem(at: pristineLaunchd, to: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func run(
|
||||
_ tool: URL,
|
||||
_ arguments: [String],
|
||||
workingDirectory: URL? = nil
|
||||
) throws -> (status: Int32, output: String) {
|
||||
let process = Process()
|
||||
process.executableURL = tool
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
try process.run()
|
||||
let output = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return (process.terminationStatus, String(decoding: output, as: UTF8.self))
|
||||
}
|
||||
|
||||
/// `cfw.py patch-launchd-jetsam <file>` — the reference implementation.
|
||||
@discardableResult
|
||||
static func runPythonJetsam(on file: URL) throws -> (status: Int32, output: String) {
|
||||
try run(
|
||||
python,
|
||||
["patchers/cfw.py", "patch-launchd-jetsam", file.path],
|
||||
workingDirectory: scriptsDirectory
|
||||
)
|
||||
}
|
||||
|
||||
/// The hex number the reference prints straight after `marker`, so the
|
||||
/// expected values come out of the reference's own stdout instead of being
|
||||
/// written down here and going stale with the next firmware.
|
||||
static func hexAfter(_ marker: String, in output: String) -> Int? {
|
||||
guard let range = output.range(of: marker) else { return nil }
|
||||
return Int(String(output[range.upperBound...].prefix { $0.isHexDigit }), radix: 16)
|
||||
}
|
||||
|
||||
/// The first byte offset at which two files differ, or nil when equal.
|
||||
static func firstDifference(_ lhs: Data, _ rhs: Data) -> Int? {
|
||||
if lhs.count != rhs.count { return min(lhs.count, rhs.count) }
|
||||
for index in 0 ..< lhs.count where lhs[index] != rhs[index] { return index }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Against the Python reference
|
||||
|
||||
@Suite("launchd jetsam guard — against the Python reference")
|
||||
struct CFWJetsamReferenceTests {
|
||||
/// The whole point of the port: same input, same bytes out.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchdAndPython))
|
||||
func matchesPythonByteForByte() throws {
|
||||
let work = try JetsamFixture.workDirectory("byte-equivalence")
|
||||
let swiftTarget = try JetsamFixture.launchdCopy(named: "launchd.swift", in: work)
|
||||
let pythonTarget = try JetsamFixture.launchdCopy(named: "launchd.python", in: work)
|
||||
|
||||
let outcome = try CFWJetsamPatcher.patch(fileAt: swiftTarget, log: nil)
|
||||
#expect(outcome.verdict == .patched)
|
||||
|
||||
let reference = try JetsamFixture.runPythonJetsam(on: pythonTarget)
|
||||
#expect(reference.status == 0, "reference patcher failed:\n\(reference.output)")
|
||||
|
||||
let swiftBytes = try Data(contentsOf: swiftTarget)
|
||||
let pythonBytes = try Data(contentsOf: pythonTarget)
|
||||
let difference = JetsamFixture.firstDifference(swiftBytes, pythonBytes)
|
||||
#expect(
|
||||
difference == nil,
|
||||
"Swift and Python diverge at offset \(difference.map { "0x" + String($0, radix: 16) } ?? "-")"
|
||||
)
|
||||
|
||||
// And the only thing that moved is inside the instruction the record
|
||||
// names. Not every one of the four bytes has to differ: on this image
|
||||
// `cbz w0, #0xfaec` is A0 02 00 34 and `b #0xfaec` is 15 00 00 14, so
|
||||
// byte 2 is 0x00 either way. Containment is the real claim — the whole
|
||||
// instruction was rewritten and nothing outside it was.
|
||||
let pristine = try Data(contentsOf: JetsamFixture.pristineLaunchd)
|
||||
let site = outcome.gateOffset ..< outcome.gateOffset + 4
|
||||
let changed = (0 ..< pristine.count).filter { pristine[$0] != swiftBytes[$0] }
|
||||
#expect(!changed.isEmpty)
|
||||
#expect(changed.allSatisfy(site.contains), "bytes changed outside the gate: \(changed)")
|
||||
#expect(Data(swiftBytes[site]) == outcome.record?.patchedBytes)
|
||||
#expect(Data(pristine[site]) == outcome.record?.originalBytes)
|
||||
}
|
||||
|
||||
/// The gate the reference reports is the gate this finds. Checked against
|
||||
/// its stdout rather than a hard-coded offset, so the day the firmware
|
||||
/// moves, this moves with it.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchdAndPython))
|
||||
func agreesWithPythonOnTheSite() throws {
|
||||
let work = try JetsamFixture.workDirectory("site-agreement")
|
||||
let pythonTarget = try JetsamFixture.launchdCopy(named: "launchd.python", in: work)
|
||||
let reference = try JetsamFixture.runPythonJetsam(on: pythonTarget)
|
||||
#expect(reference.status == 0)
|
||||
|
||||
// Both intermediate anchors the reference prints, not just its answer,
|
||||
// so a port that agreed on the gate by luck would still be caught:
|
||||
// " xref at foff:0xFB0C"
|
||||
// " [+] Patched at 0xFA98: jetsam panic guard bypass"
|
||||
let referenceXref = try #require(JetsamFixture.hexAfter("xref at foff:0x", in: reference.output))
|
||||
let referenceGate = try #require(JetsamFixture.hexAfter("[+] Patched at 0x", in: reference.output))
|
||||
|
||||
let data = try Data(contentsOf: JetsamFixture.pristineLaunchd)
|
||||
let image = try CFWJetsamPatcher.Image(data: data)
|
||||
let site = try #require(try CFWJetsamPatcher.locate(in: image))
|
||||
#expect(site.xrefOffset == referenceXref)
|
||||
#expect(site.gateOffset == referenceGate)
|
||||
|
||||
var patchable = data
|
||||
let outcome = try CFWJetsamPatcher.patch(&patchable, dryRun: true, log: nil)
|
||||
#expect(outcome.gateOffset == referenceGate)
|
||||
#expect(outcome.verdict == .wouldPatch)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idempotence
|
||||
|
||||
@Suite("launchd jetsam guard — idempotence")
|
||||
struct CFWJetsamIdempotenceTests {
|
||||
/// The bug this patcher must not have. The reference re-patches a second,
|
||||
/// different branch on an already-patched binary; this one recognises its
|
||||
/// own work and stops.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchd))
|
||||
func secondRunChangesNothing() throws {
|
||||
let work = try JetsamFixture.workDirectory("idempotence")
|
||||
let target = try JetsamFixture.launchdCopy(named: "launchd", in: work)
|
||||
|
||||
let first = try CFWJetsamPatcher.patch(fileAt: target, log: nil)
|
||||
#expect(first.verdict == .patched)
|
||||
let afterFirst = try Data(contentsOf: target)
|
||||
|
||||
let second = try CFWJetsamPatcher.patch(fileAt: target, log: nil)
|
||||
#expect(second.verdict == .alreadyPatched)
|
||||
#expect(second.gateOffset == first.gateOffset)
|
||||
#expect(second.returnBlockOffset == first.returnBlockOffset)
|
||||
#expect(second.record == nil)
|
||||
|
||||
let afterSecond = try Data(contentsOf: target)
|
||||
#expect(JetsamFixture.firstDifference(afterFirst, afterSecond) == nil)
|
||||
|
||||
// A third pass must not drift either.
|
||||
let third = try CFWJetsamPatcher.patch(fileAt: target, log: nil)
|
||||
#expect(third.verdict == .alreadyPatched)
|
||||
let afterThird = try Data(contentsOf: target)
|
||||
#expect(JetsamFixture.firstDifference(afterFirst, afterThird) == nil)
|
||||
}
|
||||
|
||||
/// Where the reference goes wrong, and proof that it does: run the Python
|
||||
/// twice and it lands a *second* site the pristine image never had patched.
|
||||
/// Recorded here so the divergence is a measurement, not a claim.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchdAndPython))
|
||||
func referenceIsNotIdempotentAndThisIs() throws {
|
||||
let work = try JetsamFixture.workDirectory("reference-double-apply")
|
||||
let pythonTarget = try JetsamFixture.launchdCopy(named: "launchd.python", in: work)
|
||||
let swiftTarget = try JetsamFixture.launchdCopy(named: "launchd.swift", in: work)
|
||||
|
||||
try JetsamFixture.runPythonJetsam(on: pythonTarget)
|
||||
let pythonOnce = try Data(contentsOf: pythonTarget)
|
||||
try JetsamFixture.runPythonJetsam(on: pythonTarget)
|
||||
let pythonTwice = try Data(contentsOf: pythonTarget)
|
||||
|
||||
try CFWJetsamPatcher.patch(fileAt: swiftTarget, log: nil)
|
||||
let swiftOnce = try Data(contentsOf: swiftTarget)
|
||||
try CFWJetsamPatcher.patch(fileAt: swiftTarget, log: nil)
|
||||
let swiftTwice = try Data(contentsOf: swiftTarget)
|
||||
|
||||
#expect(JetsamFixture.firstDifference(pythonOnce, swiftOnce) == nil)
|
||||
#expect(
|
||||
JetsamFixture.firstDifference(pythonOnce, pythonTwice) != nil,
|
||||
"the reference became idempotent — re-check what this port has to preserve"
|
||||
)
|
||||
#expect(JetsamFixture.firstDifference(swiftOnce, swiftTwice) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Re-attestation
|
||||
|
||||
@Suite("launchd jetsam guard — signature re-attestation")
|
||||
struct CFWJetsamSignatureTests {
|
||||
/// Default is the reference's behaviour: the four bytes and nothing else,
|
||||
/// because every call site re-signs with `ldid` straight afterwards.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchdAndCodesign))
|
||||
func defaultLeavesTheSignatureStale() throws {
|
||||
let work = try JetsamFixture.workDirectory("stale-signature")
|
||||
let target = try JetsamFixture.launchdCopy(named: "launchd", in: work)
|
||||
|
||||
let outcome = try CFWJetsamPatcher.patch(fileAt: target, log: nil)
|
||||
#expect(outcome.rehashes.isEmpty)
|
||||
|
||||
let verify = try JetsamFixture.run(JetsamFixture.codesign, ["-v", target.path])
|
||||
#expect(verify.status != 0, "a patch with no re-attestation must not still verify")
|
||||
}
|
||||
|
||||
/// `reattest: true` has to leave a binary `codesign` accepts — one page's
|
||||
/// slot hash, recomputed, tail slot included.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchdAndCodesign))
|
||||
func reattestedBinaryVerifies() throws {
|
||||
let work = try JetsamFixture.workDirectory("reattested")
|
||||
let target = try JetsamFixture.launchdCopy(named: "launchd", in: work)
|
||||
|
||||
let outcome = try CFWJetsamPatcher.patch(fileAt: target, reattest: true, log: nil)
|
||||
#expect(outcome.verdict == .patched)
|
||||
#expect(outcome.rehashes.count == 1)
|
||||
|
||||
let rehash = try #require(outcome.rehashes.first)
|
||||
let patched = try Data(contentsOf: target)
|
||||
let directory = try #require(
|
||||
CFWMachOCodeSignature.codeDirectories(in: patched)?
|
||||
.first { $0.hashType == CFWMachOCodeSignature.hashTypeSHA256 }
|
||||
)
|
||||
#expect(rehash.pageIndex == outcome.gateOffset / directory.pageSize)
|
||||
|
||||
let verify = try JetsamFixture.run(JetsamFixture.codesign, ["-v", target.path])
|
||||
#expect(verify.status == 0, "codesign -v rejected the re-attested binary:\n\(verify.output)")
|
||||
}
|
||||
|
||||
/// The slot hash itself, against the Python's independent re-signer run
|
||||
/// over the Python's own patched bytes. Two implementations, one number.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchdAndPython))
|
||||
func slotHashMatchesThePythonResigner() throws {
|
||||
guard JetsamFixture.exists(JetsamFixture.pythonCodeSign) else { return }
|
||||
let work = try JetsamFixture.workDirectory("slot-hash")
|
||||
let swiftTarget = try JetsamFixture.launchdCopy(named: "launchd.swift", in: work)
|
||||
let pythonTarget = try JetsamFixture.launchdCopy(named: "launchd.python", in: work)
|
||||
|
||||
let outcome = try CFWJetsamPatcher.patch(fileAt: swiftTarget, reattest: true, log: nil)
|
||||
#expect(outcome.verdict == .patched)
|
||||
|
||||
try JetsamFixture.runPythonJetsam(on: pythonTarget)
|
||||
let resign = try JetsamFixture.run(JetsamFixture.python, [
|
||||
"-c",
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, \(quoted(JetsamFixture.scriptsDirectory.path)))
|
||||
from patchers.cfw_macho_codesign import reattest_modified_offsets
|
||||
reattest_modified_offsets(
|
||||
\(quoted(pythonTarget.path)), [\(outcome.gateOffset)], verbose=False
|
||||
)
|
||||
""",
|
||||
])
|
||||
#expect(resign.status == 0, "reference re-signer failed:\n\(resign.output)")
|
||||
|
||||
let swiftBytes = try Data(contentsOf: swiftTarget)
|
||||
let pythonBytes = try Data(contentsOf: pythonTarget)
|
||||
#expect(JetsamFixture.firstDifference(swiftBytes, pythonBytes) == nil)
|
||||
}
|
||||
|
||||
private func quoted(_ path: String) -> String {
|
||||
"\"" + path.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"") + "\""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anchoring, without a reference
|
||||
|
||||
@Suite("launchd jetsam guard — anchoring")
|
||||
struct CFWJetsamAnchoringTests {
|
||||
/// Every step of the reveal lands where the disassembly says it should:
|
||||
/// the xref is inside the function, the gate is inside the function and
|
||||
/// before the xref, and the gate's target really does return.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchd))
|
||||
func revealStepsAreSelfConsistent() throws {
|
||||
let data = try Data(contentsOf: JetsamFixture.pristineLaunchd)
|
||||
let image = try CFWJetsamPatcher.Image(data: data)
|
||||
let site = try #require(try CFWJetsamPatcher.locate(in: image))
|
||||
|
||||
#expect(CFWJetsamPatcher.panicStringAnchors.contains(site.anchor))
|
||||
#expect(!site.isAlreadyPatched)
|
||||
#expect(site.functionOffset <= site.gateOffset)
|
||||
#expect(site.gateOffset < site.xrefOffset)
|
||||
#expect(image.isInText(site.xrefOffset))
|
||||
#expect(image.isInText(site.returnBlockOffset))
|
||||
#expect(CFWJetsamPatcher.isReturnBlock(site.returnBlockOffset, in: image))
|
||||
|
||||
// The function bound is a real prologue, not the blind fallback.
|
||||
#expect(data.loadLE(UInt32.self, at: site.functionOffset) == ARM64.pacibspU32)
|
||||
|
||||
// The anchor string starts where the xref computes it to start.
|
||||
let page = CFWJetsamPatcher.adrpPage(
|
||||
data.loadLE(UInt32.self, at: site.xrefOffset),
|
||||
at: image.virtualAddress(ofTextOffset: site.xrefOffset)
|
||||
)
|
||||
#expect(site.stringVMA & ~0xFFF == page)
|
||||
|
||||
// The gate is a conditional branch, and it branches to the return block.
|
||||
let disassembler = ARM64Disassembler()
|
||||
let gate = try #require(disassembler.disassembleOne(in: data, at: site.gateOffset))
|
||||
#expect(CFWJetsamPatcher.conditionalBranchMnemonics.contains(gate.mnemonic))
|
||||
#expect(CFWJetsamPatcher.branchTarget(gate) == site.returnBlockOffset)
|
||||
}
|
||||
|
||||
/// The gate is the *earliest* qualifying branch in the function — any later
|
||||
/// one leaves more of the jetsam path running.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchd))
|
||||
func gateIsTheEarliestQualifyingBranch() throws {
|
||||
let data = try Data(contentsOf: JetsamFixture.pristineLaunchd)
|
||||
let image = try CFWJetsamPatcher.Image(data: data)
|
||||
let site = try #require(try CFWJetsamPatcher.locate(in: image))
|
||||
|
||||
let disassembler = ARM64Disassembler()
|
||||
for offset in stride(from: site.functionOffset, to: site.gateOffset, by: 4) {
|
||||
guard let insn = disassembler.disassembleOne(in: data, at: offset),
|
||||
CFWJetsamPatcher.conditionalBranchMnemonics.contains(insn.mnemonic)
|
||||
|| insn.mnemonic == "b",
|
||||
let target = CFWJetsamPatcher.branchTarget(insn),
|
||||
image.isInText(target)
|
||||
else { continue }
|
||||
#expect(
|
||||
!CFWJetsamPatcher.isReturnBlock(target, in: image),
|
||||
"0x\(String(offset, radix: 16)) qualifies and is earlier than the chosen gate"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewriting the gate is what makes the second pass a no-op, and the scan
|
||||
/// has to see that on its own — the site it would pick on a re-run is a
|
||||
/// different, later branch, so checking the picked site afterwards would
|
||||
/// not catch it.
|
||||
@Test(.enabled(if: JetsamFixture.hasLaunchd))
|
||||
func patchedShapeIsRecognisedInPlace() throws {
|
||||
var data = try Data(contentsOf: JetsamFixture.pristineLaunchd)
|
||||
let outcome = try CFWJetsamPatcher.patch(&data, log: nil)
|
||||
#expect(outcome.verdict == .patched)
|
||||
|
||||
let image = try CFWJetsamPatcher.Image(data: data)
|
||||
let site = try #require(try CFWJetsamPatcher.locate(in: image))
|
||||
#expect(site.isAlreadyPatched)
|
||||
#expect(site.gateOffset == outcome.gateOffset)
|
||||
#expect(site.returnBlockOffset == outcome.returnBlockOffset)
|
||||
|
||||
// And the later branch the reference would fall back to really is
|
||||
// there, live, into the same return block — which is why the check has
|
||||
// to live inside the scan.
|
||||
let gate = try #require(CFWJetsamPatcher.findReturnGate(
|
||||
from: site.gateOffset + 4,
|
||||
to: site.xrefOffset,
|
||||
in: image
|
||||
))
|
||||
#expect(!gate.isUnconditional)
|
||||
#expect(gate.offset > outcome.gateOffset)
|
||||
#expect(gate.target == outcome.returnBlockOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Decoders and encoders
|
||||
|
||||
@Suite("launchd jetsam guard — instruction decoding")
|
||||
struct CFWJetsamDecodeTests {
|
||||
/// `ADD Xd, Xn, #imm12, LSL #0` only — an `LSL #12` form or a
|
||||
/// shifted-register add would make the xref land on the wrong string.
|
||||
@Test
|
||||
func addImmediatePredicateRejectsTheNeighbours() throws {
|
||||
let disassembler = ARM64Disassembler()
|
||||
// The real thing: the ADD half of the string xref, straight out of the
|
||||
// project encoder rather than typed in.
|
||||
let addImm = try #require(ARM64Encoder.encodeAddImm12(rd: 0, rn: 0, imm12: 0xA09))
|
||||
let decoded = try #require(disassembler.disassembleOne(addImm, at: 0))
|
||||
#expect(decoded.mnemonic == "add")
|
||||
#expect(CFWJetsamPatcher.isAddImm64(addImm.loadLE(UInt32.self, at: 0)))
|
||||
|
||||
// And the neighbours it must not accept.
|
||||
#expect(!CFWJetsamPatcher.isAddImm64(0x9140_0000)) // add x0, x0, #0, lsl #12
|
||||
#expect(!CFWJetsamPatcher.isAddImm64(0x1100_0000)) // add w0, w0, #0 (32-bit)
|
||||
#expect(!CFWJetsamPatcher.isAddImm64(0x8B08_1534)) // add x20, x9, x8, lsl #5
|
||||
#expect(!CFWJetsamPatcher.isAddImm64(0xD100_0000)) // sub x0, x0, #0
|
||||
}
|
||||
|
||||
/// `adrpPage` against Capstone, which resolves the page for us. The inputs
|
||||
/// come from `ARM64Encoder.encodeADRP`, so nothing here is a typed-in word.
|
||||
@Test
|
||||
func adrpPageAgreesWithCapstone() throws {
|
||||
let disassembler = ARM64Disassembler()
|
||||
for (pc, target) in [
|
||||
(UInt64(0x1_0000_FB0C), UInt64(0x1_0006_5A09)), // forward
|
||||
(UInt64(0x1_0000_0000), UInt64(0x1_0000_0FFF)), // same page
|
||||
(UInt64(0x1_0005_0000), UInt64(0x1_0000_1234)), // backward
|
||||
] {
|
||||
let encoded = try #require(ARM64Encoder.encodeADRP(rd: 0, pc: pc, target: target))
|
||||
let insn = try #require(disassembler.disassembleOne(encoded, at: pc))
|
||||
let operands = try #require(insn.aarch64?.operands)
|
||||
#expect(insn.mnemonic == "adrp")
|
||||
#expect(operands.count >= 2 && operands[1].type == AARCH64_OP_IMM)
|
||||
let word = encoded.loadLE(UInt32.self, at: 0)
|
||||
#expect(CFWJetsamPatcher.adrpPage(word, at: pc) == UInt64(operands[1].imm))
|
||||
#expect(CFWJetsamPatcher.adrpPage(word, at: pc) == target & ~0xFFF)
|
||||
}
|
||||
}
|
||||
|
||||
/// The replacement is `ARM64Encoder`'s, and it decodes back to a `b` at the
|
||||
/// intended target — never a hand-written instruction word.
|
||||
@Test
|
||||
func replacementBranchRoundTrips() throws {
|
||||
let disassembler = ARM64Disassembler()
|
||||
for (site, target) in [(0xFA98, 0xFAEC), (0x1000, 0x800), (0x40, 0x40)] {
|
||||
let encoded = try #require(ARM64Encoder.encodeB(from: site, to: target))
|
||||
let insn = try #require(disassembler.disassembleOne(encoded, at: UInt64(site)))
|
||||
#expect(insn.mnemonic == "b")
|
||||
#expect(CFWJetsamPatcher.branchTarget(insn) == target)
|
||||
}
|
||||
}
|
||||
|
||||
/// The return-block probe rests entirely on two questions — "does this
|
||||
/// return?" and "does control leave here?" — and both are answered from
|
||||
/// Capstone's instruction groups. Pinned against real decodes, because a
|
||||
/// mnemonic prefix gets each of the last three rows below wrong: `brk` is
|
||||
/// not a branch, and a conditional branch falls through.
|
||||
@Test
|
||||
func blockBoundariesComeFromCapstoneGroups() throws {
|
||||
let disassembler = ARM64Disassembler()
|
||||
func decode(_ bytes: Data) throws -> Instruction {
|
||||
try #require(disassembler.disassembleOne(bytes, at: 0))
|
||||
}
|
||||
|
||||
// Returns, from the project's own pre-encoded constants.
|
||||
for bytes in [ARM64.ret, ARM64.retaa, ARM64.retab] {
|
||||
let insn = try decode(bytes)
|
||||
#expect(CFWJetsamPatcher.isReturn(insn))
|
||||
}
|
||||
|
||||
// Control leaves: an unconditional relative jump and a relative call,
|
||||
// both straight out of `ARM64Encoder`.
|
||||
for bytes in [
|
||||
try #require(ARM64Encoder.encodeB(from: 0, to: 8)),
|
||||
try #require(ARM64Encoder.encodeBL(from: 0, to: 8)),
|
||||
] {
|
||||
let insn = try decode(bytes)
|
||||
#expect(CFWJetsamPatcher.leavesBlock(insn))
|
||||
#expect(!CFWJetsamPatcher.isReturn(insn))
|
||||
}
|
||||
|
||||
// The register-indirect forms, and the breakpoint that a `hasPrefix("br")`
|
||||
// test would have mistaken for one. No encoder writes these — no patch
|
||||
// emits them — so the words come from the ISA field layout and every
|
||||
// claim about them is checked against Capstone's decode.
|
||||
let indirect: [(UInt32, String, Bool)] = [
|
||||
(0xD61F_0000, "br", true), // br x0
|
||||
(0xD63F_0000, "blr", true), // blr x0
|
||||
(0xD420_0000, "brk", false), // brk #0 — an exception, not a branch
|
||||
]
|
||||
for (word, mnemonic, ends) in indirect {
|
||||
let insn = try decode(ARM64.encodeU32(word))
|
||||
#expect(insn.mnemonic == mnemonic)
|
||||
#expect(CFWJetsamPatcher.leavesBlock(insn) == ends)
|
||||
#expect(!CFWJetsamPatcher.isReturn(insn))
|
||||
}
|
||||
|
||||
// Conditional branches fall through, so they end nothing — this is what
|
||||
// lets a `b.cond` sit inside the return block being probed.
|
||||
let conditional: [UInt32] = [
|
||||
0x5400_0000 | (2 << 5), // b.eq #8
|
||||
0x3400_0000 | (2 << 5), // cbz w0, #8
|
||||
0x3600_0000 | (2 << 5), // tbz w0, #0, #8
|
||||
]
|
||||
for word in conditional {
|
||||
let insn = try decode(ARM64.encodeU32(word))
|
||||
#expect(CFWJetsamPatcher.conditionalBranchMnemonics.contains(insn.mnemonic))
|
||||
#expect(!CFWJetsamPatcher.leavesBlock(insn))
|
||||
#expect(!CFWJetsamPatcher.isReturn(insn))
|
||||
}
|
||||
}
|
||||
|
||||
/// `cbz`/`tbz` put the target last; reading the last immediate is what
|
||||
/// keeps one code path covering all of them.
|
||||
@Test
|
||||
func branchTargetReadsTheLastImmediate() throws {
|
||||
let disassembler = ARM64Disassembler()
|
||||
|
||||
// tbz w8, #1, #8 — three operands, target last, from the encoder.
|
||||
let tbz = try #require(ARM64Encoder.encodeTestBitBranch(
|
||||
nonzero: false, register: 8, bit: 1, from: 0, to: 8
|
||||
))
|
||||
let tbzInsn = try #require(disassembler.disassembleOne(tbz, at: 0))
|
||||
#expect(tbzInsn.mnemonic == "tbz")
|
||||
#expect(CFWJetsamPatcher.branchTarget(tbzInsn) == 8)
|
||||
|
||||
// cbz w0, #8 and b.eq #8 — two and one operand. Neither has an encoder
|
||||
// in `ARM64Encoder` (no patch writes one), so the words are built here
|
||||
// from the ISA field layout and checked against Capstone's decode.
|
||||
let cbz: UInt32 = 0x3400_0000 | (2 << 5) // imm19 = 8 / 4
|
||||
let beq: UInt32 = 0x5400_0000 | (2 << 5) // imm19 = 8 / 4, cond = EQ
|
||||
for word in [cbz, beq] {
|
||||
let insn = try #require(disassembler.disassembleOne(ARM64.encodeU32(word), at: 0))
|
||||
#expect(CFWJetsamPatcher.conditionalBranchMnemonics.contains(insn.mnemonic))
|
||||
#expect(CFWJetsamPatcher.branchTarget(insn) == 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// A substring anchor has to widen to the whole C string, because that is
|
||||
/// what an ADRP+ADD points at.
|
||||
@Test
|
||||
func cStringStartWidensToTheWholeString() throws {
|
||||
var bytes = Data("first\u{0}jetsam property category (%s) is not initialized\u{0}".utf8)
|
||||
var hit = try #require(bytes.range(of: Data("property".utf8))?.lowerBound)
|
||||
#expect(CFWJetsamPatcher.cStringStart(in: bytes, containing: hit, sectionStart: 0) == 6)
|
||||
|
||||
// A string that starts at the section's first byte has no NUL in front.
|
||||
bytes = Data("jetsam property category\u{0}".utf8)
|
||||
hit = try #require(bytes.range(of: Data("property".utf8))?.lowerBound)
|
||||
#expect(CFWJetsamPatcher.cStringStart(in: bytes, containing: hit, sectionStart: 0) == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// CFWMobileactivationdTests.swift — `-[DeviceType should_hactivate]` -> YES.
|
||||
//
|
||||
// The bar here is not "the Swift patcher did something". It is that this port
|
||||
// and `scripts/patchers/cfw_patch_mobileactivationd.py` produce the same bytes
|
||||
// from the same input, on the real iOS 27.0 / 24A435 iPhone17,3
|
||||
// `/usr/libexec/mobileactivationd` in `ipsws/ref_extract/macho_pristine/`, and
|
||||
// that the result passes `codesign -v` — two references, neither of them this
|
||||
// code. Every comparison is gated on the reference still being present, so the
|
||||
// suite skips rather than fails once P1.5 deletes the Python.
|
||||
//
|
||||
// Outputs are kept on disk, not in a scratch directory that vanishes, so the
|
||||
// same comparison can be re-run by hand with `cmp`, `shasum` and `codesign`.
|
||||
// They go wherever `VPHONE_TEST_ARTIFACTS` points, defaulting to a named
|
||||
// directory under `TMPDIR` — never inside the repository, and never inside
|
||||
// `ipsws/ref_extract/`, which is the read-only reference tree.
|
||||
|
||||
import Capstone
|
||||
@testable import FirmwarePatcher
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
enum MobileactivationdFixture {
|
||||
/// The package root, derived from this file rather than the working
|
||||
/// directory, which `swift test` does not promise.
|
||||
static let repositoryRoot = URL(filePath: #filePath)
|
||||
.deletingLastPathComponent() // FirmwarePatcherTests
|
||||
.deletingLastPathComponent() // tests
|
||||
.deletingLastPathComponent() // <root>
|
||||
|
||||
/// The pristine reference binary. Read-only: every test works on a copy.
|
||||
static let pristine = repositoryRoot
|
||||
.appending(path: "ipsws/ref_extract/macho_pristine/mobileactivationd")
|
||||
|
||||
/// A second real binary that does not contain the method, for the
|
||||
/// not-found path.
|
||||
static let launchd = repositoryRoot
|
||||
.appending(path: "ipsws/ref_extract/macho_pristine/launchd")
|
||||
|
||||
static let python = repositoryRoot.appending(path: ".venv/bin/python3")
|
||||
static let cfwPy = repositoryRoot.appending(path: "scripts/patchers/cfw.py")
|
||||
static let codesign = URL(filePath: "/usr/bin/codesign")
|
||||
|
||||
/// Where comparison artifacts land, so a failure can be picked apart after
|
||||
/// the run instead of being re-created from scratch.
|
||||
///
|
||||
/// Outside the repository by default. `ipsws/ref_extract/` in particular is
|
||||
/// the pristine reference the whole suite compares against; nothing here
|
||||
/// ever writes inside it.
|
||||
static let artifacts: URL = {
|
||||
if let override = ProcessInfo.processInfo.environment["VPHONE_TEST_ARTIFACTS"],
|
||||
!override.isEmpty
|
||||
{
|
||||
return URL(filePath: override).appending(path: "CFWMobileactivationdTests")
|
||||
}
|
||||
return FileManager.default.temporaryDirectory.appending(path: "CFWMobileactivationdTests")
|
||||
}()
|
||||
|
||||
static func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) }
|
||||
|
||||
static var hasPristine: Bool { exists(pristine) }
|
||||
static var hasLaunchd: Bool { exists(launchd) }
|
||||
static var hasPythonReference: Bool { hasPristine && exists(python) && exists(cfwPy) }
|
||||
static var hasCodesign: Bool { hasPristine && exists(codesign) }
|
||||
|
||||
/// A named, writable copy of the pristine binary under ``artifacts``.
|
||||
static func copyOfPristine(named name: String) throws -> URL {
|
||||
try FileManager.default.createDirectory(at: artifacts, withIntermediateDirectories: true)
|
||||
let destination = artifacts.appending(path: name)
|
||||
if exists(destination) { try FileManager.default.removeItem(at: destination) }
|
||||
try FileManager.default.copyItem(at: pristine, to: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func run(_ tool: URL, _ arguments: [String]) throws -> (status: Int32, output: String) {
|
||||
let process = Process()
|
||||
process.executableURL = tool
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = repositoryRoot
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
try process.run()
|
||||
let output = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return (process.terminationStatus, String(decoding: output, as: UTF8.self))
|
||||
}
|
||||
|
||||
/// Run the Python patcher over `file`, in place.
|
||||
@discardableResult
|
||||
static func runPythonPatcher(on file: URL) throws -> String {
|
||||
let result = try run(python, [cfwPy.path, "patch-mobileactivationd", file.path])
|
||||
#expect(result.status == 0, "cfw.py patch-mobileactivationd failed: \(result.output)")
|
||||
return result.output
|
||||
}
|
||||
|
||||
/// Re-attest `offsets` in `file` with the Python code-signature module —
|
||||
/// the step `cfw_patch_mobileactivationd.py` leaves to the `ldid_sign` that
|
||||
/// follows it in `cfw_install.sh`.
|
||||
@discardableResult
|
||||
static func runPythonReattest(on file: URL, offsets: [Int]) throws -> String {
|
||||
let script = """
|
||||
import sys
|
||||
sys.path.insert(0, "scripts")
|
||||
from patchers.cfw_macho_codesign import reattest_modified_offsets
|
||||
reattest_modified_offsets(sys.argv[1], [int(a, 0) for a in sys.argv[2:]])
|
||||
"""
|
||||
let result = try run(python, ["-c", script, file.path] + offsets.map { "0x" + String($0, radix: 16) })
|
||||
#expect(result.status == 0, "cfw_macho_codesign failed: \(result.output)")
|
||||
return result.output
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shell Runner
|
||||
|
||||
/// A shell entry point for the patcher, standing in for `vphone-patch` until
|
||||
/// that binary exists (plan §3.9).
|
||||
///
|
||||
/// ```
|
||||
/// VPHONE_PATCH_FILE=<binary> swift test --filter runPatcherFromEnvironment
|
||||
/// ```
|
||||
///
|
||||
/// patches that one file in place and prints where it landed. It exists so the
|
||||
/// comparison against the Python, and the run-it-twice check, can be driven
|
||||
/// from a shell over files a human picked — rather than living only inside
|
||||
/// assertions this same process wrote. Without the variable it does not run.
|
||||
///
|
||||
/// `VPHONE_PATCH_RESIGN=0` skips re-attestation, which is what reproduces the
|
||||
/// Python's bytes exactly.
|
||||
@Suite("mobileactivationd should_hactivate — shell runner")
|
||||
struct CFWMobileactivationdRunnerTests {
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["VPHONE_PATCH_FILE"] != nil))
|
||||
func runPatcherFromEnvironment() throws {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let path = try #require(environment["VPHONE_PATCH_FILE"])
|
||||
let resign = environment["VPHONE_PATCH_RESIGN"] != "0"
|
||||
|
||||
let report = try CFWMobileactivationd.patch(fileAt: URL(filePath: path), resign: resign)
|
||||
|
||||
print(
|
||||
"RUNNER outcome=\(report.outcome.rawValue)"
|
||||
+ " sitesWritten=\(report.sitesWritten)"
|
||||
+ " va=0x\(String(report.anchor.virtualAddress, radix: 16, uppercase: true))"
|
||||
+ " foff=0x\(String(report.anchor.fileOffset, radix: 16, uppercase: true))"
|
||||
+ " section=\(report.anchor.section)"
|
||||
+ " anchor=\(report.anchor.source.rawValue)"
|
||||
+ " rehashes=\(report.slotRehashes.count)"
|
||||
)
|
||||
for rehash in report.slotRehashes {
|
||||
print("RUNNER \(rehash.description)")
|
||||
print("RUNNER slot@0x\(String(rehash.hashFileOffset, radix: 16, uppercase: true))"
|
||||
+ " = \(rehash.after.hex)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anchoring
|
||||
|
||||
@Suite("mobileactivationd should_hactivate — anchoring")
|
||||
struct CFWMobileactivationdAnchorTests {
|
||||
/// The two routes are independent — `LC_SYMTAB` on one side, the
|
||||
/// `__objc_methname` -> `__objc_selrefs` -> relative method list walk on the
|
||||
/// other — and they agree. That agreement is the evidence the anchor is the
|
||||
/// method and not something that merely sorts first.
|
||||
@Test(.enabled(if: MobileactivationdFixture.hasPristine))
|
||||
func bothAnchorsResolveAndAgree() throws {
|
||||
let data = try Data(contentsOf: MobileactivationdFixture.pristine)
|
||||
let segments = MachOParser.parseSegments(from: data)
|
||||
|
||||
let bySymbol = try #require(
|
||||
CFWMobileactivationd.symbolVirtualAddress(in: data),
|
||||
"LC_SYMTAB should carry -[DeviceType should_hactivate]"
|
||||
)
|
||||
let byMetadata = try #require(
|
||||
CFWMobileactivationd.objcMetadataVirtualAddress(in: data, segments: segments),
|
||||
"the ObjC method lists should carry the same IMP"
|
||||
)
|
||||
#expect(bySymbol == byMetadata)
|
||||
|
||||
let anchor = try CFWMobileactivationd.locateIMP(in: data)
|
||||
#expect(anchor.source == .symbolTableAndObjCMetadata)
|
||||
#expect(anchor.virtualAddress == bySymbol)
|
||||
#expect(anchor.section == "__TEXT,__text", "the IMP must be code, not data")
|
||||
#expect(
|
||||
MachOParser.vaToFileOffset(anchor.virtualAddress, segments: segments) == anchor.fileOffset
|
||||
)
|
||||
}
|
||||
|
||||
/// The selector search must not settle for a suffix of a longer string.
|
||||
///
|
||||
/// `DeviceType`'s ivar is `_should_hactivate`, so a plain search for
|
||||
/// `should_hactivate\0` — what the Python does — lands inside the ivar's
|
||||
/// name, finds no selref for it, and gives up. The NUL-preceded match finds
|
||||
/// the real selector instead.
|
||||
@Test(.enabled(if: MobileactivationdFixture.hasPristine))
|
||||
func selectorLookupSkipsTheIvarName() throws {
|
||||
let data = try Data(contentsOf: MobileactivationdFixture.pristine)
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
let selectorVA = try #require(
|
||||
CFWMobileactivationd.selectorVirtualAddress(in: data, sections: sections)
|
||||
)
|
||||
|
||||
let methname = try #require(sections["__TEXT,__objc_methname"])
|
||||
let start = Int(methname.fileOffset)
|
||||
let offset = start + Int(selectorVA - methname.address)
|
||||
#expect(data[offset - 1] == 0, "the selector must start a string, not end one")
|
||||
|
||||
// The naive search the Python performs finds an earlier, wrong offset on
|
||||
// this binary — so this is not an assertion that passes either way.
|
||||
let needle = Data(CFWMobileactivationd.selector.utf8) + Data([0])
|
||||
let naive = try #require(data.range(of: needle)?.lowerBound)
|
||||
#expect(naive < offset)
|
||||
#expect(data[naive - 1] == UInt8(ascii: "_"))
|
||||
}
|
||||
|
||||
/// A binary without the method is a hard stop, not a silent skip: every
|
||||
/// caller of this patch needs it to have happened.
|
||||
@Test(.enabled(if: MobileactivationdFixture.hasLaunchd))
|
||||
func missingMethodThrows() throws {
|
||||
let data = try Data(contentsOf: MobileactivationdFixture.launchd)
|
||||
#expect(throws: PatcherError.self) {
|
||||
try CFWMobileactivationd.locateIMP(in: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// The replacement is assembled, not transcribed. `ARM64Encoder`'s MOVZ is
|
||||
/// asserted against keystone in `ARM64EncoderTests`; this pins that the
|
||||
/// patch asks it for the right instruction, and that the pair reads back as
|
||||
/// `mov x0, #1 ; ret`.
|
||||
@Test func replacementIsMovX0OneThenRet() throws {
|
||||
let bytes = try CFWMobileactivationd.replacementBytes()
|
||||
#expect(bytes.count == 8)
|
||||
#expect(bytes == ARM64.movX0_1 + ARM64.ret)
|
||||
|
||||
let decoded = ARM64Disassembler().disassemble(bytes, at: 0, count: 2)
|
||||
#expect(decoded.count == 2)
|
||||
#expect(decoded[0].mnemonic == "mov")
|
||||
#expect(decoded[1].mnemonic == "ret")
|
||||
let operands = try #require(decoded[0].aarch64?.operands)
|
||||
#expect(operands.count == 2)
|
||||
#expect(operands[1].type == AARCH64_OP_IMM)
|
||||
#expect(operands[1].imm == 1)
|
||||
#expect(ARM64Disassembler().firstRegisterName(decoded[0]) == "x0")
|
||||
}
|
||||
|
||||
/// Both words of the getter are re-hashed, so a getter that straddles a
|
||||
/// page boundary does not leave the second page's slot stale.
|
||||
@Test func touchedOffsetsCoverBothWords() throws {
|
||||
let anchor = CFWMobileactivationd.Anchor(
|
||||
virtualAddress: 0x1_0000_0FFC,
|
||||
fileOffset: 0xFFC,
|
||||
source: .symbolTable,
|
||||
section: "__TEXT,__text"
|
||||
)
|
||||
let bytes = try CFWMobileactivationd.replacementBytes()
|
||||
let offsets = CFWMobileactivationd.touchedOffsets(anchor, bytes)
|
||||
#expect(offsets == [0xFFC, 0x1000])
|
||||
#expect(Set(offsets.map { $0 / 4096 }).count == 2, "the two words are on different pages")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Byte parity with the Python
|
||||
|
||||
@Suite("mobileactivationd should_hactivate — parity and idempotence")
|
||||
struct CFWMobileactivationdParityTests {
|
||||
/// Bar 1: identical bytes out of identical bytes in, against the
|
||||
/// implementation being replaced.
|
||||
///
|
||||
/// `resign: false` because the Python patcher does not re-sign — the
|
||||
/// `ldid_sign` in `cfw_install.sh` does — so this compares like with like.
|
||||
@Test(.enabled(if: MobileactivationdFixture.hasPythonReference))
|
||||
func matchesThePythonByteForByte() throws {
|
||||
let swiftFile = try MobileactivationdFixture.copyOfPristine(named: "swift.bin")
|
||||
let pythonFile = try MobileactivationdFixture.copyOfPristine(named: "python.bin")
|
||||
|
||||
let report = try CFWMobileactivationd.patch(fileAt: swiftFile, resign: false, log: nil)
|
||||
try MobileactivationdFixture.runPythonPatcher(on: pythonFile)
|
||||
|
||||
#expect(report.outcome == .patched)
|
||||
#expect(report.sitesWritten == 1)
|
||||
#expect(report.slotRehashes.isEmpty, "resign: false must not touch the signature")
|
||||
|
||||
let swiftBytes = try Data(contentsOf: swiftFile)
|
||||
let pythonBytes = try Data(contentsOf: pythonFile)
|
||||
#expect(swiftBytes == pythonBytes, "Swift and Python output must be byte-identical")
|
||||
|
||||
// And the one site they both changed is the one this patch claims.
|
||||
let record = try #require(report.record)
|
||||
let pristine = try Data(contentsOf: MobileactivationdFixture.pristine)
|
||||
let differing = (0 ..< pristine.count).filter { pythonBytes[$0] != pristine[$0] }
|
||||
#expect(differing.allSatisfy { record.fileOffset ..< record.fileOffset + 8 ~= $0 })
|
||||
#expect(record.patchID == "mobileactivationd.should_hactivate")
|
||||
#expect(record.component == "mobileactivationd")
|
||||
#expect(record.patchedBytes == ARM64.movX0_1 + ARM64.ret)
|
||||
#expect(record.beforeDisasm.hasSuffix("ret"))
|
||||
}
|
||||
|
||||
/// Bar 2: the re-attested output matches the Python's own re-attestation,
|
||||
/// slot hash for slot hash, and `codesign -v` accepts it.
|
||||
///
|
||||
/// The raw patch does not: `codesign` rejects it, which is what makes this
|
||||
/// step load-bearing rather than decorative.
|
||||
@Test(.enabled(if: MobileactivationdFixture.hasPythonReference && MobileactivationdFixture.hasCodesign))
|
||||
func reattestedOutputMatchesThePythonAndVerifies() throws {
|
||||
let swiftFile = try MobileactivationdFixture.copyOfPristine(named: "swift-resigned.bin")
|
||||
let pythonFile = try MobileactivationdFixture.copyOfPristine(named: "python-resigned.bin")
|
||||
|
||||
let report = try CFWMobileactivationd.patch(fileAt: swiftFile, resign: true, log: nil)
|
||||
let rehash = try #require(report.slotRehashes.first)
|
||||
#expect(report.slotRehashes.count == 1)
|
||||
|
||||
try MobileactivationdFixture.runPythonPatcher(on: pythonFile)
|
||||
try MobileactivationdFixture.runPythonReattest(
|
||||
on: pythonFile,
|
||||
offsets: CFWMobileactivationd.touchedOffsets(report.anchor, ARM64.movX0_1 + ARM64.ret)
|
||||
)
|
||||
|
||||
let swiftBytes = try Data(contentsOf: swiftFile)
|
||||
let pythonBytes = try Data(contentsOf: pythonFile)
|
||||
#expect(swiftBytes == pythonBytes, "re-attested output must match the Python's")
|
||||
|
||||
// The slot hash itself, not just the file: read it back out of both.
|
||||
let slot = rehash.hashFileOffset ..< rehash.hashFileOffset + rehash.after.count
|
||||
#expect(swiftBytes[slot] == rehash.after)
|
||||
#expect(pythonBytes[slot] == rehash.after)
|
||||
|
||||
let verified = try MobileactivationdFixture.run(
|
||||
MobileactivationdFixture.codesign, ["-v", swiftFile.path]
|
||||
)
|
||||
#expect(verified.status == 0, "codesign -v rejected the patched binary: \(verified.output)")
|
||||
|
||||
// The contrast: without re-attestation it is rejected.
|
||||
let unsigned = try MobileactivationdFixture.copyOfPristine(named: "swift-unsigned.bin")
|
||||
try CFWMobileactivationd.patch(fileAt: unsigned, resign: false, log: nil)
|
||||
let rejected = try MobileactivationdFixture.run(
|
||||
MobileactivationdFixture.codesign, ["-v", unsigned.path]
|
||||
)
|
||||
#expect(rejected.status != 0, "an un-re-attested patch should not verify")
|
||||
}
|
||||
|
||||
/// Bar 3. A second run recognises its own output and writes nothing —
|
||||
/// neither an error nor a double-apply. `8eb6c8b` fixed exactly this class
|
||||
/// of bug for the DSC gates; it does not get to come back here.
|
||||
@Test(.enabled(if: MobileactivationdFixture.hasPristine))
|
||||
func secondRunChangesNothing() throws {
|
||||
let file = try MobileactivationdFixture.copyOfPristine(named: "swift-idempotent.bin")
|
||||
|
||||
let first = try CFWMobileactivationd.patch(fileAt: file, log: nil)
|
||||
#expect(first.outcome == .patched)
|
||||
let afterFirst = try Data(contentsOf: file)
|
||||
|
||||
let second = try CFWMobileactivationd.patch(fileAt: file, log: nil)
|
||||
#expect(second.outcome == .alreadyPatched)
|
||||
#expect(second.sitesWritten == 0)
|
||||
#expect(second.record == nil)
|
||||
#expect(second.slotRehashes.isEmpty, "the slot was already correct")
|
||||
#expect(second.anchor == first.anchor)
|
||||
#expect(try Data(contentsOf: file) == afterFirst)
|
||||
|
||||
let third = try CFWMobileactivationd.patch(fileAt: file, log: nil)
|
||||
#expect(third.outcome == .alreadyPatched)
|
||||
#expect(try Data(contentsOf: file) == afterFirst)
|
||||
}
|
||||
|
||||
/// A patched-but-not-re-attested binary — what the Python leaves behind —
|
||||
/// is repaired on the next run rather than reported as already done and
|
||||
/// left to be SIGKILLed on first page-in.
|
||||
@Test(.enabled(if: MobileactivationdFixture.hasPristine))
|
||||
func rerunRepairsAStaleSlot() throws {
|
||||
let file = try MobileactivationdFixture.copyOfPristine(named: "swift-stale-slot.bin")
|
||||
try CFWMobileactivationd.patch(fileAt: file, resign: false, log: nil)
|
||||
|
||||
let repaired = try CFWMobileactivationd.patch(fileAt: file, resign: true, log: nil)
|
||||
#expect(repaired.outcome == .alreadyPatched)
|
||||
#expect(repaired.slotRehashes.count == 1, "the stale slot must be recomputed")
|
||||
#expect(repaired.record == nil, "no code bytes changed the second time")
|
||||
}
|
||||
|
||||
/// A dry run reports the site and leaves the file exactly as it found it.
|
||||
@Test(.enabled(if: MobileactivationdFixture.hasPristine))
|
||||
func dryRunWritesNothing() throws {
|
||||
let file = try MobileactivationdFixture.copyOfPristine(named: "swift-dry-run.bin")
|
||||
let before = try Data(contentsOf: file)
|
||||
|
||||
let report = try CFWMobileactivationd.patch(fileAt: file, dryRun: true, log: nil)
|
||||
#expect(report.outcome == .wouldPatch)
|
||||
#expect(report.sitesWritten == 0)
|
||||
#expect(report.anchor.section == "__TEXT,__text")
|
||||
#expect(try Data(contentsOf: file) == before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// CFWSeputilTests.swift — Parity cross-checks for `CFWSeputil`.
|
||||
//
|
||||
// Two independent references, and every claim below is measured against one of
|
||||
// them rather than against a number written down by hand:
|
||||
//
|
||||
// * `scripts/patchers/cfw_patch_seputil.py`, driven through the exact CLI
|
||||
// `scripts/cfw_install.sh` calls — `cfw.py patch-seputil <binary>`. The
|
||||
// Swift patcher run with `reattest: false` must produce that file byte for
|
||||
// byte, and run with re-attestation on it must equal the Python's output
|
||||
// put through the Python's own re-attester
|
||||
// (`cfw_macho_codesign.reattest_modified_offsets`). The second comparison
|
||||
// is what proves the slot hashes agree: they are compared as bytes in the
|
||||
// file, not as values this module reported about itself.
|
||||
// * `/usr/bin/codesign -v`, which for a standalone Mach-O is a real second
|
||||
// opinion on whether the signature still covers the file. The test asserts
|
||||
// both directions — the re-attested binary verifies and the one that
|
||||
// matches the Python does not — so a `codesign` that passed everything
|
||||
// would fail this suite instead of quietly blessing it.
|
||||
//
|
||||
// The fixture is the real 24A435 / iPhone17,3 `seputil`. Point
|
||||
// `VPHONE_MACHO_PRISTINE` at a directory of pristine Mach-O binaries or leave
|
||||
// the default `ipsws/ref_extract/macho_pristine` in place.
|
||||
//
|
||||
// Without it these FAIL, following `DSCFoundationTests`: a `guard … else
|
||||
// { return }` is reported by Swift Testing as a pass, so a green run on a
|
||||
// machine with no fixture would be indistinguishable from a green run that
|
||||
// proved something. Set `VPHONE_MACHO_FIXTURE_OPTIONAL=1` to turn that failure
|
||||
// into a visible skip.
|
||||
//
|
||||
// Nothing here writes anywhere under `ipsws/ref_extract/`: that tree is the
|
||||
// pristine reference the whole suite compares against. Clones land in
|
||||
// `ipsws/scratch_cfwseputil/`, on the same filesystem, so `cp -c` is a
|
||||
// `clonefile(2)`.
|
||||
|
||||
import CryptoKit
|
||||
@testable import FirmwarePatcher
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
// MARK: - Fixture discovery
|
||||
|
||||
private enum SeputilFixture {
|
||||
static let repoRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
|
||||
static let binaryName = "seputil"
|
||||
|
||||
/// The read-only reference binary.
|
||||
static var pristine: URL? {
|
||||
let directory = ProcessInfo.processInfo.environment["VPHONE_MACHO_PRISTINE"]
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
?? repoRoot.appendingPathComponent("ipsws/ref_extract/macho_pristine")
|
||||
let binary = directory.appendingPathComponent(binaryName)
|
||||
return FileManager.default.fileExists(atPath: binary.path) ? binary : nil
|
||||
}
|
||||
|
||||
/// Opt-out for a machine that does not carry the extracted IPSW.
|
||||
static var isOptional: Bool {
|
||||
ProcessInfo.processInfo.environment["VPHONE_MACHO_FIXTURE_OPTIONAL"] == "1"
|
||||
}
|
||||
|
||||
/// The suite runs unless the binary is absent *and* the caller opted out.
|
||||
static var runs: Bool { pristine != nil || !isOptional }
|
||||
|
||||
static let missing: Comment = """
|
||||
the real 24A435 iPhone17,3 seputil is required — put it at \
|
||||
ipsws/ref_extract/macho_pristine/seputil, point VPHONE_MACHO_PRISTINE at \
|
||||
the directory holding it, or set VPHONE_MACHO_FIXTURE_OPTIONAL=1 to skip \
|
||||
these tests instead of failing
|
||||
"""
|
||||
|
||||
static let skipReason: Comment =
|
||||
"VPHONE_MACHO_FIXTURE_OPTIONAL=1 and no macho_pristine/seputil fixture present"
|
||||
|
||||
static let venvMissing: Comment = """
|
||||
the project venv is required: these tests are a comparison against \
|
||||
scripts/patchers/cfw_patch_seputil.py, and without it there is nothing to \
|
||||
compare against — run `make setup_venv`
|
||||
"""
|
||||
|
||||
/// Where clones are made. Same filesystem as the repo, and deliberately
|
||||
/// *not* under `ipsws/ref_extract/`.
|
||||
static var scratchRoot: URL {
|
||||
repoRoot.appendingPathComponent("ipsws/scratch_cfwseputil")
|
||||
}
|
||||
|
||||
static var python: URL? {
|
||||
let url = repoRoot.appendingPathComponent(".venv/bin/python3")
|
||||
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
||||
}
|
||||
|
||||
static var codesign: URL? {
|
||||
let url = URL(fileURLWithPath: "/usr/bin/codesign")
|
||||
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
||||
}
|
||||
|
||||
/// A private clone of the pristine binary the caller may write to.
|
||||
static func clone(named name: String) throws -> URL {
|
||||
guard let pristine else { throw CocoaError(.fileNoSuchFile) }
|
||||
try FileManager.default.createDirectory(
|
||||
at: scratchRoot,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let destination = scratchRoot.appendingPathComponent(name)
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
|
||||
var result = try Subprocess.run(
|
||||
executable: URL(fileURLWithPath: "/bin/cp"),
|
||||
arguments: ["-c", pristine.path, destination.path]
|
||||
)
|
||||
if result.status != 0 {
|
||||
// A fixture on another volume cannot be cloned. Copying is slower
|
||||
// but correct, and a refusal here would look like a patch bug.
|
||||
result = try Subprocess.run(
|
||||
executable: URL(fileURLWithPath: "/bin/cp"),
|
||||
arguments: [pristine.path, destination.path]
|
||||
)
|
||||
}
|
||||
guard result.status == 0 else { throw CocoaError(.fileWriteUnknown) }
|
||||
return destination
|
||||
}
|
||||
|
||||
/// Discard clones, and the scratch root with them once the last one is
|
||||
/// gone, so a test run leaves the working tree as it found it.
|
||||
static func discard(_ clones: URL...) {
|
||||
for clone in clones { try? FileManager.default.removeItem(at: clone) }
|
||||
let remaining = (try? FileManager.default
|
||||
.contentsOfDirectory(atPath: scratchRoot.path)) ?? []
|
||||
if remaining.isEmpty {
|
||||
try? FileManager.default.removeItem(at: scratchRoot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subprocess helper
|
||||
|
||||
private enum Subprocess {
|
||||
struct Result {
|
||||
let status: Int32
|
||||
let stdout: String
|
||||
let stderr: String
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func run(executable: URL, arguments: [String]) throws -> Result {
|
||||
let process = Process()
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
let out = Pipe()
|
||||
let err = Pipe()
|
||||
process.standardOutput = out
|
||||
process.standardError = err
|
||||
try process.run()
|
||||
// Drain before waiting, or a full pipe buffer deadlocks the child.
|
||||
let outData = out.fileHandleForReading.readDataToEndOfFile()
|
||||
let errData = err.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return Result(
|
||||
status: process.terminationStatus,
|
||||
stdout: String(decoding: outData, as: UTF8.self),
|
||||
stderr: String(decoding: errData, as: UTF8.self)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The reference Python, driven through its real CLI
|
||||
|
||||
private enum PythonReference {
|
||||
/// `cfw.py patch-seputil <binary>` — what `cfw_install.sh` runs.
|
||||
@discardableResult
|
||||
static func patch(_ binary: URL) throws -> String {
|
||||
let python = try #require(SeputilFixture.python, SeputilFixture.venvMissing)
|
||||
let result = try Subprocess.run(executable: python, arguments: [
|
||||
SeputilFixture.repoRoot.appendingPathComponent("scripts/patchers/cfw.py").path,
|
||||
"patch-seputil",
|
||||
binary.path,
|
||||
])
|
||||
guard result.status == 0 else {
|
||||
Issue.record("cfw.py patch-seputil failed: \(result.stdout)\(result.stderr)")
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
/// `cfw_macho_codesign.reattest_modified_offsets` — the independent
|
||||
/// re-attester, so the slot hashes this suite compares against are the
|
||||
/// Python's own and not a second copy of the Swift maths.
|
||||
static func reattest(_ binary: URL, offsets: [Int]) throws {
|
||||
let python = try #require(SeputilFixture.python, SeputilFixture.venvMissing)
|
||||
let script = """
|
||||
import sys
|
||||
sys.path.insert(0, "scripts/patchers")
|
||||
import cfw_macho_codesign as ref
|
||||
ref.reattest_modified_offsets(sys.argv[1], [int(a) for a in sys.argv[2:]], verbose=False)
|
||||
"""
|
||||
let process = Process()
|
||||
process.executableURL = python
|
||||
process.arguments = ["-c", script, binary.path] + offsets.map(String.init)
|
||||
process.currentDirectoryURL = SeputilFixture.repoRoot
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
try process.run()
|
||||
let output = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
try #require(
|
||||
process.terminationStatus == 0,
|
||||
"reference re-attester failed: \(String(decoding: output, as: UTF8.self))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parity against the reference implementations
|
||||
|
||||
@Suite("seputil gigalocker name", .enabled(if: SeputilFixture.runs, SeputilFixture.skipReason))
|
||||
struct CFWSeputilParityTests {
|
||||
/// The plan's P1.2 gate: same input, same bytes out.
|
||||
///
|
||||
/// Run with `reattest: false`, which is the reference's own behaviour —
|
||||
/// `cfw_install.sh` re-signs with `ldid` right after, so the Python leaves
|
||||
/// the signature stale.
|
||||
@Test func matchesTheReferencePatcherByteForByte() throws {
|
||||
let pristine = try #require(SeputilFixture.pristine, SeputilFixture.missing)
|
||||
let swiftFile = try SeputilFixture.clone(named: "swift-plain")
|
||||
let pythonFile = try SeputilFixture.clone(named: "python-plain")
|
||||
defer { SeputilFixture.discard(swiftFile, pythonFile) }
|
||||
|
||||
let outcome = try CFWSeputil.patch(fileAt: swiftFile, reattest: false, log: nil)
|
||||
try PythonReference.patch(pythonFile)
|
||||
|
||||
#expect(outcome.verdict == .patched)
|
||||
#expect(
|
||||
try Data(contentsOf: swiftFile) == (try Data(contentsOf: pythonFile)),
|
||||
"Swift and Python must produce the same seputil, byte for byte"
|
||||
)
|
||||
|
||||
// And the comparison is not two copies of the input: both moved.
|
||||
#expect(try Data(contentsOf: swiftFile) != (try Data(contentsOf: pristine)))
|
||||
}
|
||||
|
||||
/// The same for the whole pipeline, re-attestation included. The Python
|
||||
/// has no re-attesting seputil patcher, so the reference side is composed
|
||||
/// from the two Python modules the install script composes at runtime.
|
||||
///
|
||||
/// This is the slot-hash comparison: the hashes are compared where they
|
||||
/// live, in the file, against hashes the reference implementation computed.
|
||||
@Test func matchesTheReferencePlusItsOwnReattester() throws {
|
||||
let swiftFile = try SeputilFixture.clone(named: "swift-full")
|
||||
let pythonFile = try SeputilFixture.clone(named: "python-full")
|
||||
defer { SeputilFixture.discard(swiftFile, pythonFile) }
|
||||
|
||||
let outcome = try CFWSeputil.patch(fileAt: swiftFile, log: nil)
|
||||
try PythonReference.patch(pythonFile)
|
||||
try PythonReference.reattest(pythonFile, offsets: outcome.site.modifiedOffsets)
|
||||
|
||||
#expect(outcome.rehashes.count == 1, "one page was dirtied, so one slot is rewritten")
|
||||
#expect(
|
||||
try Data(contentsOf: swiftFile) == (try Data(contentsOf: pythonFile)),
|
||||
"the re-attested slot hash must be the one the reference computes"
|
||||
)
|
||||
}
|
||||
|
||||
/// `codesign -v`, in both directions. The re-attested binary verifies; the
|
||||
/// one that reproduces the Python's bytes does not, which is why
|
||||
/// `cfw_install.sh` has to run `ldid_sign` after the Python.
|
||||
@Test func reattestationIsWhatMakesTheBinaryVerify() throws {
|
||||
let codesign = try #require(SeputilFixture.codesign)
|
||||
let pristine = try #require(SeputilFixture.pristine, SeputilFixture.missing)
|
||||
let reattested = try SeputilFixture.clone(named: "swift-verify")
|
||||
let stale = try SeputilFixture.clone(named: "swift-stale")
|
||||
defer { SeputilFixture.discard(reattested, stale) }
|
||||
|
||||
// The fixture itself has to verify, or the check below measures nothing.
|
||||
let before = try Subprocess.run(executable: codesign, arguments: ["-v", pristine.path])
|
||||
try #require(before.status == 0, "the pristine fixture must verify: \(before.stderr)")
|
||||
|
||||
try CFWSeputil.patch(fileAt: reattested, log: nil)
|
||||
try CFWSeputil.patch(fileAt: stale, reattest: false, log: nil)
|
||||
|
||||
let good = try Subprocess.run(executable: codesign, arguments: ["-v", reattested.path])
|
||||
let bad = try Subprocess.run(executable: codesign, arguments: ["-v", stale.path])
|
||||
#expect(good.status == 0, "re-attested binary must verify: \(good.stderr)")
|
||||
#expect(bad.status != 0, "a stale slot hash must be caught, or codesign proves nothing")
|
||||
}
|
||||
|
||||
// MARK: Idempotence
|
||||
|
||||
/// Running twice is a clean no-op — not an error, not a second rewrite.
|
||||
///
|
||||
/// The shape the second run has to recognise is the one the first run
|
||||
/// wrote: the literal no longer reads `%s/%s.gl`, so a patcher that only
|
||||
/// knows the pristine spelling fails here instead of reporting "already
|
||||
/// patched". That is the bug `8eb6c8b` fixed for two DSC gates.
|
||||
@Test func aSecondRunChangesNothing() throws {
|
||||
let file = try SeputilFixture.clone(named: "twice")
|
||||
defer { SeputilFixture.discard(file) }
|
||||
|
||||
let first = try CFWSeputil.patch(fileAt: file, log: nil)
|
||||
let afterFirst = try Data(contentsOf: file)
|
||||
|
||||
let second = try CFWSeputil.patch(fileAt: file, log: nil)
|
||||
let afterSecond = try Data(contentsOf: file)
|
||||
|
||||
#expect(first.verdict == .patched)
|
||||
#expect(second.verdict == .alreadyPatched)
|
||||
#expect(second.record == nil)
|
||||
#expect(second.rehashes.isEmpty)
|
||||
#expect(second.sitesWritten == 0)
|
||||
#expect(afterSecond == afterFirst, "the second run must not touch a byte")
|
||||
|
||||
// The second run still found the same site, by its patched spelling.
|
||||
#expect(second.site.fieldOffset == first.site.fieldOffset)
|
||||
#expect(second.site.isPristine == false)
|
||||
#expect(second.references == first.references)
|
||||
}
|
||||
|
||||
/// A dry run reports the site and leaves the file alone.
|
||||
@Test func aDryRunWritesNothing() throws {
|
||||
let file = try SeputilFixture.clone(named: "dry")
|
||||
defer { SeputilFixture.discard(file) }
|
||||
let before = try Data(contentsOf: file)
|
||||
|
||||
let outcome = try CFWSeputil.patch(fileAt: file, dryRun: true, log: nil)
|
||||
#expect(outcome.verdict == .wouldPatch)
|
||||
#expect(outcome.record != nil)
|
||||
#expect(outcome.rehashes.isEmpty)
|
||||
#expect(try Data(contentsOf: file) == before)
|
||||
}
|
||||
|
||||
// MARK: The anchor
|
||||
|
||||
/// What the patcher anchored on, stated in full: the literal, the field
|
||||
/// inside it, and the instruction that materialises its address.
|
||||
@Test func anchorsOnTheReferencedGigalockerLiteral() throws {
|
||||
let file = try SeputilFixture.clone(named: "anchor")
|
||||
defer { SeputilFixture.discard(file) }
|
||||
let data = try Data(contentsOf: file)
|
||||
|
||||
let (cstring, text) = try CFWSeputil.sections(in: data)
|
||||
let site = try CFWSeputil.findSite(in: data, cstring: cstring)
|
||||
|
||||
#expect(site.literal == "%s/%s.gl")
|
||||
#expect(site.isPristine)
|
||||
// The field is the one after the literal's last separator, so it lands
|
||||
// on the *uuid*, not the mountpoint: patching the mountpoint would send
|
||||
// every gigalocker lookup to a path that does not exist.
|
||||
#expect(site.fieldOffset == site.literalOffset + 3)
|
||||
#expect(data[site.fieldOffset ..< site.fieldOffset + 2] == Data("%s".utf8))
|
||||
|
||||
// VA and file offset describe the same byte.
|
||||
let segments = MachOParser.parseSegments(from: data)
|
||||
#expect(MachOParser.vaToFileOffset(site.fieldVMA, segments: segments) == site.fieldOffset)
|
||||
#expect(site.literalVMA == cstring.address + UInt64(site.literalOffset - Int(cstring.fileOffset)))
|
||||
|
||||
// And something in __text actually forms that address.
|
||||
let references = CFWSeputil.references(to: site.literalVMA, in: data, text: text)
|
||||
#expect(!references.isEmpty)
|
||||
for reference in references {
|
||||
#expect(reference >= text.address && reference < text.address + text.size)
|
||||
}
|
||||
}
|
||||
|
||||
/// The record carries the reference's own `patchID`, `component` and
|
||||
/// wording, so a captured reference JSON compares field for field.
|
||||
@Test func recordsTheSiteTheWayTheReferenceDoes() throws {
|
||||
let file = try SeputilFixture.clone(named: "record")
|
||||
defer { SeputilFixture.discard(file) }
|
||||
|
||||
let outcome = try CFWSeputil.patch(fileAt: file, dryRun: true, log: nil)
|
||||
let record = try #require(outcome.record)
|
||||
#expect(record.patchID == "seputil.gigalocker_uuid")
|
||||
#expect(record.component == "seputil")
|
||||
#expect(record.fileOffset == outcome.site.fieldOffset)
|
||||
#expect(record.virtualAddress == outcome.site.fieldVMA)
|
||||
#expect(record.originalBytes == Data("%s".utf8))
|
||||
#expect(record.patchedBytes == Data("AA".utf8))
|
||||
#expect(record.patchedBytes.count == 2)
|
||||
#expect(record.patchDescription == "gigalocker path format '/%s.gl' -> '/AA.gl'")
|
||||
}
|
||||
|
||||
// MARK: Re-attestation reach
|
||||
|
||||
/// Exactly the dirtied page is re-hashed, and the short tail slot — which
|
||||
/// this fixture has, and which is the known regression in independent
|
||||
/// Mach-O re-signing — is left alone because nothing was written in it.
|
||||
@Test func reattestationTouchesOnlyTheDirtiedPage() throws {
|
||||
let file = try SeputilFixture.clone(named: "pages")
|
||||
defer { SeputilFixture.discard(file) }
|
||||
let before = try Data(contentsOf: file)
|
||||
|
||||
let directory = try #require(CFWMachOCodeSignature.codeDirectories(in: before)?.first)
|
||||
try #require(
|
||||
directory.codeLimit % directory.pageSize != 0,
|
||||
"this fixture is supposed to have a short tail slot"
|
||||
)
|
||||
|
||||
let outcome = try CFWSeputil.patch(fileAt: file, log: nil)
|
||||
let rehash = try #require(outcome.rehashes.first)
|
||||
#expect(outcome.rehashes.count == 1)
|
||||
#expect(rehash.pageIndex == outcome.site.fieldOffset / directory.pageSize)
|
||||
#expect(!rehash.isTailSlot)
|
||||
#expect(rehash.hashedLength == directory.pageSize)
|
||||
|
||||
// The slot on disk holds the SHA-256 of the page as it now reads —
|
||||
// computed here from the file, not taken from what the patcher said.
|
||||
let after = try Data(contentsOf: file)
|
||||
let range = try #require(directory.slotRange(rehash.pageIndex))
|
||||
let expected = Data(SHA256.hash(data: after[range]))
|
||||
#expect(after[rehash.hashFileOffset ..< rehash.hashFileOffset + directory.hashSize] == expected)
|
||||
|
||||
// Every other slot is byte-identical to the pristine binary.
|
||||
let table = directory.offset + directory.hashOffset
|
||||
let tableEnd = table + directory.codeSlotCount * directory.hashSize
|
||||
let slot = rehash.hashFileOffset ..< rehash.hashFileOffset + directory.hashSize
|
||||
#expect(after[table ..< slot.lowerBound] == before[table ..< slot.lowerBound])
|
||||
#expect(after[slot.upperBound ..< tableEnd] == before[slot.upperBound ..< tableEnd])
|
||||
}
|
||||
|
||||
// MARK: Refusals
|
||||
|
||||
/// A literal nothing refers to is not the gigalocker path.
|
||||
///
|
||||
/// The reference takes the first `"/%s.gl"` it finds anywhere in the file
|
||||
/// and patches it. Here the `adrp`/`add` that materialises the literal is
|
||||
/// erased first, and the patcher has to stop rather than rewrite bytes no
|
||||
/// code reads.
|
||||
@Test func refusesALiteralNothingReferences() throws {
|
||||
let file = try SeputilFixture.clone(named: "unreferenced")
|
||||
defer { SeputilFixture.discard(file) }
|
||||
var data = try Data(contentsOf: file)
|
||||
|
||||
let (cstring, text) = try CFWSeputil.sections(in: data)
|
||||
let site = try CFWSeputil.findSite(in: data, cstring: cstring)
|
||||
let references = CFWSeputil.references(to: site.literalVMA, in: data, text: text)
|
||||
try #require(!references.isEmpty)
|
||||
|
||||
let segments = MachOParser.parseSegments(from: data)
|
||||
for reference in references {
|
||||
let offset = try #require(MachOParser.vaToFileOffset(reference, segments: segments))
|
||||
data.replaceSubrange(offset ..< offset + ARM64.nop.count, with: ARM64.nop)
|
||||
}
|
||||
#expect(CFWSeputil.references(to: site.literalVMA, in: data, text: text).isEmpty)
|
||||
|
||||
#expect(throws: PatcherError.self) {
|
||||
try CFWSeputil.patch(&data, log: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shape rules, with no fixture
|
||||
|
||||
@Suite("seputil anchor shape")
|
||||
struct CFWSeputilShapeTests {
|
||||
/// A synthetic `__cstring` is enough to pin the field rule, and it is the
|
||||
/// only way to present cases the real binary does not contain.
|
||||
private func section(at offset: UInt32, size: Int, address: UInt64) -> MachOSectionInfo {
|
||||
MachOSectionInfo(
|
||||
segmentName: "__TEXT",
|
||||
sectionName: "__cstring",
|
||||
address: address,
|
||||
size: UInt64(size),
|
||||
fileOffset: offset
|
||||
)
|
||||
}
|
||||
|
||||
private func cstrings(_ literals: [String], padding: Int = 16) -> Data {
|
||||
var data = Data(repeating: 0, count: padding)
|
||||
for literal in literals {
|
||||
data.append(Data(literal.utf8))
|
||||
data.append(0)
|
||||
}
|
||||
data.append(Data(repeating: 0, count: padding))
|
||||
return data
|
||||
}
|
||||
|
||||
@Test func readsTheFieldAfterTheLastSeparator() {
|
||||
// The mountpoint field is left alone; the one after the last "/" moves.
|
||||
let field = try? #require(CFWSeputil.fileField(of: Array("%s/%s.gl".utf8)))
|
||||
#expect(field == 3 ..< 5)
|
||||
#expect(CFWSeputil.fileField(of: Array("/mnt7/%s.gl".utf8)) == 6 ..< 8)
|
||||
// No separator, wrong suffix, or a field that is not two bytes wide:
|
||||
// none of these is the site this patch knows how to rewrite.
|
||||
#expect(CFWSeputil.fileField(of: Array("%s.gl".utf8)) == nil)
|
||||
#expect(CFWSeputil.fileField(of: Array("%s/%s.plist".utf8)) == nil)
|
||||
#expect(CFWSeputil.fileField(of: Array("%s/%llu.gl".utf8)) == nil)
|
||||
#expect(CFWSeputil.fileField(of: Array(".gl".utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test func classifiesOnlyTheTwoSpellingsItWrites() {
|
||||
#expect(CFWSeputil.pristineness(of: ArraySlice("%s".utf8)) == true)
|
||||
#expect(CFWSeputil.pristineness(of: ArraySlice("AA".utf8)) == false)
|
||||
#expect(CFWSeputil.pristineness(of: ArraySlice("BB".utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test func matchesWholeLiteralsOnly() throws {
|
||||
// "%s.gl" is the next literal after "%s/%s.gl" on the real binary, and
|
||||
// also its tail; a substring search sees both, a literal search one.
|
||||
let data = cstrings(["%s/%s.gl", "%s.gl", "/mnt7"])
|
||||
let site = try CFWSeputil.findSite(
|
||||
in: data,
|
||||
cstring: section(at: 0, size: data.count, address: 0x1_0000_0000)
|
||||
)
|
||||
#expect(site.literalOffset == 16)
|
||||
#expect(site.literal == "%s/%s.gl")
|
||||
#expect(site.fieldOffset == 19)
|
||||
#expect(site.fieldVMA == 0x1_0000_0013)
|
||||
}
|
||||
|
||||
@Test func findsTheAlreadyPatchedSpelling() throws {
|
||||
let data = cstrings(["%s/AA.gl"])
|
||||
let site = try CFWSeputil.findSite(
|
||||
in: data,
|
||||
cstring: section(at: 0, size: data.count, address: 0x1_0000_0000)
|
||||
)
|
||||
#expect(site.isPristine == false)
|
||||
#expect(site.fieldOffset == 19)
|
||||
}
|
||||
|
||||
@Test func refusesWhenThereIsNoCandidate() {
|
||||
let data = cstrings(["%s.gl", "/mnt7", "/private/xarts"])
|
||||
#expect(throws: PatcherError.self) {
|
||||
try CFWSeputil.findSite(
|
||||
in: data,
|
||||
cstring: section(at: 0, size: data.count, address: 0x1_0000_0000)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Two candidates is not a coin flip to be taken; it is a binary this
|
||||
/// patcher does not recognise.
|
||||
@Test func refusesWhenThereAreTwoCandidates() {
|
||||
let data = cstrings(["%s/%s.gl", "/mnt7", "%s/%s.gl"])
|
||||
#expect(throws: PatcherError.self) {
|
||||
try CFWSeputil.findSite(
|
||||
in: data,
|
||||
cstring: section(at: 0, size: data.count, address: 0x1_0000_0000)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: adrp/add pairing
|
||||
|
||||
private func text(size: Int, address: UInt64) -> MachOSectionInfo {
|
||||
MachOSectionInfo(
|
||||
segmentName: "__TEXT",
|
||||
sectionName: "__text",
|
||||
address: address,
|
||||
size: UInt64(size),
|
||||
fileOffset: 0
|
||||
)
|
||||
}
|
||||
|
||||
@Test func pairsAnAdrpWithItsAdd() throws {
|
||||
let pc: UInt64 = 0x1_0000_0000
|
||||
let target: UInt64 = 0x1_0001_BDCF
|
||||
var code = try #require(ARM64Encoder.encodeADRP(rd: 2, pc: pc, target: target))
|
||||
code.append(try #require(ARM64Encoder.encodeAddImm12(rd: 2, rn: 2, imm12: 0xDCF)))
|
||||
|
||||
#expect(CFWSeputil.references(to: target, in: code, text: text(size: 8, address: pc)) == [pc + 4])
|
||||
// A different literal on the same page is a different address.
|
||||
#expect(CFWSeputil.references(to: target + 1, in: code, text: text(size: 8, address: pc)).isEmpty)
|
||||
}
|
||||
|
||||
@Test func rejectsAnAddIntoAnotherRegister() throws {
|
||||
let pc: UInt64 = 0x1_0000_0000
|
||||
let target: UInt64 = 0x1_0001_BDCF
|
||||
var code = try #require(ARM64Encoder.encodeADRP(rd: 2, pc: pc, target: target))
|
||||
code.append(try #require(ARM64Encoder.encodeAddImm12(rd: 3, rn: 3, imm12: 0xDCF)))
|
||||
#expect(CFWSeputil.references(to: target, in: code, text: text(size: 8, address: pc)).isEmpty)
|
||||
}
|
||||
|
||||
/// `add xD, xN, #imm, lsl #12` forms `page + (imm << 12)`, not `page + imm`.
|
||||
/// Treating it as the latter would pair it with an `adrp` it has nothing to
|
||||
/// do with, so the `sh` bit is read off the encoding — the Swift Capstone
|
||||
/// wrapper does not expose an operand's shift.
|
||||
@Test func rejectsAShiftedAddImmediate() throws {
|
||||
let pc: UInt64 = 0x1_0000_0000
|
||||
let target: UInt64 = 0x1_0001_BDCF
|
||||
let adrp = try #require(ARM64Encoder.encodeADRP(rd: 2, pc: pc, target: target))
|
||||
let add = try #require(ARM64Encoder.encodeAddImm12(rd: 2, rn: 2, imm12: 0xDCF))
|
||||
|
||||
// Set bit 22 (`sh`) on the encoder's unshifted ADD — there is no
|
||||
// shifted encoder, and hand-writing the whole word would be a second
|
||||
// encoder to get wrong.
|
||||
var shifted = add
|
||||
shifted[shifted.startIndex + 2] |= 0x40
|
||||
|
||||
let disassembler = ARM64Disassembler()
|
||||
let plain = try #require(disassembler.disassembleOne(add))
|
||||
let lsl = try #require(disassembler.disassembleOne(shifted))
|
||||
#expect(plain.mnemonic == "add")
|
||||
#expect(lsl.mnemonic == "add")
|
||||
#expect(CFWSeputil.isShiftedAddImmediate(plain) == false)
|
||||
#expect(CFWSeputil.isShiftedAddImmediate(lsl) == true)
|
||||
|
||||
#expect(CFWSeputil.references(to: target, in: adrp + shifted, text: text(size: 8, address: pc)).isEmpty)
|
||||
#expect(CFWSeputil.references(to: target, in: adrp + add, text: text(size: 8, address: pc)) == [pc + 4])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
// CFWWatchdogdTests.swift — parity, anchoring and idempotence for the
|
||||
// watchdogd hv_vmm_present cache patch.
|
||||
//
|
||||
// Two independent references are available for this patch and both are used:
|
||||
//
|
||||
// * `scripts/patchers/cfw_patch_watchdogd.py`, driven exactly as
|
||||
// `cfw_install_exp.sh` drives it (`cfw.py patch-watchdogd <binary>`). The
|
||||
// central test runs it and `CFWWatchdogd` over two clones of the same
|
||||
// pristine binary and compares the results byte for byte — patched
|
||||
// instructions and re-attested code slots alike.
|
||||
// * `/usr/bin/codesign`, which recomputes the slot hashes itself. It has no
|
||||
// part in this code, so a binary that verifies under it is evidence the
|
||||
// re-attestation is right rather than self-consistent.
|
||||
//
|
||||
// The fixture is the real `/usr/libexec/watchdogd` from iOS 27.0 (24A435,
|
||||
// iPhone17,3), at `ipsws/ref_extract/macho_pristine/watchdogd`. That tree is
|
||||
// read-only reference data: every test here clones what it needs into the
|
||||
// system temp directory and never writes inside it.
|
||||
|
||||
import Capstone
|
||||
import CryptoKit
|
||||
@testable import FirmwarePatcher
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
enum WatchdogdFixture {
|
||||
/// The package root, derived from this file rather than the working
|
||||
/// directory, which `swift test` does not promise.
|
||||
static let repositoryRoot = URL(filePath: #filePath)
|
||||
.deletingLastPathComponent() // FirmwarePatcherTests
|
||||
.deletingLastPathComponent() // tests
|
||||
.deletingLastPathComponent() // <root>
|
||||
|
||||
static let pristineDirectory = repositoryRoot.appending(path: "ipsws/ref_extract/macho_pristine")
|
||||
static let watchdogd = pristineDirectory.appending(path: "watchdogd")
|
||||
/// A Mach-O from the same firmware that does not cache the sysctl — the
|
||||
/// negative case.
|
||||
static let seputil = pristineDirectory.appending(path: "seputil")
|
||||
|
||||
static let python = repositoryRoot.appending(path: ".venv/bin/python3")
|
||||
static let cfwCLI = repositoryRoot.appending(path: "scripts/patchers/cfw.py")
|
||||
static let codesign = URL(filePath: "/usr/bin/codesign")
|
||||
|
||||
static func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) }
|
||||
|
||||
/// SHA-256 as `shasum -a 256` prints it, so a digest in this run's output
|
||||
/// can be compared against one taken from a shell.
|
||||
static func digest(_ data: Data) -> String { Data(SHA256.hash(data: data)).hex }
|
||||
|
||||
static var hasWatchdogd: Bool { exists(watchdogd) }
|
||||
static var hasSeputil: Bool { exists(seputil) }
|
||||
static var hasPythonReference: Bool { hasWatchdogd && exists(python) && exists(cfwCLI) }
|
||||
static var hasCodesign: Bool { hasWatchdogd && exists(codesign) }
|
||||
|
||||
/// A private copy of `source` the caller may modify freely. Deliberately
|
||||
/// outside the working tree.
|
||||
static func scratchCopy(of source: URL, named name: String) throws -> URL {
|
||||
let directory = URL(filePath: NSTemporaryDirectory())
|
||||
.appending(path: "CFWWatchdogdTests-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let destination = directory.appending(path: name)
|
||||
try FileManager.default.copyItem(at: source, to: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func run(_ tool: URL, _ arguments: [String]) throws -> (status: Int32, output: String) {
|
||||
let process = Process()
|
||||
process.executableURL = tool
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
try process.run()
|
||||
let output = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return (process.terminationStatus, String(decoding: output, as: UTF8.self))
|
||||
}
|
||||
|
||||
/// The `__TEXT,__text` section, or a thrown error — never a silent empty
|
||||
/// result, which a test would read as "nothing to check".
|
||||
static func text(in data: Data) throws -> MachOSectionInfo {
|
||||
guard let text = MachOParser.parseSections(from: data)["__TEXT,__text"] else {
|
||||
throw PatcherError.invalidFormat("fixture has no __TEXT,__text")
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/// Every `bl` in `__TEXT,__text` whose target resolves to `_sysctlbyname`.
|
||||
/// Computed here, not by the patcher, so "the anchor rejects the other
|
||||
/// calls" is measured against an independent count.
|
||||
static func sysctlCallSites(in data: Data) throws -> [UInt64] {
|
||||
let text = try text(in: data)
|
||||
guard let symbols = CFWWatchdogdSymbolTargets(data: data) else {
|
||||
throw PatcherError.invalidFormat("fixture has no symbol table")
|
||||
}
|
||||
let start = Int(text.fileOffset)
|
||||
let body = data.subdata(in: start ..< start + Int(text.size))
|
||||
|
||||
var found: [UInt64] = []
|
||||
for instruction in ARM64Disassembler().disassemble(body, at: text.address)
|
||||
where instruction.mnemonic == "bl"
|
||||
{
|
||||
guard let target = ARM64Encoder.decodeBranchTarget(
|
||||
insn: CFWWatchdogd.word(of: instruction), pc: instruction.address
|
||||
) else { continue }
|
||||
if symbols.name(forBranchTarget: target) == "_sysctlbyname" { found.append(instruction.address) }
|
||||
}
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anchoring
|
||||
|
||||
@Suite("watchdogd hv_vmm_present cache — anchoring")
|
||||
struct CFWWatchdogdAnchorTests {
|
||||
/// The shape the patch is written against, read off the real binary.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func locatesBothCacheSites() throws {
|
||||
let data = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
let sites = try CFWWatchdogd.locateSites(in: data)
|
||||
|
||||
#expect(sites.count == 2, "24A435 watchdogd caches the answer in two functions")
|
||||
for site in sites {
|
||||
#expect(site.state == .pristine)
|
||||
#expect(site.valueRegister == "w8")
|
||||
// The gate really is the `cbnz w0` right after the call...
|
||||
#expect(site.gateVMA == site.callVMA + 4)
|
||||
let gate = try #require(ARM64Disassembler().disassembleOne(
|
||||
data.subdata(in: site.gateFileOffset ..< site.gateFileOffset + 4), at: site.gateVMA
|
||||
))
|
||||
#expect(gate.mnemonic == "cbnz")
|
||||
// ...and the value really is a `cset`.
|
||||
let value = try #require(ARM64Disassembler().disassembleOne(
|
||||
data.subdata(in: site.valueFileOffset ..< site.valueFileOffset + 4), at: site.valueVMA
|
||||
))
|
||||
#expect(value.mnemonic == "cset")
|
||||
#expect(value.aarch64?.conditionCode == AArch64CC_NE)
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached byte is a zero-filled global, which is why the skipped store
|
||||
/// leaves it reading 0 and why forcing the stored value to 1 is the fix.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func cachedByteLivesInZeroFilledData() throws {
|
||||
let data = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
let sites = try CFWWatchdogd.locateSites(in: data)
|
||||
let sections = MachOParser.parseSections(from: data)
|
||||
let zeroFilled = ["__DATA,__bss", "__DATA,__common"].compactMap { sections[$0] }
|
||||
#expect(!zeroFilled.isEmpty)
|
||||
|
||||
for site in sites {
|
||||
let inZeroFill = zeroFilled.contains {
|
||||
site.cachedByteVMA >= $0.address && site.cachedByteVMA < $0.address + $0.size
|
||||
}
|
||||
#expect(inZeroFill, "cached byte 0x\(String(site.cachedByteVMA, radix: 16)) must be BSS")
|
||||
}
|
||||
}
|
||||
|
||||
/// The discriminating test: watchdogd calls `sysctlbyname` five times and
|
||||
/// only two of those calls cache the VM-presence answer. An anchor that
|
||||
/// matched on "a call followed by cbnz w0" alone would have to be checked
|
||||
/// by hand; this states the number.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func rejectsTheOtherSysctlCallSites() throws {
|
||||
let data = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
let calls = try WatchdogdFixture.sysctlCallSites(in: data)
|
||||
let sites = try CFWWatchdogd.locateSites(in: data)
|
||||
|
||||
#expect(calls.count == 5, "24A435 watchdogd calls sysctlbyname five times")
|
||||
#expect(sites.count == 2)
|
||||
for site in sites { #expect(calls.contains(site.callVMA)) }
|
||||
}
|
||||
|
||||
/// The in-image symbol lookup the anchor rests on, checked on its own: a
|
||||
/// stub address in `__auth_stubs` resolves through the indirect symbol
|
||||
/// table to the imported name.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func resolvesTheImportThroughTheIndirectSymbolTable() throws {
|
||||
let data = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
let symbols = try #require(CFWWatchdogdSymbolTargets(data: data))
|
||||
let sites = try CFWWatchdogd.locateSites(in: data)
|
||||
let call = try #require(sites.first)
|
||||
|
||||
let target = try #require(ARM64Encoder.decodeBranchTarget(
|
||||
insn: {
|
||||
let offset = Int(call.callVMA - 0x1_0000_0000)
|
||||
return data.loadLE(UInt32.self, at: offset)
|
||||
}(),
|
||||
pc: call.callVMA
|
||||
))
|
||||
#expect(symbols.importName(atStub: target) == "_sysctlbyname")
|
||||
// An address that is not a stub resolves to nothing rather than to the
|
||||
// nearest entry.
|
||||
#expect(symbols.importName(atStub: call.callVMA) == nil)
|
||||
}
|
||||
|
||||
/// The argument anchor on its own, over a synthesised stream, because the
|
||||
/// shipped binary only exercises one of its two branches.
|
||||
///
|
||||
/// 24A435 forms the pointer straight into x0, so the indirection path —
|
||||
/// the literal built in a scratch register and moved into x0 before the
|
||||
/// call — has no coverage from the fixture. It is the path that keeps the
|
||||
/// patch working when a later build schedules the argument differently, so
|
||||
/// it is checked here instead of assumed.
|
||||
@Test
|
||||
func acceptsTheLiteralReachingX0ThroughAMove() throws {
|
||||
let base: UInt64 = 0x1_0000_0000
|
||||
|
||||
func stream(movingInto destination: UInt32?) throws -> [Instruction] {
|
||||
var code = Data()
|
||||
code += try #require(ARM64Encoder.encodeADRP(rd: 9, pc: base, target: base + 0x4000))
|
||||
code += try #require(ARM64Encoder.encodeAddImm12(rd: 9, rn: 9, imm12: 0x453))
|
||||
if let destination {
|
||||
code += ARM64Encoder.encodeMovX(rd: destination, rm: 9)
|
||||
} else {
|
||||
code += ARM64.nop
|
||||
}
|
||||
code += try #require(ARM64Encoder.encodeBL(from: Int(base) + 12, to: Int(base) + 0x100))
|
||||
return ARM64Disassembler().disassemble(code, at: base)
|
||||
}
|
||||
|
||||
// add x9, … ; mov x0, x9 ; bl — the literal is the call's argument.
|
||||
#expect(CFWWatchdogd.passesLiteral(
|
||||
inRegister: "x9", from: 1, toCallAt: 3, in: try stream(movingInto: 0)
|
||||
))
|
||||
// The same shape moving into x1 is some other call's argument.
|
||||
#expect(!CFWWatchdogd.passesLiteral(
|
||||
inRegister: "x9", from: 1, toCallAt: 3, in: try stream(movingInto: 1)
|
||||
))
|
||||
// No move at all, and the pointer never reaches x0.
|
||||
#expect(!CFWWatchdogd.passesLiteral(
|
||||
inRegister: "x9", from: 1, toCallAt: 3, in: try stream(movingInto: nil)
|
||||
))
|
||||
// The direct form the shipped binary uses needs no move.
|
||||
#expect(CFWWatchdogd.passesLiteral(
|
||||
inRegister: "x0", from: 1, toCallAt: 3, in: try stream(movingInto: nil)
|
||||
))
|
||||
}
|
||||
|
||||
/// A Mach-O from the same firmware that never queries the sysctl is a hard
|
||||
/// error, not a silent no-op: "no site" and "already patched" must not be
|
||||
/// the same answer.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasSeputil))
|
||||
func rejectsABinaryWithoutTheCacheSite() throws {
|
||||
let data = try Data(contentsOf: WatchdogdFixture.seputil)
|
||||
#expect(throws: PatcherError.self) {
|
||||
try CFWWatchdogd.locateSites(in: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Patching
|
||||
|
||||
@Suite("watchdogd hv_vmm_present cache — patching")
|
||||
struct CFWWatchdogdPatchTests {
|
||||
/// Both instructions, and nothing else in `__TEXT`.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func rewritesTwoInstructionsPerSite() throws {
|
||||
let original = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
var data = original
|
||||
let report = try CFWWatchdogd.patch(&data, log: nil)
|
||||
|
||||
#expect(report.outcome == .patched)
|
||||
#expect(report.sitesWritten == 2)
|
||||
#expect(report.records.count == 4)
|
||||
|
||||
let expectedValue = try #require(ARM64Encoder.encodeMovzW(rd: 8, imm16: 1))
|
||||
for (index, record) in report.records.enumerated() {
|
||||
#expect(record.component == "watchdogd")
|
||||
#expect(record.originalBytes.count == 4)
|
||||
#expect(record.patchedBytes == (index % 2 == 0 ? ARM64.nop : expectedValue))
|
||||
#expect(data.subdata(in: record.fileOffset ..< record.fileOffset + 4) == record.patchedBytes)
|
||||
}
|
||||
|
||||
// Outside the two instructions and the two code slots, the file is
|
||||
// untouched: the patch is surgical by construction, not by inspection.
|
||||
var differing: [Int] = []
|
||||
for offset in 0 ..< original.count where original[offset] != data[offset] {
|
||||
differing.append(offset)
|
||||
}
|
||||
let instructionBytes = Set(report.records.flatMap { $0.fileOffset ..< $0.fileOffset + 4 })
|
||||
let slotBytes = Set(report.rehashedSlots.flatMap { $0.hashFileOffset ..< $0.hashFileOffset + 32 })
|
||||
#expect(Set(differing).isSubset(of: instructionBytes.union(slotBytes)))
|
||||
#expect(report.rehashedSlots.count == 2)
|
||||
}
|
||||
|
||||
/// Every page that was written gets its slot re-hashed — checked against
|
||||
/// the code directory's own slot arithmetic rather than the patcher's.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func reattestsThePageOfEveryWrittenInstruction() throws {
|
||||
var data = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
let report = try CFWWatchdogd.patch(&data, log: nil)
|
||||
let directory = try #require(CFWMachOCodeSignature.codeDirectories(in: data)?.first)
|
||||
|
||||
let writtenPages = Set(report.records.map { $0.fileOffset / directory.pageSize })
|
||||
#expect(Set(report.rehashedSlots.map(\.pageIndex)) == writtenPages)
|
||||
|
||||
for slot in report.rehashedSlots {
|
||||
let range = try #require(directory.slotRange(slot.pageIndex))
|
||||
let stored = data.subdata(
|
||||
in: directory.slotHashOffset(slot.pageIndex)
|
||||
..< directory.slotHashOffset(slot.pageIndex) + directory.hashSize
|
||||
)
|
||||
#expect(stored == slot.after)
|
||||
#expect(slot.pageStart ..< slot.pageEnd == range)
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotence. Commit 8eb6c8b fixed exactly this class of bug in this
|
||||
/// tree: a shape the patcher had already produced was not recognised. A
|
||||
/// second run must change nothing at all — not the instructions, not the
|
||||
/// code directory, not one byte of the file.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func secondRunChangesNothing() throws {
|
||||
var data = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
let first = try CFWWatchdogd.patch(&data, log: nil)
|
||||
#expect(first.outcome == .patched)
|
||||
|
||||
let afterFirst = data
|
||||
let second = try CFWWatchdogd.patch(&data, log: nil)
|
||||
#expect(second.outcome == .alreadyPatched)
|
||||
#expect(second.sitesWritten == 0)
|
||||
#expect(second.records.isEmpty)
|
||||
#expect(second.rehashedSlots.isEmpty)
|
||||
#expect(data == afterFirst, "a second run must be byte-for-byte a no-op")
|
||||
|
||||
// And the already-patched shape is recognised as such, site by site.
|
||||
#expect(second.sites.count == 2)
|
||||
#expect(second.sites.allSatisfy { $0.state == .patched })
|
||||
#expect(second.sites.map(\.gateVMA) == first.sites.map(\.gateVMA))
|
||||
#expect(second.sites.map(\.valueVMA) == first.sites.map(\.valueVMA))
|
||||
}
|
||||
|
||||
/// A dry run reports the sites and writes nothing.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func dryRunWritesNothing() throws {
|
||||
let original = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
var data = original
|
||||
let report = try CFWWatchdogd.patch(&data, dryRun: true, log: nil)
|
||||
#expect(report.outcome == .wouldPatch)
|
||||
#expect(report.records.count == 4)
|
||||
#expect(data == original)
|
||||
}
|
||||
|
||||
/// The file-backed entry point, which is what the install script calls.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func patchesInPlaceOnDisk() throws {
|
||||
let file = try WatchdogdFixture.scratchCopy(of: WatchdogdFixture.watchdogd, named: "watchdogd")
|
||||
defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) }
|
||||
|
||||
let attributes = FileManager.default.attributesOfItem(atPath:)
|
||||
let modeBefore = try attributes(file.path)[.posixPermissions] as? NSNumber
|
||||
|
||||
let report = try CFWWatchdogd.patch(at: file, log: nil)
|
||||
#expect(report.outcome == .patched)
|
||||
|
||||
var expected = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
try CFWWatchdogd.patch(&expected, log: nil)
|
||||
#expect(try Data(contentsOf: file) == expected)
|
||||
|
||||
// watchdogd is installed executable and stays that way: the patch
|
||||
// rewrites the file in place rather than replacing it.
|
||||
#expect(try attributes(file.path)[.posixPermissions] as? NSNumber == modeBefore)
|
||||
#expect(FileManager.default.isExecutableFile(atPath: file.path))
|
||||
}
|
||||
|
||||
/// A second run over a file on disk leaves its bytes, and its mtime,
|
||||
/// untouched — `.alreadyPatched` must not write at all.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasWatchdogd))
|
||||
func secondRunOnDiskWritesNothing() throws {
|
||||
let file = try WatchdogdFixture.scratchCopy(of: WatchdogdFixture.watchdogd, named: "watchdogd")
|
||||
defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) }
|
||||
|
||||
try CFWWatchdogd.patch(at: file, log: nil)
|
||||
let afterFirst = try Data(contentsOf: file)
|
||||
let stampAfterFirst = try FileManager.default
|
||||
.attributesOfItem(atPath: file.path)[.modificationDate] as? Date
|
||||
|
||||
let second = try CFWWatchdogd.patch(at: file, log: nil)
|
||||
#expect(second.outcome == .alreadyPatched)
|
||||
#expect(try Data(contentsOf: file) == afterFirst)
|
||||
#expect(try FileManager.default
|
||||
.attributesOfItem(atPath: file.path)[.modificationDate] as? Date == stampAfterFirst)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Independent references
|
||||
|
||||
@Suite("watchdogd hv_vmm_present cache — independent references")
|
||||
struct CFWWatchdogdReferenceTests {
|
||||
/// The migration plan's gate for P1.2: the Swift patcher and the Python it
|
||||
/// replaces must produce the same bytes from the same input.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasPythonReference))
|
||||
func matchesThePythonReferenceByteForByte() throws {
|
||||
let reference = try WatchdogdFixture.scratchCopy(
|
||||
of: WatchdogdFixture.watchdogd, named: "watchdogd-python"
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: reference.deletingLastPathComponent()) }
|
||||
|
||||
let result = try WatchdogdFixture.run(WatchdogdFixture.python, [
|
||||
WatchdogdFixture.cfwCLI.path, "patch-watchdogd", reference.path,
|
||||
])
|
||||
#expect(result.status == 0, "reference patcher failed: \(result.output)")
|
||||
|
||||
var mine = try Data(contentsOf: WatchdogdFixture.watchdogd)
|
||||
try CFWWatchdogd.patch(&mine, log: nil)
|
||||
|
||||
let theirs = try Data(contentsOf: reference)
|
||||
// Printed so the parity claim is checkable from outside this process:
|
||||
// `shasum -a 256` over either patcher's output has to read the same.
|
||||
print("""
|
||||
watchdogd parity: \
|
||||
pristine=\(WatchdogdFixture.digest(try Data(contentsOf: WatchdogdFixture.watchdogd))) \
|
||||
python=\(WatchdogdFixture.digest(theirs)) \
|
||||
swift=\(WatchdogdFixture.digest(mine))
|
||||
""")
|
||||
#expect(mine.count == theirs.count)
|
||||
#expect(mine == theirs, "Swift and Python output must be identical")
|
||||
}
|
||||
|
||||
/// The Python's idempotent path, for the same reason: both implementations
|
||||
/// must agree that a patched binary needs nothing done to it.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasPythonReference))
|
||||
func agreesWithThePythonOnAnAlreadyPatchedBinary() throws {
|
||||
let file = try WatchdogdFixture.scratchCopy(
|
||||
of: WatchdogdFixture.watchdogd, named: "watchdogd-twice"
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) }
|
||||
|
||||
try CFWWatchdogd.patch(at: file, log: nil)
|
||||
let afterSwift = try Data(contentsOf: file)
|
||||
|
||||
let result = try WatchdogdFixture.run(WatchdogdFixture.python, [
|
||||
WatchdogdFixture.cfwCLI.path, "patch-watchdogd", file.path,
|
||||
])
|
||||
#expect(result.status == 0, "reference patcher failed: \(result.output)")
|
||||
#expect(result.output.contains("already patched"))
|
||||
#expect(try Data(contentsOf: file) == afterSwift)
|
||||
}
|
||||
|
||||
/// `codesign` recomputes the page hashes independently of this code. If the
|
||||
/// re-attestation were wrong — the short tail slot being the classic way —
|
||||
/// this is where it shows.
|
||||
@Test(.enabled(if: WatchdogdFixture.hasCodesign))
|
||||
func patchedBinaryStillVerifiesUnderCodesign() throws {
|
||||
let file = try WatchdogdFixture.scratchCopy(
|
||||
of: WatchdogdFixture.watchdogd, named: "watchdogd-signed"
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) }
|
||||
|
||||
let before = try WatchdogdFixture.run(WatchdogdFixture.codesign, ["-v", "-v", file.path])
|
||||
#expect(before.status == 0, "fixture must verify before patching: \(before.output)")
|
||||
|
||||
try CFWWatchdogd.patch(at: file, log: nil)
|
||||
|
||||
let after = try WatchdogdFixture.run(WatchdogdFixture.codesign, ["-v", "-v", file.path])
|
||||
#expect(after.status == 0, "patched binary must still verify: \(after.output)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
// Equivalence tests for the Swift port of the two Python heredocs that used to
|
||||
// live inside scripts/fw_prepare.sh (`list_firmwares`,
|
||||
// `resolve_selector_from_downloads`).
|
||||
//
|
||||
// The golden strings below are not hand-written. They are the bytes the Python
|
||||
// produced when the two heredocs were lifted out of fw_prepare.sh verbatim
|
||||
// (`diff <(sed -n '167,246p' scripts/fw_prepare.sh) ref_list_firmwares.py`
|
||||
// reports no difference) and run under .venv/bin/python3 over the same fixture
|
||||
// this file freezes:
|
||||
//
|
||||
// DOWNLOADABLE_IPSW_URLS="$(cat fixture_urls.txt)" \
|
||||
// .venv/bin/python3 ref_list_firmwares.py iPhone17,3 fixture_readme.md
|
||||
//
|
||||
// Colour was compared in three conditions, each running both implementations
|
||||
// through the identical wrapper: a pipe, a real pty via script(1), and NO_COLOR
|
||||
// set. Those runs also covered the repository's own README.md against a live
|
||||
// `ipsw download ipsw --device iPhone17,3 --urls` capture.
|
||||
//
|
||||
// Trailing spaces are written as `\u{20}` on purpose: the Python padded the
|
||||
// status column to 11 *inside* the colour escape, so those spaces are real
|
||||
// output bytes, and a literal trailing space is the first thing an editor or a
|
||||
// formatter silently eats.
|
||||
//
|
||||
// One deliberate divergence, and the only one found: `version_key` built a
|
||||
// tuple of mixed ints and strs, so a list holding both `27.0` and `27.beta`
|
||||
// made CPython raise
|
||||
// `TypeError: '<' not supported between instances of 'str' and 'int'`
|
||||
// and exit 1 mid-listing. Nothing Apple serves produces that. The port orders
|
||||
// numbers before text instead of crashing; `nonNumericComponentsStillOrder`
|
||||
// below pins that choice down.
|
||||
|
||||
@testable import VPhoneCore
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
// MARK: - Frozen fixture
|
||||
|
||||
private enum Fixture {
|
||||
/// A verbatim slice of the real README's table, plus two decoys: a
|
||||
/// `17,3_…` cell before the section and another after it, neither of which
|
||||
/// may count, and a `16,1_…` row for a different device.
|
||||
static let readme = """
|
||||
## Prerequisites
|
||||
|
||||
Some prose with a `17,3_99.9_99Z99` cell that must NOT count, because it is
|
||||
outside the section.
|
||||
|
||||
## Tested Environments
|
||||
|
||||
| Host | iPhone | CloudOS |
|
||||
| --------------- | --------------------- | --------------- |
|
||||
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
|
||||
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
|
||||
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
|
||||
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
|
||||
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.6.1 | `17,3_27.0_24A435` | `26.4-23E5207q` |
|
||||
| Mac16,3 26.0 | `16,1_26.1_23B85` | `26.1-23B85` |
|
||||
|
||||
## FAQ
|
||||
|
||||
A later section with `17,3_88.8_88Z88`, which must NOT count either.
|
||||
"""
|
||||
|
||||
/// Real Apple restore URLs, plus one exact duplicate and four lines that
|
||||
/// look close enough to matter: another device, an extra `_Custom`
|
||||
/// segment, and a log line with no leading slash.
|
||||
static let urls = """
|
||||
https://updates.cdn-apple.com/2025SummerFCS/fullrestores/093-20738/98758B5A-311E-4538-B365-FEE3D8792CDF/iPhone17,3_18.6.2_22G100_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025FallFCS/fullrestores/093-40775/B7282E74-76C1-4D0A-8FAE-CE97FC2330C2/iPhone17,3_26.0_23A341_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026WinterFCS/fullrestores/047-39165/E8E603F3-A2E2-4638-8067-394754896386/iPhone17,3_26.3_23D127_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026WinterFCS/fullrestores/047-90312/17B5C7BE-C560-43BD-BA9A-7DD1E5C2FC23/iPhone17,3_26.3.1_23D8133_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringFCS/fullrestores/122-06082/FE21226A-B87F-4FC7-9D4B-B97A9EAF5C20/iPhone17,3_26.4_23E246_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringFCS/fullrestores/122-28526/10E1E3EC-6A3E-4620-A569-8E0C4361AB77/iPhone17,3_26.4.1_23E254_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-57108/5E816D0E-89BB-4B95-8825-6A3EDF22E509/iPhone17,3_27.0_24A5390f_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringSeed/2d03d580-843b-4b2a-b09d-976b31c10744/iPhone17,3_27.0_24A5430a_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026FallFCS/2d0cd01d-b4f9-4a20-a1e8-f3be54570da7/iPhone17,3_27.0_24A435_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026WinterFCS/fullrestores/047-39165/E8E603F3-A2E2-4638-8067-394754896386/iPhone17,3_26.3_23D127_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025FallFCS/fullrestores/089-13864/668EFC0E-5911-454C-96C6-E1063CB80042/iPad16,3_26.1_23B85_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025FallFCS/fullrestores/089-13864/668EFC0E-5911-454C-96C6-E1063CB80042/iPhone17,3_26.1_23B85_Custom_Restore.ipsw
|
||||
· Downloading iPhone17,3_26.1_23B85_Restore.ipsw
|
||||
"""
|
||||
|
||||
static let d = "iPhone17,3"
|
||||
static let url263 = "https://updates.cdn-apple.com/2026WinterFCS/fullrestores/047-39165/E8E603F3-A2E2-4638-8067-394754896386/iPhone17,3_26.3_23D127_Restore.ipsw"
|
||||
|
||||
static func lines(_ lines: [String]) -> String {
|
||||
lines.map { $0 + "\n" }.joined()
|
||||
}
|
||||
|
||||
static let plain = VPhoneStatusStyle(isColored: false)
|
||||
static let colored = VPhoneStatusStyle(isColored: true)
|
||||
|
||||
/// Writes `readme` to a scratch file, because the command-line half takes a
|
||||
/// path (that is what the shell hands it).
|
||||
static func withReadmeFile<T>(_ body: (String) throws -> T) rethrows -> T {
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("vphone-fwmatrix-\(UUID().uuidString).md")
|
||||
try? readme.write(to: url, atomically: true, encoding: .utf8)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
return try body(url.path)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Goldens
|
||||
|
||||
private enum Golden {
|
||||
static let listPlain = Fixture.lines([
|
||||
"Available downloadable IPSWs for iPhone17,3:",
|
||||
"",
|
||||
"Status: Supported Not Tested Unsupported",
|
||||
"",
|
||||
"VERSION BUILD STATUS",
|
||||
"27.0 24A5430a Not Tested\u{20}",
|
||||
"27.0 24A5390f Supported\u{20}\u{20}",
|
||||
"27.0 24A435 Supported\u{20}\u{20}",
|
||||
"26.4.1 23E254 Not Tested\u{20}",
|
||||
"26.4 23E246 Supported\u{20}\u{20}",
|
||||
"26.3.1 23D8133 Supported\u{20}\u{20}",
|
||||
"26.3 23D127 Supported\u{20}\u{20}",
|
||||
"26.0 23A341 Supported\u{20}\u{20}",
|
||||
"18.6.2 22G100 Supported\u{20}\u{20}",
|
||||
])
|
||||
|
||||
static let listColored = Fixture.lines([
|
||||
"Available downloadable IPSWs for iPhone17,3:",
|
||||
"",
|
||||
"Status: \u{1B}[32mSupported \u{1B}[0m \u{1B}[33mNot Tested \u{1B}[0m \u{1B}[31mUnsupported\u{1B}[0m",
|
||||
"",
|
||||
"VERSION BUILD STATUS",
|
||||
"27.0 24A5430a \u{1B}[33mNot Tested \u{1B}[0m",
|
||||
"27.0 24A5390f \u{1B}[32mSupported \u{1B}[0m",
|
||||
"27.0 24A435 \u{1B}[32mSupported \u{1B}[0m",
|
||||
"26.4.1 23E254 \u{1B}[33mNot Tested \u{1B}[0m",
|
||||
"26.4 23E246 \u{1B}[32mSupported \u{1B}[0m",
|
||||
"26.3.1 23D8133 \u{1B}[32mSupported \u{1B}[0m",
|
||||
"26.3 23D127 \u{1B}[32mSupported \u{1B}[0m",
|
||||
"26.0 23A341 \u{1B}[32mSupported \u{1B}[0m",
|
||||
"18.6.2 22G100 \u{1B}[32mSupported \u{1B}[0m",
|
||||
])
|
||||
|
||||
/// The selector's status column is *not* padded — a real asymmetry between
|
||||
/// the two heredocs that the port has to keep.
|
||||
static let ambiguousColored = Fixture.lines([
|
||||
"Version 27.0 is ambiguous for iPhone17,3; specify one of these builds:",
|
||||
"BUILD STATUS",
|
||||
"24A5430a \u{1B}[33mNot Tested\u{1B}[0m",
|
||||
"24A5390f \u{1B}[32mSupported\u{1B}[0m",
|
||||
"24A435 \u{1B}[32mSupported\u{1B}[0m",
|
||||
])
|
||||
|
||||
static let ambiguousPlain = Fixture.lines([
|
||||
"Version 27.0 is ambiguous for iPhone17,3; specify one of these builds:",
|
||||
"BUILD STATUS",
|
||||
"24A5430a Not Tested",
|
||||
"24A5390f Supported",
|
||||
"24A435 Supported",
|
||||
])
|
||||
|
||||
static let hit = "26.3\t23D127\t\(Fixture.url263)\tSupported\n"
|
||||
}
|
||||
|
||||
// MARK: - Byte-for-byte equivalence with the Python
|
||||
|
||||
struct FirmwareMatrixGoldenTests {
|
||||
@Test func listingMatchesPythonPlain() {
|
||||
let out = VPhoneFirmwareMatrix.listing(
|
||||
device: Fixture.d, readme: Fixture.readme,
|
||||
downloadURLs: Fixture.urls, style: Fixture.plain
|
||||
)
|
||||
#expect(out == .matrix(Golden.listPlain))
|
||||
}
|
||||
|
||||
@Test func listingMatchesPythonColored() {
|
||||
let out = VPhoneFirmwareMatrix.listing(
|
||||
device: Fixture.d, readme: Fixture.readme,
|
||||
downloadURLs: Fixture.urls, style: Fixture.colored
|
||||
)
|
||||
#expect(out == .matrix(Golden.listColored))
|
||||
}
|
||||
|
||||
@Test func ambiguousVersionMatchesPython() {
|
||||
for (style, golden) in [
|
||||
(Fixture.plain, Golden.ambiguousPlain),
|
||||
(Fixture.colored, Golden.ambiguousColored),
|
||||
] {
|
||||
let out = VPhoneFirmwareMatrix.selection(
|
||||
device: Fixture.d, version: "27.0", build: "",
|
||||
readme: Fixture.readme, downloadURLs: Fixture.urls, style: style
|
||||
)
|
||||
#expect(out == .ambiguous(golden))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolvedSelectorMatchesPython() {
|
||||
let out = VPhoneFirmwareMatrix.selection(
|
||||
device: Fixture.d, version: "26.3", build: "",
|
||||
readme: Fixture.readme, downloadURLs: Fixture.urls, style: Fixture.colored
|
||||
)
|
||||
guard case let .selected(release, support) = out else {
|
||||
Issue.record("expected a selection, got \(out)")
|
||||
return
|
||||
}
|
||||
// The resolved line is plain even under colour — the shell reads it
|
||||
// back through `IFS=$'\t' read`, and an escape in field 4 would end up
|
||||
// in the "Status:" line it echoes.
|
||||
#expect(out.resolvedLine == Golden.hit)
|
||||
#expect(release.build == "23D127")
|
||||
#expect(support == .supported)
|
||||
// Only a hit has a line; the failure cases have nothing to hand back.
|
||||
#expect(VPhoneFirmwareMatrix.selection(
|
||||
device: Fixture.d, version: "99.9", build: "",
|
||||
readme: Fixture.readme, downloadURLs: Fixture.urls, style: Fixture.plain
|
||||
).resolvedLine == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("99.9", "", "Unsupported: no downloadable IPSW matched device=iPhone17,3 version=99.9\n"),
|
||||
("", "99Z99", "Unsupported: no downloadable IPSW matched device=iPhone17,3 build=99Z99\n"),
|
||||
("99.9", "99Z99", "Unsupported: no downloadable IPSW matched device=iPhone17,3 version=99.9 build=99Z99\n"),
|
||||
])
|
||||
func missMatchesPython(version: String, build: String, expected: String) {
|
||||
let out = VPhoneFirmwareMatrix.selection(
|
||||
device: Fixture.d, version: version, build: build,
|
||||
readme: Fixture.readme, downloadURLs: Fixture.urls, style: Fixture.plain
|
||||
)
|
||||
#expect(out == .unmatched(expected))
|
||||
let colored = VPhoneFirmwareMatrix.selection(
|
||||
device: Fixture.d, version: version, build: build,
|
||||
readme: Fixture.readme, downloadURLs: Fixture.urls, style: Fixture.colored
|
||||
)
|
||||
#expect(colored == .unmatched("\u{1B}[31mUnsupported\u{1B}[0m" + expected.dropFirst("Unsupported".count)))
|
||||
}
|
||||
|
||||
@Test func emptyDownloadListIsAnError() {
|
||||
let out = VPhoneFirmwareMatrix.listing(
|
||||
device: "iPhone99,9", readme: Fixture.readme,
|
||||
downloadURLs: Fixture.urls, style: Fixture.colored
|
||||
)
|
||||
// Plain even in colour, exactly as the Python printed it.
|
||||
#expect(out == .nothingDownloadable("No downloadable IPSWs found for iPhone99,9\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
struct FirmwareMatrixParsingTests {
|
||||
@Test func readsOnlyTheTestedEnvironmentsSectionAndOnlyThisDevice() {
|
||||
let tested = VPhoneFirmwareMatrix.testedBuilds(readme: Fixture.readme, device: Fixture.d)
|
||||
#expect(tested.count == 7)
|
||||
#expect(tested.contains(VPhoneFirmwareBuildID(version: "26.3.1", build: "23D8133")))
|
||||
// Before the heading.
|
||||
#expect(!tested.contains(VPhoneFirmwareBuildID(version: "99.9", build: "99Z99")))
|
||||
// After the next `## ` heading.
|
||||
#expect(!tested.contains(VPhoneFirmwareBuildID(version: "88.8", build: "88Z88")))
|
||||
// A different device's row inside the section.
|
||||
#expect(!tested.contains(VPhoneFirmwareBuildID(version: "26.1", build: "23B85")))
|
||||
}
|
||||
|
||||
@Test func aMissingReadmeMeansNothingIsTested() {
|
||||
#expect(VPhoneFirmwareMatrix.testedBuilds(readme: nil, device: Fixture.d).isEmpty)
|
||||
let out = VPhoneFirmwareMatrix.listing(
|
||||
device: Fixture.d, readme: nil,
|
||||
downloadURLs: Fixture.urls, style: Fixture.plain
|
||||
)
|
||||
guard case let .matrix(text) = out else {
|
||||
Issue.record("expected a matrix")
|
||||
return
|
||||
}
|
||||
#expect(!text.contains("Supported\u{20}\u{20}\n"))
|
||||
#expect(text.components(separatedBy: "Not Tested").count == 1 + 9 + 1)
|
||||
}
|
||||
|
||||
@Test func rejectsLookalikeURLs() {
|
||||
let found = VPhoneFirmwareMatrix.releases(downloadURLs: Fixture.urls, device: Fixture.d)
|
||||
// 9 real lines, 1 exact duplicate dropped, 3 lookalikes rejected.
|
||||
#expect(found.count == 9)
|
||||
#expect(!found.contains { $0.url.contains("iPad16,3") })
|
||||
#expect(!found.contains { $0.url.contains("_Custom_") })
|
||||
#expect(!found.contains { $0.build == "23B85" })
|
||||
}
|
||||
|
||||
@Test func toleratesSurroundingWhitespaceAndBlankLines() {
|
||||
let urls = "\n \(Fixture.url263) \n\n"
|
||||
let found = VPhoneFirmwareMatrix.releases(downloadURLs: urls, device: Fixture.d)
|
||||
#expect(found.count == 1)
|
||||
// The URL is stored stripped, so the shell gets something it can fetch.
|
||||
#expect(found.first?.url == Fixture.url263)
|
||||
}
|
||||
|
||||
@Test func parsesTheRepositoryReadme() throws {
|
||||
let tested = VPhoneFirmwareMatrix.testedBuilds(
|
||||
readme: try RealData.repositoryReadme(),
|
||||
device: Fixture.d
|
||||
)
|
||||
// The live table is edited often, so this asserts the shape, not a count.
|
||||
#expect(tested.count >= 15)
|
||||
#expect(tested.contains(VPhoneFirmwareBuildID(version: "26.1", build: "23B85")))
|
||||
#expect(tested.allSatisfy { !$0.version.isEmpty && !$0.build.isEmpty })
|
||||
#expect(tested.allSatisfy { !$0.version.contains("`") && !$0.build.contains("`") })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Real data
|
||||
|
||||
/// The repository's own README table joined against a real `ipsw download ipsw
|
||||
/// --device iPhone17,3 --urls` capture — the two inputs the shell actually fed
|
||||
/// the Python, rather than anything shaped to suit the parser.
|
||||
///
|
||||
/// The README is read live, so these assert invariants rather than a golden: a
|
||||
/// row added to the table must not turn the suite red for the agent who added
|
||||
/// it. The cross-check is against a second parser written here on purpose in a
|
||||
/// different style — column splitting instead of a regex — so a regex that
|
||||
/// drifts has something independent to disagree with.
|
||||
private enum RealData {
|
||||
static func repositoryReadme() throws -> String {
|
||||
let url = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("README.md")
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Captured 2026-09-23. 31 restore images, one build per version — which is
|
||||
/// why the selector's ambiguity branch cannot be reached from live data and
|
||||
/// is exercised from `Fixture` instead.
|
||||
static let liveURLs = """
|
||||
https://updates.cdn-apple.com/2026FallFCS/5130b3f9-3b4e-469a-b60e-93f6b310cdd9/iPhone17,3_27.0_24A437_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SummerFCS/29d685ce-f70d-45a0-9823-b1cd115f3927/iPhone17,3_26.6.2_23G90_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SummerFCS/fullrestores/140-93817/B5362BAA-F3EE-49C8-BA43-309F0DAD1362/iPhone17,3_26.6.1_23G83_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SummerFCS/fullrestores/140-58193/1F477C3E-934B-43C0-B428-753B9E005EC0/iPhone17,3_26.6_23G71_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringFCS/fullrestores/140-25549/1AFB1F72-E48E-476A-9C21-42B27C846C01/iPhone17,3_26.5.2_23F84_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringFCS/fullrestores/122-63074/5E6B4A05-BDBC-45FE-9606-22B8F4315989/iPhone17,3_26.5_23F77_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringFCS/fullrestores/122-60828/A4082066-CCC4-4903-89E6-FF4801EA609C/iPhone17,3_26.4.2_23E261_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringFCS/fullrestores/122-28526/10E1E3EC-6A3E-4620-A569-8E0C4361AB77/iPhone17,3_26.4.1_23E254_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026SpringFCS/fullrestores/122-06082/FE21226A-B87F-4FC7-9D4B-B97A9EAF5C20/iPhone17,3_26.4_23E246_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026WinterFCS/fullrestores/047-90312/17B5C7BE-C560-43BD-BA9A-7DD1E5C2FC23/iPhone17,3_26.3.1_23D8133_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2026WinterFCS/fullrestores/047-39165/E8E603F3-A2E2-4638-8067-394754896386/iPhone17,3_26.3_23D127_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025FallFCS/fullrestores/047-34150/D14FB1F1-B8C5-4A20-9250-8DD35EF19BF5/iPhone17,3_26.2.1_23C71_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025FallFCS/fullrestores/089-90760/1214478F-8ED8-4AE0-B693-2F63CE0259A9/iPhone17,3_26.2_23C55_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025FallFCS/fullrestores/089-13864/668EFC0E-5911-454C-96C6-E1063CB80042/iPhone17,3_26.1_23B85_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025FallFCS/fullrestores/093-46329/C1717B2A-9E58-4131-A398-75D9B1D01A89/iPhone17,3_26.0.1_23A355_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025FallFCS/fullrestores/093-40775/B7282E74-76C1-4D0A-8FAE-CE97FC2330C2/iPhone17,3_26.0_23A341_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025SummerFCS/fullrestores/093-20738/98758B5A-311E-4538-B365-FEE3D8792CDF/iPhone17,3_18.6.2_22G100_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025SummerFCS/fullrestores/093-07229/D567EFF0-6D62-461A-9F66-B2EAE22C2DD8/iPhone17,3_18.6.1_22G90_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025SummerFCS/fullrestores/082-98952/853D36FA-A5F4-4B75-B5F3-8D38430F6132/iPhone17,3_18.6_22G86_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025SpringFCS/fullrestores/082-46019/A46C805A-4915-4552-BFD4-EFD1C7BE665E/iPhone17,3_18.5_22F76_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025SpringFCS/fullrestores/082-30380/AD76C77E-51AA-4D55-A074-9C0E7545D784/iPhone17,3_18.4.1_22E252_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025SpringFCS/fullrestores/082-14688/8DC47FED-F5B6-4BE6-B75F-8E36BCC5A484/iPhone17,3_18.4_22E240_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025WinterFCS/fullrestores/082-03679/DA16409C-A02F-46E9-ADE9-ED32BDD47539/iPhone17,3_18.3.2_22D82_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025WinterFCS/fullrestores/072-85742/283FD833-D2EF-4A97-AE6F-406E1DD998DA/iPhone17,3_18.3.1_22D72_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2025WinterFCS/fullrestores/072-68251/8CC707F3-9926-4ECC-B7F6-3497FE282048/iPhone17,3_18.3_22D63_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2024FallFCS/fullrestores/072-55500/A62CEE9F-875F-4E53-9C9D-D4172E30BE88/iPhone17,3_18.2.1_22C161_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2024FallFCS/fullrestores/072-42011/88CB7A92-6959-4FB4-A87E-77CE7CB8BB82/iPhone17,3_18.2_22C152_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2024FallFCS/fullrestores/072-31863/D2DE541A-DC63-4016-9B22-0EED8A753FB9/iPhone17,3_18.1.1_22B91_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2024FallFCS/fullrestores/072-12560/4A88CBAB-2F24-4173-BCFD-210325515BCA/iPhone17,3_18.1_22B83_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2024FallFCS/fullrestores/072-02193/DD4AADB5-492C-442E-8E79-42D72EDAF958/iPhone17,3_18.0.1_22A3370_Restore.ipsw
|
||||
https://updates.cdn-apple.com/2024FallFCS/fullrestores/062-77769/2A2DBD27-47A6-40DB-AEF5-E283454A67E8/iPhone17,3_18.0_22A3354_Restore.ipsw
|
||||
"""
|
||||
|
||||
/// The same table, read by splitting on `|` and `_` instead of by regex.
|
||||
static func testedBuildsByColumnSplitting(
|
||||
in readme: String,
|
||||
deviceSuffix: String
|
||||
) -> Set<VPhoneFirmwareBuildID> {
|
||||
// A different way to find the section too: cut the document at its `## `
|
||||
// headings rather than scanning line by line for them.
|
||||
let sections = readme.components(separatedBy: "\n## ")
|
||||
guard let table = sections.first(where: { $0.hasPrefix("Tested Environments") })
|
||||
else { return [] }
|
||||
|
||||
var tested: Set<VPhoneFirmwareBuildID> = []
|
||||
for row in table.components(separatedBy: "\n") {
|
||||
for cell in row.components(separatedBy: "|") {
|
||||
let trimmed = cell.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasPrefix("`"), trimmed.hasSuffix("`"), trimmed.count > 2
|
||||
else { continue }
|
||||
let fields = trimmed.dropFirst().dropLast().components(separatedBy: "_")
|
||||
guard fields.count == 3, fields[0] == deviceSuffix else { continue }
|
||||
tested.insert(VPhoneFirmwareBuildID(version: fields[1], build: fields[2]))
|
||||
}
|
||||
}
|
||||
return tested
|
||||
}
|
||||
}
|
||||
|
||||
struct FirmwareMatrixRealDataTests {
|
||||
@Test func theReadmeParserAgreesWithAnIndependentColumnSplitter() throws {
|
||||
let readme = try RealData.repositoryReadme()
|
||||
let byRegex = VPhoneFirmwareMatrix.testedBuilds(readme: readme, device: Fixture.d)
|
||||
let bySplitting = RealData.testedBuildsByColumnSplitting(in: readme, deviceSuffix: "17,3")
|
||||
#expect(!bySplitting.isEmpty, "the README table moved — fix the test, not the parser")
|
||||
#expect(byRegex == bySplitting)
|
||||
}
|
||||
|
||||
@Test func aLiveDownloadCaptureParsesWholeAndJoinsAgainstTheReadme() throws {
|
||||
let releases = VPhoneFirmwareMatrix.releases(
|
||||
downloadURLs: RealData.liveURLs,
|
||||
device: Fixture.d
|
||||
)
|
||||
// Every line of the capture is a restore image for this device, so the
|
||||
// URL parser has to claim all 31 — a silent drop is the failure mode
|
||||
// that would quietly hide a firmware from `--list`.
|
||||
#expect(releases.count == RealData.liveURLs.components(separatedBy: "\n").count)
|
||||
#expect(releases.count == 31)
|
||||
#expect(Set(releases.map(\.build)).count == 31)
|
||||
|
||||
let tested = VPhoneFirmwareMatrix.testedBuilds(readme: try RealData.repositoryReadme(), device: Fixture.d)
|
||||
var supported = 0, notTested = 0
|
||||
for release in releases {
|
||||
let id = VPhoneFirmwareBuildID(version: release.version, build: release.build)
|
||||
switch VPhoneFirmwareMatrix.support(of: release, tested: tested) {
|
||||
case .supported:
|
||||
#expect(tested.contains(id))
|
||||
supported += 1
|
||||
case .notTested:
|
||||
#expect(!tested.contains(id))
|
||||
notTested += 1
|
||||
case .unsupported:
|
||||
Issue.record("a listing row must never be Unsupported")
|
||||
}
|
||||
}
|
||||
// Neither count may be zero, or the loop above proved nothing.
|
||||
#expect(supported > 0)
|
||||
#expect(notTested > 0)
|
||||
}
|
||||
|
||||
/// Apple serves one build per version, so `--list` over live data is fully
|
||||
/// ordered by version alone. This is the property the shell's `sorted(…,
|
||||
/// reverse=True)` was there to produce.
|
||||
@Test func theLiveCaptureListsNewestFirst() {
|
||||
let out = VPhoneFirmwareMatrix.listing(
|
||||
device: Fixture.d, readme: nil,
|
||||
downloadURLs: RealData.liveURLs, style: Fixture.plain
|
||||
)
|
||||
guard case let .matrix(text) = out else {
|
||||
Issue.record("expected a matrix")
|
||||
return
|
||||
}
|
||||
let versions = text.components(separatedBy: "\n")
|
||||
.dropFirst(5)
|
||||
.compactMap { $0.split(separator: " ").first.map(String.init) }
|
||||
#expect(versions.first == "27.0")
|
||||
#expect(versions.last == "18.0")
|
||||
// 26.6.2 above 26.6, and 26.2.1 above 26.2: a plain string sort puts
|
||||
// both the other way round.
|
||||
let index = { (v: String) in versions.firstIndex(of: v) ?? -1 }
|
||||
#expect(index("26.6.2") < index("26.6"))
|
||||
#expect(index("26.2.1") < index("26.2"))
|
||||
#expect(index("26.0.1") < index("26.0"))
|
||||
#expect(index("26.0") < index("18.6.2"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ordering
|
||||
|
||||
struct FirmwareMatrixOrderingTests {
|
||||
private func release(_ version: String, _ build: String) -> VPhoneFirmwareRelease {
|
||||
VPhoneFirmwareRelease(version: version, build: build, url: "/\(version)_\(build)")
|
||||
}
|
||||
|
||||
@Test func sortsVersionsNewestFirstWithNumericComponents() {
|
||||
// Lexically "18.6.2" > "26.0" and "26.4.1" > "26.4"; numerically neither.
|
||||
let sorted = [
|
||||
release("26.4", "a"), release("18.6.2", "a"), release("26.4.1", "a"),
|
||||
release("27.0", "a"), release("26.0", "a"), release("26.10", "a"),
|
||||
].sorted { VPhoneFirmwareMatrix.isNewer($0, than: $1) }
|
||||
#expect(sorted.map(\.version) == ["27.0", "26.10", "26.4.1", "26.4", "26.0", "18.6.2"])
|
||||
}
|
||||
|
||||
@Test func sortsBuildsByCodePointDescending() {
|
||||
// 24A435 vs 24A5430a is the case that separates a string sort from a
|
||||
// "looks numeric" one: '4' < '5' at index 3, so 24A5430a wins.
|
||||
let sorted = [
|
||||
release("27.0", "24A435"), release("27.0", "24A5430a"), release("27.0", "24A5390f"),
|
||||
].sorted { VPhoneFirmwareMatrix.hasHigherBuild($0, than: $1) }
|
||||
#expect(sorted.map(\.build) == ["24A5430a", "24A5390f", "24A435"])
|
||||
}
|
||||
|
||||
@Test func aShorterVersionSortsBelowItsOwnPrefixExtension() {
|
||||
#expect(VPhoneFirmwareMatrix.isNewer(release("26.4.1", "a"), than: release("26.4", "a")))
|
||||
#expect(!VPhoneFirmwareMatrix.isNewer(release("26.4", "a"), than: release("26.4.1", "a")))
|
||||
}
|
||||
|
||||
@Test func nonNumericComponentsStillOrder() {
|
||||
#expect(VPhoneFirmwareMatrix.versionKey("26.beta") == [.number(26), .text("beta")])
|
||||
#expect(VPhoneFirmwareMatrix.versionKey("") == [.text("")])
|
||||
#expect(VPhoneFirmwareMatrix.VersionPart.number(9) < .text("0"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Column padding
|
||||
|
||||
struct FirmwareMatrixPaddingTests {
|
||||
@Test func leftJustifyingNeverTruncates() {
|
||||
// `String.padding(toLength:)` would cut this to 12 and lose the build.
|
||||
#expect(VPhoneFirmwareMatrix.leftJustified("27.0.1.2.3.4.5", 12) == "27.0.1.2.3.4.5")
|
||||
#expect(VPhoneFirmwareMatrix.leftJustified("26.3", 12) == "26.3 ")
|
||||
#expect(VPhoneFirmwareMatrix.leftJustified("", 3) == " ")
|
||||
}
|
||||
|
||||
@Test func paddingGoesInsideTheColourEscape() {
|
||||
// Outside the escape the text would be the same width on screen but a
|
||||
// different byte string, and every golden above would break.
|
||||
#expect(Fixture.colored.render(.supported, width: 11) == "\u{1B}[32mSupported \u{1B}[0m")
|
||||
#expect(Fixture.colored.render(.supported) == "\u{1B}[32mSupported\u{1B}[0m")
|
||||
#expect(Fixture.plain.render(.supported, width: 11) == "Supported ")
|
||||
}
|
||||
|
||||
@Test func anOverlongVersionStillLeavesOneSpaceBeforeTheNextColumn() {
|
||||
let urls = "/iPhone17,3_26.4.10.20.30_23E246_Restore.ipsw"
|
||||
let out = VPhoneFirmwareMatrix.listing(
|
||||
device: Fixture.d, readme: nil, downloadURLs: urls, style: Fixture.plain
|
||||
)
|
||||
guard case let .matrix(text) = out else {
|
||||
Issue.record("expected a matrix")
|
||||
return
|
||||
}
|
||||
#expect(text.contains("26.4.10.20.30 23E246 Not Tested\u{20}\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Colour policy
|
||||
|
||||
struct FirmwareMatrixColorPolicyTests {
|
||||
/// A pty master is a terminal; a pipe is not. Both are real file
|
||||
/// descriptors, so `isatty` is answering for real here.
|
||||
private func withTTY<T>(_ body: (Int32) throws -> T) rethrows -> T {
|
||||
let fd = posix_openpt(O_RDWR | O_NOCTTY)
|
||||
defer { if fd >= 0 { close(fd) } }
|
||||
return try body(fd)
|
||||
}
|
||||
|
||||
@Test func honoursNoColorClicolorForceAndIsattyPerStream() throws {
|
||||
let pipe = Pipe()
|
||||
let pipeFD = pipe.fileHandleForWriting.fileDescriptor
|
||||
try withTTY { ttyFD in
|
||||
try #require(isatty(ttyFD) != 0, "expected a pty master to be a terminal")
|
||||
#expect(isatty(pipeFD) == 0)
|
||||
|
||||
func colored(_ fd: Int32, _ env: [String: String]) -> Bool {
|
||||
VPhoneStatusStyle.forStream(fd, environment: env).isColored
|
||||
}
|
||||
|
||||
// A terminal colours by default; a pipe does not.
|
||||
#expect(colored(ttyFD, [:]))
|
||||
#expect(!colored(pipeFD, [:]))
|
||||
|
||||
// CLICOLOR_FORCE=1 colours a pipe; any other value does not.
|
||||
#expect(colored(pipeFD, ["CLICOLOR_FORCE": "1"]))
|
||||
#expect(!colored(pipeFD, ["CLICOLOR_FORCE": "0"]))
|
||||
#expect(!colored(pipeFD, ["CLICOLOR_FORCE": "true"]))
|
||||
#expect(!colored(pipeFD, ["CLICOLOR_FORCE": ""]))
|
||||
|
||||
// NO_COLOR wins over both the terminal and CLICOLOR_FORCE …
|
||||
#expect(!colored(ttyFD, ["NO_COLOR": "1"]))
|
||||
#expect(!colored(pipeFD, ["NO_COLOR": "1", "CLICOLOR_FORCE": "1"]))
|
||||
// … but only when it is set to something. Empty is "not set", which
|
||||
// is how both the shell (`-z`) and the Python (falsy) read it.
|
||||
#expect(colored(ttyFD, ["NO_COLOR": ""]))
|
||||
#expect(!colored(pipeFD, ["NO_COLOR": ""]))
|
||||
}
|
||||
try pipe.fileHandleForWriting.close()
|
||||
try pipe.fileHandleForReading.close()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exit codes and stream routing
|
||||
|
||||
struct FirmwareMatrixCommandLineTests {
|
||||
private func capture(
|
||||
_ body: (FileHandle, FileHandle) -> Int32
|
||||
) -> (code: Int32, out: String, err: String) {
|
||||
let outPipe = Pipe(), errPipe = Pipe()
|
||||
let code = body(outPipe.fileHandleForWriting, errPipe.fileHandleForWriting)
|
||||
try? outPipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
let out = String(decoding: outPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
||||
let err = String(decoding: errPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
||||
return (code, out, err)
|
||||
}
|
||||
|
||||
@Test func listGoesToStdoutAndExitsZero() {
|
||||
let r = Fixture.withReadmeFile { path in
|
||||
capture { out, err in
|
||||
VPhoneFirmwareMatrixCommandLine.list(
|
||||
device: Fixture.d, readmePath: path, downloadURLs: Fixture.urls,
|
||||
environment: [:], stdout: out, stderr: err
|
||||
)
|
||||
}
|
||||
}
|
||||
#expect(r.code == 0)
|
||||
#expect(r.out == Golden.listPlain)
|
||||
#expect(r.err.isEmpty)
|
||||
}
|
||||
|
||||
@Test func anEmptyDeviceListGoesToStderrAndExitsOne() {
|
||||
let r = Fixture.withReadmeFile { path in
|
||||
capture { out, err in
|
||||
VPhoneFirmwareMatrixCommandLine.list(
|
||||
device: "iPhone99,9", readmePath: path, downloadURLs: Fixture.urls,
|
||||
environment: [:], stdout: out, stderr: err
|
||||
)
|
||||
}
|
||||
}
|
||||
#expect(r.code == 1)
|
||||
#expect(r.out.isEmpty)
|
||||
#expect(r.err == "No downloadable IPSWs found for iPhone99,9\n")
|
||||
}
|
||||
|
||||
@Test func aResolvedSelectorGoesToStdoutAndExitsZero() {
|
||||
let r = Fixture.withReadmeFile { path in
|
||||
capture { out, err in
|
||||
VPhoneFirmwareMatrixCommandLine.resolve(
|
||||
device: Fixture.d, version: "26.3", build: "", readmePath: path,
|
||||
downloadURLs: Fixture.urls, environment: [:], stdout: out, stderr: err
|
||||
)
|
||||
}
|
||||
}
|
||||
#expect(r.code == 0)
|
||||
#expect(r.out == Golden.hit)
|
||||
#expect(r.err.isEmpty)
|
||||
}
|
||||
|
||||
/// 2, not 1. `fw_prepare.sh` forwards this status verbatim so a caller can
|
||||
/// tell "pick a build" from "there is no such firmware".
|
||||
@Test func anAmbiguousVersionGoesToStderrAndExitsTwo() {
|
||||
let r = Fixture.withReadmeFile { path in
|
||||
capture { out, err in
|
||||
VPhoneFirmwareMatrixCommandLine.resolve(
|
||||
device: Fixture.d, version: "27.0", build: "", readmePath: path,
|
||||
downloadURLs: Fixture.urls, environment: [:], stdout: out, stderr: err
|
||||
)
|
||||
}
|
||||
}
|
||||
#expect(r.code == 2)
|
||||
#expect(r.out.isEmpty)
|
||||
#expect(r.err == Golden.ambiguousPlain)
|
||||
}
|
||||
|
||||
@Test func aMissGoesToStderrAndExitsOne() {
|
||||
let r = Fixture.withReadmeFile { path in
|
||||
capture { out, err in
|
||||
VPhoneFirmwareMatrixCommandLine.resolve(
|
||||
device: Fixture.d, version: "99.9", build: "", readmePath: path,
|
||||
downloadURLs: Fixture.urls, environment: [:],
|
||||
stdout: out, stderr: err
|
||||
)
|
||||
}
|
||||
}
|
||||
#expect(r.code == 1)
|
||||
#expect(r.out.isEmpty)
|
||||
#expect(r.err == "Unsupported: no downloadable IPSW matched device=iPhone17,3 version=99.9\n")
|
||||
}
|
||||
|
||||
@Test func anUnreadableReadmePathIsNotAnError() {
|
||||
let r = capture { out, err in
|
||||
VPhoneFirmwareMatrixCommandLine.list(
|
||||
device: Fixture.d, readmePath: "/nonexistent/README.md",
|
||||
downloadURLs: Fixture.urls, environment: [:], stdout: out, stderr: err
|
||||
)
|
||||
}
|
||||
#expect(r.code == 0)
|
||||
#expect(r.out.contains("Not Tested"))
|
||||
#expect(!r.out.contains("Supported\u{20}\u{20}\n"))
|
||||
}
|
||||
|
||||
/// The half that is easy to get wrong: each command styles the stream it
|
||||
/// writes to, not the process. `list` writes its table to stdout, so a
|
||||
/// terminal on stderr must not colour it.
|
||||
@Test func listStylesStdoutNotStderr() throws {
|
||||
let fd = posix_openpt(O_RDWR | O_NOCTTY)
|
||||
try #require(fd >= 0)
|
||||
defer { close(fd) }
|
||||
let tty = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
|
||||
let outPipe = Pipe()
|
||||
let code = Fixture.withReadmeFile { path in
|
||||
VPhoneFirmwareMatrixCommandLine.list(
|
||||
device: Fixture.d, readmePath: path, downloadURLs: Fixture.urls,
|
||||
environment: [:], stdout: outPipe.fileHandleForWriting, stderr: tty
|
||||
)
|
||||
}
|
||||
try outPipe.fileHandleForWriting.close()
|
||||
let out = String(decoding: outPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
||||
#expect(code == 0)
|
||||
#expect(out == Golden.listPlain)
|
||||
#expect(!out.contains("\u{1B}["))
|
||||
}
|
||||
|
||||
/// And the mirror image: the selector's failures are stderr's, so a
|
||||
/// terminal on stdout must not colour them. This is the case that keeps
|
||||
/// escapes out of `selection="$(resolve_selector_from_downloads …)"`.
|
||||
@Test func resolveStylesStderrNotStdout() throws {
|
||||
let fd = posix_openpt(O_RDWR | O_NOCTTY)
|
||||
try #require(fd >= 0)
|
||||
defer { close(fd) }
|
||||
let tty = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
|
||||
let errPipe = Pipe()
|
||||
let code = Fixture.withReadmeFile { path in
|
||||
VPhoneFirmwareMatrixCommandLine.resolve(
|
||||
device: Fixture.d, version: "99.9", build: "", readmePath: path,
|
||||
downloadURLs: Fixture.urls, environment: [:],
|
||||
stdout: tty, stderr: errPipe.fileHandleForWriting
|
||||
)
|
||||
}
|
||||
try errPipe.fileHandleForWriting.close()
|
||||
let err = String(decoding: errPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
||||
#expect(code == 1)
|
||||
#expect(err == "Unsupported: no downloadable IPSW matched device=iPhone17,3 version=99.9\n")
|
||||
#expect(!err.contains("\u{1B}["))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user