安卓内核Hook技术实现分析与应用
安卓内核Hook技术实现分析与应用
本文是基于知名Root框架
APatch作者bmax121开源的KernelHook项目源码做的深度技术分析。本文从接口到实现,逐层拆解ARM64内核函数Hook的工程细节。
本文项目开源地址为:https://github.com/bmax121/KernelHook
本文作者:非虫(fei_cong@hotmail.com)
1 引言
在安卓安全研究、性能分析和内核功能扩展等场景中,内核函数 Hook 是一项基础技术。它的核心目标是在不修改内核源码、不重新编译内核的前提下,拦截并改变目标内核函数的行为。
传统方案各有局限:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
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) |
|
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 |
|
|
B.cond |
|
|
BL |
|
|
ADR |
|
|
ADRP |
|
|
LDR
|
|
|
LDRSW
|
|
|
CBZ
CBNZ |
|
|
TBZ
TBNZ |
|
|
PRFM
|
|
|
|
|
|
|
每种类型的重定位产出长度不同(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:
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
每个池用位图追踪块的分配状态。分配时线性扫描找到连续空闲块,释放时清除对应位。
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 |
|
|
| B:SDK |
|
|
| 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 |
|
_printk_crc
memcpy_crc / memset_crc |
|
vermagic |
|
this_module_size |
|
module_init_offset
module_exit_offset |
|
kallsyms_lookup_name_addr |
|
修补完成后,kmod_loader 通过 init_module 系统调用加载 .ko,同时将 kallsyms_lookup_name 地址注入模块,作为 Freestanding 模式初始化链的起点。
8 兼容性
8.1 内核版本
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8.2 符号差异
内核函数名在版本间可能变化。KernelHook维护回退列表:
vmalloc
/ vmalloc_noprofset_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。
夜雨聆风