内存断点的设置与定制内核体验

前言

在前面的文章中,我们讨论了通过代码追踪和动态分析来还原算法。本文将介绍另一种强大的分析手段——内存断点(Memory Breakpoint)。与普通的执行断点不同,内存断点可以在指定的内存地址被读取、写入或执行时触发,这在追踪加密数据变化、定位密钥派生时机等场景中非常有用。此外,我们还将探讨定制 Android 内核以实现更高级的调试能力。

内存断点的概念和类型

什么是内存断点

内存断点(也称为数据断点或观察点 Watchpoint)是一种特殊类型的断点,它不是在特定指令地址处触发,而是在特定内存地址被访问时触发。这使得我们能够观察数据在何时、何地、以何种方式被修改。

内存断点的三种类型

类型 触发条件 典型用途
执行断点(Execute) CPU 尝试从该地址取指令执行 检测代码自修改、Shellcode 分析
写入断点(Write) 数据被写入该地址 追踪密钥生成、数据加密过程
访问断点(Access/Read) 数据被读取或写入该地址 追踪数据使用、参数传递

硬件断点 vs 软件断点

内存断点通常基于 硬件调试寄存器(Hardware Debug Registers)实现。x86/x64 架构提供了 DR0-DR3 四个调试寄存器,ARM 架构也提供了类似的硬件断点/观察点寄存器。

  • 硬件断点:数量有限(通常 4-6 个),但不修改被调试程序的内存和代码,隐蔽性好
  • 软件断点(如 INT 3):数量无限制,但会修改被调试程序的代码,容易被反调试检测

由于硬件断点数量有限,内存断点的设置需要精心规划,在关键位置使用。

IDA 中设置内存断点

方法一:通过 Hex View 设置

  1. 在 IDA 中打开 Hex View 窗口(View → Open subviews → Hex dump)
  2. 找到要监视的内存地址
  3. 右键点击目标地址,选择 Add breakpoint 或直接按 F2
  4. 在弹出的对话框中选择断点类型:
    • Execution:执行时触发
    • Write:写入时触发
    • Read/Write:读取或写入时触发

方法二:通过 Debugger 设置

在 IDA 调试模式下:

  1. 打开 Debugger → Breakpoints → Memory breakpoints(或按 Ctrl+Shift+B)
  2. 点击 Add,输入要监视的内存地址
  3. 选择断点大小(1/2/4/8 字节)和类型(Read/Write/Execute)

方法三:通过脚本批量设置

# IDAPython 脚本:批量设置内存断点
import idc
import idaapi

def set_memory_breakpoint(address, size=4, bptype='write'):
    """
    address: 内存地址
    size: 监视的字节数 (1, 2, 4, 8)
    bptype: 'read', 'write', 'execute', 'rw'
    """
    bp_type = {
        'read': idaapi.bpt_read,
        'write': idaapi.bpt_write,
        'execute': idaapi.bpt_exec,
        'rw': idaapi.bpt_read | idaapi.bpt_write
    }.get(bptype, idaapi.bpt_write)
    
    # 获取当前调试器
    dbg = idaapi.get_debugger()
    if dbg is None:
        print("[-] Debugger not active")
        return False
    
    # 设置断点
    ret = idaapi.add_bpt(address, size, bp_type)
    if ret:
        print("[+] Memory breakpoint set at 0x%X (size=%d, type=%s)" % (address, size, bptype))
        return True
    else:
        print("[-] Failed to set breakpoint")
        return False

# 使用示例
set_memory_breakpoint(0x12345678, 16, 'write')  # 监视 16 字节的写入

GDB/LLDB 中设置内存断点

GDB 中使用 watchpoint

GDB 通过 watch 系列命令设置内存断点(在 GDB 中称为 watchpoint):

# 写入观察点——当指定地址被写入时中断
(gdb) watch *(int*)0x12345678

# 读取观察点——当指定地址被读取时中断
(gdb) rwatch *(int*)0x12345678

# 访问观察点——读取或写入时都中断
(gdb) awatch *(int*)0x12345678

