iOS 无痕 Hook:基于硬件断点(ARM Debug 寄存器)的实现与实战

2026/06/05 iOS 共 13501 字,约 39 分钟
矛和盾的故事

一、背景 & 场景

1.1 环境

设备型号:iPhone 14、iOS 版本:16.x、越狱方案:Dopamine(rootless,/var/jb)

工具链:Theos、IDA Pro 9.2、ellekit

1.2 引言

做 iOS 逆向,hook 是绕不开的基本功。主流方案——Substrate / Dobby / fishhook——本质都是 inline hook:改写目标函数开头几条指令,跳到自己的 trampoline。这套在大多数场景够用,但碰到带完整性校验的目标就难受了:

  • 改了 __text 段,App 自己做的 CRC / 段哈希一算就对不上;
  • iOS 的 W^X 和代码签名让”把跳板写进代码段”本身就要折腾 MAP_JIT;
  • 函数头的 B / LDR PC 跳板有明显特征,反 hook 扫描一眼就能发现。

有没有一种 hook 完全不碰目标内存?有——硬件断点。它是 CPU 里专门的地址比较器,命中后产生调试异常,全程不改一个字节的指令。代价是数量有限(arm64 通常 6 个指令断点)、且强依赖异常机制。

本文从 lldb debugserver 的寄存器填法出发,自己撸一套 iOS arm64(e) 进程内硬件断点 hook 框架,封装成越狱插件,最后实战 hook 某加固 App 的设备信息加密函数,抓出明文 JSON 和每次调用的密钥。(文中目标 App 名、专有符号、采集到的指纹/密钥/明文等均已脱敏。)

1.3 对抗场景:安全 SDK 的 hook 检测

直接的动机来自实战:目标 App 集成了一套商业安全 SDK(做设备风控 / 反作弊),它自带 hook 检测。一旦发现自己被 hook,轻则在上报里打一个”环境异常”标记把你拉黑,重则直接返回脏数据、让你逆出来的算法对不上。常见的检测手段有:

  • 代码完整性校验:对自己的 __text 段做 CRC / 哈希,inline hook 改了开头几条指令 → 校验失败;
  • 函数序言扫描:检查关键函数开头是不是 B / LDR PC / BR Xn 这类跳板特征;
  • Frida / 注入特征:扫 frida-gum、gadget、可疑 DYLD_INSERT_LIBRARIES、异常的内存可执行区、监听端口等;
  • 符号/IMP 校验:对比 ObjC method 的 IMP 是否指向自己模块之外(抓 method swizzle)。

可以看到,这些检测全部针对”代码/内存被改”或”有外来注入特征”。于是思路很自然:

用一种完全不改目标代码、不留 trampoline、不依赖 Frida 的 hook,绕过上面所有针对”改内存 / 注入特征”的检测——这正是硬件断点的主场。它的命中靠 CPU 寄存器比较,目标的指令、method 表一个字节都不动,前 4 类检测全部失效。残留的检测面只剩”调试寄存器是否被设置”和”异常端口是否被占”,对抗收敛到这两点(见第六节)。

1.4 研究动机与目标

  • 搞清楚 iOS 上硬件断点到底怎么落到寄存器、怎么经内核分发到用户态;
  • 自己实现一套无痕 hook,绕过安全 SDK 针对 inline-hook / 注入特征的检测;
  • 在真实加固 App(带反插桩与 hook 检测的商业安全 SDK)上验证可用性与稳定性。

二、硬件断点原理

2.1 三层机制

iOS(arm64)上的硬件断点,本质是 ARMv8 Debug 架构的断点寄存器 + XNU 把它封装成 thread state + Mach 异常。分三层:

① 底层:CoreSight / ARMv8 调试寄存器

硬件断点不是软件断点那种改指令写 BRK,而是 CPU 里专门的比较器:

  • 指令断点:DBGBVR<n>_EL1(存地址)+ DBGBCR<n>_EL1(控制/使能)
  • 数据观察点:DBGWVR<n>_EL1 + DBGWCR<n>_EL1

