溺的文档

某XXX断点检测业务详细分析

2026-07-23 · 阅读 173

更新时间:2026-07-23

范围:重点覆盖 ARM64 HWBP/HWWP 自检、SIGTRAP 协同链和硬件断点槽位快照。

证据:07-23 D 段 IDB、当前 PID 28730 的只读内存/runtime 数据,以及 Kernel 硬件断点槽位快照。本文没有写入目标进程内存,也没有新增临时断点。

1. 地址口径和重新定位规则

1.1 固定证据与动态地址必须分开

以下值是稳定标识,应优先写入文档、脚本和复核记录:

  • UE4 入口 RVA,例如 libUE4.so + 0x1165DD44libUE4.so + 0xEA534B8
  • D 段内偏移,例如 +0x3810+0x6140+0x4878+0x4880
  • D 段大小 0xD000,以及它在 objects_external_alloc 映射中的相对位置。

以下值只属于一次 runtime 快照,不能跨 PID 复用:

  • libUE4.solibanogs.so 基址。
  • [anon:objects_external_alloc] 各段基址。
  • D 段中所有全局、探针、函数和槽的绝对地址。
  • runtime 快照中的线程 TID、event id、CPU 和 active/inactive 状态。

每次需要 runtime 数据时,按下面顺序重新计算:

1. get_status()                         -> 当前 PID
2. get_module_base("libUE4.so")        -> UE4 base
3. get_module_base("libanogs.so")      -> anogs base(需要时)
4. list_modules("objects_external_alloc")
                                           -> 重新识别 size=0xD000 的 D 段
5. absolute = module_base + UE4_RVA
   absolute = d_segment_base + D_offset
6. 读取入口/handler 字节,确认地址没有因 PID 或映射变化而失效

不能继续使用 07-23 旧样本 D 段基址 0x764F604000

