乐于分享
好东西不私藏

安卓内核Hook技术实现分析与应用

安卓内核Hook技术实现分析与应用

安卓内核Hook技术实现分析与应用

本文是基于知名Root框架APatch作者bmax121开源的KernelHook项目源码做的深度技术分析。本文从接口到实现,逐层拆解ARM64内核函数Hook的工程细节。

本文项目开源地址为:https://github.com/bmax121/KernelHook

本文作者:非虫(fei_cong@hotmail.com)

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_openat2orig_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,  NULLNULL100);
hook_wrap(target, 4, (void *)filter_callback, NULLNULL50);
hook_wrap(target, 4, (void *)log_callback,    NULLNULL0);

// 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 函数,负责组装回调上下文并按序遍历回调链:

  1. 从 rw_ptr 读取 sorted_indices[] 和回调列表
  2. 构建 hook_fargs 结构体,填入参数、返回值、本地存储
  3. 正序
    遍历 sorted_indices[],依次调用每个 before 回调
  4. 若无回调设置 skip_origin = true,调用重定位后的原始函数
  5. 逆序
    遍历 sorted_indices[],依次调用每个 after 回调
  6. 返回 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)初始化步骤:

  1. 读取 TCR_EL1.TG1,判断页大小(4K / 16K / 64K)
  2. 读取 TCR_EL1.T1SZ,计算虚拟地址位宽和页表级数
  3. 通过 ksyms 解析 swapper_pg_dirkimage_voffsetmemstart_addr

代码写入流程:

  1. 遍历页表,找到目标虚拟地址的 PTE
  2. 清除 PTE_RDONLY,设置 PTE_DBM,使页面可写
  3. TLBI 刷新 TLB
  4. 写入指令
  5. 恢复 PTE 原始权限
  6. 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开发,第四阶段有安全对抗应用实战。