Fix C23: vnode_getattr string anchor resolved to wrong function (AppleImage4)

Root cause: find_string("vnode_getattr") matched "%s: vnode_getattr: %d"
format string inside an AppleImage4 function. The old code then took that
function as vnode_getattr itself, causing BL to call into AppleImage4 with
wrong arguments → PAC failure on indirect branch at a2+48.

Fix: _find_vnode_getattr_via_string() now scans backward from the string
ref for a BL instruction and extracts its target — the real vnode_getattr
(sub_FFFFFE0007CCD1B4 at foff 0xCC91B4).

Bisection confirmed: variants A (stack frame) and B (+ tpidr_el1) boot OK,
variant C (+ BL vnode_getattr) panics with old resolution, boots OK with fix.

Boot-tested: full C23 patch with corrected vnode_getattr — BOOTS OK.
This commit is contained in:
Lakr
2026-03-04 21:21:23 +08:00
parent 916c9c2168
commit 894c2d1551
5 changed files with 437 additions and 12 deletions

View File

@@ -222,7 +222,7 @@ restore:
# Ramdisk
# ═══════════════════════════════════════════════════════════════════
.PHONY: ramdisk_build ramdisk_send testing_ramdisk_build testing_ramdisk_send testing_do testing_do_save testing_kernel_patch testing_do_patch
.PHONY: ramdisk_build ramdisk_send testing_ramdisk_build testing_ramdisk_send testing_do testing_do_save testing_kernel_patch testing_do_patch testing_c23_bisect
ramdisk_build:
cd $(VM_DIR) && $(PYTHON) "$(CURDIR)/$(SCRIPTS)/ramdisk_build.py" .
@@ -251,6 +251,9 @@ testing_do_patch:
testing_batch:
zsh "$(CURDIR)/$(SCRIPTS)/testing_batch.sh" $(PATCHES)
testing_c23_bisect:
cd $(VM_DIR) && $(PYTHON) "$(CURDIR)/$(SCRIPTS)/testing_c23_bisect.py" . $(VARIANT)
# ═══════════════════════════════════════════════════════════════════
# CFW
# ═══════════════════════════════════════════════════════════════════

View File

