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:
zqxwce
2026-05-18 16:14:55 +03:00
committed by zqxwce
parent af90c9a903
commit 674a86bfd4
5 changed files with 878 additions and 2 deletions
Regular → Executable
+34
View File
@@ -357,6 +357,40 @@ ssh_cmd "/bin/chmod 0755 /mnt1/usr/libexec/debugserver"
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 ══════════════════
echo ""
echo "[JB-4] Installing procursus bootstrap..."
+16 -1
View File
@@ -1,7 +1,7 @@
#!/bin/zsh
# 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>
# Patch the canonical sysctlbyname("kern.hv_vmm_present", ...) sites
@@ -15,6 +15,15 @@
# Patch a single standalone Mach-O file in place. Idempotent.
# 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
# exists so cfw_install_dev.sh and cfw_install_jb.sh can call a single
# entry point without duplicating Python venv/python3 resolution logic.
@@ -42,6 +51,7 @@ usage() {
Usage:
$0 dsc <chunks_dir>
$0 standalone <binary>
$0 watchdogd <binary>
EOF
exit 2
}
@@ -60,6 +70,11 @@ case "$op" in
echo "[*] Patching hv_vmm_present consumers in: $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
;;
+28 -1
View File
@@ -29,6 +29,16 @@ Commands:
the mounted SystemOS Cryptex). Targets a fixed list of identity,
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 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_jetsam import patch_launchd_jetsam
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
else:
from .cfw_patch_seputil import patch_seputil
@@ -61,6 +72,7 @@ else:
from .cfw_patch_mobileactivationd import patch_mobileactivationd
from .cfw_patch_jetsam import patch_launchd_jetsam
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
@@ -115,6 +127,21 @@ def main():
results = patch_hv_vmm_in_dsc(sys.argv[2], dry_run=dry_run)
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":
if len(sys.argv) < 4:
print("Usage: patch_cfw.py inject-daemons <launchd.plist> <daemon_dir>")
@@ -146,7 +173,7 @@ def main():
print(f"Unknown command: {cmd}")
print("Commands: cryptex-paths, patch-seputil, patch-launchd-cache-loader,")
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)
+344
View File
@@ -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
+456
View File
@@ -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