# 监视特定大小的内存区域
(gdb) watch *(char[16]*)0x12345678

# 条件观察点——仅在满足条件时中断
(gdb) watch *(int*)0x12345678
(gdb) condition 1 *(int*)0x12345678 == 0xDEADBEEF

LLDB 中使用 watchpoint

LLDB 的 watchpoint 设置方式类似:

# 设置写入观察点
(lldb) watch set write 0x12345678 --size 4

# 设置读写观察点
(lldb) watch set read-write 0x12345678 --size 16

# 查看所有观察点
(lldb) watch list

# 删除观察点
(lldb) watch delete 1

# 条件观察点
(lldb) watch set write 0x12345678 --condition '*(int*)0x12345678 == 0xDEADBEEF'

在 Android 调试中使用

通过 adb forward 将调试端口转发后,可以使用 GDB 或 LLDB 远程调试 Android 进程:

# 转发调试端口
adb forward tcp:1234 jdwp:$(adb shell pidof com.target.app)

# 使用 lldb-server(Android 7.0+)
adb push lldb-server /data/local/tmp/
adb shell chmod 755 /data/local/tmp/lldb-server
adb shell /data/local/tmp/lldb-server platform --server

# 本地连接
lldb
(lldb) platform select remote-android
(lldb) platform connect connect://localhost:1234
(lldb) attach <pid>
(lldb) watch set write 0x12345678 --size 16

使用内存断点追踪加密数据变化

场景:追踪 AES 密钥的生成和使用

假设我们需要找出 AES 密钥是在哪里被写入内存的,以及密钥在加密过程中经历了哪些变化。

Step 1:找到密钥缓冲区的地址

首先通过静态分析或 Frida Hook 确定密钥在内存中的存储位置:

// Frida 脚本:找到密钥缓冲区地址
Java.perform(function() {
    // Hook AES 初始化
    var Cipher = Java.use("javax.crypto.Cipher");
    Cipher.init.overload('int', 'java.security.Key', 'java.security.spec.AlgorithmParameterSpec')
        .implementation = function(mode, key, spec) {
            var keyBytes = key.getEncoded();
            console.log("[*] Key bytes at: " + keyBytes);
            console.log("[*] Key: " + bytes2hex(keyBytes));
            this.init(mode, key, spec);
        };
});

Step 2:设置内存断点

获取到密钥地址后,在 GDB/LLDB 中设置写入观察点:

# 设置写入断点
(gdb) watch *(char[16])0x12345678

# 继续执行
(gdb) continue

# 断点触发后查看调用栈
(gdb) bt
# 0  0x7f12345678 in aes_key_setup at crypto.c:123
# 1  0x7f12345679 in encrypt_init at security.cpp:456
# 2  0x7f1234567a in Java_com_target_app_NativeHelper_encrypt at native.cpp:789

调用栈清晰地展示了密钥生成的完整调用链:从 JNI 入口 → 加密初始化 → AES 密钥设置。

Step 3:追踪密钥使用过程

在加密过程中,密钥可能被复制到多个位置:

# 设置多个观察点追踪密钥的传播
(gdb) watch *(char[16])0x12345678  # 原始密钥位置
(gdb) watch *(char[16])0x12345688  # 轮密钥存储位置
(gdb) watch *(char[16])0x12345698  # 状态矩阵

Step 4:观察数据变化

每次断点触发时,检查内存内容的变化:

# 断点触发后检查内存
(gdb) x/16xb 0x12345678

# 使用自定义命令自动记录每次变化
define trace_key_change
    printf "Key change at PC=0x%x: ", $pc
    x/16xb $arg0
    continue
end

# 每次密钥变化时自动记录并继续
(gdb) trace_key_change 0x12345678

找出关键数据被修改的时机

策略:全链路追踪

在算法还原中,我们通常关注以下关键数据的修改时机:

  1. 密钥:何时生成、何时被修改、从哪里派生
  2. IV/Nonce:何时生成、是否为固定值
  3. 中间状态:加密各轮的状态变化
  4. 输出:密文何时写入、最终结果在哪里