1.2 当前样本(PID 28730

项目 当前值 备注
进程 app_process64 com.proxima.dfm 当前 runtime 状态
libUE4.so base 0x7400249000 当前 PID 快照
libanogs.so base 0x74ED84A000 当前 PID 快照
D 段 base 0x7649B82000 size 0xD000
D 段范围 0x7649B82000..0x7649B8F000 rwxp
D 段 context D+0x4870 = 0x7649B86870 当前运行结果区

当前入口跳转链已重新从 UE4 base 计算并读回:

libUE4 + 0x1165DD44 = 0x74118A6D44
  bytes: 50 00 00 58 00 02 1F D6 ...
  second hop -> D+0x3000 page
  page branch -> 0x7649B85810 = D+0x3810

libUE4 + 0xEA534B8 = 0x740EC9C4B8
  bytes: 50 00 00 58 00 02 1F D6 ...
  second hop -> D+0x6000 page
  page branch -> 0x7649B88140 = D+0x6140

因此当前两个入口分别落在 HWBP/HWWP 检测器和 SIGTRAP/线程完整性检测器,不能用页面首地址 +0x1C 的邻接 helper 代替真实 handler。

2. 静态函数边界和 Hook 归属

07-23 D 段 IDB 的稳定边界如下:

D 段偏移 IDB 名称 业务身份
+0x301C..+0x31C8 segD_memcpy_unaligned 邻接 memcpy helper,不是 +0x3000 的主检测器
+0x3758..+0x3810 segD_prepare_thread_probe 自检 SIGTRAP handler,负责命中探针后更新结果并跳过一条指令
+0x3810..+0x46A8 segD_detect_arm64_hwbp_hwwp 枚举关键线程并检查 ARM64 硬件断点/观察点寄存器
+0x46A8..+0x46EC segD_hwbp_query_helper 等待 ptrace stop,忽略 EINTR,验证停止状态
+0x46EC..+0x4758 segD_hwbp_report_helper 用 ptrace request 4 将结果写回上层结果槽
+0x501C..+0x511C segD_timing_probe_setup 时延探针准备/统计 helper
+0x5154..+0x51C0 segD_timing_measure_helper 计时、异常样本过滤和均值计算
+0x51C0..+0x5834 segD_detect_inline_hook_by_timing 临时 RET 与 null stub 的执行时延比较
+0x601C..+0x60D8 segD_sigtrap_probe_setup SIGTRAP 命中地址识别、掩码更新、PC 加 4
+0x60D8..+0x6140 segD_sigtrap_state_helper 6 路/4 路 Fisher-Yates 洗牌
+0x6140..+0x6564 segD_check_sigtrap_thread_integrity 高频入口计数、handler 链检查、轮转探针和失败上报

Hook 对应关系:

UE4 RVA 当前 runtime 落点 D 段内偏移 业务
0x1165DD44 0x7649B85810 +0x3810 ARM64 HWBP/HWWP 自检
0xABF544C D+0x51C0 +0x51C0 inline-hook 时延探针
0xEA534B8 0x7649B88140 +0x6140 SIGTRAP/线程完整性

0x1165DD44 在完整 UE4 IDB 中引用 USeamlessReconnectSubSystem 相关 UObject/Subsystem 生命周期和异步对象路径,本文只把它作为稳定的 UE4 触发入口,不强行命名为某个正式方法。0xEA534B8 是包含 r.PRTLightingBlendOCTReflectionCubemapCacheMeshDrawCommands 等字符串的渲染线程 primitive/mesh draw command 缓存路径。

3. ARM64 HWBP/HWWP 检测器

3.1 初始化和动态探针

segD_detect_arm64_hwbp_hwwp 首先通过模块符号解析器取得 libc.so 中的:

sigemptyset, sigaction,
opendir, readdir, closedir,
open, close, read,
snprintf, atoi, strstr

然后完成三组初始化:

  1. 把 4 个观察点测试槽清为 0xDEADBEEF
  2. 保存 a1[0] 作为当前 UE4 基址缓存(D 段的 qword_4A98,当前为 0x7400249000)。
  3. 通过 getrandom 生成 6 个随机索引,每个索引取值 0..49,从 6 组 50 项 NOP 表中选择执行探针。

执行探针地址按 D 段相对位置表达为:

exec_probe[0] = D + 0x3290 + 4 * random[0];
exec_probe[1] = D + 0x335C + 4 * random[1];
exec_probe[2] = D + 0x3428 + 4 * random[2];
exec_probe[3] = D + 0x34F4 + 4 * random[3];
exec_probe[4] = D + 0x35C0 + 4 * random[4];
exec_probe[5] = D + 0x368C + 4 * random[5];

每个候选项实际内容应为 ARM64 NOP 0xD503201F(内存字节 1F 20 03 D5)。旧文档中的 0xD502201F 是错误 magic,应统一修正为 0xD503201F

4 个观察点测试地址固定为:

D + 0x4880, D + 0x4888, D + 0x4890, D + 0x4898

每个地址存放 0xDEADBEEF,它是硬件观察点的自有测试数据,不是 UE4 业务数据。

3.2 线程枚举和 ptrace 路径

检测器先取得当前 TID,然后扫描 /proc/self/task,读取每个 TID 的 comm,按以下优先级收集目标线程:

当前线程
RenderThread
TaskGraphNP
GameThread

扫描结果会去重,最多处理 6 个 TID。每个 TID 由 fork/clone 出的辅助路径进入 ptrace stop,segD_hwbp_query_helper 使用 wait 类 syscall 等待状态变化;EINTR 会重试,只有目标进入预期 stop 状态才继续。

核心寄存器接口为:

PTRACE_GETREGSET = 0x4204
PTRACE_SETREGSET = 0x4205
NT_ARM_HW_BREAK  = 0x402
NT_ARM_HW_WATCH  = 0x403

处理顺序固定为 BP 再 WP:

  1. GETREGSET 读取执行断点寄存器块。
  2. dbg_info 低字节取得槽数量,统计空槽、地址范围和控制字异常。
  3. 将 6 个自有 NOP 探针写入执行断点寄存器,并再次 GETREGSET 回读。
  4. 验证每个探针地址和控制字,记录匹配数;SETREGSET == -28ENOSPC)单独计数并恢复原状态。
  5. NT_ARM_HW_WATCH 重复同样流程,写入 4 个 0xDEADBEEF 槽。
  6. 写回结果、detach,并进入下一个 TID。

控制字和验证 magic:

项目 用途
BP 控制字 0x25 6 个执行探针的 ctrl
WP 控制字 0x1FED 4 个观察点的 ctrl
BP 内容 magic 0xD503201F ARM64 NOP
WP 内容 magic 0xDEADBEEF 自有测试槽

3.3 阶段码和结果槽

静态控制流中阶段码在同一个 TID 的报告槽里写入。它不是业务返回值,而是 ptrace 自检进度/分支状态:

阶段码 代码路径含义
0 尚未完成有效 ptrace 检查
1 已进入目标 stop 并完成基础查询 helper
2 BP GETREGSET 已完成,准备 BP 写入/回读
3 BP 写入分支已进入
4 WP GETREGSET 已完成
5 WP 写入返回 ENOSPC,走容量异常分支
7 WP 写入和回读成功,完整自检路径完成

在当前成功样本中,6 个非空 TID 的 stage 都为 7。阶段码应结合四个异常计数和两个匹配计数解释,不能单独作为成功判据。

D 段结果区的稳定布局(均为 D 段内偏移)如下:

偏移 语义
+0x4870 共享结果上下文 header,当前 header 为 20260609
+0x4878 BP 自检命中/完成签名,当前 0x111111
+0x4880..+0x4898 4 个 WP 自有测试槽,当前均为 0xDEADBEEF
+0x48A0 WP 自检命中/完成签名,当前 0x1111
+0x48B0 每个 TID 的 BP invalid 计数
+0x48E0 每个 TID 的 BP ENOSPC 计数
+0x4910 每个 TID 的 WP invalid 计数
+0x4940 每个 TID 的 WP ENOSPC 计数
+0x4970 每个 TID 的 BP slot count
+0x49A0 每个 TID 的 WP slot count
+0x49D0 每个 TID 的 BP probe match count
+0x4A00 每个 TID 的 WP probe match count
+0x4A30 每个 TID 的 stage
+0x4A60 收集到的 TID 列表及辅助状态
+0x4A98 UE4 base cache

0x1111110x1111 不是从外部配置读取的普通常量,而是自有 SIGTRAP/ptrace 探针的成功签名。IDB 中 handler 的表达式分别是:

bp_signature = (1u << (4 * probe_index)) | 0x111111;
wp_signature = (1u << (4 * slot_index))  | 0x1111;

基值已经占据各个 nibble 的 1 位,所以当前完整命中时观测为固定的 0x111111/0x1111。不要将其误写成 6 个或 4 个独立计数器。

报告 helper 的关键行为是:先把 32/64 位字段复制到临时变量,再通过 ptrace request 4 分两次写入被跟踪地址。这是辅助进程向主检测上下文提交结果的路径,不是网络上报。

3.4 规范化伪代码

void detect_arm64_hwbp_hwwp(Context *ctx) {
    resolve_libc_symbols();
    for (int i = 0; i < 4; ++i)
        ctx->watch_test[i] = 0xDEADBEEF;

    ctx->ue4_base = ctx->a1_base;
    for (int i = 0; i < 6; ++i)
        rnd[i] = getrandom_u32() % 50;
    for (int i = 0; i < 6; ++i)
        ctx->exec_probe[i] = exec_table[i] + 4 * rnd[i];

    clear_report_arrays();
    tids = collect_unique_tids("/proc/self/task",
                               { gettid(), "RenderThread",
                                 "TaskGraphNP", "GameThread" }, 6);

    for (int i = 0; i < 6; ++i) {
        if (!tids[i]) continue;
        int stage = 0;
        Child c = fork_or_clone_helper();
        if (!ptrace_stop(c, tids[i])) {
            report_stage(i, stage);
            continue;
        }

        HWState bp = ptrace_getregset(tids[i], NT_ARM_HW_BREAK);
        stage = 2;
        report(i, BP_SLOTS, bp.count);
        report(i, BP_INVALID, count_non_nop_slots(bp, 0xD503201F));
        int rc = ptrace_setregset(tids[i], NT_ARM_HW_BREAK,
                                  make_exec_state(ctx->exec_probe, 6,
                                                  0x25));
        if (rc == -ENOSPC) report(i, BP_ENOSPC, 1);
        if (rc == 0) stage = 3;
        bp = ptrace_getregset(tids[i], NT_ARM_HW_BREAK);
        report(i, BP_MATCH, count_matching_exec(bp, ctx->exec_probe, 6));

        HWState wp = ptrace_getregset(tids[i], NT_ARM_HW_WATCH);
        stage = 4;
        report(i, WP_SLOTS, wp.count);
        report(i, WP_INVALID, count_non_sentinel_slots(wp, 0xDEADBEEF));
        rc = ptrace_setregset(tids[i], NT_ARM_HW_WATCH,
                              make_watch_state(ctx->watch_test, 4,
                                               0x1FED));
        if (rc == -ENOSPC) { report(i, WP_ENOSPC, 1); stage = 5; }
        if (rc == 0) stage = 7;
        wp = ptrace_getregset(tids[i], NT_ARM_HW_WATCH);
        report(i, WP_MATCH, count_matching_watch(wp, ctx->watch_test, 4));

        report_stage(i, stage);
        ptrace_detach(c, tids[i]);
    }
}

