问题

本文中,「内核模块」、「可加载(内核)模块」、「LKM」、「ko」是同义词。

DDK 让适用于 Android GKI 的内核模块开发更加便捷了,我们很容易就能编译出 ABI 正确的内核模块。然而想要在内核玩魔法,解决访问各种内核符号的问题是少不了的一关。

Linux 内核只给可加载模块导出了很少一部分函数,而大量有用的符号都隐藏在 kallsyms 中。一个符号只有通过 EXPORT_SYMBOL[_GPL] 标记的符号可以被导出。导出符号被记录到名为 ksymtab[_gpl] 的表中,内核加载模块只会从这个地方来查找未定义符号。因此直接如果 ko 的 undef 符号包含非导出符号,那么内核加载的时候在解析符号阶段就会出现错误,导致模块无法加载。

内核提供了 kallsyms_lookup_name 这个函数来查找内核符号,因此内核自身可以访问全部的 kallsyms ,然而不幸的是, kallsyms_lookup_name 也不在导出符号之中,因此可加载内核模块无法访问这个函数。

对此,KernelSU LKM 的解决方案是使用 ksuinit 加载模块 ,这个程序的 lkm loader 会解析 /proc/kallsyms ,然后将 ko 的符号表中的未定义符号(section=SHN_UNDEF)全部修改成已定义的绝对地址(同时 section=SHN_ABS),再加载被 patch 的 ko ,这样一来内核就没法说什么了,因为地址都是已知的。这个方案当然很简单且方便,不过缺点是要临时打开 /proc/sys/kernel/kptr_restrict 才能获得正确的绝对地址。由于 KASLR 的存在,内核每次加载的虚拟地址都会有一定偏移,因此获得的符号地址对同一个内核不同启动也不能复用。

ksuinit 方案也有弊端:如果符号表过于庞大,则读取符号表的开销就很巨大(特别是内核启动后,加载了大量内核模块,而 /proc/kallsyms 包含内核及内核模块的所有 kallsyms),在小米 6.12 已启动的内核上,需要几秒才能加载完成,其中大部分时间花在了读取符号上。

既然内核无法加载包含未导出符号的 ko ,我们也可以让 ko 只包含已导出的符号,成功加载到内核后再想办法解析 kallsyms ,那么具体该如何实现呢?

解决方案

方案 1 :内存中查找 kallsyms 结构(pass)

前篇文章中我们已经了解了如何解析一个内核镜像的 kallsyms ,现在换到运行中的内核,我们当然也可以这么做,但是缺点在于我们对内核内存的加载地址和大小并无确切的认知,我们只能获取个别导出的内核符号的绝对地址,而大小更是难以获取,只能读取页表来探测哪块内存是合法的(好在页表寄存器可以获取,且内核的线性映射地址也是固定的,memstart_addr 也是导出的)。不过,这个方案过于复杂,即使能够实现开销也很大(我认为不亚于直接对着内核镜像搜索),有没有可能直接利用导出的内核函数来查找 kallsyms 呢?

方案 2:利用 kprobes (部分可用)

已开源的内核模块中常常会利用这一点(例子):register_kprobe 是一个天然的符号解析器,而且它导出。我们可以简单注册对 kallsyms_lookup_name 的 kprobes ,这样就能获取 kallsyms_lookup_name 的地址,进而解析其他的内核符号。为了避免不必要的麻烦,我们同时设置 flag KPROBE_FLAG_DISABLED 避免真的启用这个 kprobes ,只要经过了符号解析环节,地址就会被写入 kprobe 结构体的 addr 中,无论是否 kprobe 成功,我们都可以读取这个 addr ,随后假如成功了,再将其 unregister 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
unsigned long (*kallsyms_lookup_name_fn)(const char *name) = NULL;
struct kprobe dummy_kp = {
.symbol_name = "kallsyms_lookup_name",
.flags = KPROBE_FLAG_DISABLED,
};

static void init_kallsyms_lookup_name(void) {
int ret = register_kprobe(&dummy_kp);
if (!ret) {
pr_info("register success\n");
unregister_kprobe(&dummy_kp);
} else {
pr_info("register failed: %d\n", ret);
}
kallsyms_lookup_name_fn = (typeof(kallsyms_lookup_name_fn))dummy_kp.addr;
pr_info("kallsyms_lookup_name ptr: 0x%lx\n", (unsigned long)kallsyms_lookup_name_fn);
if (kallsyms_lookup_name_fn) {
unsigned long sym = kallsyms_lookup_name_fn("kallsyms_lookup_name");
pr_info("kallsyms_lookup_name ptr: 0x%lx\n", sym);
sym = kallsyms_lookup_name_fn("_text");
pr_info("_text ptr: 0x%lx\n", sym);
}
}

这一方法在小米的 android16-6.12 abogki 内核能成功,然而到了 pixel6 的 android13-5.10 反而不行,原因是 register_kprobe 没有导出!