# 全链路追踪脚本
# 1. 在密钥生成处设置断点
b key_derivation_function

# 2. 在密钥使用处设置写入观察点
watch *(char[16])key_buffer_addr

# 3. 在 IV 生成处设置断点
b iv_generation

# 4. 在密文输出处设置写入观察点
watch *(char[16])output_buffer_addr

# 运行并记录
commands 1
    silent
    printf "[KEY_DERIVE] Called from: "
    bt 3
    continue
end

commands 2
    silent
    printf "[KEY_WRITE] At PC=0x%lx, value: ", $pc
    x/16xb key_buffer_addr
    continue
end

# 开始执行
run

定制 Android 内核实现高级调试

为什么需要定制内核

Android 默认的用户态调试存在以下限制:

  • ptrace 限制:默认情况下,一个进程只能被一个调试器附加
  • 硬件断点数量有限:ARM 架构通常只支持 4-6 个硬件断点
  • 无法调试内核态代码:用户态调试器无法跟踪进入内核
  • 无法监视特定进程的内存访问:无法在不附加调试器的情况下监视内存

定制 Android 内核可以解锁以下能力:

  • 增加硬件断点数量
  • 支持内核级内存监视
  • 支持跨进程内存断点
  • 添加自定义的调试钩子

编译自定义内核的步骤

Step 1:获取设备内核源码

# 从 Android 内核源码仓库获取对应设备的内核
git clone https://android.googlesource.com/kernel/common.git
# 或从设备厂商获取对应机型的内核源码

# 切换到对应分支
git checkout android12-5.10  # 根据设备 Android 版本选择

Step 2:获取编译工具链

# 下载 Android NDK 中的交叉编译工具链
# 或使用预编译的 Linaro 工具链
wget https://releases.linaro.org/components/toolchain/binaries/latest-7/aarch64-linux-gnu/gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu.tar.xz

Step 3:配置内核

# 使用设备的 defconfig
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- <device>_defconfig

# 启用调试选项
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- menuconfig

关键配置项:

Kernel hacking  --->
    [*] Kernel debugging
    [*] Compile the kernel with debug info
    [*] KGDB: kernel debugger
    [*] KGDB: Allow debugging with kdb
    [*] KGDB: Allow debugging over serial console
    [*] KGDB: Allow debugging with gdb

Step 4:编译内核

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc)

Step 5:刷入自定义内核

# 将编译产物刷入设备
# 不同设备刷入方式不同,通常使用 boot.img
# 方式一:替换 boot.img 中的内核
# 方式二:使用 fastboot 刷入
adb reboot bootloader
fastboot flash boot custom_boot.img
fastboot reboot

内核级调试在算法还原中的应用

1. 使用 Kprobes 进行内核级函数追踪

Kprobes 是 Linux 内核的动态追踪机制,可以在内核函数的入口和出口处插入探测点:

// 自定义内核模块:追踪特定的内存写入
#include <linux/kprobes.h>
#include <linux/module.h>

static struct kprobe kp = {
    .symbol_name = "copy_to_user",  // 追踪内核向用户态复制数据的操作
};

static int handler_pre(struct kprobe *p, struct pt_regs *regs) {
    // 检查是否是目标进程
    if (current->pid == target_pid) {
        pr_info("[KPROBE] copy_to_user: dst=0x%lx, size=%zu\n",
                regs->regs[1], regs->regs[2]);
    }
    return 0;
}

static int __init kprobe_init(void) {
    kp.pre_handler = handler_pre;
    register_kprobe(&kp);
    return 0;
}

2. 使用 ftrace 追踪函数调用

ftrace 是内核内置的函数追踪器,可以高效地记录内核函数调用:

# 在设备上启用 ftrace
adb shell
cd /sys/kernel/debug/tracing

# 追踪特定的内核函数
echo function > current_tracer
echo do_page_fault > set_ftrace_filter   # 追踪页错误(内存访问异常)
echo 1 > tracing_on

# 读取追踪结果
cat trace

3. 使用 KASAN 追踪内存问题