伪代码保留了业务语义;实际实现用匿名映射、标签指针、syscall wrapper 和相邻 helper 混淆了局部变量,不能把 IDA 生成的临时变量名当成结构体字段名。

4. SIGTRAP 协同检测

4.1 触发频率

segD_check_sigtrap_thread_integrity 在每次 UE4 高频入口被调用时,先用一个全局计数器做低概率门控:

uint64_t x = counter * 0xD288CE703AFB7E91ULL;
if (ror64(x, 5) > 0x346DC5D638865ULL) {
    ++counter;
    return;
}

阈值对应概率约 1/20000。当前 runtime 连续 5 秒读取到计数增加 2833,约 567 次/秒,所以完整探针平均约每 20000 / 567 = 35.3 秒 执行一次;这是统计平均,不是固定定时器。

4.2 handler 链和线程身份

通过 gettid 检查当前线程是否在 HWBP 检测器收集的 6 个 TID 中。然后:

  1. 复制 HWBP 共享上下文中的 stage、BP/WP 槽数量和 WP 测试槽指针。
  2. 调用 sigaction(SIGTRAP, NULL, &old) 查询当前 handler。
  3. 如果当前 handler 不是 segD_sigtrap_probe_setup,安装自己的 SA_SIGINFO handler,并把旧 handler 保存到 off_65C8
  4. 旧 handler 不会被简单覆盖;未知 SIGTRAP 会沿保存的 handler 转发。

当前 PID 的 handler 关系为:

SIGTRAP detector       D+0x6140 = 0x7649B88140
probe setup             D+0x601C = 0x7649B8801C
saved previous handler D+0x3758 = 0x7649B85758
                         = HWBP detector probe handler

这证明两个检测器形成了 SIGTRAP 链,而不是互相覆盖后失去旧 handler。

4.3 轮转探针和掩码

segD_sigtrap_state_helper 将数组初始化为 0..n-1,使用 getrandom 做 Fisher-Yates 洗牌:一组长度 6,一组长度 4。每次完整触发只执行当前轮转索引对应的一条执行探针和一个观察点访问,随后分别递增并按 6/4 取模。

segD_sigtrap_probe_setup 的静态行为是:

void sigtrap_probe_setup(siginfo_t *si, ucontext_t *uc) {
    for (int i = 0; i < 6; ++i) {
        if (si->si_addr == exec_probe[i]) {
            sigtrap_exec_mask = (1u << (4 * i)) | 0x1000;
            uc->pc += 4;              // 跳过触发的 NOP
            return;
        }
    }

    if (watch_base <= si->si_addr &&
        si->si_addr < watch_base + 4 * 8) {
        int i = (si->si_addr - watch_base) / 8;
        sigtrap_watch_mask = (1u << (4 * i)) | 1;
        uc->pc += 4;                  // 跳过触发的访问指令
        return;
    }

    forward_to_saved_sigtrap_handler(si, uc);
}

注意:SIGTRAP 层的掩码基值是 0x10001,与 HWBP 结果区的 0x1111110x1111 不同。SIGTRAP 主函数在探针后检查掩码是否为零:执行探针失败提交 type 2,观察点失败提交 type 3,失败报告计数限制为前 6 次。

5. 硬件断点槽位观察结果

5.1 当前 PID 28730 的槽位快照

