文章总结: 本文深度分析基于APatch框架开源的KernelHook项目,详细阐述ARM64内核函数Hook技术实现。通过对比传统方案的局限性,重点介绍InlineHook方案的优势:支持安卓9-16全版本、不依赖内核编译选项、性能接近原生调用。文章从接口设计、指令重定位、中转桩、内存管理等维度解析技术细节,并提供完整的代码示例展示函数替换、回调链注册等实际应用方法。 综合评分: 85 文章分类: 移动安全,二进制安全,渗透测试,红队,内网渗透
安卓内核Hook技术实现分析与应用
原创
非虫 非虫
软件安全与逆向分析
2026年4月13日 20:24 湖北
在小说阅读器读本章
去阅读
安卓内核Hook技术实现分析与应用
本文是基于知名Root框架
APatch作者bmax121开源的KernelHook项目源码做的深度技术分析。本文从接口到实现,逐层拆解ARM64内核函数Hook的工程细节。本文项目开源地址为:https://github.com/bmax121/KernelHook
本文作者:非虫([email protected])
1 引言
在安卓安全研究、性能分析和内核功能扩展等场景中,内核函数 Hook 是一项基础技术。它的核心目标是在不修改内核源码、不重新编译内核的前提下,拦截并改变目标内核函数的行为。
传统方案各有局限:
| 方案 | 原理 | 不足 | | — | — | — | | kprobes | 断点指令触发异常 | 性能开销大 | | ftrace | 函数入口处的 NOP 桩 | arm64在6.4版本内核后这功能才能用 | | 修改系统调用表 | 替换系统调用表项 | 6.9版本内核系统调用表需要调整处理 | | eBPF | 内核态虚拟机 | 只能观测不能修改控制流 |
KernelHook采用Inline Hook方案——直接修改目标函数入口处的机器码,将控制流导向自定义逻辑。相比上述方案,它不依赖内核编译选项,不受GKI 模块限制,支持安卓9到安卓16全版本。能Hook任意导出或未导出的内核函数,且性能接近原生调用。
本文以KernelHook项目的源码为基础,从接口设计、指令重定位、中转桩、内存管理、安全机制适配等维度,完整剖析其技术实现。
2 接口与示例
KernelHook 对外暴露一组 C 接口,覆盖四大类操作:符号查找、函数替换、回调链注册、函数指针 Hook。所有接口声明在 include/hook.h 和 include/ksyms.h。
2.1 初始化
Freestanding模式(Mode A)下,模块加载后需依次完成符号系统、内存池、页表遍历器和代码写入器的初始化:
#include<hook.h>
#include<ksyms.h>
#include<kmod_compat.h>
staticint __init my_module_init(void)
{
// kallsyms_lookup_name_addr 由 kmod_loader 在加载时注入
int err = kmod_compat_init(kallsyms_lookup_name_addr);
if (err) return err;
err = kmod_hook_mem_init(); // ROX/RW 内存池
if (err) return err;
kh_pgtable_init(); // 页表遍历器(读取 TCR_EL1 检测页大小)
kh_write_insts_init(); // 解析 set_memory_rw/ro/x
return0;
}
Kbuild 模式(Mode C)使用内核构建系统,直接调用内核头文件中的函数,初始化链更短。
2.2 符号查找
ksyms_lookup 和 ksyms_lookup_cache 是运行时符号查找的核心接口:
#include<ksyms.h>
// 按名称查找内核符号地址
unsignedlong addr = ksyms_lookup("do_sys_openat2");
// 带缓存版本:首次查找后缓存结果,后续直接命中
unsignedlong addr2 = ksyms_lookup_cache("vfs_read");
ksyms_lookup_cache 在全局缓存表中维护 (name, addr) 映射。对于反复查找同一符号的场景(如模块初始化期间多次引用),可显著减少开销。
2.3 函数替换
最直接的 Hook 方式:用自定义函数完全取代目标函数。
#include<hook.h>
staticunsignedlong orig_func;
// 替换函数,签名必须与目标函数一致
staticintmy_openat2(int dfd, constchar *filename,
struct open_how *how, size_t usize)
{
// 自定义前置逻辑 ...
int ret = ((typeof(&my_openat2))orig_func)(dfd, filename, how, usize);
// 自定义后置逻辑 ...
return ret;
}
staticint __init example_init(void)
{
unsignedlong target = ksyms_lookup("do_sys_openat2");
int err = hook((void *)target, (void *)my_openat2, (void **)&orig_func);
return err;
}
staticvoid __exit example_exit(void)
{
unsignedlong target = ksyms_lookup("do_sys_openat2");
unhook((void *)target);
}
hook() 在目标函数入口写入跳板指令,跳转到 my_openat2。orig_func 指向一段经过重定位的代码——它执行被跳板覆盖的原始指令后,跳回原函数继续运行。
2.4 回调链
相比直接替换,回调链(Hook Wrap)更灵活——多个模块可以在同一函数上注册 before/after 回调,彼此互不干扰:
#include<hook.h>
// before 回调:在目标函数执行前调用
// hook_fargs4_t 表示目标函数有 4 个参数
staticvoidbefore_openat2(hook_fargs4_t *fargs, void *udata)
{
int dfd = (int)fargs->arg0;
constchar *filename = (constchar *)fargs->arg1;
logki("openat2: dfd=%d, file=%s", dfd, filename);
// 修改参数
// fargs->arg1 = (uint64_t)new_filename;
// 跳过原始函数并直接返回
// fargs->skip_origin = true;
// fargs->ret = -EPERM;
}
// after 回调:在目标函数执行后调用
staticvoidafter_openat2(hook_fargs4_t *fargs, void *udata)
{
long ret = fargs->ret;
logki("openat2 returned: %ld", ret);
// 修改返回值
// fargs->ret = -EACCES;
}
staticint __init example_init(void)
{
unsignedlong target = ksyms_lookup("do_sys_openat2");
// hook_wrap4: 4 参数的便捷宏,优先级默认为 0
int err = hook_wrap4(target, before_openat2, after_openat2, NULL);
return err;
}
staticvoid __exit example_exit(void)
{
unsignedlong target = ksyms_lookup("do_sys_openat2");
hook_unwrap(target, (void *)before_openat2, (void *)after_openat2);
}
hook_wrapN 是 hook_wrap 的便捷宏,N 表示目标函数的参数个数(0 到 12),默认优先级为 0。完整形式为:
inthook_wrap(void *func, int argno, void *before, void *after,
void *udata, int priority);
其中 before 和 after 均可为 NULL——只传 before 可以做纯拦截,只传 after 可以做纯审计。
多回调与优先级
同一目标函数上可注册最多 8 个回调。每个回调携带一个priority值,决定执行顺序:
-
before 回调
:按 priority 降序执行——值越大越先执行
-
after 回调
:按 priority 升序执行——值越小越先执行
这形成了洋葱式的包裹结构:
before(100) -> before(50) -> before(0)
-> 原始函数 ->
after(0) -> after(50) -> after(100)
多回调注册示例:
unsignedlong target = ksyms_lookup("do_sys_openat2");
// 高 priority 值的 before 回调先执行
hook_wrap(target, 4, (void *)audit_callback, NULL, NULL, 100);
hook_wrap(target, 4, (void *)filter_callback, NULL, NULL, 50);
hook_wrap(target, 4, (void *)log_callback, NULL, NULL, 0);
// before 执行顺序:audit(100) -> filter(50) -> log(0) -> 原始函数
// after 执行顺序:log(0) -> filter(50) -> audit(100)
回调间数据传递
before/after 回调对可通过 hook_local_t 共享数据:
staticvoidbefore_func(hook_fargs4_t *fargs, void *udata)
{
hook_local_t *local = &fargs->chain.local;
local->data0 = ktime_get_ns(); // 记录进入时间戳
}
staticvoidafter_func(hook_fargs4_t *fargs, void *udata)
{
hook_local_t *local = &fargs->chain.local;
uint64_t elapsed = ktime_get_ns() - local->data0;
logki("function took %llu ns", elapsed);
}
hook_local_t 提供 4 个 uint64_t 字段(data0 ~ data3),在同一次调用的 before 和 after 之间共享。不同回调槽位的local相互独立。
如需在回调中手动调用原始函数(较少见),可通过以下接口获取函数指针:
void *orig = wrap_get_origin_func(fargs); // inline hook 场景
void *orig = fp_get_origin_func(fargs); // 函数指针 hook 场景
2.5 函数指针 Hook
函数指针 Hook 用于拦截通过函数指针表(如 struct file_operations)间接调用的函数。与 Inline Hook 不同,它不修改目标函数的代码,而是替换指针本身的值。
直接替换:
#include<hook.h>
staticunsignedlong orig_read;
staticssize_tmy_read(struct file *filp, char __user *buf,
size_t count, loff_t *pos)
{
logki("read intercepted: count=%zu", count);
return ((typeof(&my_read))orig_read)(filp, buf, count, pos);
}
staticint __init example_init(void)
{
// fp_addr 指向某个 file_operations 结构体的 read 字段
void **fp_addr = get_target_fops_read_ptr();
int err = fp_hook(fp_addr, (void *)my_read, (void **)&orig_read);
return err;
}
staticvoid __exit example_exit(void)
{
void **fp_addr = get_target_fops_read_ptr();
fp_unhook(fp_addr, (void *)orig_read);
}
回调链与Inline Hook 类似,但最多支持16个回调:
fp_hook_wrap4(fp_addr, before_read, after_read, NULL);
// ...
fp_hook_unwrap(fp_addr, (void *)before_read, (void *)after_read);
2.6 接口一览
| 接口 | 功能 |
| — | — |
| hook(func, replace, &backup) | 替换目标函数 |
| unhook(func) | 还原目标函数 |
| hook_wrap(func, argno, before, after, udata, pri) | 注册回调链 |
| hook_unwrap(func, before, after) | 移除回调 |
| hook_wrapN(func, before, after, udata) | 便捷宏(N=0..12,pri=0) |
| fp_hook(fp_addr, replace, &backup) | 替换函数指针 |
| fp_unhook(fp_addr, backup) | 还原函数指针 |
| fp_hook_wrap(fp_addr, argno, before, after, udata, pri) | 函数指针回调链 |
| fp_hook_unwrap(fp_addr, before, after) | 移除函数指针回调 |
| fp_hook_wrapN(fp_addr, before, after, udata) | 便捷宏(N=0..12) |
| ksyms_lookup(name) | 查找内核符号地址 |
| ksyms_lookup_cache(name) | 带缓存的符号查找 |
3 Inline Hook 原理
Inline Hook 的核心思路:覆盖目标函数入口处的若干条指令,替换为跳板指令(trampoline),将控制流引向自定义代码。被覆盖的原始指令经过重定位后保存在另一块内存中,执行完毕后跳回原函数继续运行。
3.1 跳板结构
ARM64 上,KernelHook 使用 4 条指令(16 字节)构造跳板:
MOV X16, #imm16_low ; 目标地址低 16 位
MOVK X16, #imm16_mid, LSL #16 ; 中 16 位
MOVK X16, #imm16_high, LSL #32 ; 高 16 位
BR X16 ; 无条件间接跳转
如果目标函数的首条指令是 BTI 或 PAC 指令(BTI JC / PACIASP / PACIBSP),跳板扩展为 5 条,首条保留为 BTI JC:
BTI JC ; 保留分支目标标识
MOV X16, #imm16_low
MOVK X16, #imm16_mid, LSL #16
MOVK X16, #imm16_high, LSL #32
BR X16
选择 X16 是因为 ARM64 调用约定将其定义为 IP0(Intra-Procedure-call scratch register):不被调用者保存,且 BTI 允许 BR X16 作为合法的间接分支目标。
3.2 指令重定位
被跳板覆盖的原始指令不能简单复制到新地址执行——ARM64 中大量指令使用 PC 相对寻址,复制后偏移量会指向错误位置。
KernelHook 的指令重定位引擎(src/arch/arm64/inline.c)识别并处理 17 种指令类型:
| 指令类型 | 寻址方式 | 重定位策略 |
| — | — | — |
| B | PC +/- 128 MB | 重算偏移或展开为绝对跳转 |
| B.cond | PC +/- 1 MB | 反转条件 + 绝对跳转 |
| BL | PC +/- 128 MB | 绝对跳转 + 手动设置 LR |
| ADR | PC +/- 1 MB | 替换为 MOV/MOVK 绝对地址序列 |
| ADRP | PC +/- 4 GB (页对齐) | 同上 |
| LDR (literal) | PC +/- 1 MB (32/64/SIMD) | 转为寄存器间接加载 |
| LDRSW (literal) | PC +/- 1 MB | 同上 |
| CBZ / CBNZ | PC +/- 1 MB | 反转条件 + 绝对跳转 |
| TBZ / TBNZ | PC +/- 32 KB | 反转条件 + 绝对跳转 |
| PRFM (literal) | PC +/- 1 MB | 转为寄存器间接预取 |
| 其他 | 无 PC 相对寻址 | 直接复制 |
每种类型的重定位产出长度不同(2 ~ 8 条 uint32_t 指令)。引擎预先扫描全部被覆盖指令,计算总输出长度,一次性分配缓冲区,再逐条写入。
重定位后的代码布局:
+--------------------------------------+
| BTI JC | <- 入口(满足 BTI)
+--------------------------------------+
| NOP padding | <- 对齐填充
+--------------------------------------+
| 重定位后的指令序列 | <- 原始指令的等价实现
+--------------------------------------+
| MOV X16, #addr; BR X16 | <- 跳回原函数(跳板之后)
+--------------------------------------+
3.3 kCFI 哈希
GKI 6.1+ 内核启用了 kCFI(Kernel Control Flow Integrity):Clang 在每个函数入口前 4 字节写入类型哈希值。间接调用前,编译器检查目标地址 -4 处的哈希是否匹配,不匹配则 panic。
KernelHook 将原始函数入口前 4 字节的 kCFI 哈希复制到重定位代码入口前 4 字节(_relo_cfi_hash 字段)。中转桩入口同样携带正确的哈希值,使 kCFI 检查正常通过。
hook_chain_rox_t 的内存布局:
+---------------------+
| _relo_cfi_hash | <- 复制自原函数的 kCFI 哈希
+---------------------+
| relo_insts[] | <- 重定位后的指令序列
+---------------------+
| hook_t | <- Hook 状态
+---------------------+
| rw_ptr | <- 指向 RW 区域
+---------------------+
| transit[] | <- 中转桩(64 字节对齐)
+---------------------+
4 中转桩
4.1 设计目标
直接替换模式(hook / unhook)下,跳板直接跳到替换函数。但回调链模式(hook_wrap)需要中间层——中转桩(transit stub)——来调度 before/after 回调、管理参数传递和返回值。
4.2 汇编模板
中转桩的汇编模板定义在 src/arch/arm64/transit.c,编译时作为独立代码模板存在。每次注册新 Hook 时,模板被复制到 hook_chain_rox_t.transit[] 缓冲区,同时将 transit[0..1] 写入指向所属 hook_chain_rox_t 的自引用指针。
中转桩执行流程:
BTI JC ; 满足 BTI 要求
ADR X16, #0 ; 取当前 PC
SUB X16, X16, #offset ; 回算 transit[] 基址
LDR X15, [X16] ; 加载 rox_ptr(自引用)
LDR X14, [X15, #rw_offset] ; 加载 rw_ptr
STP X29, X30, [SP, #-frame]! ; 保存帧
; 参数右移:X7->栈, X6->X7, ..., X1->X2, X0->X1
; 腾出 X0 传递 rw_ptr
MOV X0, X14
BLR transit_body ; 调用 C 调度函数
LDP X29, X30, [SP], #frame ; 恢复帧
RET
4.3 调度逻辑
transit_body() 是纯 C 函数,负责组装回调上下文并按序遍历回调链:
-
从
rw_ptr读取sorted_indices[]和回调列表 -
构建
hook_fargs结构体,填入参数、返回值、本地存储 -
正序
遍历
sorted_indices[],依次调用每个 before 回调 -
若无回调设置
skip_origin = true,调用重定位后的原始函数 -
逆序
遍历
sorted_indices[],依次调用每个 after 回调 -
返回
fargs.ret
函数指针 Hook 有独立的 fp_transit_body(),步骤 4 调用保存的原始函数指针而非重定位代码。
5 内存管理
5.1 内存池
KernelHook 自行管理两个内存池,避免频繁调用内核的 vmalloc / vfree:
| 池 | 用途 | 容量 | 块大小 | 权限 | | — | — | — | — | — | | ROX | Hook 结构体、重定位代码、中转桩 | 1 MB | 64 字节 | 读 + 执行 | | RW | 回调链数据(槽位、排序索引等) | 512 KB | 64 字节 | 读 + 写 |
每个池用位图追踪块的分配状态。分配时线性扫描找到连续空闲块,释放时清除对应位。
hook_chain_rox_t(含 64 字节对齐的 transit 缓冲区)分配在 ROX 池,占用多个连续块。hook_chain_rw_t 分配在 RW 池,包含最多 8 个回调槽位和优先级排序索引数组。
5.2 来源映射
origin_map 是 128 项的线性表,记录原始函数地址到 ROX 结构体指针的映射。unhook 和 hook_unwrap 通过此表快速定位对应的 Hook 结构体。
5.3 页表遍历
Freestanding 模式下无法使用 set_memory_rw/ro/x,需直接操作页表修改代码段权限。
页表遍历器(src/arch/arm64/pgtable.c)初始化步骤:
- 读取
TCR_EL1.TG1,判断页大小(4K / 16K / 64K) - 读取
TCR_EL1.T1SZ,计算虚拟地址位宽和页表级数 - 通过
ksyms解析swapper_pg_dir、kimage_voffset、memstart_addr
代码写入流程:
- 遍历页表,找到目标虚拟地址的 PTE
- 清除
PTE_RDONLY,设置PTE_DBM,使页面可写 - TLBI 刷新 TLB
- 写入指令
- 恢复 PTE 原始权限
- IC IVAU 刷新指令缓存
如果通过 ksyms 找到了 set_memory_rw / set_memory_ro / set_memory_x,KernelHook 优先使用这些 API。回退到页表直接操作仅在上述函数不可用时发生。
6 安全机制适配
ARM64 和 GKI 内核引入了多层安全机制。KernelHook 需逐一适配,否则 Hook 操作会触发 panic。
6.1 CFI
安卓 GKI 内核有两代 CFI:
-
Shadow CFI
(GKI 5.4 ~ 5.15):编译器在间接调用前插入运行时类型检查。Hook 本身不直接受影响,但替换函数的类型签名须与目标一致。
-
kCFI
(GKI 6.1+):函数入口前 4 字节是类型哈希。间接调用前检查哈希匹配,不匹配则 panic。
kCFI 的适配策略已在第 3.3 节说明。
6.2 PAC 与 BTI
-
PAC
(Pointer Authentication,ARMv8.3+):入口处
PACIASP对返回地址签名,返回时AUTIASP验证。中转桩自行配对STP/LDP X29, X30,不破坏签名链。 -
BTI
(Branch Target Identification,ARMv8.5+):间接分支目标须为 BTI 指令。KernelHook 在跳板入口、重定位代码入口和中转桩入口均放置
BTI JC。
6.3 SCS
Shadow Call Stack(影子调用栈):GKI 内核在专用栈中保存返回地址副本,返回时比对。KernelHook 的中转桩通过标准 STP/LDP X29, X30 保存恢复帧,不破坏影子栈一致性。
7 构建与加载
7.1 三种模式
| 模式 | 依赖 | 适用场景 | | — | — | — | | A:Freestanding | 无内核头文件 | 一个 .ko 适配多个内核版本 | | B:SDK | 预加载的 kernelhook.ko | 多业务模块共享 Hook 基础设施 | | C:Kbuild | 完整内核源码 / 头文件 | 有目标内核源码的开发环境 |
Freestanding 构建(CMake + NDK):
mkdir build && cd build
cmake -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-30 \
..
make
Kbuild 构建:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- \
KERNEL_DIR=/path/to/kernel/source
7.2 kmod_loader
安卓内核对第三方 .ko 有严格的加载校验:vermagic 须匹配、导入符号 CRC 须一致、struct module 布局须正确。kmod_loader 是用户态 ELF 修补工具,在加载前自动解决这些问题。
它的值解析采用策略链架构——每个需要解析的值有独立的策略链,按优先级依次尝试:
cli_override <- 命令行直接指定
-> probe_loaded_module <- 从已加载模块提取
-> probe_ondisk_module <- 从磁盘 .ko 提取
-> probe_procfs <- 从 /proc 提取
-> config_explicit <- 精确设备匹配(内置设备表)
-> config_automatch <- 自动设备匹配
-> config_fuzzy <- 模糊匹配
-> probe_disasm <- 反汇编 /proc/kcore
-> probe_binary_search <- 内存二进制搜索
第一个成功返回的策略“获胜”,后续跳过。核心解析值:
| 值 | 说明 |
| — | — |
| module_layout_crc | module_layout 的 CRC |
| _printk_crc / memcpy_crc / memset_crc | 常用导入符号 CRC |
| vermagic | 内核版本与配置标识串 |
| this_module_size | struct module 大小 |
| module_init_offset / module_exit_offset | init/exit 在结构体中的偏移 |
| kallsyms_lookup_name_addr | kallsyms_lookup_name 内核地址 |
修补完成后,kmod_loader 通过 init_module 系统调用加载 .ko,同时将 kallsyms_lookup_name 地址注入模块,作为 Freestanding 模式初始化链的起点。
8 兼容性
8.1 内核版本
| 内核 | 安卓版本 | 关键特性 | | — | — | — | | 4.4 | 9 (API 28) | 基线 | | 4.9 | 10 (API 29) | – | | 4.14 | 11 (API 30) | – | | 4.19 | 12 (API 31) | – | | 5.4 | 12 (API 31) | Shadow CFI | | 5.10 | 13 (API 33) | GKI 模块限制 | | 5.15 | 14 (API 34) | – | | 6.1 | 14 (API 34) | kCFI | | 6.6 | 15 (API 35) | – | | 6.12 | 16 (API 36/37) | 16K 页 |
8.2 符号差异
内核函数名在版本间可能变化。KernelHook维护回退列表:
-
vmalloc/
vmalloc_noprof -
set_memory_x/
set_memory_exec -
__flush_dcache_area/
dcache_clean_inval_poc
查找时依次尝试主名称和回退名称,首个命中即采用。
8.3 16K页
安卓16设备使用16K页内核。KernelHook在 kh_pgtable_init中通过TCR_EL1.TG1动态检测页大小,所有页表操作基于运行时值而非编译时常量,天然兼容4K和16K。
最后,如果本文对您有帮助,欢迎点赞关注与转发,感谢您的阅读。
如果您对安卓安全相关内核模块开发感兴趣,可以关注我的安卓软件开发与逆向分析系列课程,第一阶段有LKM开发,第二阶段有KPM开发,第四阶段有安全对抗应用实战。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:软件安全与逆向分析 非虫 非虫《安卓内核Hook技术实现分析与应用》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论