内存断点的设置与定制内核体验
前言
在前面的文章中,我们讨论了通过代码追踪和动态分析来还原算法。本文将介绍另一种强大的分析手段——内存断点(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 设置
- 在 IDA 中打开 Hex View 窗口(View → Open subviews → Hex dump)
- 找到要监视的内存地址
- 右键点击目标地址,选择 Add breakpoint 或直接按 F2
- 在弹出的对话框中选择断点类型:
- Execution:执行时触发
- Write:写入时触发
- Read/Write:读取或写入时触发
方法二:通过 Debugger 设置
在 IDA 调试模式下:
- 打开 Debugger → Breakpoints → Memory breakpoints(或按 Ctrl+Shift+B)
- 点击 Add,输入要监视的内存地址
- 选择断点大小(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
找出关键数据被修改的时机
策略:全链路追踪
在算法还原中,我们通常关注以下关键数据的修改时机:
- 密钥:何时生成、何时被修改、从哪里派生
- IV/Nonce:何时生成、是否为固定值
- 中间状态:加密各轮的状态变化
- 输出:密文何时写入、最终结果在哪里
# 全链路追踪脚本
# 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 动态分析相结合,可以构建出更完整、更高效的分析方案。在实际工作中,根据具体场景选择合适的工具组合,是高效完成算法还原的关键。