汇总字段 结果
查询 PID 28730
线程总数 191
查询成功 191
查询失败 0
返回槽位总数 60
非空线程数 6
每个非空线程 10 个槽:BRP=6WRP=4
来源 所有槽 source=ptrace
module slots 0
perf slots 0

6 个非空 TID(十进制/目标内部常用十六进制)及状态:

TID Hex count BRP/WRP active_count flags/state on_cpu
6143 0x17FF 10 6 / 4 0 0x5 / 5(inactive) -1
6247 0x1867 10 6 / 4 0 0x5 / 5(inactive) -1
6423 0x1917 10 6 / 4 10 0x7 / 6(active) 2
6424 0x1918 10 6 / 4 0 0x5 / 5(inactive) -1
6427 0x191B 10 6 / 4 0 0x5 / 5(inactive) -1
6767 0x1A6F 10 6 / 4 10 0x7 / 6(active) 5

flags=0x5 表示 enabled+pinned;flags=0x7 表示 enabled+active+pinned。state=5 是 inactive,state=6 是 active。on_cpu=-1 表示当时未在 CPU 上运行,不能解释为槽不存在。

每个非空 TID 的 10 个地址完全相同:

查询 type 返回 length 当前绝对地址 D 段偏移 数量
4=execute 1(快照原值) 0x7649B85294 +0x3294 1
4=execute 1 0x7649B853D8 +0x33D8 1
4=execute 1 0x7649B854BC +0x34BC 1
4=execute 1 0x7649B8551C +0x351C 1
4=execute 1 0x7649B855D0 +0x35D0 1
4=execute 1 0x7649B8571C +0x371C 1
1=read 8 0x7649B86880 +0x4880 1
1=read 8 0x7649B86888 +0x4888 1
1=read 8 0x7649B86890 +0x4890 1
1=read 8 0x7649B86898 +0x4898 1

快照记录中的 execute length=1 是查询结果字段;它不能直接等同于 ARM64 DBG_BCR 控制字宽,也不能替代控制字 0x25 的分析。

5.2 与检测器自检的对应关系

槽位查询和静态/内存证据逐项对应:

6 个 type=4 地址  -> D+0x3290/.../0x368C 六组 NOP 表内的随机项
4 个 type=1 地址  -> D+0x4880..D+0x4898 的四个 0xDEADBEEF 槽
source=ptrace     -> 与检测器 PTRACE_SETREGSET 路径一致
6 个非空 TID      -> 与 detector 最多收集 6 个关键线程一致

因此当前快照强力支持“D 段检测器的自有探针已经在关键线程的硬件调试资源中注册/可见”这一结论。槽位快照不携带安装调用栈,不能单独证明每个条目永远由 segD_detect_arm64_hwbp_hwwp 留存;严谨表述应是“地址集合、数量、来源和线程集合与该检测器自检布局完全一致”。本轮只读复核没有设置新断点,这些槽不是由文档分析过程新增的。

5.3 复现业务伪代码

下面分两种复现目标:第一种只复现“观察到的证据”,不改变目标进程;第二种描述检测器本身的实现路径,供离线 harness 或自有测试进程重建控制流。第二种伪代码不应直接对线上目标进程执行。

5.3.1 只读 runtime 复核器

