运行时修补 Linux 内核






4.93/5 (11投票s)
如何在不重启系统的情况下,使用自定义机制为 Linux 内核打补丁。
下载源代码 - kp.zip
引言
有多少次新功能在经过深入测试后发布,却只是为了再次以我们都知道的经典演示效果呈现?代码是由人编写的,而人会犯错;这是一个普遍真理,如果你不这么认为,要么是你过于自满,要么是你没有足够的编程经验。
软件 bug 并不是现有代码打补丁的唯一原因:随着时间的推移,你的软件可能会出现新的需求,现在必须支持之前未定义的新功能。虽然这个过程通常是通过重新编译内核/模块并将其部署到运行中的机器上完成的,但有时这样的机器暂时不能重启,但仍然需要这些功能以获得改进/正确的行为(想想看,在服务器上需要清理的后门)。
本文将介绍一些执行补丁和解决问题的策略。没有一种万能的方法可以完成这项工作,在某些情况下,一些解决方案比其他方案更适合。
那么,让我们来看看其中一种方法……
背景
本文中的概念以一种轻松的方式呈现,以便所有用户都能理解其中的思想和技术。如果你想完全理解、使用和修改这些代码以便在你的内核上工作(这里提供的代码是人类编写的),那么你需要对 Linux 内部原理和内核有更深入的了解。
乏味的方法:使用已有的东西。
Linux 内核自带了 **kPatch** 功能,它允许你实时修补内核。实时修补意味着修改是在内核运行时进行的。这项技术是由 Red Hat 的工程师开发的,内部利用了 ftrace 机制来重路由内核例程的执行路径。
那么,既然一切都通过 ftrace 机制重路由,它是如何工作的呢?
Ftrace 之所以能工作,得益于 GCC 的编译选项 "-pg",它在 **编译时** 会添加一个对特殊函数 mcount 的调用。现在,正如你所料,为每个例程添加一个额外的调用是一项昂贵的操作,因为它涉及到在栈上推送/弹出数据。因此,内核通常使用 CONFIG_DYNAMIC_FTRACE 选项进行编译,该选项在启动时将对 mcount 的调用替换为 NOP 指令,并索引所有这些点以跟踪可以进行修补的位置。
你可以通过创建一个虚拟应用程序来直接检查这一点,并尝试打开/关闭此编译器选项。
让我们以以下虚拟应用程序为例
#include <stdio.h> #include <stdlib.h> /****************************************************************************** * Entry point. * ******************************************************************************/ int a() { return 0; } int main(int argc, char ** argv) { return 0; }
你所要做的就是编译并分别在不带和带 -pg 标志的情况下进行检查,以了解 GCC 如何组织二进制文件。对于普通编译,**不带** 编译选项,我们的过程组织如下:
(gdb) disas a Dump of assembler code for function a: 0x00000000004004ed <+0>: push %rbp 0x00000000004004ee <+1>: mov %rsp,%rbp 0x00000000004004f1 <+4>: mov $0x0,%eax 0x00000000004004f6 <+9>: pop %rbp 0x00000000004004f7 <+10>: retq End of assembler dump. (gdb) disas main Dump of assembler code for function main: 0x0000000000400503 <+0>: push %rbp 0x0000000000400504 <+1>: mov %rsp,%rbp 0x0000000000400507 <+4>: mov %edi,-0x4(%rbp) 0x000000000040050a <+7>: mov %rsi,-0x10(%rbp) 0x000000000040050e <+11>: mov $0x0,%eax 0x0000000000400513 <+16>: pop %rbp 0x0000000000400514 <+17>: retq End of assembler dump.
而如果你使用编译选项 **带** 编译选项来编译这个虚拟应用程序,你将得到类似这样的结果:
(gdb) disas a Dump of assembler code for function a: 0x00000000004005fd <+0>: push %rbp 0x00000000004005fe <+1>: mov %rsp,%rbp 0x0000000000400601 <+4>: callq 0x4004b0 <mcount@plt> 0x0000000000400606 <+9>: mov $0x0,%eax 0x000000000040060b <+14>: pop %rbp 0x000000000040060c <+15>: retq End of assembler dump. (gdb) disas main Dump of assembler code for function main: 0x000000000040061d <+0>: push %rbp 0x000000000040061e <+1>: mov %rsp,%rbp 0x0000000000400621 <+4>: sub $0x10,%rsp 0x0000000000400625 <+8>: callq 0x4004b0 <mcount@plt> 0x000000000040062a <+13>: mov %edi,-0x4(%rbp) 0x000000000040062d <+16>: mov %rsi,-0x10(%rbp) 0x0000000000400631 <+20>: mov $0x0,%eax 0x0000000000400636 <+25>: leaveq 0x0000000000400637 <+26>: retq End of assembler dump.
关于这方面工作原理的更多信息可以在互联网和 Linux 内核文档中找到。
如果你查看 Linux 内核源代码中的文件 samples/livepatch/livepatch-sample.c,你会发现一个易于理解的示例,展示了整个机制是如何工作的。这个简单的例子只修补了允许读取操作系统引导参数的 proc 条目。
static int livepatch_cmdline_proc_show(struct seq_file *m, void *v) { seq_printf(m, "%s\n", "this has been live patched"); return 0; } static struct klp_func funcs[] = { { .old_name = "cmdline_proc_show", .new_func = livepatch_cmdline_proc_show, }, { /* NULL entry */ } }; static struct klp_object objs[] = { { /* name being NULL means vmlinux */ .funcs = funcs, }, { /* NULL entry */ } }; static struct klp_patch patch = { .mod = THIS_MODULE, .objs = objs, }; static int livepatch_init(void) { int ret; ret = klp_register_patch(&patch); if (ret) return ret; ret = klp_enable_patch(&patch); if (ret) { WARN_ON(klp_unregister_patch(&patch)); return ret; } return 0; }
上面的代码展示了 livepatch 机制的使用方法:补丁被插入到一个加载到系统中的内核模块中。首先,补丁需要注册到 livepatch 子系统中,然后你可以根据需要启用/禁用它。模块的 init 例程实际上执行了这两个步骤。
Livepatch 结构体提供了同时进行多次调整的可能性,所有这些都包含在一个要应用的补丁中(因为修改可能涉及多个现有函数,需要进行修复)。补丁 klp_func 结构体的 old_name 字段指向要修补的过程的符号,而 new_func 包含将替换旧过程的新过程的地址。请注意,新过程必须与旧过程具有相同的配置文件(返回值和参数),否则你的系统可能会出现非常严重的堆栈损坏。
static void livepatch_exit(void) { WARN_ON(klp_disable_patch(&patch)); WARN_ON(klp_unregister_patch(&patch)); }
模块的退出点展示了如何禁用并最终移除系统中的补丁。
总结: Livepatch 机制是一种现成的修补系统的方法(如果你知道你在做什么),它依赖于知名且经过测试的机制,如 ftrace 和 kernel modules。为了获得此功能,你必须使用这两个选项编译你的内核。在当今的发行版中,这通常是正确的,因为系统将运行在通用桌面或笔记本电脑上。问题出现在当你需要设置更复杂的系统,而内存占用和其他要求迫使你禁用使用此工具所需的选项时。
在这种情况下,你就“需要自己动手”。虽然走这条路可能非常危险,并导致重复发明轮子,但出于学术目的,深入了解如何使其工作是非常有趣的。只有当你别无选择时,才使用自定义机制。
有趣的方法:自己动手!
执行补丁需要解决一系列挑战,如果一次性处理可能会让人不知所措。为了做到这一点,让我们将任务分解为一系列必须解决的小问题。最后,所有解决方案都可以合并成一个大的 API 式机制,该机制将一步一步地完成整个工作。
我们现在假设
- 要修补的内核**未**编译 Livepatch 机制。
- 要修补的内核**未**编译 CONFIG_DYNAMIC_FTRACE 或其他 ftrace 符号。
- 要修补的内核**不知道**它将被修补。
修补内核
对于修补内核的操作,如 **memcpy**、**memset** 和其他同类函数,足以完成工作(感谢架构抽象),但如果你尝试在内核代码中使用它们,就会出现问题。就像普通应用程序一样,内核驻留在内存中,而这些内存页受到**写**保护。代码通常不希望改变自身,而数据通常不应被执行。这意味着,显然不可能改变内核区域内的执行流。
#include <linux/kernel.h> #include <linux/init.h> #include <linux/module.h> #include <linux/string.h> #include "utils.h" int target() { return 1; } static int __init kp_init(void) { char * m = ((char *)target) + 0xa; /* Read our target procedure in order to evaluate the state before * patching it's content. */ kp_print(target, 16); /* Try to modify the return value to 2. * * NOTE: * This will cause a crash! */ *m = 2; return 0; } static void __exit kp_exit(void) { return; } module_init(kp_init); module_exit(kp_exit); MODULE_LICENSE("GPL");
以下内核模块(**read-only.c**)试图修补 target 例程的返回值。我已经反汇编了该例程,并找到了必须更改的部分,以便将其返回值从 1 更改为 2(例程开始后的第 10 个字节)。如果按原样运行,只会导致你的机器崩溃,或者模块无法加载,最坏的情况下会留下一个糟糕的痕迹。如果你打算尝试一下,请仅在**测试机器**上运行,因为内核损坏/崩溃可能导致某些系统上的数据丢失。
root@debian:/home/user/kp# insmod ro.ko Killed root@debian:/home/user/kp# dmesg [....] [ 122.661372] Dumping memory at ffffffffc05b7000: [ 122.661373] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 122.661378] 0000 0f 1f 44 00 00 55 48 89 e5 b8 01 00 00 00 5d c3 [ 122.661393] BUG: unable to handle kernel paging request at ffffffffc05b700a [ 122.661414] IP: [<ffffffffc02fb025>] kp_init+0x25/0x1000 [ro] [ 122.661430] PGD 4ce0a067 [ 122.661436] PUD 4ce0c067 [ 122.661461] PMD 36f81067 [ 122.661465] PTE 7b63c161 [ 122.661476] Oops: 0003 [#1] SMP [ 122.661484] Modules linked in: ro(O+) vboxsf(O) vboxvideo(O) vboxguest(O) joydev ppdev snd_intel8x0 snd_ac97_codec ac97_bus snd_pcm sg snd_timer snd soundcore intel_powerclamp crct10dif_pclmul crc32_pclmul ghash_clmulni_intel intel_rapl_perf pcspkr evdev serio_raw ttm drm_kms_helper drm battery parport_pc parport video ac button acpi_cpufreq ip_tables x_tables autofs4 ext4 crc16 jbd2 crc32c_generic fscrypto ecb mbcache hid_generic usbhid hid sr_mod cdrom sd_mod ata_generic crc32c_intel aesni_intel aes_x86_64 glue_helper lrw gf128mul ablk_helper cryptd psmouse ata_piix ahci libahci ohci_pci ehci_pci ohci_hcd ehci_hcd usbcore usb_common i2c_piix4 e1000 libata scsi_mod [ 122.661661] CPU: 0 PID: 3691 Comm: insmod Tainted: G W O 4.9.0-3-amd64 #1 Debian 4.9.30-2+deb9u2 [ 122.661682] Hardware name: innotek GmbH VirtualBox/VirtualBox, BIOS VirtualBox 12/01/2006 [ 122.661699] task: ffff9dfe7b2f8040 task.stack: ffffc0cac1458000 [ 122.661712] RIP: 0010:[<ffffffffc02fb025>] [<ffffffffc02fb025>] kp_init+0x25/0x1000 [ro] [ 122.661731] RSP: 0018:ffffc0cac145bcc8 EFLAGS: 00010282 [ 122.661742] RAX: ffffffffc05b700a RBX: 0000000000000000 RCX: 0000000000000006 [ 122.661757] RDX: 0000000000000000 RSI: 0000000000000297 RDI: ffff9dfe7fc0de20 [ 122.661772] RBP: ffffc0cac145bcd0 R08: 0000000000000001 R09: 0000000000008cc4 [ 122.661787] R10: ffffffffacb13540 R11: 0000000000000001 R12: ffff9dfe369c2760 [ 122.661810] R13: ffff9dfe7a446080 R14: ffffffffc05b9000 R15: ffffffffc05b9050 [ 122.661826] FS: 00007fadb6903700(0000) GS:ffff9dfe7fc00000(0000) knlGS:0000000000000000 [ 122.661843] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033 [ 122.661855] CR2: ffffffffc05b700a CR3: 000000007cb7c000 CR4: 00000000000406f0 [ 122.661873] DR0: 0000000000000000 DR1: 0000000000000000 DR2: 0000000000000000 [ 122.661888] DR3: 0000000000000000 DR6: 00000000fffe0ff0 DR7: 0000000000000400 [ 122.661902] Stack: [ 122.661908] ffffffffc05b700a ffffffffc02fb000 ffffffffabe0218b ffff9dfe7ffeb5c0 [ 122.661926] 000000000000001f 3dd28ef37e71a3ae 0000000000000286 ffff9dfe79b99400 [ 122.661945] ffffffffabfc3b5d 3dd28ef37e71a3ae ffffffffc05b9000 3dd28ef37e71a3ae [ 122.661963] Call Trace: [ 122.661971] [<ffffffffc05b700a>] ? target+0xa/0x10 [ro] [ 122.661983] [<ffffffffc02fb000>] ? 0xffffffffc02fb000 [ 122.662418] [<ffffffffabe0218b>] ? do_one_initcall+0x4b/0x180 [ 122.662837] [<ffffffffabfc3b5d>] ? __vunmap+0x6d/0xc0 [ 122.663240] [<ffffffffabf7a58c>] ? do_init_module+0x5b/0x1ed [ 122.663650] [<ffffffffabeff8d2>] ? load_module+0x2602/0x2a50 [ 122.664061] [<ffffffffabefc030>] ? __symbol_put+0x60/0x60 [ 122.664468] [<ffffffffabefff66>] ? SYSC_finit_module+0xc6/0xf0 [ 122.664903] [<ffffffffac40627b>] ? system_call_fast_compare_end+0xc/0x9b [ 122.665377] Code: <c6> 00 02 b8 00 00 00 00 c9 c3 00 00 00 00 00 00 00 00 00 00 00 00 [ 122.665789] RIP [<ffffffffc02fb025>] kp_init+0x25/0x1000 [ro] [ 122.666144] RSP <ffffc0cac145bcc8> [ 122.666485] CR2: ffffffffc05b700a [ 122.666815] fbcon_switch: detected unhandled fb_set_par error, error code -16 [ 122.667695] fbcon_switch: detected unhandled fb_set_par error, error code -16 [ 122.668571] ---[ end trace 4d41b1ec10988ac5 ]---
正如你在上述内核跟踪中看到的,错误是在发出写页面请求时触发的,地址为 **0xffffffffc05b700a**(0xffffffffc05b7000 + 0xa 的结果),这正是我们的 kp_init 尝试替换返回值的确切位置。
现在需要做的是禁用这些内存页的写保护,应用更改,然后再次将内存重置为之前的访问标志。通过深入研究 Linux 内核内存子系统,你最终会发现有一组例程可以帮助你做到这一点,而无需直接操作页面或原始细节。这些例程是:
- set_memory_rw,它关闭内存写保护机制。
- set_memory_ro,它再次保护内存免受不必要的写入。
不幸的是,这些过程并未导出供内核模块使用。
从内核模块使用内核私有过程
那么,“未导出的过程”是内核黑客的障碍吗?当然不是 :-)
要完成工作,我们只需要知道该过程位于内核的哪个部分;拥有可见的源代码(开源)使我们能够了解过程的配置文件(返回值和所需的参数)。如果我们能够获得过程的地址,我们就完成了。一如既往,有多种方法可以绕过这个障碍,我在这里只展示两种执行此操作的方法,具体取决于内核配置。
如果你有一个编译时包含 CONFIG_KALLSYMS 符号的内核,你只需要使用这个内核子系统来获取过程的指针。这个 API 非常简单易用,只需要一个字符串,包含要调用的过程的名称。
unsigned long rw = kallsyms_lookup_name("set_memory_rw");
此操作的结果将是该过程所在的地址。最后一步,我们只需要将其分配给一个过程指针即可完成工作。
int (* smem_rw) (unsigned long addr, int pages); int (* smem_ro) (unsigned long addr, int pages); static int __init kp_init(void) { unsigned long rw = kallsyms_lookup_name("set_memory_rw"); unsigned long ro = kallsyms_lookup_name("set_memory_ro"); if(!rw || !ro) { printk(KERN_INFO "Cannot resolve set_memory_* procedures!\n"); return 0; } smem_rw = (void *)rw; smem_ro = (void *)ro; [...] }
在内核编译时 CONFIG_KALLSYMS **未**设置的情况下,这种情况总是可能发生;在这种情况下,解决方案会稍微复杂一些,但仍然可行。内核中共享的所有过程和变量的符号实际上已经存在于你的机器上,任何人都可以读取(甚至是非 root 用户)。位于文件 **/boot/System.map-***(其中最后一部分是目标内核的版本)中,列出了所有重要的内核符号。
root@debian:/home/user/kp# cat /boot/System.map-4.9.0-3-amd64 0000000000000000 D __per_cpu_start 0000000000000000 D irq_stack_union 0000000000000000 A xen_irq_disable_direct_reloc 0000000000000000 A xen_save_fl_direct_reloc 00000000000001c5 A kexec_control_code_size 0000000000004000 d exception_stacks 0000000000009000 D gdt_page 000000000000a000 D espfix_waddr 000000000000a008 D espfix_stack 000000000000a040 D cpu_info 000000000000a140 D cpu_llc_shared_map 000000000000a180 D cpu_core_map ... ffffffff810635c0 T _set_memory_wc ffffffff81063650 T set_memory_wc ffffffff81063710 T _set_memory_wt ffffffff81063750 T set_memory_wt ffffffff81063810 T _set_memory_wb ffffffff81063850 T set_memory_ro ffffffff81063890 T set_memory_rw ffffffff810638d0 T set_memory_np ffffffff81063910 T set_memory_4k ffffffff81063950 T set_pages_ro ffffffff810639c0 T set_pages_rw
这里需要做的就是通过使用一个简单的 misc 设备或 sysfs 条目将这些信息从用户空间传递到内核空间,以共享地址和名称。
一旦你在运行的内核中定位了必要的过程,你所需要做的就是使用正确的参数调用它们,工作就完成了;你的页面现在将可写,并且可以切换回只读模式。工作模块可以在源文件 **simple-patch-kallsyms.c** 中找到,它将被构建为 **spk.ko** 模块。它将定位我们要应用补丁的内存位置,将该页面变为可写,应用简单的补丁,然后将权限恢复到其原始值。修改的跟踪将在内核日志文件中找到,其格式如下:
[ 35.550397] Target procedure at ffffffffc058202d returned 1 [ 35.550398] Dumping memory at ffffffffc058202d: [ 35.550399] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 35.550404] 0000 0f 1f 44 00 00 55 48 89 e5 b8 01 00 00 00 5d c3 [ 35.550421] Target procedure at ffffffffc058202d returned 2 [ 35.550421] Dumping memory at ffffffffc058202d: [ 35.550422] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 35.550426] 0000 0f 1f 44 00 00 55 48 89 e5 b8 02 00 00 00 5d c3
请注意,在实际修补之前,位于 ffffffffc058202d 的 target 例程返回 1,内存转储显示如下:在例程的常规序言(指令 55 48 89 e5)之后,CPU 将值 1 移入 EAX 寄存器(指令 b8 01 00 00 00),然后返回调用者(c3)。在第二个内存转储中,你可以看到修改已应用,同一个过程现在返回不同的值。
总结: 我们现在克服了从内核模块内部无法使用私有过程的限制。之后,spk 内核模块向我们展示了修改内核的“文本”部分是可能的,并且不再产生错误。但仅仅这样修改是不够的,因为它非常简单,并且依赖于对过程如何被翻译成汇编代码的深入了解。需要做的是用一个具有复杂组织且会调用其他例程的函数替换一组功能:基本上是用一个改进的版本替换一个过程。
用自定义过程修补现有过程。
修补过程与我们已经看到的内容没有什么不同或过于复杂;想法是,对要修补的过程的旧引用仍然保留在原处,但过程的主体被更改以将执行路径路由到其他地方。这意味着当你调用修补过的过程时,你最终会执行新的过程。但你如何让它像这样工作呢?
这现在与内核运行的架构有关,因为你必须在低级别修改代码。总的想法是在过程开始时注入一个**跳转**(技术上称为陷阱),它指向你的新补丁所在的位置。
我将使用的跳转操作是 E9 指令,它需要一个额外的 32 位立即值(要跳转的相对偏移量)。该值本身是一个有符号双字,这意味着你可以向前跳转 2,147,483,647 字节,向后跳转 -2,147,483,648 字节;这对于当前的 Linux 内核内存设置应该绰绰有余了。
注入这样的跳转非常容易:首先,我们需要计算我们的新补丁和旧过程之间的空间有多大,然后我们需要将指令放在适当的位置,将立即值放在其后。**procedure-patch.c** 文件展示了这一技巧是如何完成的。
/* Size of the jmp opcode with its displacement value. */ #define KP_X86_64_JMP_SIZE 5 /* * Procedure which will patch the original one: */ int do_something_new(void) { printk(KERN_INFO "do_something_new() invoked\n"); return 1; } /* * Original procedure: */ int do_something(void) { printk(KERN_INFO "do_something() invoked\n"); return 0; } /* * Entry/exit points: */ static int __init kp_init(void) { int32_t new = (int32_t)((unsigned long)do_something_new - (unsigned long)do_something); char * ptr = (char *)do_something; /* Initialize kp subsystem. */ if(kp_resolve_procedures()) { return -EFAULT; } /* Original call... */ kp_print(do_something, 16); do_something(); /* ---- HERE MEMORY is WRITABLE --------------------------------------------- */ kp_memrw(do_something, KP_X86_64_JMP_SIZE); /* Jump to... */ ptr[0] = 0xe9; /* ... here. */ memcpy(ptr + 1, &new, sizeof(int32_t)); kp_memro(do_something, KP_X86_64_JMP_SIZE); /* -------------------------------------------------------------------------- */ kp_print(do_something, 16); do_something(); return 0; }
解析过程地址、将内存切换为可写然后切换回只读现在已经移到了一个实用代码表(**utils.c**)中。通过简单地将其转换为字节指针(char *),正如你所见,E9 跳转被注入为第一个操作,而立即值则通过使用 memcpy 过程恰好放在其后。当模块加载时,我们最终会得到类似这样的结果:
[ 48.852597] Dumping memory at ffffffffc04c7049: [ 48.852598] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 48.852603] 0000 0f 1f 44 00 00 55 48 89 e5 48 c7 c7 46 80 4c c0 [ 48.852608] do_something() invoked [ 48.852621] Dumping memory at ffffffffc04c7049: [ 48.852621] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 48.852626] 0000 e9 e4 ff ff ff 55 48 89 e5 48 c7 c7 46 80 4c c0 [ 48.852631] do_something_new() invoked
第一次调用 do_something 例程会导致该例程的正常执行,而在修补之后,执行路径被重定向到新的函数。以粗体显示的你可以看到新注入的跳转以及到新入口点的偏移量。
此时,我预计有些人会注意到,正如我之前所说,过程序言是由指令 55 48 89 e5 标识的。那么:内核日志中始终显示的前 5 个字节是什么?嗯,模式 0f 1f 44 00 00 基本上是一个 5 字节长的 NOP(什么都不做),这正是我们执行跳转所需要的。这些指令之所以存在,是因为我**太懒**,不想重新编译整个内核,而使用的是标准内核进行测试,这意味着实际上(即使我没有使用它)我的内核支持 **kpatch**。
所以,让我们看看如果我将补丁移动到**没有** ftrace 机制包含在内核中的位置会发生什么。通过修改补丁以跳过前 5 个字节,我将最终将修改直接应用于过程序言。
int32_t new = (int32_t)((unsigned long)do_something_new - ((unsigned long)do_something + 5)); [...] /* Jump to... */ ptr[5] = 0xe9; /* ... here. */ memcpy(ptr + 1 + 5, &new, sizeof(int32_t));
这将导致以下日志:
[ 53.351702] Dumping memory at ffffffffc03e1049: [ 53.351703] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 53.351710] 0000 0f 1f 44 00 00 55 48 89 e5 48 c7 c7 46 20 3e c0 [ 53.351716] do_something() invoked [ 53.351729] Dumping memory at ffffffffc03e1049: [ 53.351729] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 53.351735] 0000 0f 1f 44 00 00 e9 df ff ff ff c7 c7 46 20 3e c0 [ 53.351741] do_something_new() invoked
此操作的缺点是,修补过的过程序言**丢失**了(序言和后续指令的一部分,55 48 89 e5 48),并且无法恢复到其原始值。这个问题当然可以解决,即在应用补丁之前,我们将这些数据保存在另一个与修补地址绑定的位置(映射如 ffffffffc03e1049 --> 55 48 89 e5 48)。这样,如果我们想回滚修改,我们只需要将这些字节写回它们的位置。
总结: 我们现在不仅能够写入内核代码,而且实际上可以注入一个陷阱,将执行流移动到内存中的任意位置。无论 ftrace 是否就位,都可以做到这一点;新机制与其无关,并且完全由我们控制。此操作使得修补过的过程不再可用,但通过在应用补丁之前保存被覆盖的指令,我们可以轻松回滚修改并恢复内存到其原始状态。
以透明的方式执行修补。
问题解决了,结束了吗?一点也没有,我们才刚刚开始。接下来的问题实际上更棘手,更难处理,并且与要修补的内核**不知道**它将被修补的事实有关。
内核中会发生很多事件:中断可能由硬件调用以响应请求,内核线程会被抢占,用户空间的系统调用需要被服务。在这种复杂的情况下,你如何确定你没有在修补过程的同时,另一个线程正在尝试运行该函数?你能始终假设修补程序不会在中途被打断吗(例如,在写到一半时)?如果发生此类事件,你的内核很可能会崩溃,最坏的情况下。更糟糕的是,你可能会与代码失去同步,并且你在执行非预期的合法数据操作,这同样会导致随机崩溃,这些崩溃要困难得多,难以调试。
你需要一个同步机制,允许阻止 CPU 执行该代码,直到它被修补。你显然不能使用任何自旋锁、信号量或原子操作,因为这将意味着修改内核代码以支持这样的东西。
if (spin_lock(&something_lock)) { do_something(); }
这意味着内核期望被修补,而我们说它不会。
我决定采用的方法是将机器的在线 CPU 设置为**受控死锁**。
这种原始方法允许我们控制**所有**计算核心,从而在有限的时间内阻止所有操作。这个锁不应该很长;在我初步测试期间,我锁定了我的 PC 5 秒钟(对于内核来说很长时间),Linux 调度程序检测到了超时的计时并开始抱怨(在进入 RT 限制时)。尽管锁定的时间很长,但唯一可见的问题是 PC 完全冻结,并在一段时间后重新启动。在测试期间,该机器没有出现其他问题。尽管初始运行成功,但没有必要锁定这么长时间,较短的时间使内核的离线时间几乎无法检测到。
与此透明修补相关的代码可以在 **atomic-procedure-patch.c** 源文件中找到。
struct my_task { struct task_struct * t; unsigned int cpu; }; /* Number of detected CPUs. */ int nof_cpus = 0; /* IDs of the CPUs. */ int cpu_ids[KP_MAX_CPUS] = {-1}; /* State of CPUs; * no locking required since each CPU just touch it's own byte. */ int cpu_s[KP_MAX_CPUS] = {0}; /* 0 - unlocked, 1 - locked. */ /* Kernel threads info. */ struct my_task cpu_tasks[KP_MAX_CPUS] = {{0}}; /* In case of error kill anyone. */ int kp_die = 0; /* The elected patching CPU. */ int kp_patcher = -1; /* Switching this to 1 will triggers our threads. */ int kp_proceed = 0;
首先,我们组织一些将在初始化步骤中填写的全局变量。在线 CPU 的数量、它们的 ID、它们的状态以及其他一些附加标志。检测到的 CPU 中的一个将被指定为修补程序,因此将执行内存修改的操作(默认情况下是第一个检测到的 CPU)。
static int __init kp_init(void) { int i; int cpu; /* Initialize kp subsystem. */ if(kp_resolve_procedures()) { return -EFAULT; } /* Original call... */ kp_print(do_something, 16); do_something(); for_each_cpu(cpu, cpu_online_mask) { if(nof_cpus > KP_MAX_CPUS) { printk(KERN_ERR "Too much CPUs to handle!\n"); return -1; } cpu_ids[nof_cpus] = cpu; nof_cpus++; printk(KERN_INFO "CPU %u is online...\n", cpu); } /* The first one is our patcher. */ kp_patcher = cpu_ids[0]; for(i = 0; i < nof_cpus; i++) { cpu_tasks[i].cpu = cpu_ids[i]; cpu_tasks[i].t = kthread_create( kp_thread, &cpu_tasks[i], "kp%d", cpu_ids[i]); if(!cpu_tasks[i].t) { printk(KERN_ERR "Error while starting %d.\n", cpu_ids[i]); /* Kill threads which aready started. */ kp_die = 1; return -1; } /* Bing the task on that CPU....*/ kthread_bind(cpu_tasks[i].t, cpu_tasks[i].cpu); wake_up_process(cpu_tasks[i].t); } /* Wait a "little". */ schedule(); kp_proceed = 1; return 0; }
初始化过程现在一次性填充了几乎所有全局变量:首先,它检测 CPU 并保存它们的 ID,然后从它们中选出一个修补程序(默认是第一个)。之后,它为每个 CPU 启动一个线程,该线程将在该核心上运行(**kthread_bind** 将完成这项工作)。最后,它安排自己等待一小段时间,然后命令线程继续执行同步和修补步骤。
int kp_thread(void *arg) { int i; int p; struct my_task * t = (struct my_task *)arg; int32_t new = (int32_t)((unsigned long)do_something_new - (unsigned long)do_something); char * ptr = (char *)do_something; printk(KERN_INFO "Thread of CPU %u running...\n", t->cpu); wait: /* Wait until someone(kp_init) told us to proceed with the job. */ while(!kp_proceed) { if(kp_die) { /* Kill this thread... */ goto out; } /* Be scheduled. */ schedule(); } if(kp_patcher < 0) { /* No patching CPU elected; this is CRITICAL. goto wait; */ printk(KERN_CRIT "Patching CPU %d elected!!!\n", kp_patcher); goto out; } /* ---- CPU LOCKED ---------------------------------------------------------- */ get_cpu(); cpu_s[t->cpu] = 1; do { p = 1; /* The patching process is up to us! */ if(t->cpu == kp_patcher) { for(i = 0; i < nof_cpus; i++) { p &= cpu_s[i]; } /* If 1, everyone is locked. */ if(p) { kp_memrw(do_something, 5); ptr[0] = 0xe9; memcpy(ptr + 1, &new, sizeof(int32_t)); kp_memro(do_something, 5); kp_proceed = 0; } } if(!kp_proceed) { break; } } while(1); cpu_s[t->cpu] = 0; put_cpu(); /* -------------------------------------------------------------------------- */ /* Back to the waiting state where you wait for another patch time. goto wait; */ /* Every CPU will call do_something now.*/ do_something(); out: printk(KERN_INFO "Thread of CPU %u died...\n", t->cpu); if(t->cpu == kp_patcher) { kp_print(do_something, 16); } return 0; }
最后,每个线程(在不同的 CPU 上)将运行 kp_thread 例程(参见上面的代码)。这个逻辑只是让线程等待有人命令它继续执行操作(使用 kp_proceed 变量);如果在等待期间出现问题,将向所有线程发出一个 die 命令,它们会立即自行终止。当发出 proceed 命令时,最后一个检查确保已设置了修补程序,然后线程继续锁定 CPU。当 CPU 被锁定后,内核中的并发性会增加(大量工作,少数核心处理它们),这确保所有 CPU 最终都会一个接一个地快速被锁定。
进入锁定状态后,线程将一个专用字节切换为 1,以表明 CPU 已被锁定,只有修补程序会遍历数组以确保没有人正在运行内核代码。如果确实如此,则应用补丁,修补程序线程向所有其他线程发出信号以释放核心,将其还给内核。
逻辑被插入到一个 do while 循环中,以便在等待时间过长的情况下(主要是因为 CPU 未被锁定)添加一个适当的条件。目前我插入了一个“1”,表示总是,但这并不是处理这种极端事件的正确方法;必须在此处插入超时机制,以避免无限锁定循环。不幸的是,使用 getnstimeofday 等过程在这里没有用,因为所有 CPU 都被锁定了,无法处理来自定时器和高精度定时器的中断。
最后,每个线程运行我们的 do_something 例程,以查看发生了什么以及补丁是否已应用,结果如下日志:
[ 349.718009] Dumping memory at ffffffffc04c30af: [ 349.718010] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 349.718015] 0000 0f 1f 44 00 00 55 48 89 e5 48 c7 c7 fe 40 4c c0 [ 349.718020] do_something() invoked [ 349.718021] CPU 0 is online... [ 349.718561] Thread of CPU 0 running... [ 349.718570] do_something_new() invoked [ 349.718571] Thread of CPU 0 died... [ 349.718571] Dumping memory at ffffffffc04c30af: [ 349.718572] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 349.718577] 0000 e9 e4 ff ff ff 55 48 89 e5 48 c7 c7 fe 40 4c c0
对于多核虚拟机,我们有:
[ 39.865623] Dumping memory at ffffffffc075d094: [ 39.865624] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 39.865630] 0000 0f 1f 44 00 00 55 48 89 e5 48 c7 c7 fe e0 75 c0 [ 39.865635] do_something() invoked [ 39.865636] CPU 0 is online... [ 39.865636] CPU 1 is online... [ 39.865637] CPU 2 is online... [ 39.865637] CPU 3 is online... [ 39.865854] Thread of CPU 0 running... [ 39.865999] Thread of CPU 1 running... [ 39.866116] Thread of CPU 2 running... [ 39.866488] Thread of CPU 3 running... [ 39.866563] do_something_new() invoked [ 39.866563] do_something_new() invoked [ 39.866564] do_something_new() invoked [ 39.866564] do_something_new() invoked [ 39.866565] Thread of CPU 2 died... [ 39.866565] Thread of CPU 1 died... [ 39.866566] Thread of CPU 3 died... [ 39.866566] Thread of CPU 0 died... [ 39.866567] Dumping memory at ffffffffc075d094: [ 39.866567] 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 [ 39.866588] 0000 e9 e4 ff ff ff 55 48 89 e5 48 c7 c7 fe e0 75 c0
当然,这段代码是初步的,如果你打算在更企业级的系统中使用它,还可以进一步完善,但它仍然足够有效以完成工作。
总结: 克服了所有初步问题后,我们最终得到了一个允许我们将一个过程修补为另一个过程的机制,通过注入一个重定向执行流的陷阱。为了避免在 SMP 架构和抢占中出现问题,修补操作现在对所有 CPU 都是原子的:在快速修补过程中,没有人会运行该过程代码,或一般情况下运行内核代码。这将确保在内存被写入新值时不会发生崩溃。
历史
2017年7月6日 - 文章首次发布
2017年7月11日 - 添加了源代码直接下载链接