数量由 ID_AA64DFR0_EL1 报告,苹果 A 系列大核典型是 6 个指令断点 + 4 个观察点。命中后 CPU 产生一个同步 Debug 异常。

DBGBCR 关键位:

位名称含义
bit0E使能
bits[2:1]PMC特权级匹配,EL0(用户态)填 0b10
bits[8:5]BASByte Address Select,AArch64 下填 0b1111

② 内核层:XNU 不让用户态直接写 EL1 寄存器

这些是 EL1 特权寄存器,App(EL0)不能直接 MSR。XNU 用 arm_debug_state64_t 做中转:

struct arm_debug_state64 {
    __uint64_t bvr[16];   // 断点地址
    __uint32_t bcr[16];   // 断点控制
    __uint64_t wvr[16];   // 观察点地址
    __uint32_t wcr[16];   // 观察点控制
    __uint64_t mdscr_el1; // 单步等
};

通过 thread_set_state(thread, ARM_DEBUG_STATE64, ...) 写进去,线程被调度回 CPU 时内核把这些值真正 load 进 DBGBVR/DBGBCR。

③ 异常分发:走 Mach 异常,不是信号

这是 iOS 和 Linux 最大的区别。命中后 XNU 把 Debug 异常转成 Mach 异常 EXC_BREAKPOINT,投递给注册的异常端口:

thread_set_exception_ports(thread, EXC_MASK_BREAKPOINT, exc_port,
        EXCEPTION_STATE_IDENTITY | MACH_EXCEPTION_CODES, ARM_THREAD_STATE64);

ptrace 在 iOS 上残废,真正的调试控制全靠 Mach 异常 + thread state。所以硬件断点完全不经过 ptrace。

2.2 为什么叫”无痕”

  1. 目标代码段一个字节都不改 → 过 __text CRC、段哈希、代码签名、W^X;
  2. 不依赖 trampoline → 没有可被扫描的 B / LDR PC 跳板特征;
  3. 检测面只剩两个:调试寄存器本身(对方读 bcr 能发现非零)和异常端口被占(thread_get_exception_ports 能看到)。对抗就发生在这两点。

三、引擎实现

整体流程分注入期(把断点和异常处理铺好)和运行期(命中后处理并踏过断点)两段,全程不改目标一个字节的指令:

硬件断点 hook 整体流程

文字版同样的流程:

═══════════════ 注入期 (越狱框架 dlopen 进目标进程 → %ctor) ═══════════════

  ① task_set_exception_ports(EXC_MASK_BREAKPOINT, 我们的端口)   接管断点异常
  ② pthread_create ─► 异常服务器线程 (跑 mach_msg 接收循环)
  ③ 对所有线程 thread_set_state(ARM_DEBUG_STATE64):
        DBGBVR[n] = 目标函数地址 (strip PAC, 4字节对齐)
        DBGBCR[n] = BAS | PMC_EL0 | ENABLE
     + watcher 线程: 每 200ms 给「新出现的线程」补下断 (硬件断点 per-thread)

═══════════════════════════ 运行期 ═══════════════════════════

   目标线程执行到被下断的地址:  PC == DBGBVR[n]
            │  CPU 地址比较器命中 (指令不变,不触发任何完整性/反hook检测)
            ▼
     产生同步 Debug 异常  ──►  XNU 转成 Mach 异常 EXC_BREAKPOINT
            │  (目标线程挂起,等待异常处理回复)         │
            │                                          ▼
            │                          投递到我们注册的异常端口
            ▼                                          │
   ┌──────────────────────── 异常服务器线程 ────────────────────────┐
   │  mach_msg 收到消息 (携带 ARM_THREAD_STATE64 即寄存器现场)       │
   │     │                                                          │
   │     ├─ 这是「单步」产生的异常? ─是─► 复原断点 + 关单步 ──┐     │
   │     │                                                    │     │
   │     └─ 否,是断点命中                                      │     │
   │          │                                               │     │
   │          ▼  调用回调 cb(state): 读/改 x0~x30 / pc / sp    │     │
   │          │     (vm_read_overwrite 安全 dump 参数,不崩)    │     │
   │          ▼                                               │     │
   │      回调把 PC 改走了? ─是─► 直接放行 (无需单步) ────────┤     │
   │          │ 否                                            │     │
   │          ▼  踏过断点: 禁用本断点 E 位 + 置 mdscr.SS+cpsr.SS│     │
   └──────────│──────────────────────────────────────────────│─────┘
              │  把(可能改过的)寄存器回填 new_state            │
              ▼  内核据此恢复目标线程                          │
   目标线程单步执行命中处那一条指令 ──► 再次触发 EXC_BREAKPOINT │
              │                                                │
              └────────────────────────────────────────────────┘
                     (回到服务器,走上面「单步异常」分支:复原断点、关单步)
              │
              ▼
        目标线程继续正常执行,App 毫无察觉