def reproduce_read_only_snapshot(runtime):
    # 1. 所有绝对地址从当前 PID 重新计算
    status = runtime.current_process()
    pid = status.pid
    ue4 = runtime.module_base("libUE4.so")
    anogs = runtime.module_base("libanogs.so")
    dseg = select_mapping(
        runtime.list_mappings("objects_external_alloc"), size=0xD000
    )

    def ue4_addr(rva):
        return ue4 + rva

    def d_addr(off):
        return dseg.start + off

    # 2. 先确认入口仍是 LDR X16; BR X16,再读 handler 页内字节
    assert runtime.read_bytes(ue4_addr(0x1165DD44), 8) == \
           bytes.fromhex("50 00 00 58 00 02 1F D6")
    assert runtime.read_bytes(ue4_addr(0xEA534B8), 8) == \
           bytes.fromhex("50 00 00 58 00 02 1F D6")

    # 3. 记录探针内容和结果区,不写入任何地址
    exec_tables = [0x3290, 0x335C, 0x3428,
                   0x34F4, 0x35C0, 0x368C]
    exec_candidates = [
        [d_addr(base + 4 * i) for i in range(50)]
        for base in exec_tables
    ]
    for table in exec_candidates:
        assert all(runtime.read_u32(a) == 0xD503201F
                   for a in table)
    watch = [d_addr(0x4880 + 8 * i) for i in range(4)]
    assert all(runtime.read_u64(a) == 0xDEADBEEF for a in watch)

    # 4. 只读查询 Kernel 槽,并按 TID/地址集合做交叉验证
    snapshot = runtime.read_hardware_breakpoint_slots()
    nonempty = [t for t in snapshot.threads if t.count != 0]
    assert snapshot.pid == pid
    assert all(t.query_succeeded for t in snapshot.threads)
    assert len(nonempty) == 6
    for t in nonempty:
        assert t.brp_count == 6 and t.wrp_count == 4
        assert {s.type for s in t.slots} == {1, 4}
        assert all(s.source == 1 for s in t.slots)  # ptrace
        assert {s.address for s in t.slots} == \
               set(a for table in exec_candidates for a in table
                   if is_currently_selected_probe(a)) | set(watch)

    # 5. 只读读取 detector result arrays;绝对地址仍由 d_addr() 得到
    result = {
        "bp_signature": runtime.read_u32(d_addr(0x4878)),
        "wp_signature": runtime.read_u32(d_addr(0x48A0)),
        "ue4_cache": runtime.read_u64(d_addr(0x4A98)),
    }
    return {"pid": pid, "ue4": ue4, "anogs": anogs,
            "dseg": dseg, "slots": snapshot, "result": result}

实际脚本无法仅靠结果区反推出随机索引,因此 is_currently_selected_probe 应使用槽查询返回的 6 个 execute 地址做集合匹配;不能把六组各 50 项全部当成当前已注册槽。

5.3.2 离线检测器复现器

以下伪代码在自有 ARM64 测试进程中复现 segD_detect_arm64_hwbp_hwwp 的寄存器操作顺序。测试器保存并恢复原始状态,适合验证 Kernel/ptrace 后端;原始业务是否在 detach 后保留槽位,应以 runtime 槽查询为准。

typedef struct {
    uint64_t addr;
    uint32_t ctrl;
    uint32_t pad;
} DebugReg;

typedef struct {
    uint32_t dbg_info;
    uint32_t pad;
    DebugReg reg[16];
} UserHwDebugState;

static const uint32_t BP_CTRL = 0x25;
static const uint32_t WP_CTRL = 0x1FED;
static const uint32_t NOP = 0xD503201F;
static const uint64_t SENTINEL = 0xDEADBEEF;

int get_hw_state(pid_t tid, uint32_t note, UserHwDebugState *state) {
    struct iovec io = { .iov_base = state, .iov_len = sizeof(*state) };
    return ptrace(PTRACE_GETREGSET, tid,
                  (void *)(uintptr_t)note, &io);
}

int set_hw_state(pid_t tid, uint32_t note,
                 const UserHwDebugState *state) {
    struct iovec io = {
        .iov_base = (void *)state,
        .iov_len = sizeof(*state)
    };
    return ptrace(PTRACE_SETREGSET, tid,
                  (void *)(uintptr_t)note, &io);
}

