前段时间,我需要在运行 Android 内核 4.14.186 的设备上测试一个外设驱动。编译好驱动模块后,才发现内核启用了严格的模块签名验证,无法直接加载。网上搜索解决方案,大多是关于小米或一加手机的教程,核心步骤是先用工具定位内核中的 load_module 函数,然后在 IDA 中修补几行汇编指令即可。然而,我手上的内核二进制文件与教程中的样本差异巨大,根本无从下手。无奈之下,只能自己动手分析,于是便有了这次实践记录。
测试设备是 Honor 50 SE,内核版本为 4.14.186,已解锁 BootLoader,并通过 Magisk 27 管理 root 权限。
一、提取内核文件
首先,需要从设备中提取当前正在运行的内核镜像。
-
查看当前系统使用的槽位。我的设备是 b 槽:
getprop ro.boot.slot_suffix
-
找到内核文件所在的分区。在我这里,boot_b 分区对应 /dev/block/sdc64:
ls -al /dev/block/by-name/ | grep boot
-
将分区镜像复制到当前目录:
dd if=/dev/block/sdc64 of=./boot.img
-
使用 magiskboot 工具解包 boot.img。该工具通常位于 /data/adb/magisk/。如果只有 Magisk APK 文件,则需要从中提取 libmagiskboot.so。
# 使用 magiskboot 二进制
./magiskboot unpack ./boot.img
# 或使用 so 库
./libmagiskboot.so unpack ./boot.img
解包后得到的 kernel 文件,就是我们需要分析并修改的目标。
二、对照内核源代码进行修补
从设备厂商官网下载对应的内核源码,解压后进入 Code_Opensource/kernel 目录。模块加载的主要逻辑在 kernel/module.c 文件中,load_module 函数及其调用的签名校验函数都在这里。
下面是一段关键的源码节选,展示了签名校验的入口:
static int load_module(struct load_info *info, const char __user *uargs,
int flags){
struct module *mod;
long err;
char *after_dashes;
err = module_sig_check(info, flags);
if (err)
goto free_copy;
// ... 其他初始化代码 ...
}
签名校验的具体实现在 module_sig_check 函数中:
static int module_sig_check(struct load_info *info, int flags){
int err = -ENOKEY;
const unsigned long markerlen = sizeof(MODULE_SIG_STRING) - 1;
const void *mod = info->hdr;
if (flags == 0 &&
info->len > markerlen &&
memcmp(mod + info->len - markerlen, MODULE_SIG_STRING, markerlen) == 0) {
info->len -= markerlen;
err = mod_verify_sig(mod, &info->len);
}
if (!err) {
info->sig_ok = true;
return 0;
}
// 厂商可能加入的审计日志
#ifdef CONFIG_HUAWEI_PROC_CHECK_ROOT
saudit_log(MOD_SIGN, STP_RISK, 0, "result=%d,", err);
#endif
if (err == -ENOKEY && !sig_enforce)
err = 0;
return err;
}
乍一看,思路似乎很简单:绕过 module_sig_check 的校验,让其直接返回 0 即可。通常可以通过 NOP 掉对其的函数调用(BL 指令)来实现。然而,当我用 IDA 打开提取出的 kernel 文件,定位到 load_module 函数时,却发现事情没那么简单。
对应的反汇编代码片段如下,可以看到编译器进行了深度优化和内联:
v6 = a1;
v575 = &off_0;
v8 = (const char *)(a1 + 8);
v7 = *(_QWORD *)(a1 + 8);
if ( a3
|| (v10 = (const char **)(v6 + 16), v9 = *(_QWORD *)(v6 + 16), v9 < 0x1D)
|| *(_QWORD *)(v7 + v9 - 28) ^ 0x20656C75646F4D7ELL
| *(_QWORD *)(v7 + v9 - 20) ^ 0x727574616E676973LL
| *(_QWORD *)(v7 + v9 - 12) ^ 0x646E657070612065LL
| *(unsigned int *)(v7 + v9 - 4) ^ 0xA7E6465LL )
{
v11 = -126;
LABEL_5:
sub_68939C(10, 1, 0, "result=%d,", v11);
v12 = v11;
goto LABEL_6;
}
module_sig_check 乃至 mod_verify_sig 的逻辑都被内联到了 load_module 函数内部,并没有清晰的函数调用边界。这意味着无法简单地通过 NOP 一个 BL 指令来解决问题。没有函数调用,也就没有标准的栈帧和寄存器保护,直接修改跳转可能会破坏后续代码执行所依赖的寄存器状态。
因此,必须手动分析 load_module 函数开头的这段 ARM 汇编 代码,理解其校验逻辑,并判断哪些寄存器值在后续流程中会被使用,从而设计出安全的修补方案。
对应的机器码与反汇编视图如下:

经过一番分析和试错,最终确定了安全的修改方案。核心思路是:绕过签名校验的逻辑分支,直接跳转到校验成功后的代码继续执行。同时,需要妥善保存某些后续会用到的寄存器值。具体修改如下表所示:

这个修改过程涉及到对 Android 内核 和 ARM 指令集 的深入理解,是典型的 逆向工程 实践。虽然编译器优化增加了难度,但通过仔细分析,总能找到突破口。
三、回刷修改后的内核
内核文件修改完成后,需要将其打包并刷回设备。
-
将修改后的文件保持命名为 kernel,放回原目录,然后重新打包:
./magiskboot repack ./boot.img
命令执行后会生成 new-boot.img。
-
将新的镜像文件写入原来的 boot 分区:
dd if=./new-boot.img of=/dev/block/sdc64
-
重启手机。请注意:如果修改有误,手机可能无法启动。因此,务必提前备份好原始的 boot.img。如果启动失败,可以进入 Fastboot 模式刷回原镜像:
fastboot flash boot boot.img
四、编译内核驱动模块
内核签名校验被绕过后,就可以加载自行编译的未签名驱动模块了。编译环境搭建与其他 4.x 内核类似。
除了之前下载的内核源代码,还需要准备交叉编译工具链:
- GCC 工具链:用于编译部分底层代码。可以从 LineageOS 的仓库获取。
- Clang 工具链:Android 内核主要使用 Clang 编译。可以从 Google 的官方仓库下载。
建议在 Ubuntu 20.04 x64 系统下进行编译。编译内核模块(而非整个内核)对配置的要求相对宽松,我并未使用设备中的 /proc/config.gz 文件也编译成功了。
设置环境变量并准备内核构建目录:
MAKE_ARGS='ARCH=arm64 SUBARCH=arm64 CLANG_TRIPLE=aarch64-linux-gnu- CC=clang LD=ld.lld CROSS_COMPILE=aarch64-linux-android- O=out'
export PATH="/home/ubuntu/clang/bin:/home/ubuntu/gcc/bin:$PATH"
cd Code_Opensource/kernel/
make $MAKE_ARGS merge_full_k6877v1_64_defconfig # 请替换为你的 defconfig 名称
make $MAKE_ARGS modules_prepare
请根据你的实际路径修改 PATH 和 defconfig 名称。
接下来,创建一个简单的测试驱动来验证。需要两个文件:
Makefile:
obj-m += hello.o
KDIR := /home/ubuntu/Code_Opensource/kernel/out
MAKE_ARGS := ARCH=arm64 CC=clang LD=ld.lld CLANG_TRIPLE=aarch64-linux-gnu- CROSS_COMPILE=aarch64-linux-android-
all:
$(MAKE) -C $(KDIR) M=$(PWD) $(MAKE_ARGS) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) $(MAKE_ARGS) clean
hello.c:
#include <linux/module.h>
static int __init hello_init(void) {
pr_info("Test: Hello, world!\n");
return 0;
}
static void __exit hello_exit(void) {
pr_info("Test: Goodbye, world!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
KDIR 指向之前 make modules_prepare 生成的输出目录。将这两个文件放在同一目录下,在新的终端中确保 PATH 环境变量已正确设置,然后执行 make 即可生成 hello.ko 驱动模块。
将其推送到设备,使用 insmod hello.ko 加载,如果看到 dmesg 中输出 “Test: Hello, world!”,则表明从内核修改到驱动编译的整个流程已完全走通。如果你对 系统底层 和 驱动开发 有更多兴趣,欢迎到 云栈社区 的 安全/渗透/逆向 或 网络/系统 板块交流讨论。
