mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +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"
|
||||
|
||||
|
||||
# ═══════════ 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..."
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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