bool replay_one_thread(pid_t tid, const ProbeSet *p, Report *out) {
    UserHwDebugState old_bp = {0}, old_wp = {0};
    UserHwDebugState test_bp = {0}, test_wp = {0};
    bool bp_saved = false, wp_saved = false;
    Child helper = fork_probe_helper();
    if (helper.pid == 0) {
        if (ptrace(PTRACE_ATTACH, tid, 0, 0) < 0)
            _exit(REPORT_ATTACH_FAILED);
        if (!wait_for_ptrace_stop(tid))
            _exit(REPORT_STOP_FAILED);

        out->stage = 2;
        if (get_hw_state(tid, NT_ARM_HW_BREAK, &old_bp) < 0)
            goto detach;
        bp_saved = true;
        out->bp_slots = old_bp.dbg_info & 0xff;
        out->bp_invalid = count_non_nop(old_bp, NOP);

        test_bp = make_exec_state(p->exec, 6, BP_CTRL);
        if (set_hw_state(tid, NT_ARM_HW_BREAK, &test_bp) == -1 &&
            errno == ENOSPC)
            out->bp_enospc = 1;
        out->stage = 3;
        UserHwDebugState rb_bp = {0};
        if (get_hw_state(tid, NT_ARM_HW_BREAK, &rb_bp) == 0)
            out->bp_match = count_matching_exec(rb_bp, p->exec, 6);

        out->stage = 4;
        if (get_hw_state(tid, NT_ARM_HW_WATCH, &old_wp) < 0)
            goto restore_bp;
        wp_saved = true;
        out->wp_slots = old_wp.dbg_info & 0xff;
        out->wp_invalid = count_non_sentinel(old_wp, SENTINEL);

        test_wp = make_watch_state(p->watch, 4, WP_CTRL);
        int rc = set_hw_state(tid, NT_ARM_HW_WATCH, &test_wp);
        if (rc == -1 && errno == ENOSPC) {
            out->wp_enospc = 1;
            out->stage = 5;
        } else if (rc == 0) {
            out->stage = 7;
        }
        UserHwDebugState rb_wp = {0};
        if (get_hw_state(tid, NT_ARM_HW_WATCH, &rb_wp) == 0)
            out->wp_match = count_matching_watch(rb_wp, p->watch, 4);

restore_wp:
        if (wp_saved)
            set_hw_state(tid, NT_ARM_HW_WATCH, &old_wp);
restore_bp:
        if (bp_saved)
            set_hw_state(tid, NT_ARM_HW_BREAK, &old_bp);
detach:
        send_report_to_parent(tid, out); // 原始代码在 stop 状态用 request 4 写回
        ptrace(PTRACE_DETACH, tid, 0, 0);
        _exit(0);
    }
    wait_child_and_collect(helper, out);
    return out->stage != REPORT_ATTACH_FAILED;
}

void replay_detector(pid_t self) {
    ProbeSet p = {0};
    for (int i = 0; i < 6; ++i)
        p.exec[i] = exec_table[i] + 4 * (getrandom_u32() % 50);
    for (int i = 0; i < 4; ++i)
        p.watch[i] = &watch_test[i];

    install_siginfo_handler(SIGTRAP, sigtrap_probe_setup, &old_handler);
    for (pid_t tid : collect_named_tids(self, 6))
        replay_one_thread(tid, &p, &report[tid]);
}

这里的 make_exec_state/make_watch_state 应按 Linux ARM64 user_hwdebug_state ABI 填充 dbg_infoaddrctrl;槽位快照中的 length 字段不能替代该 ABI 的控制字。

5.3.3 SIGTRAP 轮转复现

void replay_sigtrap_tick(TrapContext *t) {
    uint64_t x = t->counter * 0xD288CE703AFB7E91ULL;
    if (ror64(x, 5) > 0x346DC5D638865ULL) {
        ++t->counter;
        return;
    }

    if (!tid_in_set(gettid(), t->tracked_tids, 6))
        goto finish;
    ensure_sigtrap_chain(t);          // 保存旧 handler,不覆盖后转发
    if (!t->exec_order_ready)
        fisher_yates(t->exec_order, 6);
    if (!t->watch_order_ready)
        fisher_yates(t->watch_order, 4);

    t->exec_mask = 0;
    t->watch_mask = 0;
    call_nop(t->exec_probe[t->exec_order[t->exec_index]]);
    (void)*(volatile uint64_t *)
        t->watch_probe[t->watch_order[t->watch_index]];

    if (t->exec_mask == 0 && t->failed_reports < 6)
        report_type(2, t);
    if (t->watch_mask == 0 && t->failed_reports < 6)
        report_type(3, t);
    t->exec_index = (t->exec_index + 1) % 6;
    t->watch_index = (t->watch_index + 1) % 4;
finish:
    ++t->counter;
}

void sigtrap_probe_setup(siginfo_t *si, ucontext_t *uc) {
    int i = find_exec_probe(si->si_addr);
    if (i >= 0) {
        current->exec_mask = (1u << (4 * i)) | 0x1000;
        uc->pc += 4;
        return;
    }
    i = find_watch_slot(si->si_addr);
    if (i >= 0) {
        current->watch_mask = (1u << (4 * i)) | 1;
        uc->pc += 4;
        return;
    }
    forward_to_previous_handler(si, uc);
}

