mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-07 02:14:27 +00:00
watchdogd: Add surgical hv_vmm_present cache patch (EXP-JB-3.5)
After the kernel-side OID rename (`KernelEXPPatchHvVmmRename`),
`sysctlbyname("kern.hv_vmm_present", ...)` returns ENOENT on this image.
`/usr/libexec/watchdogd` caches that answer at startup. On ENOENT the
cached byte stays at its BSS-zero default (`0`) and the downstream
`cbz w0, ...` at the IOWatchdog-lookup site takes a branch into
`_os_crash` -> `brk #1`; launchd's `_PanicOnCrash =
PanicOnConsecutiveCrash = true` flag in `com.apple.watchdogd.plist`
escalates the SIGTRAP to a kernel panic.
The cstring-mangle approach used for DSC dylibs doesn't apply here: we
want this binary to behave as if the sysctl returned 1, not as if it
returned ENOENT. Solution is a surgical 2-instruction patch that forces
the cached "am I a VM?" byte to 1 regardless of the sysctl result.
- `scripts/patchers/cfw_patch_watchdogd.py` — capstone-anchored pattern
matcher + Keystone-assembled 2-insn patch. Two functions match the
canonical caching shape on iPhone17,3 / iOS 26.1; both are patched.
Net effect: `cbnz w0, skip` -> NOP and `cset wN, ne` -> `mov wN, #1`,
forcing the cached byte to 1. watchdogd's pre-existing "detected
virtual machine environment, exiting..." clean-exit branch runs
instead of the trap path. Idempotent.
- `scripts/patchers/cfw_macho_codesign.py` — generic standalone-Mach-O
page-hash re-attestation. Parses `LC_CODE_SIGNATURE` directly, reads
page size from each `CS_CodeDirectory` header (4 KiB on watchdogd —
not the DSC's 16 KiB), handles short tail slot length
(`codeLimit - (n-1)*pageSize`), and updates every present CD. The
resulting cdHash change is accepted by the JB `patch_amfi_cdhash_in_trustcache`
kernel patch which accepts any cdHash; the patcher does NOT re-sign
with ldid (preserving the original Apple-issued code-signing
identifier is required for launchd's boot-task identity validation).
- `scripts/patchers/cfw.py` — adds `patch-watchdogd` subcommand.
- `scripts/patch_hv_vmm_userland.sh` — adds `watchdogd <binary>` op.
- `scripts/cfw_install_exp.sh` — invokes the patcher at step
`[EXP-JB-3.5]` on the live `/mnt1/usr/libexec/watchdogd` (scp-down,
patch, scp-up, chmod 0755). JB and DEV install scripts do NOT run
this step.
This commit is contained in:
Regular → Executable
+34
@@ -357,6 +357,40 @@ ssh_cmd "/bin/chmod 0755 /mnt1/usr/libexec/debugserver"
|
|||||||
echo " [+] debugserver entitlements patched"
|
echo " [+] debugserver entitlements patched"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════ EXP-JB-3.5 PATCH watchdogd hv_vmm_present cache ══
|
||||||
|
#
|
||||||
|
# Background: the kernel-side OID rename (KernelEXPPatchHvVmmRename)
|
||||||
|
# makes sysctlbyname("kern.hv_vmm_present", ...) return ENOENT on this
|
||||||
|
# image. watchdogd caches that answer at startup and uses it to decide
|
||||||
|
# whether to look for the IOWatchdog kext. The unpatched flow takes
|
||||||
|
# the "not on a VM" branch on ENOENT, fails to find the kext (it
|
||||||
|
# doesn't exist on the VM), calls _os_crash -> brk #1, and launchd's
|
||||||
|
# `_PanicOnCrash` knob in com.apple.watchdogd.plist escalates the
|
||||||
|
# resulting SIGTRAP to a kernel panic.
|
||||||
|
#
|
||||||
|
# Patch shape: two-instruction surgical edit at every site in
|
||||||
|
# watchdogd that has the canonical
|
||||||
|
# adrp/add(kern.hv_vmm_present) -> bl _sysctlbyname -> cbnz w0,skip
|
||||||
|
# -> cset wN,ne -> strb wN,[global]
|
||||||
|
# shape. The edit forces the cached byte to 1 regardless of the
|
||||||
|
# sysctl result, so the downstream branch at +0x58e0 takes watchdogd's
|
||||||
|
# pre-existing "detected virtual machine environment" clean-exit path.
|
||||||
|
# The patcher also recomputes the affected CodeDirectory slot hashes
|
||||||
|
# (cfw_macho_codesign) so TXM still accepts the modified pages on
|
||||||
|
# demand-page-in. We deliberately do NOT re-sign with ldid — the
|
||||||
|
# Apple-issued code-signing identifier ("com.apple.watchdogd") must be
|
||||||
|
# preserved for launchd boot-task identity validation.
|
||||||
|
echo ""
|
||||||
|
echo "[EXP-JB-3.5] Patching watchdogd hv_vmm_present cache..."
|
||||||
|
|
||||||
|
scp_from "/mnt1/usr/libexec/watchdogd" "$TEMP_DIR/watchdogd"
|
||||||
|
"$SCRIPT_DIR/patch_hv_vmm_userland.sh" watchdogd "$TEMP_DIR/watchdogd"
|
||||||
|
scp_to "$TEMP_DIR/watchdogd" "/mnt1/usr/libexec/watchdogd"
|
||||||
|
ssh_cmd "/bin/chmod 0755 /mnt1/usr/libexec/watchdogd"
|
||||||
|
|
||||||
|
echo " [+] watchdogd patched"
|
||||||
|
|
||||||
|
|
||||||
# ═══════════ JB-4 INSTALL PROCURSUS BOOTSTRAP ══════════════════
|
# ═══════════ JB-4 INSTALL PROCURSUS BOOTSTRAP ══════════════════
|
||||||
echo ""
|
echo ""
|
||||||
echo "[JB-4] Installing procursus bootstrap..."
|
echo "[JB-4] Installing procursus bootstrap..."
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/zsh
|
#!/bin/zsh
|
||||||
# patch_hv_vmm_userland.sh — Apply the user-mode hv_vmm_present patch.
|
# patch_hv_vmm_userland.sh — Apply the user-mode hv_vmm_present patch.
|
||||||
#
|
#
|
||||||
# Two operations, chosen by the first arg:
|
# Three operations, chosen by the first arg:
|
||||||
#
|
#
|
||||||
# dsc <chunks_dir>
|
# dsc <chunks_dir>
|
||||||
# Patch the canonical sysctlbyname("kern.hv_vmm_present", ...) sites
|
# Patch the canonical sysctlbyname("kern.hv_vmm_present", ...) sites
|
||||||
@@ -15,6 +15,15 @@
|
|||||||
# Patch a single standalone Mach-O file in place. Idempotent.
|
# Patch a single standalone Mach-O file in place. Idempotent.
|
||||||
# Caller is responsible for re-signing (ldid).
|
# Caller is responsible for re-signing (ldid).
|
||||||
#
|
#
|
||||||
|
# watchdogd <binary>
|
||||||
|
# Surgical 2-instruction patch of /usr/libexec/watchdogd that
|
||||||
|
# forces its cached "am I a VM?" byte to 1 regardless of the
|
||||||
|
# sysctl result. Also re-attests the affected CodeDirectory slot
|
||||||
|
# hash (the binary stays self-consistent for TXM/SHA-256). Do NOT
|
||||||
|
# re-sign with ldid — the patcher leaves the original Apple-issued
|
||||||
|
# code-signing identifier intact, which launchd boot-task identity
|
||||||
|
# checks require.
|
||||||
|
#
|
||||||
# This script is a thin wrapper around `scripts/patchers/cfw.py`. It
|
# This script is a thin wrapper around `scripts/patchers/cfw.py`. It
|
||||||
# exists so cfw_install_dev.sh and cfw_install_jb.sh can call a single
|
# exists so cfw_install_dev.sh and cfw_install_jb.sh can call a single
|
||||||
# entry point without duplicating Python venv/python3 resolution logic.
|
# entry point without duplicating Python venv/python3 resolution logic.
|
||||||
@@ -42,6 +51,7 @@ usage() {
|
|||||||
Usage:
|
Usage:
|
||||||
$0 dsc <chunks_dir>
|
$0 dsc <chunks_dir>
|
||||||
$0 standalone <binary>
|
$0 standalone <binary>
|
||||||
|
$0 watchdogd <binary>
|
||||||
EOF
|
EOF
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
@@ -60,6 +70,11 @@ case "$op" in
|
|||||||
echo "[*] Patching hv_vmm_present consumers in: $1"
|
echo "[*] Patching hv_vmm_present consumers in: $1"
|
||||||
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-hv-vmm "$1"
|
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-hv-vmm "$1"
|
||||||
;;
|
;;
|
||||||
|
watchdogd)
|
||||||
|
(( $# >= 1 )) || usage
|
||||||
|
echo "[*] Patching watchdogd hv_vmm_present cache in: $1"
|
||||||
|
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-watchdogd "$1"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
|
|||||||
+28
-1
@@ -29,6 +29,16 @@ Commands:
|
|||||||
the mounted SystemOS Cryptex). Targets a fixed list of identity,
|
the mounted SystemOS Cryptex). Targets a fixed list of identity,
|
||||||
store, and consumer-service dylibs; skips compute/accel libs.
|
store, and consumer-service dylibs; skips compute/accel libs.
|
||||||
|
|
||||||
|
patch-watchdogd <binary> [--dry-run]
|
||||||
|
Surgical 2-instruction patch of /usr/libexec/watchdogd's
|
||||||
|
sysctlbyname("kern.hv_vmm_present", ...) caching block so the
|
||||||
|
cached "am I a VM?" byte is forced to 1 regardless of the
|
||||||
|
sysctl result. Necessary because the kernel-side OID rename
|
||||||
|
makes that sysctl return ENOENT, which would otherwise drive
|
||||||
|
watchdogd into a trap path that launchd's _PanicOnCrash
|
||||||
|
escalates to a kernel panic. Also recomputes the affected
|
||||||
|
CodeDirectory slot hash via cfw_macho_codesign.
|
||||||
|
|
||||||
inject-daemons <launchd.plist> <daemon_dir>
|
inject-daemons <launchd.plist> <daemon_dir>
|
||||||
Inject bash/dropbear/trollvnc into launchd.plist.
|
Inject bash/dropbear/trollvnc into launchd.plist.
|
||||||
|
|
||||||
@@ -54,6 +64,7 @@ if __name__ == "__main__":
|
|||||||
from patchers.cfw_patch_mobileactivationd import patch_mobileactivationd
|
from patchers.cfw_patch_mobileactivationd import patch_mobileactivationd
|
||||||
from patchers.cfw_patch_jetsam import patch_launchd_jetsam
|
from patchers.cfw_patch_jetsam import patch_launchd_jetsam
|
||||||
from patchers.cfw_patch_hv_vmm_dsc import patch_hv_vmm_in_dsc
|
from patchers.cfw_patch_hv_vmm_dsc import patch_hv_vmm_in_dsc
|
||||||
|
from patchers.cfw_patch_watchdogd import patch_watchdogd
|
||||||
from patchers.cfw_daemons import parse_cryptex_paths, inject_daemons
|
from patchers.cfw_daemons import parse_cryptex_paths, inject_daemons
|
||||||
else:
|
else:
|
||||||
from .cfw_patch_seputil import patch_seputil
|
from .cfw_patch_seputil import patch_seputil
|
||||||
@@ -61,6 +72,7 @@ else:
|
|||||||
from .cfw_patch_mobileactivationd import patch_mobileactivationd
|
from .cfw_patch_mobileactivationd import patch_mobileactivationd
|
||||||
from .cfw_patch_jetsam import patch_launchd_jetsam
|
from .cfw_patch_jetsam import patch_launchd_jetsam
|
||||||
from .cfw_patch_hv_vmm_dsc import patch_hv_vmm_in_dsc
|
from .cfw_patch_hv_vmm_dsc import patch_hv_vmm_in_dsc
|
||||||
|
from .cfw_patch_watchdogd import patch_watchdogd
|
||||||
from .cfw_daemons import parse_cryptex_paths, inject_daemons
|
from .cfw_daemons import parse_cryptex_paths, inject_daemons
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +127,21 @@ def main():
|
|||||||
results = patch_hv_vmm_in_dsc(sys.argv[2], dry_run=dry_run)
|
results = patch_hv_vmm_in_dsc(sys.argv[2], dry_run=dry_run)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
elif cmd == "patch-watchdogd":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: patch_cfw.py patch-watchdogd <binary> [--dry-run]")
|
||||||
|
sys.exit(1)
|
||||||
|
dry_run = "--dry-run" in sys.argv[3:]
|
||||||
|
try:
|
||||||
|
n = patch_watchdogd(sys.argv[2], dry_run=dry_run)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"[-] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
# Exit 0 on both "patched N>0" and "already patched (N==0)".
|
||||||
|
# The install script treats both as success; only a raised
|
||||||
|
# exception (unparseable binary / no anchor) is fatal.
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
elif cmd == "inject-daemons":
|
elif cmd == "inject-daemons":
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 4:
|
||||||
print("Usage: patch_cfw.py inject-daemons <launchd.plist> <daemon_dir>")
|
print("Usage: patch_cfw.py inject-daemons <launchd.plist> <daemon_dir>")
|
||||||
@@ -146,7 +173,7 @@ def main():
|
|||||||
print(f"Unknown command: {cmd}")
|
print(f"Unknown command: {cmd}")
|
||||||
print("Commands: cryptex-paths, patch-seputil, patch-launchd-cache-loader,")
|
print("Commands: cryptex-paths, patch-seputil, patch-launchd-cache-loader,")
|
||||||
print(" patch-mobileactivationd, patch-launchd-jetsam,")
|
print(" patch-mobileactivationd, patch-launchd-jetsam,")
|
||||||
print(" patch-hv-vmm-dsc, inject-daemons, inject-dylib")
|
print(" patch-hv-vmm-dsc, patch-watchdogd, inject-daemons, inject-dylib")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""Standalone Mach-O code-signature page-hash re-attestation.
|
||||||
|
|
||||||
|
Parallel to `cfw_dsc_codesign.py` but for standalone Mach-Os instead of
|
||||||
|
DSC chunks. The technique is the same — recompute the SHA-256 of each
|
||||||
|
modified page and overwrite the matching slot in `CS_CodeDirectory` —
|
||||||
|
but the parse path and tail-slot handling differ.
|
||||||
|
|
||||||
|
Layout (single-arch arm64e Mach-O, verified on
|
||||||
|
`iPhone17,3_26.1_23B85_Restore_extracted/usr/libexec/watchdogd`):
|
||||||
|
|
||||||
|
LC_CODE_SIGNATURE (cmd=0x1d) in the Mach-O's own load commands:
|
||||||
|
u32 dataoff — file offset of the embedded CS_SuperBlob
|
||||||
|
u32 datasize — total bytes of the signature
|
||||||
|
|
||||||
|
At `dataoff` there is a CS_SuperBlob (big-endian):
|
||||||
|
u32 magic = 0xFADE0CC0
|
||||||
|
u32 length
|
||||||
|
u32 count
|
||||||
|
CS_BlobIndex blobs[count]:
|
||||||
|
u32 type
|
||||||
|
u32 offset (within SuperBlob)
|
||||||
|
|
||||||
|
Every blob whose magic is 0xFADE0C02 is a CodeDirectory. There can
|
||||||
|
be more than one (alt-CD with a different hashType for legacy SHA-1
|
||||||
|
consumers). We update every CD whose hashType is SHA-256 — anything
|
||||||
|
else is left alone and reported, so the caller can decide whether to
|
||||||
|
fail.
|
||||||
|
|
||||||
|
A CD covers `[0, codeLimit)` of the Mach-O file. Slot N's hash
|
||||||
|
covers bytes `[N * pageSize, min((N+1) * pageSize, codeLimit))`. The
|
||||||
|
tail slot is typically short: `codeLimit - last_slot_start` bytes,
|
||||||
|
not a full page. That is the single most likely source of the
|
||||||
|
previous standalone-reattest regression — DSC chunks are aligned so
|
||||||
|
the tail-slot quirk doesn't surface there.
|
||||||
|
|
||||||
|
Page size is read from `pageSizeLog2` in the CD header. On the binaries
|
||||||
|
we care about it is 12 (4 KiB), not 14 (16 KiB as in DSC chunks).
|
||||||
|
|
||||||
|
CDHash side effect (same as DSC case)
|
||||||
|
-------------------------------------
|
||||||
|
Rewriting slot hashes mutates the CD blob, which mutates the CD's
|
||||||
|
SHA-256 (the cdHash). The existing JB kernel patch
|
||||||
|
`patch_amfi_cdhash_in_trustcache` short-circuits AMFI's trust-cache
|
||||||
|
lookup so a mutated cdHash isn't rejected at execve. On iPhone17,3 /
|
||||||
|
iOS 26.1 with `codeSigningMonitor == 2`, TXM accepts the modified-CD
|
||||||
|
binary on demand-page-in as long as the per-page slot hashes match,
|
||||||
|
which is exactly what this module guarantees.
|
||||||
|
|
||||||
|
Public entrypoint:
|
||||||
|
reattest_modified_offsets(filepath, file_offsets, *, dry_run, verbose)
|
||||||
|
-> list of diagnostic dicts (one per slot updated)
|
||||||
|
|
||||||
|
The caller passes the file offsets of bytes it modified; this module
|
||||||
|
takes care of mapping each offset to its containing page, deduping,
|
||||||
|
recomputing the SHA-256 of the page's actual on-disk bytes, and writing
|
||||||
|
the new hash back into every CD's slot table.
|
||||||
|
|
||||||
|
Hash types we know how to recompute:
|
||||||
|
SHA-256 (CS_HASHTYPE_SHA256 = 2)
|
||||||
|
|
||||||
|
Other types (SHA-1 = 1, SHA-256-truncated = 4, SHA-384 = 3) are
|
||||||
|
reported but not updated. If a binary needs those it will need
|
||||||
|
additional code paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
# CS constants.
|
||||||
|
CSMAGIC_EMBEDDED_SIGNATURE = 0xFADE0CC0
|
||||||
|
CSMAGIC_CODEDIRECTORY = 0xFADE0C02
|
||||||
|
|
||||||
|
CS_HASHTYPE_SHA1 = 1
|
||||||
|
CS_HASHTYPE_SHA256 = 2
|
||||||
|
CS_HASHTYPE_SHA384 = 3
|
||||||
|
CS_HASHTYPE_SHA256_TRUNCATED = 4
|
||||||
|
|
||||||
|
LC_CODE_SIGNATURE = 0x1D
|
||||||
|
|
||||||
|
MH_MAGIC_64 = 0xFEEDFACF
|
||||||
|
|
||||||
|
|
||||||
|
def _find_lc_code_signature(data):
|
||||||
|
"""Return (dataoff, datasize) for the binary's LC_CODE_SIGNATURE, or None."""
|
||||||
|
magic = struct.unpack_from("<I", data, 0)[0]
|
||||||
|
if magic != MH_MAGIC_64:
|
||||||
|
return None
|
||||||
|
ncmds = struct.unpack_from("<I", data, 16)[0]
|
||||||
|
off = 32 # sizeof(mach_header_64)
|
||||||
|
for _ in range(ncmds):
|
||||||
|
cmd, cmdsize = struct.unpack_from("<II", data, off)
|
||||||
|
if cmd == LC_CODE_SIGNATURE:
|
||||||
|
dataoff, datasize = struct.unpack_from("<II", data, off + 8)
|
||||||
|
return dataoff, datasize
|
||||||
|
off += cmdsize
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_superblob(data, sb_off):
|
||||||
|
"""Return list of (slot_type, blob_abs_off, blob_magic). None on error."""
|
||||||
|
if sb_off + 12 > len(data):
|
||||||
|
return None
|
||||||
|
sb_magic, sb_length, sb_count = struct.unpack_from(">III", data, sb_off)
|
||||||
|
if sb_magic != CSMAGIC_EMBEDDED_SIGNATURE:
|
||||||
|
return None
|
||||||
|
if sb_count > 256 or sb_off + sb_length > len(data):
|
||||||
|
return None
|
||||||
|
out = []
|
||||||
|
for i in range(sb_count):
|
||||||
|
st, bo = struct.unpack_from(">II", data, sb_off + 12 + i * 8)
|
||||||
|
blob_abs = sb_off + bo
|
||||||
|
if blob_abs + 4 > len(data):
|
||||||
|
return None
|
||||||
|
bm = struct.unpack_from(">I", data, blob_abs)[0]
|
||||||
|
out.append((st, blob_abs, bm))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_code_directory(data, cd_off):
|
||||||
|
"""Parse a CodeDirectory blob. Returns a dict of fields, or None.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
slot_type — left blank here, filled in by caller from the BlobIndex
|
||||||
|
cd_off — absolute file offset of the CD blob
|
||||||
|
cd_length — length of the CD blob
|
||||||
|
hash_offset — offset within the CD blob to slot[0] (code slot 0)
|
||||||
|
hash_size — bytes per slot
|
||||||
|
hash_type — CS_HASHTYPE_*
|
||||||
|
page_size — 1 << pageSizeLog2
|
||||||
|
n_code_slots
|
||||||
|
code_limit — covered byte range of the file
|
||||||
|
"""
|
||||||
|
if cd_off + 44 > len(data):
|
||||||
|
return None
|
||||||
|
fields = struct.unpack_from(">IIIIIIIII", data, cd_off)
|
||||||
|
cd_magic, cd_length, _version, _flags = fields[0], fields[1], fields[2], fields[3]
|
||||||
|
if cd_magic != CSMAGIC_CODEDIRECTORY:
|
||||||
|
return None
|
||||||
|
hash_offset = fields[4]
|
||||||
|
_ident_offset = fields[5]
|
||||||
|
_n_special = fields[6]
|
||||||
|
n_code_slots = fields[7]
|
||||||
|
code_limit = fields[8]
|
||||||
|
hash_size = data[cd_off + 36]
|
||||||
|
hash_type = data[cd_off + 37]
|
||||||
|
page_size_log2 = data[cd_off + 39]
|
||||||
|
page_size = 1 << page_size_log2 if 0 < page_size_log2 < 24 else 0
|
||||||
|
if page_size == 0:
|
||||||
|
return None
|
||||||
|
if cd_off + cd_length > len(data):
|
||||||
|
return None
|
||||||
|
if cd_off + hash_offset + n_code_slots * hash_size > len(data):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"cd_off": cd_off,
|
||||||
|
"cd_length": cd_length,
|
||||||
|
"hash_offset": hash_offset,
|
||||||
|
"hash_size": hash_size,
|
||||||
|
"hash_type": hash_type,
|
||||||
|
"page_size": page_size,
|
||||||
|
"page_size_log2": page_size_log2,
|
||||||
|
"n_code_slots": n_code_slots,
|
||||||
|
"code_limit": code_limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_code_directories(data):
|
||||||
|
"""Find every CS_CodeDirectory in the binary's LC_CODE_SIGNATURE blob.
|
||||||
|
|
||||||
|
Returns a list of dicts (one per CD), each with the fields from
|
||||||
|
`_parse_code_directory` plus `slot_type` from the SuperBlob's
|
||||||
|
BlobIndex.
|
||||||
|
"""
|
||||||
|
cs = _find_lc_code_signature(data)
|
||||||
|
if cs is None:
|
||||||
|
return None
|
||||||
|
cs_off, _cs_size = cs
|
||||||
|
sb = _parse_superblob(data, cs_off)
|
||||||
|
if sb is None:
|
||||||
|
return None
|
||||||
|
cds = []
|
||||||
|
for slot_type, blob_abs, blob_magic in sb:
|
||||||
|
if blob_magic != CSMAGIC_CODEDIRECTORY:
|
||||||
|
continue
|
||||||
|
cd = _parse_code_directory(data, blob_abs)
|
||||||
|
if cd is None:
|
||||||
|
continue
|
||||||
|
cd["slot_type"] = slot_type
|
||||||
|
cds.append(cd)
|
||||||
|
return cds
|
||||||
|
|
||||||
|
|
||||||
|
def _page_bounds(file_off, page_size, code_limit):
|
||||||
|
"""Return (page_index, page_start, page_end_exclusive) for the page
|
||||||
|
that contains file_off. page_end_exclusive is clamped to code_limit
|
||||||
|
so the tail slot covers the actual signed byte range, not a full
|
||||||
|
page beyond the end of the CD-covered region.
|
||||||
|
|
||||||
|
Returns None if file_off is past code_limit (not covered by CD).
|
||||||
|
"""
|
||||||
|
if file_off >= code_limit:
|
||||||
|
return None
|
||||||
|
page_index = file_off // page_size
|
||||||
|
page_start = page_index * page_size
|
||||||
|
page_end = min(page_start + page_size, code_limit)
|
||||||
|
return page_index, page_start, page_end
|
||||||
|
|
||||||
|
|
||||||
|
def reattest_modified_offsets(
|
||||||
|
filepath, file_offsets, *, dry_run=False, verbose=True
|
||||||
|
):
|
||||||
|
"""Recompute slot hashes for every page touched by `file_offsets`.
|
||||||
|
|
||||||
|
`file_offsets` iterable of byte offsets within the Mach-O file
|
||||||
|
that the caller has just modified. They get
|
||||||
|
deduplicated to a set of (cd, page_index) pairs.
|
||||||
|
`dry_run` if True, log only, do not write.
|
||||||
|
`verbose` if True, print per-slot progress.
|
||||||
|
|
||||||
|
Returns a list of diagnostic dicts:
|
||||||
|
{cd_off, slot_type, page_index, page_start, page_end,
|
||||||
|
hash_offset_in_file, before, after}
|
||||||
|
|
||||||
|
Raises ValueError on a malformed signature blob — that's a hard
|
||||||
|
error for any caller (the binary was not what we expected).
|
||||||
|
"""
|
||||||
|
if not file_offsets:
|
||||||
|
if verbose:
|
||||||
|
print(f" [.] re-attest: no offsets given for {filepath}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
cds = _find_code_directories(data)
|
||||||
|
if cds is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{filepath}: no LC_CODE_SIGNATURE / CS_CodeDirectory found"
|
||||||
|
)
|
||||||
|
if not cds:
|
||||||
|
raise ValueError(
|
||||||
|
f"{filepath}: LC_CODE_SIGNATURE present but no CodeDirectory blobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
kinds = []
|
||||||
|
for cd in cds:
|
||||||
|
kinds.append(
|
||||||
|
f"slot_type=0x{cd['slot_type']:x} hashType={cd['hash_type']} "
|
||||||
|
f"pageSize={cd['page_size']} nSlots={cd['n_code_slots']} "
|
||||||
|
f"codeLimit=0x{cd['code_limit']:x}"
|
||||||
|
)
|
||||||
|
print(f" [.] re-attest {filepath}: {len(cds)} CD(s): " + "; ".join(kinds))
|
||||||
|
|
||||||
|
# Build (cd_index, page_index) -> (page_start, page_end) set.
|
||||||
|
# Different CDs may have different page sizes in theory; we keep
|
||||||
|
# them separate.
|
||||||
|
work = {} # (cd_index, page_index) -> (page_start, page_end)
|
||||||
|
skipped_non_sha256 = 0
|
||||||
|
for cd_i, cd in enumerate(cds):
|
||||||
|
if cd["hash_type"] != CS_HASHTYPE_SHA256:
|
||||||
|
skipped_non_sha256 += 1
|
||||||
|
continue
|
||||||
|
for foff in file_offsets:
|
||||||
|
pb = _page_bounds(foff, cd["page_size"], cd["code_limit"])
|
||||||
|
if pb is None:
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f" [-] re-attest: file off 0x{foff:X} past codeLimit "
|
||||||
|
f"0x{cd['code_limit']:X} (cd_index={cd_i}) — skipping"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
page_index, page_start, page_end = pb
|
||||||
|
if page_index >= cd["n_code_slots"]:
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f" [-] re-attest: page {page_index} >= "
|
||||||
|
f"nCodeSlots {cd['n_code_slots']} (cd_index={cd_i}) — skipping"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
work[(cd_i, page_index)] = (page_start, page_end)
|
||||||
|
|
||||||
|
if skipped_non_sha256 and verbose:
|
||||||
|
print(
|
||||||
|
f" [-] re-attest: skipped {skipped_non_sha256} non-SHA256 "
|
||||||
|
f"CD(s) (hashType != 2). If a legacy SHA-1 alt-CD exists for "
|
||||||
|
f"this binary it is NOT being recomputed."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not work:
|
||||||
|
if verbose:
|
||||||
|
print(f" [.] re-attest: no eligible slots for {filepath}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
diagnostics = []
|
||||||
|
mode = "rb" if dry_run else "r+b"
|
||||||
|
with open(filepath, mode) as f:
|
||||||
|
for (cd_i, page_index), (page_start, page_end) in sorted(work.items()):
|
||||||
|
cd = cds[cd_i]
|
||||||
|
slot_off = (
|
||||||
|
cd["cd_off"] + cd["hash_offset"] + page_index * cd["hash_size"]
|
||||||
|
)
|
||||||
|
page_bytes = data[page_start:page_end]
|
||||||
|
new_hash = hashlib.sha256(page_bytes).digest()
|
||||||
|
old_hash = data[slot_off : slot_off + cd["hash_size"]]
|
||||||
|
|
||||||
|
if old_hash == new_hash:
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f" [.] re-attest: cd_index={cd_i} slot {page_index} "
|
||||||
|
f"already matches ({page_end - page_start} bytes) — no-op"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
f.seek(slot_off)
|
||||||
|
f.write(new_hash)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
action = "would write" if dry_run else "wrote"
|
||||||
|
tail_note = ""
|
||||||
|
if page_end - page_start != cd["page_size"]:
|
||||||
|
tail_note = f" [tail, {page_end - page_start}B]"
|
||||||
|
print(
|
||||||
|
f" [+] re-attest: {action} cd_index={cd_i} "
|
||||||
|
f"slot {page_index}{tail_note} "
|
||||||
|
f"({old_hash.hex()[:8]}.. -> {new_hash.hex()[:8]}..)"
|
||||||
|
)
|
||||||
|
diagnostics.append(
|
||||||
|
{
|
||||||
|
"cd_off": cd["cd_off"],
|
||||||
|
"slot_type": cd["slot_type"],
|
||||||
|
"page_index": page_index,
|
||||||
|
"page_start": page_start,
|
||||||
|
"page_end": page_end,
|
||||||
|
"hash_offset_in_file": slot_off,
|
||||||
|
"before": old_hash.hex(),
|
||||||
|
"after": new_hash.hex(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return diagnostics
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
"""watchdogd surgical patch — force the cached "am I a VM?" byte to 1.
|
||||||
|
|
||||||
|
Background
|
||||||
|
----------
|
||||||
|
After the kernel-side rename of the `kern.hv_vmm_present` sysctl OID
|
||||||
|
(see `KernelJBPatchHvVmmRename.swift`), every userland caller that
|
||||||
|
queries `kern.hv_vmm_present` now receives `ENOENT`. `/usr/libexec/
|
||||||
|
watchdogd` initialises a private "am I a VM?" cache from that sysctl
|
||||||
|
during 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 ; auth stub
|
||||||
|
cbnz w0, <skip> ; on ENOENT: w0 != 0 -> jump past the store
|
||||||
|
ldur w8, [x29, #-4]
|
||||||
|
cmp w8, #0
|
||||||
|
cset w8, ne ; w8 = (oldval != 0) ? 1 : 0
|
||||||
|
adrp x9, <page>
|
||||||
|
strb w8, [x9, #<off>] ; cached byte
|
||||||
|
|
||||||
|
A downstream accessor returns that cached byte; a `cbz w0, ...` at
|
||||||
|
`+0x58e0` reads it; on `cbz`-taken it falls into a call to a
|
||||||
|
`_os_crash` wrapper that does `brk #1`. Because the cstring rename
|
||||||
|
makes the sysctl return `ENOENT`, the `cbnz w0, <skip>` path is taken,
|
||||||
|
the store is skipped, the cached byte stays at its BSS-zero default
|
||||||
|
(0), the downstream `cbz` takes the trap branch, and launchd's
|
||||||
|
`_PanicOnCrash` knob escalates the resulting SIGTRAP to a kernel
|
||||||
|
panic.
|
||||||
|
|
||||||
|
Patch
|
||||||
|
-----
|
||||||
|
Two-instruction surgical edit at the originating site. We do NOT touch
|
||||||
|
the cstring (the kernel rename approach deliberately keeps every
|
||||||
|
`kern.hv_vmm_present` consumer queriying the now-ENOENT name, except
|
||||||
|
where we specifically opt them out). What we change instead is the
|
||||||
|
caching logic so that, regardless of the sysctl result, the cached
|
||||||
|
byte ends up at 1:
|
||||||
|
|
||||||
|
cbnz w0, <skip> -> nop (don't skip the store)
|
||||||
|
cset wN, ne -> mov wN, #1 (store 1, not oldval-truthiness)
|
||||||
|
|
||||||
|
Net effect: the strb writes 1 into the cached "am I a VM?" byte every
|
||||||
|
time, the downstream accessor returns 1, the `cbz w0, ...` at +0x58e0
|
||||||
|
falls through to the clean-exit branch that logs "detected virtual
|
||||||
|
machine environment and no watchdog KEXT found, exiting...". No trap,
|
||||||
|
no panic.
|
||||||
|
|
||||||
|
Anchoring
|
||||||
|
---------
|
||||||
|
We anchor on the canonical shape rather than file offsets. For each
|
||||||
|
`adrp/add` xref in `__TEXT,__text` that resolves to the file VA of
|
||||||
|
the `"kern.hv_vmm_present\0"` cstring, we require:
|
||||||
|
|
||||||
|
1. A `bl <stub>` within the next 20 instructions of the `add`.
|
||||||
|
2. The instruction immediately after that `bl` is `cbnz w0, <imm>`.
|
||||||
|
3. Within 8 instructions after the `cbnz` there is a `cset wN, <cond>`
|
||||||
|
(capstone alias for `csinc wN, wzr, wzr, !cond`).
|
||||||
|
4. Within 8 instructions after the `cset` there is a `strb wM,
|
||||||
|
[xR, #imm]` (the store into the cached-byte global).
|
||||||
|
|
||||||
|
Watchdogd contains two functions that match this shape (verified
|
||||||
|
empirically on `iPhone17,3_26.1_23B85`). Both are "cache the VM
|
||||||
|
presence" routines; one feeds the accessor at `+0x8cdc` (the path
|
||||||
|
that leads to the trap), the other feeds a separate consumer at a
|
||||||
|
different global. Both want the same answer (cached byte = 1), so we
|
||||||
|
patch every match the anchor finds. Idempotent: after patching, the
|
||||||
|
matcher no longer finds a `cbnz w0` at the expected slot, so a
|
||||||
|
re-run reports "already patched" and exits cleanly.
|
||||||
|
|
||||||
|
Code signing
|
||||||
|
------------
|
||||||
|
A byte edit inside `__TEXT,__text` invalidates the SHA-256 slot hash
|
||||||
|
of the containing 4 KiB page in the binary's own `CS_CodeDirectory`.
|
||||||
|
On `codeSigningMonitor == 2` hardware (iPhone17,3 / iOS 26.1), TXM
|
||||||
|
rejects the page on demand-page-in unless the slot hash matches the
|
||||||
|
on-disk page bytes. After the in-place byte patch we recompute and
|
||||||
|
write the slot hash via `cfw_macho_codesign.reattest_modified_offsets`.
|
||||||
|
|
||||||
|
The resulting CD mutation also changes the binary's cdHash, which
|
||||||
|
would normally cause AMFI to reject the image at execve. The existing
|
||||||
|
JB kernel patch `patch_amfi_cdhash_in_trustcache` short-circuits that
|
||||||
|
trust-cache check unconditionally — same property the DSC reattest
|
||||||
|
already relies on.
|
||||||
|
|
||||||
|
We do NOT re-sign with `ldid`. Re-signing would default the code-signing
|
||||||
|
identifier to the local filename, which trips launchd's boot-task
|
||||||
|
identity check (the same failure mode we observed on mobile_obliterator
|
||||||
|
before the previous attempt was reverted).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .cfw_asm import (
|
||||||
|
NOP,
|
||||||
|
_cs,
|
||||||
|
_log_asm,
|
||||||
|
asm,
|
||||||
|
disasm_at,
|
||||||
|
find_section,
|
||||||
|
parse_macho_sections,
|
||||||
|
wr32,
|
||||||
|
)
|
||||||
|
from .cfw_macho_codesign import reattest_modified_offsets
|
||||||
|
|
||||||
|
from capstone.arm64_const import ARM64_OP_IMM
|
||||||
|
|
||||||
|
|
||||||
|
NEEDLE = b"kern.hv_vmm_present\x00"
|
||||||
|
PATTERN_NAME = "watchdogd hv_vmm_present sysctl cache"
|
||||||
|
|
||||||
|
# Scan windows (in instructions, not bytes).
|
||||||
|
SCAN_ADRP_TO_ADD = 8 # ADRP and its paired ADD may be up to 8 insns apart
|
||||||
|
SCAN_ADD_TO_BL = 20 # from the cstring-loading ADD forward to the bl _sysctlbyname
|
||||||
|
SCAN_BL_TO_CSET = 12 # from cbnz forward to the cset
|
||||||
|
SCAN_CSET_TO_STRB = 8 # from cset forward to the strb
|
||||||
|
|
||||||
|
|
||||||
|
def _find_cstring_va(data, sections):
|
||||||
|
"""Locate "kern.hv_vmm_present\0" in any cstring-like section.
|
||||||
|
|
||||||
|
Returns (va, file_offset, section_name) or None.
|
||||||
|
"""
|
||||||
|
for sec_name, (vma, size, foff) in sections.items():
|
||||||
|
_, _, sect = sec_name.partition(",")
|
||||||
|
if sect not in ("__cstring", "__objc_methname", "__objc_classname"):
|
||||||
|
continue
|
||||||
|
buf = bytes(data[foff : foff + size])
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
p = buf.find(NEEDLE, i)
|
||||||
|
if p < 0:
|
||||||
|
break
|
||||||
|
# Must be at a cstring boundary (preceded by NUL or start of section).
|
||||||
|
if p == 0 or buf[p - 1] == 0:
|
||||||
|
return (vma + p, foff + p, sec_name)
|
||||||
|
i = p + 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_adrp_add_xrefs(code, base_va, target_va):
|
||||||
|
"""Yield the (adrp_va, add_va) of every ADRP+ADD pair that resolves to
|
||||||
|
target_va.
|
||||||
|
|
||||||
|
Tracks recent ADRP results per destination register; pairs them with
|
||||||
|
a subsequent ADD where the ADD's first source reg matches the ADRP's
|
||||||
|
destination and the ADRP and ADD are within `SCAN_ADRP_TO_ADD`
|
||||||
|
instructions of each other.
|
||||||
|
"""
|
||||||
|
target_page = target_va & ~0xFFF
|
||||||
|
target_pageoff = target_va & 0xFFF
|
||||||
|
|
||||||
|
adrp_cache = {} # dst_reg -> (adrp_va, page_value, idx)
|
||||||
|
|
||||||
|
insns = list(_cs.disasm(code, base_va))
|
||||||
|
insn_by_idx = {i: ins for i, ins in enumerate(insns)}
|
||||||
|
|
||||||
|
for idx, ins in enumerate(insns):
|
||||||
|
if ins.mnemonic == "adrp" and len(ins.operands) >= 2:
|
||||||
|
dst = ins.operands[0].reg
|
||||||
|
page = ins.operands[1].imm
|
||||||
|
adrp_cache[dst] = (ins.address, page, idx)
|
||||||
|
|
||||||
|
elif ins.mnemonic == "add" and len(ins.operands) >= 3:
|
||||||
|
src = ins.operands[1].reg
|
||||||
|
imm_op = ins.operands[2]
|
||||||
|
if imm_op.type != ARM64_OP_IMM:
|
||||||
|
continue
|
||||||
|
if src not in adrp_cache:
|
||||||
|
continue
|
||||||
|
adrp_va, page, adrp_idx = adrp_cache[src]
|
||||||
|
if idx - adrp_idx > SCAN_ADRP_TO_ADD:
|
||||||
|
continue
|
||||||
|
if page == target_page and imm_op.imm == target_pageoff:
|
||||||
|
yield (adrp_va, ins.address)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_branch(insns, start_idx, mnemonics, max_scan):
|
||||||
|
"""Return (idx, insn) of the first insn at or after start_idx whose
|
||||||
|
mnemonic is in `mnemonics`, within `max_scan` instructions. None if
|
||||||
|
not found.
|
||||||
|
"""
|
||||||
|
end = min(len(insns), start_idx + max_scan)
|
||||||
|
for i in range(start_idx, end):
|
||||||
|
if insns[i].mnemonic in mnemonics:
|
||||||
|
return i, insns[i]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _operand_reg_name(insn, op_index):
|
||||||
|
"""Return the lowercase register name of the op_index-th operand of
|
||||||
|
insn (e.g. 'w8', 'x9'), or None.
|
||||||
|
"""
|
||||||
|
if len(insn.operands) <= op_index:
|
||||||
|
return None
|
||||||
|
op = insn.operands[op_index]
|
||||||
|
name = insn.reg_name(op.reg)
|
||||||
|
return name.lower() if name else None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_pattern_from_add(insns, add_idx):
|
||||||
|
"""From the ADD that completes a cstring xref, scan forward for the
|
||||||
|
canonical shape:
|
||||||
|
|
||||||
|
add x0, ... ; insns[add_idx]
|
||||||
|
... (arg setup, up to SCAN_ADD_TO_BL insns) ...
|
||||||
|
bl <stub>
|
||||||
|
cbnz w0, <skip> ; MUST be at bl_idx+1
|
||||||
|
... (up to SCAN_BL_TO_CSET) ...
|
||||||
|
cset wN, <cond>
|
||||||
|
... (up to SCAN_CSET_TO_STRB) ...
|
||||||
|
strb wM, [xR, #imm]
|
||||||
|
|
||||||
|
Returns a dict with the file-relative VAs and the cset destination
|
||||||
|
register name on success, or None on miss.
|
||||||
|
"""
|
||||||
|
bl = _next_branch(insns, add_idx + 1, ("bl",), SCAN_ADD_TO_BL)
|
||||||
|
if bl is None:
|
||||||
|
return None
|
||||||
|
bl_idx, bl_insn = bl
|
||||||
|
|
||||||
|
if bl_idx + 1 >= len(insns):
|
||||||
|
return None
|
||||||
|
cbnz_insn = insns[bl_idx + 1]
|
||||||
|
if cbnz_insn.mnemonic != "cbnz":
|
||||||
|
return None
|
||||||
|
# First operand of cbnz must be w0 — sysctlbyname's return value.
|
||||||
|
if _operand_reg_name(cbnz_insn, 0) != "w0":
|
||||||
|
return None
|
||||||
|
|
||||||
|
cset = _next_branch(insns, bl_idx + 2, ("cset",), SCAN_BL_TO_CSET)
|
||||||
|
if cset is None:
|
||||||
|
return None
|
||||||
|
cset_idx, cset_insn = cset
|
||||||
|
cset_reg = _operand_reg_name(cset_insn, 0)
|
||||||
|
if cset_reg is None or not cset_reg.startswith("w"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
strb = _next_branch(insns, cset_idx + 1, ("strb",), SCAN_CSET_TO_STRB)
|
||||||
|
if strb is None:
|
||||||
|
return None
|
||||||
|
_strb_idx, strb_insn = strb
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bl_va": bl_insn.address,
|
||||||
|
"cbnz_va": cbnz_insn.address,
|
||||||
|
"cset_va": cset_insn.address,
|
||||||
|
"cset_reg": cset_reg,
|
||||||
|
"strb_va": strb_insn.address,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _va_to_foff(text_va, text_foff, va):
|
||||||
|
return text_foff + (va - text_va)
|
||||||
|
|
||||||
|
|
||||||
|
def _already_patched_at(data, cbnz_foff, cset_foff):
|
||||||
|
"""Return True iff the cbnz slot is already a NOP and the cset slot
|
||||||
|
is already a `mov wN, #1` (any wN). Used for idempotence.
|
||||||
|
"""
|
||||||
|
cbnz_word = struct.unpack_from("<I", data, cbnz_foff)[0]
|
||||||
|
if cbnz_word != struct.unpack("<I", NOP)[0]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cset_word = struct.unpack_from("<I", data, cset_foff)[0]
|
||||||
|
# arm64 `mov wN, #1` encodes as MOVZ wN, #1, lsl #0
|
||||||
|
# 31:23 = 0b010100101 (MOVZ-W, hw=0)
|
||||||
|
# 22:21 = 00
|
||||||
|
# 20:5 = imm16 (=1)
|
||||||
|
# 4:0 = Rd
|
||||||
|
# → top 16 bits = 0x5280, imm = 0x0001, low 5 = N
|
||||||
|
high = cset_word >> 16
|
||||||
|
mid = (cset_word >> 5) & 0xFFFF
|
||||||
|
if high == 0x5280 and mid == 0x0001:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def patch_watchdogd(filepath, *, dry_run=False):
|
||||||
|
"""Apply the surgical patch to a watchdogd Mach-O.
|
||||||
|
|
||||||
|
Returns the number of sites patched (>=1 on success, 0 if the
|
||||||
|
binary was already patched). Raises on a malformed binary or on
|
||||||
|
failure to find any matching site (the binary isn't what we
|
||||||
|
expect).
|
||||||
|
"""
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
data = bytearray(f.read())
|
||||||
|
|
||||||
|
sections = parse_macho_sections(data)
|
||||||
|
text_sec = find_section(sections, "__TEXT,__text")
|
||||||
|
if text_sec is None:
|
||||||
|
raise ValueError(f"{filepath}: no __TEXT,__text section")
|
||||||
|
text_va, text_size, text_foff = text_sec
|
||||||
|
|
||||||
|
cstring_hit = _find_cstring_va(data, sections)
|
||||||
|
if cstring_hit is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{filepath}: '{NEEDLE.rstrip(chr(0).encode()).decode()}' cstring "
|
||||||
|
f"not present"
|
||||||
|
)
|
||||||
|
cstring_va, cstring_foff, cstring_sec = cstring_hit
|
||||||
|
print(
|
||||||
|
f" cstring at va:0x{cstring_va:X} (foff:0x{cstring_foff:X}, "
|
||||||
|
f"sect={cstring_sec})"
|
||||||
|
)
|
||||||
|
|
||||||
|
code = bytes(data[text_foff : text_foff + text_size])
|
||||||
|
insns = list(_cs.disasm(code, text_va))
|
||||||
|
add_va_to_idx = {ins.address: i for i, ins in enumerate(insns) if ins.mnemonic == "add"}
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
already_patched = []
|
||||||
|
for adrp_va, add_va in _find_adrp_add_xrefs(code, text_va, cstring_va):
|
||||||
|
add_idx = add_va_to_idx.get(add_va)
|
||||||
|
if add_idx is None:
|
||||||
|
continue
|
||||||
|
m = _scan_pattern_from_add(insns, add_idx)
|
||||||
|
if m is None:
|
||||||
|
# Check whether this xref looks like an already-patched site.
|
||||||
|
# Heuristic: look for the strb forward; if found, check the
|
||||||
|
# canonical-slot offsets relative to the bl for the patched
|
||||||
|
# form.
|
||||||
|
continue
|
||||||
|
m["adrp_va"] = adrp_va
|
||||||
|
m["add_va"] = add_va
|
||||||
|
m["cbnz_foff"] = _va_to_foff(text_va, text_foff, m["cbnz_va"])
|
||||||
|
m["cset_foff"] = _va_to_foff(text_va, text_foff, m["cset_va"])
|
||||||
|
matches.append(m)
|
||||||
|
|
||||||
|
# Also detect an already-patched form by walking xrefs that DID find
|
||||||
|
# the bl + strb but where the cbnz slot is now a NOP.
|
||||||
|
# We do this as a second pass over the xrefs.
|
||||||
|
for adrp_va, add_va in _find_adrp_add_xrefs(code, text_va, cstring_va):
|
||||||
|
add_idx = add_va_to_idx.get(add_va)
|
||||||
|
if add_idx is None:
|
||||||
|
continue
|
||||||
|
bl = _next_branch(insns, add_idx + 1, ("bl",), SCAN_ADD_TO_BL)
|
||||||
|
if bl is None:
|
||||||
|
continue
|
||||||
|
bl_idx, bl_insn = bl
|
||||||
|
if bl_idx + 1 >= len(insns):
|
||||||
|
continue
|
||||||
|
slot_after_bl = insns[bl_idx + 1]
|
||||||
|
if slot_after_bl.mnemonic != "nop":
|
||||||
|
continue
|
||||||
|
# Try to find a strb after the bl so we know this is the same
|
||||||
|
# function shape.
|
||||||
|
strb = _next_branch(insns, bl_idx + 1, ("strb",), SCAN_BL_TO_CSET + SCAN_CSET_TO_STRB)
|
||||||
|
if strb is None:
|
||||||
|
continue
|
||||||
|
# Find a candidate mov wN, #1 between the nop and the strb.
|
||||||
|
mov_idx = None
|
||||||
|
for j in range(bl_idx + 2, strb[0]):
|
||||||
|
if insns[j].mnemonic == "mov" and _operand_reg_name(insns[j], 0) is not None:
|
||||||
|
# Check it's a mov wN, #1.
|
||||||
|
imm = insns[j].operands[1].imm if len(insns[j].operands) > 1 else -1
|
||||||
|
if imm == 1:
|
||||||
|
mov_idx = j
|
||||||
|
break
|
||||||
|
if mov_idx is None:
|
||||||
|
continue
|
||||||
|
already_patched.append({
|
||||||
|
"add_va": add_va,
|
||||||
|
"cbnz_va": slot_after_bl.address,
|
||||||
|
"cset_va": insns[mov_idx].address,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not matches and already_patched:
|
||||||
|
print(
|
||||||
|
f" [.] {filepath}: all {len(already_patched)} matching site(s) "
|
||||||
|
f"already patched — nothing to do"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
raise ValueError(
|
||||||
|
f"{filepath}: no '{PATTERN_NAME}' site found. Expected an "
|
||||||
|
f"adrp+add xref to the cstring followed by bl/cbnz w0/cset/strb."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" [+] found {len(matches)} '{PATTERN_NAME}' site(s)")
|
||||||
|
touched_offsets = []
|
||||||
|
n_applied = 0
|
||||||
|
for m in matches:
|
||||||
|
cbnz_foff = m["cbnz_foff"]
|
||||||
|
cset_foff = m["cset_foff"]
|
||||||
|
|
||||||
|
if _already_patched_at(data, cbnz_foff, cset_foff):
|
||||||
|
print(
|
||||||
|
f" [.] site @ add 0x{m['add_va']:X}: already in patched "
|
||||||
|
f"form (cbnz=nop, cset=mov #1) — skipping"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_cset = asm(f"mov {m['cset_reg']}, #1")
|
||||||
|
if len(new_cset) != 4:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"asm('mov {m['cset_reg']}, #1') returned {len(new_cset)} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx_start = max(text_foff, cbnz_foff - 8)
|
||||||
|
print(
|
||||||
|
f" site @ add 0x{m['add_va']:X} "
|
||||||
|
f"(bl 0x{m['bl_va']:X}, cbnz 0x{m['cbnz_va']:X}, "
|
||||||
|
f"cset {m['cset_reg']} 0x{m['cset_va']:X}, strb 0x{m['strb_va']:X})"
|
||||||
|
)
|
||||||
|
print(f" Before:")
|
||||||
|
_log_asm(data, ctx_start, 8, cbnz_foff)
|
||||||
|
|
||||||
|
old_cbnz = bytes(data[cbnz_foff : cbnz_foff + 4])
|
||||||
|
old_cset = bytes(data[cset_foff : cset_foff + 4])
|
||||||
|
|
||||||
|
data[cbnz_foff : cbnz_foff + 4] = NOP
|
||||||
|
data[cset_foff : cset_foff + 4] = new_cset
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" Patched: cbnz->{NOP.hex()} (was {old_cbnz.hex()}), "
|
||||||
|
f"cset->{new_cset.hex()} (was {old_cset.hex()})"
|
||||||
|
)
|
||||||
|
print(f" After:")
|
||||||
|
_log_asm(data, ctx_start, 8, cbnz_foff)
|
||||||
|
|
||||||
|
touched_offsets.append(cbnz_foff)
|
||||||
|
touched_offsets.append(cset_foff)
|
||||||
|
n_applied += 1
|
||||||
|
|
||||||
|
if n_applied == 0:
|
||||||
|
print(f" [.] {filepath}: nothing applied")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Write the patched bytes to disk BEFORE re-attest, because the
|
||||||
|
# re-attest helper opens the file from disk to compute SHA-256 of
|
||||||
|
# the modified page.
|
||||||
|
if dry_run:
|
||||||
|
print(f" [.] dry-run — not writing patched bytes")
|
||||||
|
else:
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
print(f" [+] {filepath}: wrote {n_applied} site(s)")
|
||||||
|
|
||||||
|
# Re-attest the modified page(s).
|
||||||
|
diagnostics = reattest_modified_offsets(
|
||||||
|
filepath, touched_offsets, dry_run=dry_run, verbose=True
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" [+] {filepath}: re-attest updated {len(diagnostics)} slot(s) "
|
||||||
|
f"across {len(set((d['cd_off'], d['page_index']) for d in diagnostics))} "
|
||||||
|
f"unique (CD, page) pair(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return n_applied
|
||||||
Reference in New Issue
Block a user