按理来说这个 register_kprobe 在很早的版本就是导出符号,到了 13-5.10 也是导出的。然而我把这个版本的内核 kallsyms 提取,把 ksymtab 和 ksymtab_gpl 翻了个底朝天都没找到 register_kprobe 。

原因是 GKI 会过滤导出符号,只允许使用 aarch64_additional_kmi_symbol_lists 和 kmi_symbol_list 列表定义的符号,这个列表就是 common/android/abi_gki_aarch64_* 的并集,其中不包含 register_kprobe

即使是 register_kretprobe 也是 2022 年 12 月新增,而这个 5.10 内核 uname Linux localhost 5.10.107-android13-4-00004-gf0fe4f768061-ab8935229 #1 SMP PREEMPT Thu Aug 11 01:16:28 UTC 2022 aarch64 Toybox 是 2022 年 8 月,可以查看对应 ci 的 abi_symbollist同样没有 kprobe 和 kretprobe 的踪影。
相关代码参考 1. UNUSED_KSYMS_WHITELIST 2. _config_symbol_list_impl 3. trim_nonlisted_kmi set 4. kmi_symbol_lists

因此这个方案无法适用于所有的 GKI 内核。

方案 3:全新思路

以上两种方法,一种实现难度大,一种无法适用于所有内核,怎么办呢?

想起我们调试内核的时候,经常需要看 backtrace ,而 backtrace 的内容就是调用栈上函数的名字、偏移和大小。在打印 /proc/pid/stack 也能看到这些 backtrace 。我们来分析一下:

common/arch/arm64/kernel/stacktrace.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static bool dump_backtrace_entry(const struct kunwind_state *state, void *arg)
{
printk("%s %pSb%s%s%s%s%s\n", loglvl,
(void *)state->common.pc,
has_info ? " (" : "",
source ? source : "",
flags.fgraph ? "F" : "",
flags.kretprobe ? "K" : "",
has_info ? ")" : "");

return true;
}

void dump_backtrace(struct pt_regs *regs, struct task_struct *tsk,
const char *loglvl)
{
// ...
printk("%sCall trace:\n", loglvl);
kunwind_stack_walk(dump_backtrace_entry, (void *)loglvl, tsk, regs);
}

其中 %pSb 格式串值得注意,因为它格式化的是 pc 地址,最终会打印出上述提到的函数的名字、偏移和大小。

printk 会调用到 vsnprintf ,其中调用 pointer 处理指针格式化,而后缀 S 表示进入 symbol_string,后缀 Sb 进入 sprint_symbol_build_id,最终进入 kallsyms.c 的 __sprint_symbol,在 kallsyms_lookup_buildid 中,用 get_symbol_pos 解析给定地址的符号的大小和偏移。

kallsyms 中实际上并未记录符号的大小,而内核的「符号大小」实际上是通过查找地址比当前符号大的下一个符号(或者 _end ),让这个符号地址作为上一个符号的结尾,这样给定一个内核地址,它总是夹在某两个符号之间,也就是一个内核区域的地址总能对应到「某个符号+偏移/符号大小」。

这就意味着,只要我们知道内核区域的某个地址,就可以利用 sprintf 开始往前或往后遍历符号了:对于往后遍历,就是用 sprintf 解析出所在符号大小,用地址加上符号大小即可走到下一个符号;对于往前遍历,则是解析出所在偏移,用当前地址减去符号偏移再减去一个小偏移(可以取 4,因为 aarch64 的指令长度为 4 ,实际上也可以取 1),就能走到前一个符号。至于地址怎么来?当然是从导出符号获取(读者可以思考如何获取导出符号的绝对地址)。

既然能遍历符号表,那么找到 kallsyms_lookup_name 就不是什么难事了。我尝试从 sprintf 地址自身开始遍历,发现 kallsyms_lookup_name 往往在它前面约几万个符号找到,整个过程开销不算大,只有 0.03s 左右。

13-5.10:

5.10

16-6.12:

6.12

不过优化还是有的:只要我们能够选取距离 kallsyms_lookup_name 较近的符号,就可以很快通过遍历找到了。具体是哪一个符号,读者不妨自己想一想。

优化

结论

看来我们找到了无需用户空间辅助或解析内核镜像,只加载内核模块并利用内核的导出符号就能快速找到 kallsyms_lookup_name 的方法,进而方便地查询各种符号。sprintf 作为基本函数,不太可能被屏蔽导出,因此我们得到了一个稳定的纯内核的 LKM 查找 kallsyms 方案。未来可能会考虑实现在让 LKM 被加载到内核后自行解析符号并完成重定位,这样编译出来的 LKM 即使引用未导出符号,也可以经过简单处理后直接 insmod 加载到内核并正常使用。

思考题

  1. 如何获取导出符号的绝对地址?
  2. 如何选取导出符号,使得 kallsyms_lookup_name 能够被快速找到?
  3. 怎么实现遍历符号?