该复现器的成功判据应同时满足:每轮执行/观察点 mask 非零、handler 链保留旧 handler、PC 恰好跳过 4 字节、轮转索引分别在 64 的范围内。只看到 SIGTRAP 被触发,不能证明硬件槽地址和控制字已经正确。

6. 当前 runtime 闭合结果

6.1 HWBP/HWWP 结果区

当前 D 段读取到:

D+0x4870  = 0x7649B86870  context/header 区
D+0x4878  = 0x111111       BP signature
D+0x48A0  = 0x1111         WP signature
D+0x4880  = 0xDEADBEEF
D+0x4888  = 0xDEADBEEF
D+0x4890  = 0xDEADBEEF
D+0x4898  = 0xDEADBEEF
D+0x4A98  = 0x7400249000   UE4 base cache

6 个非空线程的结果字段一致:

stage               = 7
BP slot count       = 6
WP slot count       = 4
BP probe matches    = 6
WP probe matches    = 4
BP invalid          = 0
WP invalid          = 0
BP ENOSPC           = 0
WP ENOSPC           = 0

这组结果与 Kernel 侧的 6+4 槽位快照相互验证:不是只读到“有槽”,而是验证了槽数量、地址集合、读回匹配和异常计数均符合检测器预期。

6.2 SIGTRAP 结果

当前 runtime 已确认:

  • SIGTRAP detector 保存并链接旧 handler,旧 handler 为 D 段 +0x3758 的 HWBP probe handler。
  • 执行探针和观察点均能把 PC 向前移动 4 字节,避免重复执行同一条触发指令。
  • 低概率门控计数以约 567/s 增长,完整探针平均约 35 秒一次。
  • 执行/观察点失败分别走 type 2/3 报告,失败次数最多记录 6 次。

7. 结论、边界和复测清单

7.1 业务结论

  1. 0x1165DD44 触发的是针对关键线程 ARM64 debug-register 资源的主动自检,不是普通 UE4 内存范围 guard。
  2. 检测器具备 6 个执行探针和 4 个观察点探针,使用 ptrace 读写 NT_ARM_HW_BREAK/NT_ARM_HW_WATCH,并验证槽数量、地址、控制字、写回能力和容量异常。
  3. SIGTRAP 检测器与 HWBP 检测器通过旧 handler 保存形成链式协同;它会低频轮转执行探针和观察点,并对未知 SIGTRAP 转发。
  4. 当前 Kernel 槽查询看到的 6 个线程、6 个 execute + 4 个 read 槽,与 D 段自检布局完全对应;当前所有线程读回完整、无 invalid、无 ENOSPC

7.2 不应过度推断的部分

  • 槽位记录是点时快照,不能推出它们在整个进程生命周期内恒定存在。
  • 查询返回的 TID 不携带 comm,线程业务名称仍应以同一时刻的 /proc/self/task/<tid>/comm 或 detector 静态枚举为准。
  • source=ptrace 说明槽的后端来源,不等于获得了安装调用栈;归属结论来自地址/数量/线程集合的交叉验证。
  • 0x111111/0x1111 是检测器成功签名,不是可以直接解释为六位/四位独立计数的位图。
  • 运行时绝对地址只对当前 PID 28730 有效。

7.3 下次复测最小流程

1. get_status(),确认 PID 是否变化。
2. 重新获取 libUE4.so base 和 objects_external_alloc 的 0xD000 段 base。
3. 用 RVA/段内偏移计算入口、handler、context、probe 和 watch 地址。
4. 读入口跳转字节和 6 个 NOP 探针,确认仍是当前映射。
5. 只读获取当前进程的硬件断点槽位快照。
6. 只读当前 D+0x4878、D+0x48A0、D+0x4A30 等结果槽。
7. 将 TID、event id、active/state、slot 地址和异常字段作为同一时间点记录。
8. 不要复用旧 PID 的任何匿名段绝对地址;不需要命中数据时不要设置新的断点。

评论

  • 还没有评论,来说点什么吧。

无需注册或登录,填个昵称即可评论。