KASAN(Kernel Address Sanitizer)可以检测内存越界、Use-After-Free 等问题,对于发现加密算法中的内存漏洞也有帮助:

Kernel hacking  --->
    [*] Kernel Address Sanitizer
    (0x20000000) Shadow offset

内存断点 + Frida 的组合分析方案

组合方案概述

内存断点适合精确追踪特定数据的修改时机,但对于复杂的 OLLVM 混淆代码,单纯使用内存断点可能效率不高。将内存断点与 Frida 结合,可以实现更灵活的分析方案:

  • Frida 负责:函数级别的监控、参数提取、数据修改
  • 内存断点负责:精确追踪关键数据的修改时机和调用链

组合方案实现

方案一:Frida 辅助定位 + 内存断点精确追踪

// Step 1: Frida 定位关键数据地址
Java.perform(function() {
    var Cipher = Java.use("javax.crypto.Cipher");
    Cipher.doFinal.overload('[B').implementation = function(input) {
        // 记录关键缓冲区地址
        var key = this._key;  // 通过反射获取内部密钥
        console.log("[*] Cipher key address: " + key);
        return this.doFinal(input);
    };
});

// Step 2: 在 GDB 中使用内存断点精确追踪
// (gdb) watch *(char[16])<key_address>

方案二:Frida Stalker + 内存访问监控

// 使用 Frida 模拟内存写入断点
function monitorMemoryWrites(address, size) {
    var pageStart = address.and(ptr("0xFFFFFFFFF000"));  // 页面对齐
    var originalProtect = Memory.protect(pageStart, Process.pageSize, 'rwx');
    
    // 使用 Stalker 追踪对该地址的写入
    Stalker.follow(Process.getCurrentThreadId(), {
        transform: function(iterator) {
            var instruction;
            while ((instruction = iterator.next()) !== null) {
                // 检查是否为存储指令(ARM: STR, STRB, STRH)
                if (instruction.mnemonic.startsWith("str")) {
                    iterator.putCallout(function(context) {
                        // 检查目标地址是否匹配
                        // 注意:ARM 的 STR 目标地址可能在寄存器中,需要运行时解析
                    });
                }
            }
        }
    });
}

方案三:使用 Frida 的 MemoryAccessMonitor

// Frida 的 MemoryAccessMonitor(仅部分平台支持)
if (ObjC.available) {
    // iOS 平台
    MemoryAccessMonitor.enable([{base: targetAddress, size: 16}], {
        onAccess: function(details) {
            console.log("[MEMORY] " + details.operation + 
                       " at " + details.from +
                       " offset=" + details.offset +
                       " address=" + details.address);
        }
    });
}

实战案例:追踪密钥派生过程

场景:某 APP 使用 PBKDF2 从密码派生 AES 密钥,需要追踪完整的密钥派生过程。

// Step 1: Frida Hook PBKDF2
Java.perform(function() {
    var SecretKeyFactory = Java.use("javax.crypto.SecretKeyFactory");
    SecretKeyFactory.generateSecret.implementation = function(spec) {
        console.log("[*] Key generation started");
        var result = this.generateSecret(spec);
        var keyBytes = result.getEncoded();
        console.log("[*] Derived key: " + bytes2hex(keyBytes));
        console.log("[*] Key address: " + keyBytes);
        return result;
    };
});

// Step 2: 在 GDB 中对派生出的密钥地址设置写入断点
// (gdb) watch *(char[16])<derived_key_address>

// Step 3: 追踪密钥在后续加密中的使用
// 继续执行,观察密钥何时被传递到 AES 加密函数

小结

内存断点是算法还原中的重要工具,它能够精确追踪关键数据的修改时机和调用链。在 Android 逆向中,我们可以通过 IDA、GDB/LLDB 设置内存断点,也可以通过定制 Android 内核实现更高级的调试能力。将内存断点与 Frida 动态分析相结合,可以构建出更完整、更高效的分析方案。在实际工作中,根据具体场景选择合适的工具组合,是高效完成算法还原的关键。