前言

这篇文章,打算比较系统的梳理一下内核模块的问题,顺便聊一聊 GKI 。

对于 Android 平台,在 GKI 诞生之前( 5.4 以下内核版本),我们对于内核模块的认知往往是:内核模块必须和内核一起编译才能被正确加载。从结果来看,这种基于经验得出的结论其实没错。但是从过程来看,这句话的成立其实少不了一些机缘巧合。现在,就让我们重新梳理一下这个问题,看看这个过程中到底牵扯到了什么。

洞见

模块签名

之所以第一个就聊模块签名,是因为它就是使我们得出上面那个结论的直接原因。
在非 GKI 的高通设备内核上,以下几个 config 选项默认呈打开状态:

CONFIG_MODULE_SIG=y
CONFIG_MODULE_SIG_FORCE=y
CONFIG_MODULE_SIG_SHA512=y

其中,第一个选项 CONFIG_MODULE_SIG 打开了对模块签名的支持,它至少会做以下事情:

  • 生成密钥对。若 CONFIG_MODULE_SIG_KEY 选项的内容没有被特别指定(1)(默认行为),则在编译内核时生成签名所需的密钥对。
  • 保存公钥。将上面生成的密钥对或 CONFIG_MODULE_SIG_KEY 所特别指定密钥对的公钥保存在内核本体中(2)。
  • 启用校验。存储在内核本体中的公钥将被用于对模块的签名验证。但是,单纯开启 CONFIG_MODULE_SIG 并不会拒绝加载签名验证失败的模块,而只是会打印一下警告。

第二个选项, CONFIG_MODULE_SIG_FORCE 则是对第一个选项进行了加强:拒绝加载任何未签名或者签名校验失败的模块。
第三个选项,则是指定了一下模块签名使用的算法。

可以发现,密钥对是在编译时生成的,而模块想要成功被加载,就必须使用与“内核本体中所保存公钥”相匹配的私钥进行签名,也就意味着签名的私钥必须来自这一编译时生成的密钥对。于是,上面的“内核模块必须和内核一起编译才能被正确加载”似乎就得到了印证。

但这,其实并没有那么准确。在默认情况下,选项 CONFIG_MODULE_SIG_ALL 是没有被打开的,此时内核并不会自动为生成的模块进行签名。也就是属于:生成了密钥对,保存了公钥,做好了校验准备,但是把密钥对丢在 out 目录里就不管了,私钥你自己拿去玩吧。很显然,签名模块的过程是由编译系统的其它部分完成的——拿着刚刚编译内核时生成的私钥进行签名。这其实是强调了:想要通过签名验证,内核和模块也并不一定真的需要来自同一次编译,只要公私钥匹配就可以了,比如:

  • 重新 dirty build 了内核本体,但密钥对没有重新生成,那之前编译 + 签名的模块自然还可以通过签名验证。
  • 重新编译了内核模块,但是使用了上次编译内核时生成的私钥进行签名,那这次编译的模块也能在上次编译的内核上通过签名验证。

对了,作为第三方开发者,在单独编译内核时如果你想生成带有签名的模块,别忘了打开 CONFIG_MODULE_SIG_ALL 选项,否则生成的模块是需要手动使用 scripts/sign-file 工具进行签名才能过验证的。

注释:

  • (1) CONFIG_MODULE_SIG_KEY 的默认值是 certs/signing_key.pem ,这并不是一个有效的签名文件而只是一个占位字符串,代表在编译时生成密钥对。
  • (2) 其实还可以通过指定 CONFIG_SYSTEM_TRUSTED_KEYS 来额外保存一些别的公钥到内核中。

匹配性检测

上面那一坨签名相关的东西,主要保证的还是模块的来源可信,毕竟并不是所有人都会选择在编译时随机生成一个签名密钥对,那自然而然拿它作为模块与内核匹配性的参照是不够格的。

匹配性的检测方式主要有两种——简单而优雅的 vermagic 和看似高级但实际上没那么靠谱的 modversions 。接下来,就来简单的分别介绍一下两种方式。

vermagic

vermagic 是一串字符串,而且很幸运的是它还是人类可读的,我们可以使用 modinfo 指令来查看一个模块的 vermagic:

$ modinfo -F vermagic snd-soc-core.ko
6.1.1-zen1-1-zen SMP preempt mod_unload

vermagic 反映了内核的版本信息和部分特性。 vermagic 不仅会保存在内核本体中,也会保存在内核模块中。因此,只需要简单的比较内核本体和模块的 vermagic 值,即可得知其匹配情况。

你可能会说:这玩意儿不就是手动维护一个版本号吗,那假如遇到 Linux 版本号相同但是自己打了点补丁导致接口发生变化的情况咋办?
嗯,没错,就是手动维护版本号。在接口变化时,其实还有个在 Android 上没怎么用到的 EXTRAVERSION 也是可以修改的对象:

# SPDX-License-Identifier: GPL-2.0
VERSION = 4
PATCHLEVEL = 19
SUBLEVEL = 157
EXTRAVERSION =
NAME = "People's Front"

这个 EXTRAVERSION 会 append 在主版本号的后面,比如 4.19.157-2 这样作为辅助,这也是大多数 Linux 发行版维护版本号的方式。
所以,vermagic 的核心原理就是:在接口发生改变(或者干脆每次编译时)修改版本号,拒绝加载可能不匹配的模块。

modversions

modversions 有一个更加高级的理想:不看版本号,只看模块接口的匹配情况。
那么这该如何实现呢?
modversions 是这样做的:为内核中所有的模块接口计算校验值,并在模块中保存所有其调用的接口的校验值。于是,在加载模块时,只需要匹配“模块所期待的校验值”和“内核所提供的校验值”即可确定这个接口有没有被更改过。

我们可以使用 modprobe --dump-modversions 指令来查看一个模块保存的所有校验值:

$ modprobe --dump-modversions qca_cld3_qca6390.ko
0xfd03be2d      module_layout
0x62d6013e      PDE_DATA
0x47ce0010      ipa_get_wdi_stats
......

接口校验值的计算由 scripts/genksyms 负责,校验值采用的是 CRC32 的形式,我们可以简单的“试用”一下它(1):

test.h

#ifndef TEST_H
#define TEST_H

struct a {
    int a;
    long b;
};

#endif

test.c

#include "test.h"

int test(struct a *a) {

}

EXPORT_SYMBOL(test);
$ gcc -E test.c | genksyms
__crc_test = 0x8ebc093d;

于是,我们拿到了 test 方法的校验值:0x8ebc093d
校验值的计算是有一系列的参照的,包括方法本身的名称、参数、返回值等等。

我们可以加上 -d 选项看看详细的输出:

$ gcc -E test.c | genksyms -d 
Defn for struct a == <struct a { int a ; long b ; } >
Defn for type0 test == <int test ( s#a * ) >
__crc_test = 0x8ebc093d;
Hash table occupancy 2/4096 = 0.000488281

可以看到,参数 struct a * 被识别并展开到了成员变量级别(2),此时 struct a {}; 的结构体布局将会直接影响到最终的校验值,这也是维护接口稳定的一方面。

当然,不是所有的结构体都需要被展开,比如上面的代码传入的仅仅只是 struct a 的指针,我们并不对其取值,那结构体的布局对接口稳定性就没有任何影响,于是我们可以将头文件中 struct a {}; 这样的具体声明改为 struct a; 这样的“前向声明”(forward declaration),此时接口校验值的计算将不会牵扯到 struct a 的成员,于是起到了扩大接口兼容性的效果。
可以这样形象的理解一下:

  • 有一个接口 int test(struct a *) ,位于源文件 A 。
  • struct a {}; 这样的具体声明放在头文件 B 中。
  • struct a; 这样的前向声明放在头文件 C 中。
  • A 并不关心 struct a 的具体细节,而且因为采用指针传递,也无需关心其具体细节,于是 A 引用了 C。
  • 这样,接口的兼容性实现了扩大化,修改 B 中的具体声明将不会对接口的校验值产生任何影响。
  • 该接口内部可能会调用别的源文件(D)中的函数进一步对 struct a * 进行处理,如果想要取得 struct a * 的具体内容,则 D 需要引用头文件 B,当然这对 A 是没有影响的,自然也不会影响到接口的校验值。

假如 A 引用的头文件从 C 换成了 B ,即使结构体的具体声明没有被修改,那接口的校验值也会发生改变,这被称为 数据类型可见性变更

这种采用指针 + 前向声明的方式进行数据可见性屏蔽的操作其实蛮常见的,不仅是内核。

那么,为什么说 modversions 看似高级但实际上没那么靠谱呢?
继续上面的“形象理解”。正常情况下,模块是只允许引用头文件 C 的,即不允许访问 struct a 的具体内容。但是假如我们在模块代码中引用了头文件 B ,然后对 struct a * 取值并修改呢?试想:在某次提交后,B 中的结构体具体声明发生了变化,由于 A 引用的是 C ,因此接口的校验值不受影响;而我们只重新编译了内核本体,却没有重新编译模块,模块被加载了,校验值匹配通过了,对 struct a * 一取值修改,boom (3)。
此外,还有像是由编译器版本变化导致的 ABI 不匹配,modversions 压根就没办法捕获了吧。

我想,难以处理这些奇怪的情况便是文档中 (hopefully) spot any changes 的原因吧。
在 LWN.net 的文章 The end of modversions? 也有对该功能不靠谱性的提及:By "quite limited," he is referring to the fact that many changes will elude the modversions check. In particular, changes to a structure passed to a function will not be caught.

注释:

  • (1) 对于这种“试用”,EXPORT_SYMBOL 宏无需定义,它将直接被 genksyms 读取(即使真正的编译不一定能通过)。
  • (2) struct 的声明必须放在头文件里,如果简单的放在源码文件的顶部是无法被 genksyms 识别并展开到成员的。
  • (3) 不知道为什么,AOSP 的文档认为这种情况可以被 modversions 捕获 ,然而按照我自己的理解,这种情况是不行的。如果你对此有一些认知,欢迎在评论区讨论。

实际应用

在实际工作过程中,CONFIG_MODVERSIONS 选项决定了上述 modversions 匹配性检测方式的开启状况。

CONFIG_MODVERSIONS 关闭的情况下,vermagic 会成为唯一的匹配性检测方法,此时内核将会完整比较 vermagic 字符串的全部内容,并拒绝加载 vermagic 字符串与内核本体不一致的模块。

CONFIG_MODVERSIONS 开启的情况下,内核首先会比较 vermagic 字符串中的“特性段”(即版本号之后的部分,比如 SMP preempt mod_unload),在“特性段”匹配的情况下再去比较 modversions 生成的接口校验值。只有在“特性段”和接口校验值全都成功匹配的情况下,模块才会允许被加载。

需要注意的是,modversions 本身也是“特性段”的内容之一:

$ modinfo -F vermagic qca_cld3_qca6390.ko
4.19.157-perf+ SMP preempt mod_unload modversions aarch64

因此,尝试加载 CONFIG_MODVERSIONS 选项开关状态不匹配的模块,无论如何都是不能够成功的(即,别想通过关闭 CONFIG_MODVERSIONS 选项来绕过 modversions 检查)。

在高通设备内核上,CONFIG_MODVERSIONS 默认都呈打开状态,即会采用上述的第二种方式对模块匹配性进行检测。

强制加载

接下来聊一聊模块强制加载相关的问题。

在原厂系统上,有许多的内核组件都被编译成了模块的形式。当这些模块无法被正确加载时,常常会发生:wifi 失效、音频失效、触摸失效...

借助上面的知识,先来思考这样一个问题:既然高通设备内核默认都开着 CONFIG_MODVERSIONS ,那是不是意味着只要关闭 CONFIG_MODULE_SIG_FORCE ,原厂系统自带的模块就能够顺利加载了呢?

按照上面我们的分析从理论上想想,好像确实是这样。但事实基本不会这么友好(1):

OnePlus8T:/vendor/lib/modules # modprobe -d . qca_cld3_qca6390.ko
Loading module /vendor/lib/modules/qca_cld3_qca6390.ko with args ''
modprobe: Failed to insmod '/vendor/lib/modules/qca_cld3_qca6390.ko' with args '': Exec format error
modprobe: LoadWithAliases was unable to load qca_cld3_qca6390.ko
modprobe: Failed to load module qca_cld3_qca6390.ko: Exec format error

OnePlus8T:/vendor/lib/modules # dmesg
......
[   99.950252] (6)[8263:modprobe]wlan: disagrees about version of symbol module_layout
......

可以看到,模块的加载被拒绝了,原因是接口 module_layout 的校验值不匹配。
这个 module_layout 并不是一个真正意义上的接口,它的存在仅仅只是为了通过复用接口校验机制的方式来确保模块相关的关键结构体没有改变:

kernel/module.c

#ifdef CONFIG_MODVERSIONS
/* Generate the signature for all relevant module structures here.
 * If these change, we don't want to try to parse the module. */
void module_layout(struct module *mod,
           struct modversion_info *ver,
           struct kernel_param *kp,
           struct kernel_symbol *ks,
           struct tracepoint * const *tp)
{
}
EXPORT_SYMBOL(module_layout);
#endif

即,校验值不匹配意味着:方法参数中牵扯到的这堆结构体存在布局上的变化。
我很难知道具体的原因,因为这些结构体在层层展开之后会牵扯到很多很多的东西。但是从根本上说,原厂系统自带的模块无法被加载是因为厂商开源的代码和内部真正使用的代码压根不是同一份(2)。可能是因为内部代码有一些额外的修改,也有可能是因为内部代码额外开关了一些 config ,不管原因是什么,结果就是:某些结构体的布局发生变化并被 modversions 校验值所捕获,从而导致原厂模块被拒绝加载。

面对这种情况,最好的解决方法永远是重新编译新的模块或者干脆把模块们编译进内核,因为接口不匹配的情况已经存在,模块要是真的被加载进去是有跑飞风险的。
但是别忘了这段内容的主题是“强制加载”,那么,该如何强制加载这些不匹配的模块呢?

内核提供了一个选项 CONFIG_MODULE_FORCE_LOAD 来启用模块强制加载的支持,但遗憾的是,打开这个选项并不意味着模块就会被强制加载,打开它仅仅只是允许了在执行 modprobe 指令时加上 -f / --force-modversion / --force-vermagic 之类的选项来忽略掉相关的校验。往深层了讲,它所做的事情是允许在执行 finit_module syscall 时通过在 flags 中标记指定的位来跳过校验。

但更遗憾的是,为了绕过 GPL ,Android 并没有使用 module-init-tools 提供的 modprobe 指令,而是自己在 toolbox 中 rewrite 了一个 ,但是,这个 rewrite 的版本压根就不支持 -f 之类的强制加载选项。因此,想要走正常渠道利用 CONFIG_MODULE_FORCE_LOAD 看似是无缘了。

天无绝人之路,想要强制加载模块你当然可以去编译适用于 Android 的 module-init-tools ,但是更简单的方法是直接在内核中修改上面所说的 flags ,以此达到与 modprobe -f 相同的效果:

diff --git a/kernel/module.c b/kernel/module.c
index 9dfc374bfeb4..743b8064364e 100644
--- a/kernel/module.c
+++ b/kernel/module.c
@@ -3704,6 +3704,9 @@ static int load_module(struct load_info *info, const char __user *uargs,
        long err = 0;
        char *after_dashes;
 
+       flags |= MODULE_INIT_IGNORE_MODVERSIONS;
+       flags |= MODULE_INIT_IGNORE_VERMAGIC;
+
        err = elf_header_check(info);
        if (err)
                goto free_copy;

想要它生效,别忘了打开 CONFIG_MODULE_FORCE_LOAD

注释:

  • (1) 真正加载 qca_cld3_qca6390.ko 时是需要传入一系列参数的,我这里只是为了复现报错,故没有传入参数。
  • (2) 某些厂商甚至开出来的 Linux 版本号和原厂系统都不一样。

GKI

接下来聊一聊 GKI 。总体看下来,GKI 是对原有内核模块机制的增强应用,并没有逃出我们上面所介绍的那些内容。
事先声明,由于电子阳痿,我并没有真正上手过 GKI 内核,因此以下内容只是基于部分代码和官方文档的片面之谈,如有不准确的地方欢迎在评论区补充和讨论。

先说说 GKI 的目标是什么。我们不谈那些高大上“内核镜像通刷”,聚焦于具体目标,GKI 其实就是在实现内核模块和内核本体之间的“跨版本匹配”,即保证一次编译的内核模块能够在不同的内核本体上顺利加载和运行。

正确加载和运行内核模块的前提是“接口匹配”。
然而,这并不是一件非常容易的事情,因为 Linux 内核并不保证模块接口的稳定性,即使是小版本更新也可能破坏模块接口(比如某个 struct 里新增了一个成员,那相关接口可能就会炸一片)。而内核源码中定义的模块接口又实在是太多了,想要维护所有接口的稳定是十分困难的。所以,GKI 选出了其中一部分 vendor 模块常用的接口,将它们记录为 KMI(Kernel Module Interface) ,它们的稳定性将由 Android Common Kernel(ACK) 负责。

那么,ACK 是如何维护 KMI 的稳定性的呢?其实很简单,来自 Linux 上游的哪个提交破坏了接口稳定性,就 revert 掉它,比如: https://github.com/aosp-mirror/kernel_common/commit/202ee063496e2050f55e508ba2c03be1444a6dea 。然后,如果有需要(比如这是一个重要的漏洞修复),那再尝试用不破坏接口的方式绕着圈重新应用它。

KMI 是根据需要挑选出来的,相关内容被保存在内核源码的 android/ 目录下,AOSP 提供了一系列的工具用来追踪 KMI 的稳定性 。而那些没有被选中的内核接口,自然而然还是不稳定的,于是,GKI 内核的模块会被分为两类:第一类被称为 “GKI 模块” (GKI module) ,它们使用的模块接口不受限制,但是需要随 GKI 内核本体一起更新;另一类被称为 “供应商模块” (vendor module) ,它们只能使用 KMI 中列出的稳定接口,但是在 GKI 内核本体更新后仍然能够正常工作(1) 。

接下来结合一下上面的知识:

  • GKI 内核全部默认开启了 CONFIG_MODVERSIONS ,因此 vermagic 中的内核版本号字段并不会影响模块的加载(方便跨版本加载“供应商模块”),模块的兼容性则根据记录的接口校验值进行辅助判断。
  • GKI 内核不允许开启 CONFIG_MODULE_SIG_FORCE ,因为它需要加载不同来源的“供应商模块”。同理,“供应商模块”也不会被签名,防篡改则由 AVB 负责(将模块们装在受 AVB 保护的分区里)。

此外,上面我们还提到了“GKI 模块”和“供应商模块”能够调用的内核模块接口范围是不同的,那么有没有一个机制能够将“供应商模块”的接口调用范围限制在 KMI 中呢?
有这样的尝试,但是还不太完善:

  • GKI 在正式编译时 会打开内核中的 CONFIG_TRIM_UNUSED_KSYMS 选项,修剪掉未使用的模块接口,KMI 接口则会通过 CONFIG_UNUSED_KSYMS_WHITELIST 保留下来。在没有编译 “GKI 模块” 时,这招能够确保暴露在外的只有 KMI 接口,从而屏蔽“供应商模块”对非 KMI 接口的访问。
  • 在 5.10+ 内核上,GKI 引入了对 “GKI 模块” 的签名保护,即所有 “GKI 模块” 都需要被签名,只有签名验证通过的模块才能访问非 KMI 接口,剩下的模块只能使用稳定的 KMI 接口。这一特性由 CONFIG_MODULE_SIG_PROTECT 负责,由于需要签名校验的支持,这些内核还需要打开 CONFIG_MODULE_SIG (但是上面的 CONFIG_MODULE_SIG_FORCE 绝对不可以)。签名保护的接口列表被写死在 android/abi_gki_modules_protectedandroid/abi_gki_modules_exports ,并在编译时生成头文件 + 塞到内核本体中。不过,这俩列表到目前为止都还是空的,config 却打开了,可能是这项功能还没正式启用?

非常值得一提的是,GKI 的分发是要严格遵守流程的,从编译脚本、编译器版本到使用的 config 都有 规定 ,毕竟它们都是可能影响接口稳定性的因素。

注释:

  • (1) 确切的来说,是在 GKI 内核本体的 KMI 更新之前都能正常工作。在 Android 大版本更新时,ACK 会创建新的分支提供更新的 KMI ,此时“供应商模块”可能需要重新编译。

参考资料