寄存器位怎么填、单步怎么搞,参考 lldb 的 DNBArchImplARM64.cpp(EnableHardwareBreakpoint / 单步逻辑)。

3.1 下断点

// arm64 调试架构常量(参考 lldb DNBArchImplARM64)
#define BCR_ENABLE   (1u << 0)        // E:  使能
#define BCR_PMC_EL0  (0b10u << 1)     // PMC: 在 EL0(用户态)匹配
#define BCR_BAS_ALL  (0b1111u << 5)   // BAS: AArch64 下全 4 字节
#define MDSCR_SS     (1u << 0)        // MDSCR_EL1.SS: 软件单步使能
#define PSTATE_SS    (1u << 21)       // CPSR/PSTATE.SS

static void apply_bps_to_thread(mach_port_t th) {
    arm_debug_state64_t d;
    mach_msg_type_number_t c = ARM_DEBUG_STATE64_COUNT;
    thread_get_state(th, ARM_DEBUG_STATE64, (thread_state_t)&d, &c);
    for (int i = 0; i < MAX_HW_BP; i++) {
        if (g_bps[i].used) {
            d.__bvr[i] = g_bps[i].addr & ~3ULL;            // 4 字节对齐
            d.__bcr[i] = BCR_BAS_ALL | BCR_PMC_EL0 | BCR_ENABLE;
        }
    }
    thread_set_state(th, ARM_DEBUG_STATE64, (thread_state_t)&d, ARM_DEBUG_STATE64_COUNT);
}

注意 arm64e 上函数指针带 PAC,下断前要 ptrauth_strip:

static uint64_t strip_pac(uint64_t p) {
#if __has_feature(ptrauth_calls)
    return (uint64_t)ptrauth_strip((void *)p, ptrauth_key_function_pointer);
#else
    return p;
#endif
}

3.2 Mach 异常服务器(手写消息,免 MIG)

注册异常端口,起一个线程跑 mach_msg 接收循环。为了单文件自包含,这里不用 mig,直接手写 mach_exception_raise_state_identity(id=2407)的请求/应答结构:

#pragma pack(4)
typedef struct {
    mach_msg_header_t          Head;
    mach_msg_body_t            msgh_body;
    mach_msg_port_descriptor_t thread;
    mach_msg_port_descriptor_t task;
    NDR_record_t               NDR;
    exception_type_t           exception;
    mach_msg_type_number_t     codeCnt;
    int64_t                    code[2];
    int                        flavor;
    mach_msg_type_number_t     old_stateCnt;
    natural_t                  old_state[THREAD_STATE_MAX];
} exc_request_t;

typedef struct {
    mach_msg_header_t       Head;
    NDR_record_t           NDR;
    kern_return_t          RetCode;
    int                    flavor;
    mach_msg_type_number_t new_stateCnt;
    natural_t              new_state[THREAD_STATE_MAX];
} exc_reply_t;
#pragma pack()

用 EXCEPTION_STATE_IDENTITY,内核会把 thread state 直接放进消息,我们改完 old_state→new_state 内核就用它恢复线程——这是无痕的关键,全程不碰目标内存:

static void *exc_server_thread(void *arg) {
    for (;;) {
        exc_request_t req; exc_reply_t rep;
        memset(&req, 0, sizeof(req));
        mach_msg(&req.Head, MACH_RCV_MSG, 0, sizeof(req),
                 g_exc_port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

        mach_port_t thread = req.thread.name;
        arm_thread_state64_t *st = (arm_thread_state64_t *)req.old_state;
        kern_return_t rc = handle_exception(thread, req.exception, st);

        memset(&rep, 0, sizeof(rep));
        rep.Head.msgh_bits = MACH_MSGH_BITS(MACH_MSGH_BITS_REMOTE(req.Head.msgh_bits), 0);
        rep.Head.msgh_remote_port = req.Head.msgh_remote_port;
        rep.Head.msgh_id = req.Head.msgh_id + 100;   // 2407 -> 2507
        rep.NDR = req.NDR; rep.RetCode = rc; rep.flavor = req.flavor;
        if (rc == KERN_SUCCESS) {
            rep.new_stateCnt = req.old_stateCnt;
            memcpy(rep.new_state, req.old_state, req.old_stateCnt * sizeof(natural_t));
            rep.Head.msgh_size = offsetof(exc_reply_t, new_state)
                               + req.old_stateCnt * sizeof(natural_t);
        } else {
            rep.Head.msgh_size = offsetof(exc_reply_t, flavor);
        }
        mach_msg(&rep.Head, MACH_SEND_MSG, rep.Head.msgh_size, 0,
                 MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
        mach_port_deallocate(mach_task_self(), req.thread.name);
        mach_port_deallocate(mach_task_self(), req.task.name);
    }
    return NULL;
}

3.3 踏过断点:本框架最核心的难点

命中后怎么继续执行而不死循环?因为硬件断点还挂在那个地址,异常返回后会立刻再次命中。解法是经典的「禁用断点 + 硬件单步 + 复原」:

  1. 命中 → 跑用户回调;
  2. 如果回调没改 PC(还要执行原指令)→ 临时关掉本断点的 bcr.E,同时打开单步(mdscr_el1.SS + cpsr.SS);
  3. CPU 单步执行完命中那条指令后,再产生一个单步异常(也走 EXC_BREAKPOINT);
  4. 在这次异常里把断点重新打开、关掉单步。
static kern_return_t handle_exception(mach_port_t thread, exception_type_t exc,
                                      arm_thread_state64_t *st) {
    if (exc != EXC_BREAKPOINT) return KERN_FAILURE;

    uint64_t tid = tid_of(thread);
    pthread_mutex_lock(&g_lock);
    step_ctx_t *sc = step_get(tid);

    // 情况 1:这是我们刚才发起的「单步」产生的异常 —— 复原断点,关单步,放行
    if (sc && sc->slot >= 0) {
        arm_debug_state64_t d;
        if (get_dbg(thread, &d) == KERN_SUCCESS) {
            d.__bcr[sc->slot] |= BCR_ENABLE;   // 重新挂上之前禁用的断点
            d.__mdscr_el1     &= ~MDSCR_SS;     // 关单步
            set_dbg(thread, &d);
        }
        st->__cpsr &= ~PSTATE_SS;
        sc->slot = -1; sc->tid = 0;            // 释放槽位
        pthread_mutex_unlock(&g_lock);
        return KERN_SUCCESS;
    }

    // 情况 2:断点命中 —— 找到是哪个槽位
    uint64_t pc = strip_pac((uint64_t)arm_thread_state64_get_pc(*st));
    hb_entry_t *hit = NULL;
    for (int i = 0; i < MAX_HW_BP; i++)
        if (g_bps[i].used && (g_bps[i].addr & ~3ULL) == (pc & ~3ULL)) { hit = &g_bps[i]; break; }
    if (!hit) { pthread_mutex_unlock(&g_lock); return KERN_FAILURE; } // 不是我们的,转发出去

    hb_callback_t cb = hit->cb; void *user = hit->user; int slot = hit->slot;
    pthread_mutex_unlock(&g_lock);

    if (cb) cb(st, user);   // ===== 用户 hook 逻辑 =====

    // 用户改了 PC 就不会再撞断点,直接放行,无需单步
    uint64_t newpc = strip_pac((uint64_t)arm_thread_state64_get_pc(*st));
    if ((newpc & ~3ULL) != (pc & ~3ULL)) return KERN_SUCCESS;

    // PC 没动 —— 必须「踏过去」
    pthread_mutex_lock(&g_lock);
    arm_debug_state64_t d;
    if (get_dbg(thread, &d) == KERN_SUCCESS) {
        d.__bcr[slot] &= ~BCR_ENABLE;
        d.__mdscr_el1 |= MDSCR_SS;
        set_dbg(thread, &d);
    }
    st->__cpsr |= PSTATE_SS;
    step_ctx_t *ns = step_find_or_alloc(tid);
    if (ns) ns->slot = slot;
    pthread_mutex_unlock(&g_lock);
    return KERN_SUCCESS;
}

mdscr_el1.SS 和 cpsr.SS 必须配合置位,arm64 软件单步才真正生效。

3.4 per-thread 难题

这是硬件断点相对 inline hook 最大的弱点:调试寄存器是 per-thread 的。thread_set_state 只对一个线程生效,新建的线程不会自动带上断点。如果目标函数跑在 hook 之后才创建的线程上,永远不命中。

第一版用一次性 hb_install_all_threads()(遍历 task_threads 给所有现存线程下断),但这只覆盖”下断时已存在的线程”。后面会讲怎么用一个后台 watcher 彻底解决。

四、封装成越狱插件

引擎是自包含的 C,不依赖 Substrate。注入交给越狱框架(ellekit)按 bundle id 把 dylib dlopen 进目标进程,%ctor 里起服务器 + 下断。

Tweak.xm 的目标定位支持两种方式:

// 方式 A:hook 某 ObjC 实例方法的 IMP —— 拦方法但不 swizzle,method 表/指令都不动
static const char *TARGET_CLASS = NULL;
static const char *TARGET_SEL   = NULL;

// 方式 B:hook native 函数。IMAGE=mach-o 名后缀, OFFSET = IDA 地址 - IDA image base
static const char *TARGET_IMAGE  = "TargetApp";
static const uint64_t TARGET_OFFSET = 0x9XXXXX;   // 目标函数偏移,按自己 IDA 实测填

运行期基址(含 ASLR slide)这样取:

static uintptr_t image_base_by_suffix(const char *suffix) {
    uint32_t cnt = _dyld_image_count();
    for (uint32_t i = 0; i < cnt; i++) {
        const char *name = _dyld_get_image_name(i);
        size_t nl = strlen(name), sl = strlen(suffix);
        if (nl >= sl && strcmp(name + nl - sl, suffix) == 0)
            return (uintptr_t)_dyld_get_image_header(i);
    }
    return 0;
}

%ctor 里下断 + 开 watcher:

%ctor {
    @autoreleasepool {
        install_hooks();
        hb_enable_thread_watcher();   // 自动给新线程补断点(见第六节)
    }
}

非越狱也能用:把 hbhook.c/.h 直接拖进自己的 Xcode 工程,Debug 包自带 get-task-allow,进程就有权给自己设异常端口和调试寄存器。适合自调试 / 自保护 / PoC。

五、实战:hook 某加固 App 的设备信息加密

5.1 选目标

IDA 里目标主程序某个 native 函数(命名 getdeviceinfo_sub),被一个形如 +[UIDevice xxx_riskInfoCryptInner:andLength:andGen:andKey:…andAppendData:] 的 ObjC 方法调用——典型的设备信息采集 + 加密内核。函数体是 OLLVM 控制流平坦化(一个取 opcode 的派发循环 + 大量内联 EOR 串解密),但只 hook 入口、不进 VM,完全不受混淆影响。

序言保存的寄存器给出参数布局(地址用相对偏移示意):

func+0x20  MOV  X22, X7        ; x7
func+0x28  STUR X6, [...]      ; x6
func+0x30  STUR W5, [...]      ; w5(32位)
func+0x38  MOV  X20, X3        ; x3
func+0x3C  MOV  X21, X2        ; x2
func+0x44  STUR W1, [...]      ; w1(32位,length)
func+0x4C  STUR X0, [...]      ; x0

5.2 回调:安全读参数

第一版回调直接把参数当 OC 对象 isKindOfClass,结果 EXC_BAD_ACCESS 闪退——@try 抓不住 BAD_ACCESS(那是 Mach 异常不是 NSException)。教训:逆向场景别盲目发消息,用 vm_read_overwrite 安全读,地址非法只返回错误码、绝不崩:

static void dump_mem(const char *tag, uint64_t addr, size_t len) {
    if (!addr) { NSLog(@"  %-8s = (null)", tag); return; }
    if (len == 0) len = 32; if (len > 4096) len = 4096;
    static uint8_t buf[4096]; vm_size_t out = 0;
    kern_return_t kr = vm_read_overwrite(mach_task_self(),
                          (vm_address_t)addr, len, (vm_address_t)buf, &out);
    if (kr != KERN_SUCCESS) {
        NSLog(@"  %-8s @0x%llx 不可读(可能是标量, kr=%d)", tag, (unsigned long long)addr, kr);
        return;
    }
    NSMutableString *h = [NSMutableString string], *a = [NSMutableString string];
    for (vm_size_t i = 0; i < out; i++) {
        [h appendFormat:@"%02x", buf[i]];
        [a appendFormat:@"%c", (buf[i] >= 0x20 && buf[i] < 0x7f) ? buf[i] : '.'];
    }
    NSLog(@"  %-8s @0x%llx (%lu): %@ | %@", tag, (unsigned long long)addr, (unsigned long)out, h, a);
}

5.3 命中结果

触发一次风控操作,命中(注意是后台线程)。以下日志中的指纹、密钥、明文等敏感字节均已脱敏,仅保留结构:

[hbhook] crypt-inner hit  thread=0x16xxxxxxx
  raw x0..x7 = 1xxxxxxxx 100 1xxxxxxxx 2xxxxxxxx 1 3e9 0 0
  w1 len = 256   stk append = 0x2xxxxxxxx
  x0 data @0x1xxxxxxxx (256): 7a65……(256B 高熵,已脱敏)
  x3 obj  @0x2xxxxxxxx (64): 4158bf1b02000003 20000000 00000000
                              ……(紧随 16 字节对象头的 32B 高熵,已脱敏)
  append  @0x2xxxxxxxx: <对象头16B> 357b0a……("xxxxdf" 字段的 UTF8,已脱敏)……
                        | 5{  "xxxxdf" : "AC45****……****C251"\n}

解码三个关键参数:

  • append = 单字段明文 JSON:对象布局 isa(8) + len32 + flags32 + [1字节长度前缀][UTF8],内容形如 {"xxxxdf":"AC45****……****C251"}(指纹值已脱敏)。该字段是稳定设备指纹(多次调用不变),这就是上层安全 SDK 加密前的 body 明文。
  • x3 = 32 字节 per-call 高熵:两次调用完全不同 → 每次随机的密钥 / nonce / salt,AEAD key 的有力候选(值已脱敏)。
  • x0 = 256 字节裸 buffer:每次变,可能是密文输出、keystream 或 RC4-style 256 字节状态。

一个细节:x0 在普通堆是裸 buffer;x3/append 在 nano 堆区(0x2xx……)、带 isa 头是 OC/CF 对象。这就是为什么盲目对 x0 发消息会崩。

至此,用一个不改任何指令的硬件断点,就把加固 App 的设备信息明文和动态密钥抓了出来。

六、坑与优化

把它长期挂在目标 App(线程多、命中频繁)上,暴露出几个会实际咬人的问题:

6.1 单步状态表只增不减 → 跑久了卡死

踏断点的 per-thread 状态用一张小表记录。第一版单步完成只把 slot=-1,没释放线程项,死线程把表占满后 → 分配失败 → 踏不过断点 → 死循环。修复:单步完成时连 key 一起清掉。

6.2 用 Mach 端口名做 key 不可靠

g_step 一开始用 mach_port_t 当 key,但端口名会跨消息回收:入口命中和单步完成是两条独立 Mach 消息,中间端口名被 deallocate 了,一旦不复用,单步那条消息匹配不上 → 该断点永久卡死。正解是用稳定线程 id:

static uint64_t tid_of(mach_port_t th) {
    thread_identifier_info_data_t ii;
    mach_msg_type_number_t c = THREAD_IDENTIFIER_INFO_COUNT;
    if (thread_info(th, THREAD_IDENTIFIER_INFO, (thread_info_t)&ii, &c) == KERN_SUCCESS)
        return ii.thread_id;
    return 0;
}

g_step 改存 tid,端口名就能放心 deallocate。

6.3 per-thread 的终极解法:后台 watcher

用一个后台线程,只给新出现的线程补断点(按 tid 记忆,死线程自动剔除),并每 ~5s 全量重下一次——顺带兜底”被反调试擦寄存器”的情况:

static void *watcher_thread(void *arg) {
    g_watch_tid = tid_of(pthread_mach_thread_np(pthread_self()));
    static uint64_t seen[1024]; static int nseen = 0;
    int tick = 0;
    while (g_watch_run) {
        if (++tick >= 25) { nseen = 0; tick = 0; }   // 每 ~5s 全量重下(兜底)
        thread_act_array_t threads; mach_msg_type_number_t n;
        if (task_threads(mach_task_self(), &threads, &n) == KERN_SUCCESS) {
            uint64_t now[1024]; int nnow = 0;
            pthread_mutex_lock(&g_lock);
            for (unsigned i = 0; i < n; i++) {
                mach_port_t th = threads[i];
                uint64_t tid = tid_of(th);
                if (tid && tid != g_server_tid && tid != g_watch_tid) {
                    if (nnow < 1024) now[nnow++] = tid;
                    bool was = false;
                    for (int k = 0; k < nseen; k++) if (seen[k] == tid) { was = true; break; }
                    step_ctx_t *sc = step_get(tid);
                    if (!was && !(sc && sc->slot >= 0)) apply_bps_to_thread(th);
                }
                mach_port_deallocate(mach_task_self(), th);
            }
            nseen = nnow;
            for (int k = 0; k < nnow; k++) seen[k] = now[k];
            pthread_mutex_unlock(&g_lock);
            vm_deallocate(mach_task_self(), (vm_address_t)threads, n * sizeof(thread_act_t));
        }
        usleep(200 * 1000);
    }
    return NULL;
}

稳态开销从”每秒数百 syscall”降到”基本只在新线程出现时动一下”。

6.4 还没做但要知道的

  • 异常端口转发:task_set_exception_ports 会顶掉 App 原来的 EXC_BREAKPOINT 处理者(crash 上报 SDK、反调试)。要和它们长期共存,得 task_get_exception_ports 存旧端口,遇到非我方异常时 mach_msg 转发回去。否则可能互相打架,或者你被对方顶掉(表现为命中突然停止)。
  • 只有 6 个断点槽,多 hook 点要复用 + 动态换。

七、总结

 inline hook(Dobby 等)硬件断点 hook
改目标内存✅ 改前 N 条指令❌ 一字节不改
过 CRC / 代码签名❌ 会被挡✅ 无痕
数量限制无只有 6 个
依赖trampoline + 指令重定位arm_debug_state64 + Mach 异常端口
检测面函数头跳板特征调试寄存器非零、异常端口被占

硬件断点 hook 不是 inline hook 的替代品,而是它的补充:当目标做了完整性校验、反 inline-hook,或者你只想安静地观察而不留痕时,它就是趁手的武器。核心三件套——arm_debug_state64 下断、Mach 异常服务器拿 state、单步踏过断点——配齐了就是一套能在真实加固 App 上跑的无痕 hook。

实战里它帮我们用零内存改动,从一个 OLLVM 平坦化的加密函数入口抓出了设备信息明文和动态密钥。剩下的——抓输出密文凑齐 (明文, key, 密文) 三元组定位算法——只需要在返回地址(LR)再下一个断点,留给下一篇。

本文涉及的逆向均在自有设备、用于安全研究与对抗学习,请勿用于非法用途。

文档信息

Search

    订阅

    新文章邮件提醒,输入邮箱即可:

    或使用 RSS 订阅

    Table of Contents