@@ -43,7 +43,13 @@
- [29]: `0xFE00093B0830` (696 bytes)
### vnode_getattr
- String-related hit: xref `0xFE00084C08EC` → function start `0xFE00084C0718`
- Real `vnode_getattr`: `sub_FFFFFE0007CCD1B4` (file offset `0xCC91B4`)
- Signature: `int vnode_getattr(vnode_t vp, struct vnode_attr *vap, vfs_context_t ctx)`
- Located in XNU kernel proper (not a kext)
- **Bug 3 note**: The string `"vnode_getattr"` appears in format strings like
`"%s: vnode_getattr: %d"` inside callers (e.g., AppleImage4 at `0xFE00084C0718`).
The old string-anchor approach resolved to the AppleImage4 caller, not vnode_getattr.
See Bug 3 below.
### Original hook prologue
```
@@ -102,6 +108,37 @@ Replace PACIBSP at function entry with `B cave`. The cave runs PACIBSP first
resume the original function. Uses only PC-relative B/BL instructions —
no PAC involvement, no chained fixup modification.
### Bug 3: BL to wrong function — string anchor misresolution (PAC PANIC)
The string-anchor approach for finding `vnode_getattr` was:
1. `find_string(b"vnode_getattr")` → finds `"%s: vnode_getattr: %d"` (format string)
2. `find_string_refs()` → finds ADRP+ADD at `0xFE00084C08EC` (inside AppleImage4 function)
3. `find_function_start()` → returns `0xFE00084C0718` (an **AppleImage4** function)
This function is NOT `vnode_getattr` — it is an AppleImage4 function that CALLS
`vnode_getattr` and prints the error message when the call fails. The BL in
our shellcode was calling into AppleImage4's function with wrong arguments.
At `0xFE00084C0774`, this function does:
```
v9 = (*(__int64 (**)(void))(a2 + 48))(); // indirect PAC-signed call
```
With our arguments, `a2` (vattr buffer) had garbage at offset +48, causing a
PAC-authenticated branch to fail → same panic as Bug 2.
**Bisection results** (systematic boot tests):
- Variant A (stack frame save/restore only): **BOOTS OK**
- Variant B (+ mrs tpidr_el1 + vfs_context): **BOOTS OK**
- Variant C (+ BL vnode_getattr): **PANICS** ← crash introduced here
- Full shellcode: PANICS
**Fix**: Replaced the string-anchor resolution with `_find_vnode_getattr_via_string()`:
1. Find the format string `"%s: vnode_getattr: %d"`
2. Find the ADRP+ADD xref to it (inside the caller function)
3. Scan backward from the xref for a BL instruction (the call to the real vnode_getattr)
4. Extract the BL target → `sub_FFFFFE0007CCD1B4` = real `vnode_getattr`
The real `vnode_getattr` is at file offset `0xCC91B4`, not `0x14BC718`.
## 6) Current Implementation
- 47 patches: 46 shellcode instructions in __TEXT_EXEC cave + 1 trampoline
(B cave replacing PACIBSP at hook function entry).

View File

@@ -6,6 +6,76 @@ PACIBSP = bytes([0x7F, 0x23, 0x03, 0xD5]) # 0xD503237F
class KernelJBPatchHookCredLabelMixin:
def _find_vnode_getattr_via_string(self):
"""Find vnode_getattr by locating a caller function via string ref.
The string "vnode_getattr" appears in format strings like
"%s: vnode_getattr: %d" inside functions that CALL vnode_getattr.
We find such a caller, then extract the BL target near the string
reference to get the real vnode_getattr address.
Previous approach: find_string → find_string_refs → find_function_start
was wrong because it returned the CALLER (e.g. an AppleImage4 function)
instead of vnode_getattr itself.
"""
str_off = self.find_string(b"vnode_getattr")
if str_off < 0:
return -1
refs = self.find_string_refs(str_off)
if not refs:
return -1
# The string ref is inside a function that calls vnode_getattr.
# Scan backward from the string ref for a BL instruction — the
# nearest preceding BL is very likely the BL vnode_getattr call
# (the error message prints right after the call fails).
ref_off = refs[0][0] # ADRP offset
for scan_off in range(ref_off - 4, ref_off - 64, -4):
if scan_off < 0:
break
insn = _rd32(self.raw, scan_off)
if (insn >> 26) == 0x25: # BL opcode
imm26 = insn & 0x3FFFFFF
if imm26 & (1 << 25):
imm26 -= 1 << 26 # sign extend
target = scan_off + imm26 * 4
if any(s <= target < e for s, e in self.code_ranges):
self._log(
f" [+] vnode_getattr at 0x{target:X} "
f"(via BL at 0x{scan_off:X}, "
f"near string ref at 0x{ref_off:X})"
)
return target
# Fallback: try additional string hits
start = str_off + 1
for _ in range(5):
str_off2 = self.find_string(b"vnode_getattr", start)
if str_off2 < 0:
break
refs2 = self.find_string_refs(str_off2)
if refs2:
ref_off2 = refs2[0][0]
for scan_off in range(ref_off2 - 4, ref_off2 - 64, -4):
if scan_off < 0:
break
insn = _rd32(self.raw, scan_off)
if (insn >> 26) == 0x25: # BL
imm26 = insn & 0x3FFFFFF
if imm26 & (1 << 25):
imm26 -= 1 << 26
target = scan_off + imm26 * 4
if any(s <= target < e for s, e in self.code_ranges):
self._log(
f" [+] vnode_getattr at 0x{target:X} "
f"(via BL at 0x{scan_off:X})"
)
return target
start = str_off2 + 1
return -1
def patch_hook_cred_label_update_execve(self):
"""Inline-trampoline the sandbox cred_label_update_execve hook.
@@ -26,16 +96,7 @@ class KernelJBPatchHookCredLabelMixin:
# ── 1. Find vnode_getattr via string anchor ──────────────
vnode_getattr_off = self._resolve_symbol("_vnode_getattr")
if vnode_getattr_off < 0:
str_off = self.find_string(b"vnode_getattr")
if str_off >= 0:
refs = self.find_string_refs(str_off)
if refs:
vnode_getattr_off = self.find_function_start(refs[0][0])
if vnode_getattr_off >= 0:
self._log(
f" [+] vnode_getattr at 0x"
f"{vnode_getattr_off:X} (via string)"
)
vnode_getattr_off = self._find_vnode_getattr_via_string()
if vnode_getattr_off < 0:
self._log(" [-] vnode_getattr not found")

View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""
testing_c23_bisect.py — Bisect C23 shellcode to find which part causes PAC panic.
Usage:
python3 testing_c23_bisect.py <vm_dir> <variant>
Variants (progressive complexity):
A — PACIBSP + save/restore regs + B hook+4 (stack frame, no calls)
B — A + mrs tpidr_el1 + vfs_context build (register reads, no calls)
C — B + BL vnode_getattr (external function call)
D — C + ownership propagation (uid/gid/csflags writes)
E — full shellcode (same as kernel_jb_patch_hook_cred_label.py)
Each variant is strictly additive — if A boots, B adds only the next layer.
"""
import os
import shutil
import sys
from fw_patch import find_file, find_restore_dir, load_firmware, save_firmware
from patchers.kernel_jb import KernelJBPatcher
from patchers.kernel_jb_base import asm, _rd32, _rd64, NOP
PACIBSP = bytes([0x7F, 0x23, 0x03, 0xD5])
def build_variant(kp, variant, cave, orig_hook, vnode_getattr_off):
"""Build shellcode for the given variant, return list of 4-byte parts."""
# Helper: encode BL/B
def bl(src, dst):
return kp._encode_bl(src, dst)
def b(src, dst):
return kp._encode_b(src, dst)
# B resume always at the last slot
# We'll pad all variants to 46 slots for consistency.
#
# Variant A: stack frame only
# Variant B: + tpidr_el1 / vfs_context
# Variant C: + BL vnode_getattr
# Variant D: + ownership propagation
# Variant E: full (same as production)
parts = []
if variant in ("A", "B", "C", "D", "E"):
parts.append(PACIBSP) # 0: relocated from hook
# In full shellcode, slot 1 is: cbz x3, #0xb0 → slot 45
# For variant A, skip the cbz (just NOP), so we always enter the frame
if variant == "A":
parts.append(NOP) # 1
else:
parts.append(asm("cbz x3, #0xb0")) # 1: if vp==NULL → slot 45
parts.append(asm("sub sp, sp, #0x400")) # 2
parts.append(asm("stp x29, x30, [sp]")) # 3
parts.append(asm("stp x0, x1, [sp, #16]")) # 4
parts.append(asm("stp x2, x3, [sp, #32]")) # 5
parts.append(asm("stp x4, x5, [sp, #48]")) # 6
parts.append(asm("stp x6, x7, [sp, #64]")) # 7
if variant in ("B", "C", "D", "E"):
# Build vfs_context
parts.append(asm("mrs x8, tpidr_el1")) # 8: current_thread
parts.append(asm("stp x8, x0, [sp, #0x70]")) # 9: {thread, cred}
parts.append(asm("add x2, sp, #0x70")) # 10: ctx = &vfs_ctx
# Setup vnode_getattr args
parts.append(asm("ldr x0, [sp, #0x28]")) # 11: x0 = vp (saved x3)
parts.append(asm("add x1, sp, #0x80")) # 12: x1 = &vattr
parts.append(asm("mov w8, #0x380")) # 13: vattr size
parts.append(asm("stp xzr, x8, [x1]")) # 14: init vattr
parts.append(asm("stp xzr, xzr, [x1, #0x10]")) # 15: init vattr+16
parts.append(NOP) # 16
parts.append(NOP) # 17
elif variant == "A":
# Pad slots 8-17 with NOP
for _ in range(10):
parts.append(NOP)
if variant in ("C", "D", "E"):
# BL vnode_getattr
vnode_bl_off = cave + 18 * 4
vnode_bl = bl(vnode_bl_off, vnode_getattr_off)
if not vnode_bl:
print(" [-] BL to vnode_getattr out of range")
return None
parts.append(vnode_bl) # 18: BL vnode_getattr
elif variant in ("A", "B"):
parts.append(NOP) # 18
if variant in ("C", "D", "E"):
# After BL, check result — jump to restore on error
parts.append(asm("cbnz x0, #0x4c")) # 19: error → slot 38
elif variant in ("A", "B"):
parts.append(NOP) # 19
if variant in ("D", "E"):
# Ownership propagation
parts.append(asm("mov w2, #0")) # 20: changed = 0
parts.append(asm("ldr w8, [sp, #0xCC]")) # 21: va_mode
parts.append(bytes([0xA8, 0x00, 0x58, 0x36])) # 22: tbz w8,#11
parts.append(asm("ldr w8, [sp, #0xC4]")) # 23: va_uid
parts.append(asm("ldr x0, [sp, #0x18]")) # 24: new_cred
parts.append(asm("str w8, [x0, #0x18]")) # 25: cred->uid
parts.append(asm("mov w2, #1")) # 26: changed = 1
parts.append(asm("ldr w8, [sp, #0xCC]")) # 27: va_mode
parts.append(bytes([0xA8, 0x00, 0x50, 0x36])) # 28: tbz w8,#10
parts.append(asm("mov w2, #1")) # 29: changed = 1
parts.append(asm("ldr w8, [sp, #0xC8]")) # 30: va_gid
parts.append(asm("ldr x0, [sp, #0x18]")) # 31: new_cred
parts.append(asm("str w8, [x0, #0x28]")) # 32: cred->gid
parts.append(asm("cbz w2, #0x14")) # 33: if !changed → slot 38
parts.append(asm("ldr x0, [sp, #0x20]")) # 34: proc
parts.append(asm("ldr w8, [x0, #0x454]")) # 35: p_csflags
parts.append(asm("orr w8, w8, #0x100")) # 36: CS_VALID
parts.append(asm("str w8, [x0, #0x454]")) # 37: store
elif variant in ("A", "B", "C"):
# Pad slots 20-37 with NOP
for _ in range(18):
parts.append(NOP)
# Restore and resume — always present (slots 38-45)
if variant in ("A", "B", "C", "D", "E"):
parts.append(asm("ldp x0, x1, [sp, #16]")) # 38
parts.append(asm("ldp x2, x3, [sp, #32]")) # 39
parts.append(asm("ldp x4, x5, [sp, #48]")) # 40
parts.append(asm("ldp x6, x7, [sp, #64]")) # 41
parts.append(asm("ldp x29, x30, [sp]")) # 42
parts.append(asm("add sp, sp, #0x400")) # 43
parts.append(NOP) # 44
# B hook+4
b_resume_off = cave + 45 * 4
b_resume = b(b_resume_off, orig_hook + 4)
if not b_resume:
print(" [-] B to hook+4 out of range")
return None
parts.append(b_resume) # 45
assert len(parts) == 46, f"Expected 46 parts, got {len(parts)}"
return parts
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <vm_dir> <variant>")
print(f" Variants: A B C D E")
sys.exit(1)
vm_dir = os.path.abspath(sys.argv[1])
variant = sys.argv[2].upper()
if variant not in ("A", "B", "C", "D", "E"):
print(f"[-] Unknown variant: {variant}")
sys.exit(1)
restore_dir = find_restore_dir(vm_dir)
if not restore_dir:
print(f"[-] No *Restore* directory found in {vm_dir}")
sys.exit(1)
kernel_path = find_file(restore_dir, ["kernelcache.research.vphone600"], "kernelcache")
backup_path = kernel_path + ".base_backup"
if not os.path.exists(backup_path):
print(f"[-] No backup found: {backup_path}")
sys.exit(1)
# Restore from backup
shutil.copy2(backup_path, kernel_path)
print(f"[*] Restored kernel from backup")
# Load
im4p, data, was_im4p, original_raw = load_firmware(kernel_path)
print(f"[*] Loaded: {len(data)} bytes")
kp = KernelJBPatcher(data)
# ── Find vnode_getattr ──
vnode_getattr_off = kp._resolve_symbol("_vnode_getattr")
if vnode_getattr_off < 0:
vnode_getattr_off = kp._find_vnode_getattr_via_string()
if vnode_getattr_off < 0:
print("[-] vnode_getattr not found")
sys.exit(1)
print(f"[+] vnode_getattr at 0x{vnode_getattr_off:X}")
# ── Find sandbox ops table ──
ops_table = kp._find_sandbox_ops_table_via_conf()
if ops_table is None:
print("[-] sandbox ops table not found")
sys.exit(1)
# ── Find hook (largest in ops[0:30]) ──
hook_index = -1
orig_hook = -1
best_size = 0
for idx in range(0, 30):
entry = kp._read_ops_entry(ops_table, idx)
if entry is None or entry <= 0:
continue
if not any(s <= entry < e for s, e in kp.code_ranges):
continue
fend = kp._find_func_end(entry, 0x2000)
fsize = fend - entry
if fsize > best_size:
best_size = fsize
hook_index = idx
orig_hook = entry
if hook_index < 0 or best_size < 1000:
print(f"[-] hook not found (best: idx={hook_index}, size={best_size})")
sys.exit(1)
print(f"[+] hook at ops[{hook_index}] = 0x{orig_hook:X} ({best_size} bytes)")
# Verify PACIBSP
first_insn = data[orig_hook:orig_hook + 4]
if first_insn != PACIBSP:
print(f"[-] first insn not PACIBSP (got 0x{_rd32(data, orig_hook):08X})")
sys.exit(1)
# ── Find code cave (200 bytes) ──
cave = kp._find_code_cave(200)
if cave < 0:
print("[-] no code cave found")
sys.exit(1)
print(f"[+] code cave at 0x{cave:X}")
# ── Build variant shellcode ──
print(f"\n[*] Building variant {variant}")
parts = build_variant(kp, variant, cave, orig_hook, vnode_getattr_off)
if parts is None:
sys.exit(1)
# Write shellcode to data
for i, part in enumerate(parts):
off = cave + i * 4
data[off:off + 4] = part
# Patch function entry: PACIBSP → B cave
b_to_cave = kp._encode_b(orig_hook, cave)
if not b_to_cave:
print("[-] B to cave out of range")
sys.exit(1)
data[orig_hook:orig_hook + 4] = b_to_cave
print(f"[+] Variant {variant}: {len(parts)} instructions written to cave")
print(f"[+] Trampoline: B 0x{cave:X} at 0x{orig_hook:X}")
# Save
save_firmware(kernel_path, im4p, data, was_im4p, original_raw)
print(f"[+] Saved: {kernel_path}")
if __name__ == "__main__":
main()

66
scripts/testing_c23_bisect.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env zsh
set -euo pipefail
# Bisect C23 shellcode — restore, patch variant, rebuild ramdisk, boot.
#
# Usage: ./testing_c23_bisect.sh <variant>
# or: make testing_c23_bisect_boot VARIANT=A
#
# Variants: A B C D E (see testing_c23_bisect.py)
typeset -a CHILD_PIDS=()
cleanup() {
echo "\n[c23] cleaning up..."
for pid in "${CHILD_PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
echo "[c23] killing PID $pid"
kill -9 "$pid" 2>/dev/null || true
fi
done
exit 0
}
trap cleanup EXIT INT TERM
PROJECT_DIR="$(cd "$(dirname "${0:a:h}")" && pwd)"
cd "$PROJECT_DIR"
VARIANT="${1:-}"
if [[ -z "$VARIANT" ]]; then
echo "Usage: $0 <variant>"
echo " Variants: A B C D E"
exit 1
fi
VM_DIR="${VM_DIR:-vm}"
echo "[c23] ═══════════════════════════════════════════"
echo "[c23] Bisect variant: $VARIANT"
echo "[c23] ═══════════════════════════════════════════"
# Kill existing
echo "[c23] killing existing vphone-cli..."
pkill -9 vphone-cli 2>/dev/null || true
sleep 1
# Restore + patch variant
echo "[c23] restoring base kernel + applying variant $VARIANT"
make testing_c23_bisect VARIANT="$VARIANT"
# Rebuild ramdisk
echo "[c23] testing_ramdisk_build..."
make testing_ramdisk_build
# Send ramdisk in background
echo "[c23] testing_ramdisk_send (background)..."
make testing_ramdisk_send &
CHILD_PIDS+=($!)
# Boot
echo "[c23] boot_dfu..."
make boot_dfu &
CHILD_PIDS+=($!)
echo "[c23] waiting for boot (PID ${CHILD_PIDS[-1]})..."
wait "${CHILD_PIDS[-1]}" 2>/dev/null || true