mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
Companion user-mode patches to the kernel-side OID rename. Mangles
byte 5 of every `kern.hv_vmm_present` cstring inside DSC dylibs EXCEPT
those in `DONT_PATCH_INSTALL_NAMES` (sign-in / device-likeness consumers,
~15 entries). Patched dylibs query the renamed OID and get the truthful
1 (graphics + accel passthrough); blacklisted dylibs keep the original
cstring, hit ENOENT on the renamed kernel, defensively cache 0 ("not
running on a VM") for sign-in / device-attestation surfaces.
- `scripts/patchers/cfw_patch_hv_vmm_dsc.py` — DSC orchestrator. Walks
every `kern.hv_vmm_present\\0` cstring in any executable mapping,
resolves the containing dylib via Mach-O-header walk-back +
LC_ID_DYLIB, applies the byte-5 mangle to non-blacklisted dylibs, and
drives slot-hash re-attestation in the chunk's `CS_CodeDirectory`.
- `scripts/patchers/cfw_dsc_chunks.py` — pure-Python helper for the
chunked DSC layout: vmaddr<->chunk-fileoff mapping, install-name
walk-back, byte-level read/write at a vmaddr.
- `scripts/patchers/cfw_dsc_codesign.py` — per-page SHA-256 slot-hash
re-attestation for DSC chunks (16 KiB pages). Required on
`codeSigningMonitor == 2` (TXM) hardware where per-page hash checks
would otherwise SIGKILL the patched dylibs at first demand-page-in.
- `scripts/patchers/cfw_patch_hv_vmm.py` — standalone Mach-O variant of
the cstring mangle (kept for completeness; the historical
standalone-binary loop step was removed in favor of the
blacklist-flip-via-kernel-rename design).
- `scripts/patchers/cfw_patch_hv_vmm_rootfs.py` — rootfs-path inventory
shared with the install scripts (former JB-3.5 / 6.5/7 loop input).
- `scripts/patch_hv_vmm_userland.sh` — thin wrapper used by the install
script (`dsc` and `standalone` operations; `watchdogd` is added by
the next commit).
- `scripts/patchers/cfw.py` — new subcommands: `patch-hv-vmm`,
`patch-hv-vmm-dsc`, `list-hv-vmm-rootfs-paths`.
- `scripts/cfw_install_exp.sh` — adds a pre-step that decrypts the
SystemOS Cryptex AEA into the cache location `cfw_install.sh` already
uses, mounts it, applies the DSC patch, and unmounts. The unmodified
base `cfw_install.sh` then picks up the cached (patched) DMG.
Research docs in this commit describe the full EXP variant comprehensively
(`research/0_binary_patch_comparison.md` top-of-doc note, EXP-Only
Kernel Methods section, DSC userland subsection, plus forward references
to EXP-JB-3.5 / EXP-JB-6 / EXP-JB-7 subsections wired up by the following
commits; `research/firmware_manifest_and_origins.md` sections 9-11
similarly forward-describe the DT and build-version pieces).
JB and DEV variants are NOT affected: their install scripts don't invoke
any of this.
362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""dyld shared cache (split / chunked) byte-level helper.
|
|
|
|
The iOS 26 SystemOS Cryptex ships the DSC as multiple files:
|
|
|
|
/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e
|
|
/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e.01
|
|
/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e.02
|
|
...
|
|
|
|
The first file (no suffix) holds the cache header; suffixed files
|
|
(`.NN`) hold subcache regions. Together they map a contiguous virtual
|
|
address space.
|
|
|
|
This module gives us:
|
|
|
|
chunks = DSCChunks(chunks_dir)
|
|
chunks.bytes_at_vma(vma, length) -> bytes
|
|
chunks.find_chunk_for_vma(vma) -> (chunk_path, file_offset)
|
|
chunks.write_at_vma(vma, data) -> None # persists to chunk on disk
|
|
|
|
It does *not* extract dylibs. It treats the DSC as a flat virtual
|
|
address space and gives byte-level access. That's the minimum we need
|
|
to apply 4-byte instruction patches at known vmaddrs.
|
|
|
|
The dyld_cache_header layout we use is intentionally minimal:
|
|
char magic[16];
|
|
uint32_t mappingOffset;
|
|
uint32_t mappingCount;
|
|
...
|
|
uint64_t sharedRegionStart; (offset 0x60 in modern caches)
|
|
uint64_t sharedRegionSize;
|
|
...
|
|
|
|
The dyld_cache_mapping_info entries (mappingCount of them) each look:
|
|
uint64_t address;
|
|
uint64_t size;
|
|
uint64_t fileOffset;
|
|
uint32_t maxProt;
|
|
uint32_t initProt;
|
|
|
|
For chunked caches (.NN files), each chunk has its own header with its
|
|
own mappings; the mappings address ranges are non-overlapping across
|
|
chunks.
|
|
|
|
Reference: dyld project, dyld_cache_format.h.
|
|
"""
|
|
|
|
import os
|
|
import struct
|
|
import re
|
|
import glob
|
|
|
|
|
|
CHUNK_GLOB_NAMES = (
|
|
"dyld_shared_cache_arm64e",
|
|
"dyld_shared_cache_arm64e.[0-9]",
|
|
"dyld_shared_cache_arm64e.[0-9][0-9]",
|
|
"dyld_shared_cache_arm64e.symbols",
|
|
)
|
|
|
|
|
|
def _enumerate_chunks(chunks_dir):
|
|
"""Return list of chunk file paths in numeric order; the unsuffixed file
|
|
first, then .01, .02, ... `.symbols` is excluded (it doesn't map code).
|
|
"""
|
|
names = []
|
|
for name in os.listdir(chunks_dir):
|
|
if not name.startswith("dyld_shared_cache_arm64e"):
|
|
continue
|
|
if name.endswith(".symbols") or name.endswith(".map"):
|
|
continue
|
|
names.append(name)
|
|
|
|
def sort_key(n):
|
|
# base file (no suffix) first, then .01, .02, ...
|
|
m = re.match(r"^dyld_shared_cache_arm64e(?:\.(\d+))?$", n)
|
|
if m:
|
|
return (0, int(m.group(1)) if m.group(1) else -1)
|
|
return (1, n)
|
|
|
|
names.sort(key=sort_key)
|
|
return [os.path.join(chunks_dir, n) for n in names]
|
|
|
|
|
|
def _parse_chunk_mappings(path):
|
|
"""Return list of dicts {addr, size, file_off, max_prot, init_prot}
|
|
for one chunk file.
|
|
|
|
Only the standard dyld_cache_mapping_info entries are used. Slide-info
|
|
mappings (`mappingWithSlideOffset`) are ignored — for our purposes we
|
|
only care about which virtual address comes from which file offset,
|
|
and the standard mappings cover that.
|
|
|
|
`init_prot` is the VM_PROT mask: VM_PROT_READ=1, VM_PROT_WRITE=2,
|
|
VM_PROT_EXECUTE=4.
|
|
"""
|
|
with open(path, "rb") as f:
|
|
head = f.read(0x100)
|
|
magic = head[:16].rstrip(b"\x00")
|
|
# Modern arm64e caches start with "dyld_v1 arm64e"
|
|
if not magic.startswith(b"dyld"):
|
|
return []
|
|
|
|
mapping_off = struct.unpack_from("<I", head, 16)[0]
|
|
mapping_cnt = struct.unpack_from("<I", head, 20)[0]
|
|
if mapping_cnt > 64 or mapping_off > 0x10000:
|
|
# sanity bounds
|
|
return []
|
|
|
|
with open(path, "rb") as f:
|
|
f.seek(mapping_off)
|
|
raw = f.read(mapping_cnt * 32)
|
|
|
|
out = []
|
|
for i in range(mapping_cnt):
|
|
off = i * 32
|
|
addr, size, file_off, max_prot, init_prot = \
|
|
struct.unpack_from("<QQQII", raw, off)
|
|
out.append({
|
|
"addr": addr, "size": size, "file_off": file_off,
|
|
"max_prot": max_prot, "init_prot": init_prot,
|
|
})
|
|
return out
|
|
|
|
|
|
class DSCChunks:
|
|
"""Lazy random-access byte view over a chunked dyld shared cache."""
|
|
|
|
def __init__(self, chunks_dir):
|
|
self.chunks_dir = chunks_dir
|
|
self._chunk_paths = _enumerate_chunks(chunks_dir)
|
|
if not self._chunk_paths:
|
|
raise FileNotFoundError(
|
|
f"No dyld_shared_cache_arm64e* chunks found under {chunks_dir!r}"
|
|
)
|
|
|
|
# Build (addr_start, addr_end, file_off, init_prot, chunk_path) ranges.
|
|
ranges = []
|
|
for cp in self._chunk_paths:
|
|
for m in _parse_chunk_mappings(cp):
|
|
if m["size"] == 0:
|
|
continue
|
|
ranges.append(
|
|
(
|
|
m["addr"],
|
|
m["addr"] + m["size"],
|
|
m["file_off"],
|
|
m["init_prot"],
|
|
cp,
|
|
)
|
|
)
|
|
if not ranges:
|
|
raise RuntimeError(
|
|
f"No DSC mappings parsed under {chunks_dir!r} — "
|
|
f"chunks present but headers unrecognised"
|
|
)
|
|
|
|
# Sort by addr_start for binary-search later.
|
|
ranges.sort(key=lambda r: r[0])
|
|
self._ranges = ranges
|
|
|
|
def __repr__(self):
|
|
return (
|
|
f"DSCChunks({self.chunks_dir!r}, "
|
|
f"{len(self._chunk_paths)} chunk(s), "
|
|
f"{len(self._ranges)} mapping(s), "
|
|
f"vm 0x{self._ranges[0][0]:X}..0x{self._ranges[-1][1]:X})"
|
|
)
|
|
|
|
def find_chunk_for_vma(self, vma):
|
|
"""Return (chunk_path, file_offset) for `vma`, or None if unmapped."""
|
|
# Linear scan is fine; mapping count is < ~80.
|
|
for addr_start, addr_end, file_off, _init_prot, cp in self._ranges:
|
|
if addr_start <= vma < addr_end:
|
|
return (cp, file_off + (vma - addr_start))
|
|
return None
|
|
|
|
def bytes_at_vma(self, vma, length):
|
|
"""Read `length` bytes starting at `vma`. Must be contained in one
|
|
mapping (we don't stitch across mappings)."""
|
|
loc = self.find_chunk_for_vma(vma)
|
|
if loc is None:
|
|
raise KeyError(f"vma 0x{vma:X} is not mapped by any chunk")
|
|
cp, foff = loc
|
|
with open(cp, "rb") as f:
|
|
f.seek(foff)
|
|
data = f.read(length)
|
|
if len(data) != length:
|
|
raise IOError(
|
|
f"short read at vma 0x{vma:X} ({len(data)} of {length})"
|
|
)
|
|
return data
|
|
|
|
def write_at_vma(self, vma, data):
|
|
"""Persist `data` bytes at `vma` to the right chunk file. The
|
|
target span must lie within a single mapping."""
|
|
loc = self.find_chunk_for_vma(vma)
|
|
if loc is None:
|
|
raise KeyError(f"vma 0x{vma:X} is not mapped by any chunk")
|
|
# Verify the entire span is in this mapping.
|
|
end_loc = self.find_chunk_for_vma(vma + len(data) - 1)
|
|
if end_loc is None or end_loc[0] != loc[0]:
|
|
raise ValueError(
|
|
f"write at vma 0x{vma:X} length {len(data)} "
|
|
f"crosses a chunk boundary — refusing"
|
|
)
|
|
cp, foff = loc
|
|
with open(cp, "r+b") as f:
|
|
f.seek(foff)
|
|
f.write(data)
|
|
|
|
# ---- additional helpers used by the DSC-native canonical-site finder ----
|
|
|
|
VM_PROT_EXECUTE = 4
|
|
|
|
def mappings(self):
|
|
"""Return [(vmaddr_start, vmaddr_end, file_offset, init_prot, chunk_path)]."""
|
|
return list(self._ranges)
|
|
|
|
def find_string_vmas(self, needle):
|
|
"""Return all vmaddrs where `needle` (bytes) appears within a single
|
|
mapping, anchored either at the mapping start or after a NUL byte.
|
|
|
|
Restricted to executable (RX) mappings — `__TEXT` segments where
|
|
`__TEXT,__cstring` lives. This skips the giant LINKEDIT and
|
|
__DATA/__DATA_CONST mappings entirely.
|
|
"""
|
|
out = []
|
|
for addr_start, addr_end, file_off, init_prot, cp in self._ranges:
|
|
if not (init_prot & self.VM_PROT_EXECUTE):
|
|
continue
|
|
size = addr_end - addr_start
|
|
with open(cp, "rb") as f:
|
|
f.seek(file_off)
|
|
buf = f.read(size)
|
|
i = 0
|
|
while True:
|
|
p = buf.find(needle, i)
|
|
if p < 0:
|
|
break
|
|
if p == 0 or buf[p - 1] == 0:
|
|
out.append(addr_start + p)
|
|
i = p + 1
|
|
return out
|
|
|
|
def iter_executable_mapping_bytes(self):
|
|
"""Yield (chunk_path, file_offset, vmaddr_start, mapping_bytes) for
|
|
every mapping whose `initProt` includes VM_PROT_EXECUTE.
|
|
|
|
Non-executable mappings (LINKEDIT, __DATA, __DATA_CONST) are
|
|
skipped so we never try to disassemble them.
|
|
"""
|
|
for addr_start, addr_end, file_off, init_prot, cp in self._ranges:
|
|
if not (init_prot & self.VM_PROT_EXECUTE):
|
|
continue
|
|
size = addr_end - addr_start
|
|
with open(cp, "rb") as f:
|
|
f.seek(file_off)
|
|
buf = f.read(size)
|
|
yield cp, file_off, addr_start, buf
|
|
|
|
def read_at_vma(self, vma, length, *, allow_short=False):
|
|
"""Read `length` bytes starting at `vma`, contained in a single
|
|
mapping. With `allow_short=True`, returns whatever is available
|
|
within the containing mapping instead of raising."""
|
|
for addr_start, addr_end, file_off, _init_prot, cp in self._ranges:
|
|
if addr_start <= vma < addr_end:
|
|
avail = addr_end - vma
|
|
want = length if avail >= length else (avail if allow_short else 0)
|
|
if want == 0:
|
|
raise IOError(
|
|
f"read at vma 0x{vma:X} len {length} would cross "
|
|
f"a chunk boundary"
|
|
)
|
|
with open(cp, "rb") as f:
|
|
f.seek(file_off + (vma - addr_start))
|
|
data = f.read(want)
|
|
return data
|
|
raise KeyError(f"vma 0x{vma:X} is not mapped by any chunk")
|
|
|
|
_MH_MAGIC_64_LE = b"\xcf\xfa\xed\xfe"
|
|
|
|
def find_macho_header_before(self, vma, *, max_walk=64 * 1024 * 1024):
|
|
"""Walk backwards from `vma` looking for a Mach-O 64 header
|
|
(magic 0xfeedfacf) at a page boundary. Returns the magic's
|
|
vmaddr, or None if not found within `max_walk` bytes inside the
|
|
same mapping.
|
|
|
|
Mach-O headers in the DSC are placed at page-aligned vmaddrs at
|
|
the start of each dylib's __TEXT segment, and an arm64e iOS
|
|
dylib's header is in the same mapping as its __text section, so
|
|
we only have to search within the containing mapping.
|
|
|
|
Implementation: read the relevant bytes from disk once and use
|
|
`bytes.rfind` to locate the magic, then accept only page-aligned
|
|
hits. Searching ~36 MiB takes <50 ms in pure Python.
|
|
"""
|
|
for addr_start, addr_end, file_off, _init_prot, cp in self._ranges:
|
|
if not (addr_start <= vma < addr_end):
|
|
continue
|
|
local_off = vma - addr_start
|
|
scan_len = min(local_off, max_walk)
|
|
scan_start = local_off - scan_len
|
|
with open(cp, "rb") as f:
|
|
f.seek(file_off + scan_start)
|
|
buf = f.read(scan_len + 4) # +4 so a hit at end is captured
|
|
i = len(buf)
|
|
while True:
|
|
found = buf.rfind(self._MH_MAGIC_64_LE, 0, i)
|
|
if found < 0:
|
|
return None
|
|
candidate_vma = addr_start + scan_start + found
|
|
if (candidate_vma & 0xFFF) == 0:
|
|
return candidate_vma
|
|
# Keep searching backwards.
|
|
i = found
|
|
# unreachable
|
|
return None
|
|
|
|
def read_install_name_at(self, header_vma):
|
|
"""Given the vmaddr of a Mach-O 64 header in the DSC, return the
|
|
dylib's install name (LC_ID_DYLIB), or None if the file isn't a
|
|
dylib or the LC isn't present.
|
|
"""
|
|
# Read the first 64KB of LCs (more than enough).
|
|
try:
|
|
head = self.read_at_vma(header_vma, 64 * 1024, allow_short=True)
|
|
except (KeyError, IOError):
|
|
return None
|
|
if struct.unpack_from("<I", head, 0)[0] != 0xFEEDFACF:
|
|
return None
|
|
ncmds = struct.unpack_from("<I", head, 16)[0]
|
|
sizeofcmds = struct.unpack_from("<I", head, 20)[0]
|
|
# Sanity-bound the LC walk.
|
|
if ncmds > 4096 or sizeofcmds > 64 * 1024:
|
|
return None
|
|
o = 32
|
|
for _ in range(ncmds):
|
|
if o + 8 > len(head):
|
|
return None
|
|
cmd, cmdsize = struct.unpack_from("<II", head, o)
|
|
if cmdsize < 8 or cmdsize > 64 * 1024:
|
|
return None
|
|
if cmd == 0xD: # LC_ID_DYLIB
|
|
# struct dylib_command { cmd, cmdsize, dylib { name(uint32 offset),
|
|
# timestamp, current_version, compatibility_version } }
|
|
name_off_in_cmd = struct.unpack_from("<I", head, o + 8)[0]
|
|
name_pos = o + name_off_in_cmd
|
|
if name_pos >= len(head) or name_pos >= o + cmdsize:
|
|
return None
|
|
end = head.find(b"\x00", name_pos, o + cmdsize)
|
|
if end < 0:
|
|
return None
|
|
try:
|
|
return head[name_pos:end].decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return head[name_pos:end].decode("latin-1")
|
|
o += cmdsize
|
|
if o > sizeofcmds + 32:
|
|
return None
|
|
return None
|