引言

千呼万唤始出来,从今天起,《UCloud 技术大观园》系列正式开张,撒花╭(●`∀´●)╯!

UCloud 生而为云,一直专注在云计算的泥潭里摸爬滚打,踩过数不清的坑,写过数不清的 BUG。所幸,在不断的试错中,也锤炼出一些能在江湖傍身的大杀器。这些经过千锤百炼的大杀器和宝贵的踩坑经验,一起成为今天 UCloud 的核心科技。

现在,我们将在《UCloud 技术大观园》系列里,把这些核心科技全部开放出来,毫无保留,逐一为大家讲解,哪些坑是我们已经踩过的,引以为诫,哪些是优质的技术实践经验,值得借鉴。

我们始终相信——开放,才是技术的本心。

本篇作为《UCloud 技术大观园》系列的开篇,聚焦 UCloud 应用程序热补丁技术,将介绍一种简单实用的应用程序热补丁技术。不少场景下,用该方法编写几行代码即可免重启修复应用程序 BUG!

那,我们开始吧~

前言

应用程序,作为核心业务组件,每天都面临着严峻的高可用挑战,每次重启,都会导致服务受损。尤其是单点的虚拟化组件和有状态的应用程序,一旦重启,影响更甚。

热补丁,一种在程序运行时动态修复内存中代码 bug 的技术,能避免系统重启导致的业务中断、有效保证操作系统的可用性。

经过大量的研究和实践,UCloud 从 0 到 1,自研了一套应用程序热补丁技术。千锤百炼出真金,经过内部数十万台次修复验证,UCloud 应用程序热补丁技术已自成体系,成为 UCloud 核心黑科技之一。

原理

一般来说,应用程序热补丁的流程是,首先通过编译器将热补丁源码制作成可加载的动态链接库,然后通过加载程序将热补丁加载到目标进程的地址空间,最后在进行一致性模型检查确认安全的情况下,把原始代码替换成新的代码,完成在线修复的过程。

下面我们分别介绍热补丁本身和热补丁加载程序,热补丁本身是因 patch 而异的,加载程序是通用的。

假设我们有热补丁加载程序 Loader、目标进程 T、热补丁 patch.so,目标程序的 func 函数替换为 func_v2。

热补丁

  1. 编写热补丁源码,编译成动态链接库的格式的热补丁 patch.so,patch.so 中包含 func 和 func_v2 的信息。

  2. 热补丁 patch.so 在被加载程序 Loader 加载到目标进程 T 地址空间的过程中,通过 dlsym 调用找到 func 的地址,并将 func 的入口指令改为可写,同时改变为跳转到 func_v2。

  3. 至此,所有对 func 的调用都会被重定向到 func_v2,func_v2 执行完毕后返回,程序继续运行。

  4. 如图所示:
    应用程序热补丁(一):如何用几行代码打造应用程序热补丁 - 知乎 - 图1

热补丁加载程序

  1. 加载程序 Loader 找到目标进程 T 的 dlopen 函数入口地址。
  2. Loader 通过 ptrace 依附到目标进程 T,Loader 将热补丁的名字放入放入目标进程 T 的堆栈,将 IP 寄存器设置为 dlopen 函数的地址。
  3. Loader 使目标进程 T 继续运行。因为 IP 寄存器已经设置为 dlopen 函数的入口,目标进程 T 会调用 dlopen 把热补丁加载到 T 的地址空间中。
  4. 如图所示:

应用程序热补丁(一):如何用几行代码打造应用程序热补丁 - 知乎 - 图2

了解原理之后,我们一步步实现一种简单的基于 x86_64 的热补丁。

(对于需要制作热补丁的同学,只需自己编写 patch.so,而 Loader 是通用的。patch.so 编写可以参考下面的例子,往往只需几行代码做相应替换。)

实现

热补丁

  1. 目标进程 T 执行 dlopen 的过程中,通过预先在热补丁(动态链接库)中写入的 constructor 函数,在加载过程中函数 func_v1 替换函数 func。

    1. static void __attribute__((constructor)) init(void)
    2. {
    3. int numpages;
    4. void *old_func_entry, *new_func_entry;
    5. old_func_entry = dlsym(NULL, "func");
    6. new_func_entry = dlsym(NULL, "func_v2");
    7. #define PAGE_SHIFT 12
    8. #define PAGE_SIZE (1UL << PAGE_SHIFT)
    9. #define PAGE_MASK (~(PAGE_SIZE-1))
    10. numpages = (PAGE_SIZE - (old_func_entry & ~PAGE_MASK) >= size) ? 1 : 2;
    11. mprotect((void *)(old_func_entry & PAGE_MASK), numpages * PAGE_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC);
    12. /*
    13. * Translate the following instructions
    14. *
    15. * mov $new_func_entry, %rax
    16. * jmp %rax
    17. *
    18. * into machine code
    19. *
    20. * 48 b8 xx xx xx xx xx xx xx xx
    21. * ff e0
    22. */
    23. memset(old_func_entry, 0x48, 1);
    24. memset(old_func_entry + 1, 0xb8, 1);
    25. memcpy(old_func_entry + 2, &new_func_entry, 8);
    26. memset(old_func_entry + 10, 0xff, 1);
    27. memset(old_func_entry + 11, 0xe0, 1);
    28. }

热补丁加载程序

  1. Loader 得到目标进程 T 地址空间中 dlopen 入口地址
    1.1. dlopen 函数有 libdl 提供,并不是所有的程序都加载 libdl,幸运的是,libc 中提供了同样功能的函数 libc_dlopen_mode,并且接受的参数和 dlopen 相同。除非特殊情况,所有程序都会加载 libc。所以我们需要找到 libc_dlopen_mode 在目标进程 T 地址空间中的函数入口地址。
    1.2. 我们知道,不同进程中 libc 会被加载到不同的基地址,但是 libc 中函数的地址相对基地址的偏移是不变的。
    1.3. 通过 Loader 和目标进程 T 的 / proc/pid/maps,我们可以得到 libc 在 Loader 和目标进程 T 中加载的基地址。通过 Loader 运行 dlsym,我们可以得到 Loader 中的 libc_dlopen_mode 的地址。这样我们可以得到目标进程 T 中 libc_dlopen_mode 的地址(Loader_dlopen - Loader_libc + T_libc)。

    1. / Take a hint and find start addr in /proc/pid/maps /
    2. static unsigned long find_lib_base(pid_t pid, char *so_hint)
    3. {
    4. FILE *fp;
    5. char maps[4096], mapbuf[4096], perms[32], libpath[4096];
    6. char *libname;
    7. unsigned long start, end, file_offset, inode, dev_major, dev_minor;
    8. sprintf(maps, "/proc/%d/maps", pid);
    9. fp = fopen(maps, "rb");
    10. if (!fp) {
    11. fprintf(stderr, "Failed to open %s: %s\n", maps, strerror(errno));
    12. return 0;
    13. }
    14. while (fgets(mapbuf, sizeof(mapbuf), fp)) {
    15. sscanf(mapbuf, "%lx-%lx %s %lx %lx:%lx %lu %s", &start,
    16. &end, perms, &file_offset, &dev_major, &dev_minor, &inode, libpath);
    17. libname = strrchr(libpath, '/');
    18. if (libname)
    19. libname++;
    20. else
    21. continue;
    22. if (!strncmp(perms, "r-xp", 4) && strstr(libname, so_hint)) {
    23. fclose(fp);
    24. return start;
    25. }
    26. }
    27. fclose(fp); return 0;
    28. }
    29. loader_libc = find_lib_base(getpid(), libc-c”);
    30. T_libc = find_lib_base(T_pid, libc-“);
    31. Loader_dlopen = (unsigned long)dlsym(NULL, __libc_dlopen_mode”);
    32. T_dlopen = T_libc + (Loader_dlopen - Loader_libc);
  1. Loader 对目标进程 T 使用 ptrace attach,并保存 T 此时的寄存器信息。

    1. static int ptrace_attach(pid_t pid)
    2. {
    3. int status;
    4. if (ptrace(PTRACE_ATTACH, pid, NULL, NULL)) {
    5. fprintf(stderr, "Failed to ptrace_attach: %s\n", strerror(errno));
    6. return 1;
    7. }
    8. if (waitpid(pid, &status, __WALL) < 0) {
    9. fprintf(stderr, "Failed to wait for PID %d, %s\n", pid, strerror(errno));
    10. return 1;
    11. }
    12. return 0;
    13. }
    14. static int ptrace_call(pid_t pid, unsigned long func_addr, unsigned long arg1, unsigned long arg2, unsigned long *func_ret)
    15. {
    16. memset(&saved_regs, 0, sizeof(struct user_regs_struct));
    17. ptrace_getregs(pid, &saved_regs);
    18. }
  1. 将目标进程 T 的 %RIP 指向 dlopen,热补丁的名字的字符串放入堆栈,字符串的地址写入 %rdi,RTLD_NOW 的值写入 %rsi 作为 dlopen 的 flag。同时把 dlopen 返回地址设置为非法地址 0x0(把 0x0 压入栈中),这样 Loader 可以捕获目标进程 T 产生的 SIGSEGV 信号进而重新获得 T 的控制权。
    1. unsigned long invalid = 0x0;
    2. regs.rsp -= sizeof(invalid);
    3. ptrace_poketext(pid, regs.rsp, ((void *)&invalid), sizeof(invalid));
    4. ptrace_poketext(pid, regs.rsp + 512, filename, strlen(filename) + 1);
    5. regs.rip = dlopen_addr;
    6. regs.rdi = regs.rsp + 512;
    7. regs.rsi = RTLD_NOW;
    8. ptrace_setregs(pid, &regs);
  1. Loader 使目标进程 T 继续运行。当 T 执行完 dlopen 之后,T 产生的 SIGSEGV 信号被 Loader 捕获,Loader 重新获得 T 进程的控制权。
  1. static int ptrace_cont(pid_t pid)
  2. {int status;
  3. if (ptrace(PTRACE_CONT, pid, NULL, 0)) {
  4. fprintf(stderr, "Failed to ptrace_cont: %s\n", strerror(errno));return 1;
  5. }
  6. if (waitpid(pid, &status, __WALL) < 0) {fprintf(stderr, "Failed to wait for PID %d, %s\n", pid, strerror(errno));
  7. return 1;}
  8. return 0;}
  1. Loader 通过读取目标进程 T 此时的 %rax 寄存器得到 dlopen 的返回值,恢复 T 最开始的执行状态,最后释放对 T 的控制
  1. ptrace_getregs(pid, &regs);
  2. dlopen_ret = regs.rax;
  3. ptrace_setregs(pid, &saved_regs);
  4. ptrace_detach(pid);

至此对目标进程 T 的热补丁就完成了。下面我们看一个例子。

验证

假设我们运行 target 程序,每隔一秒打印 Hello 一次:

target 程序由 target 本身和 libold.so 组成,分别代码如下:

  1. /* target.c */
  2. #include <unistd.h>
  3. #include "old.h"
  4. int main() {
  5. for (;;) {
  6. print();
  7. sleep(1);
  8. }
  9. }
  10. /* old.c */
  11. #include <stdio.h>
  12. void print(void)
  13. {
  14. printf("Hello\n");
  15. }

编译

  1. gcc -fPIC --shared old.c -o libold.so
  2. gcc target.c ./libold.so -o target

我们想要修改 print 函数,变成打印 “Goodbye”。我们需要编写热补丁 new.c,并添加新函数和 constructor:

  1. /* new.c */
  2. #include <stdio.h>
  3. #include <string.h>
  4. #include <sys/mman.h>
  5. #include <dlfcn.h>
  6. print_v2(void)
  7. {
  8. printf("Goodbye\n");
  9. }
  10. static void __attribute__((constructor)) init(void)
  11. {
  12. int numpages;
  13. void *old_func_entry, *new_func_entry;
  14. old_func_entry = dlsym(NULL, print);
  15. new_func_entry = dlsym(NULL, print_v2);
  16. #define PAGE_SHIFT 12
  17. #define PAGE_SIZE (1UL << PAGE_SHIFT)
  18. #define PAGE_MASK (~(PAGE_SIZE-1))
  19. numpages = (PAGE_SIZE - (old_func_entry & ~PAGE_MASK) >= size) ? 1 : 2;
  20. mprotect((void *)(old_func_entry & PAGE_MASK), numpages * PAGE_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC);
  21. memset(old_func_entry, 0x48, 1);
  22. memset(old_func_entry + 1, 0xb8, 1);
  23. memcpy(old_func_entry + 2, &new_func_entry, 8);
  24. memset(old_func_entry + 10, 0xff, 1);
  25. memset(old_func_entry + 11, 0xe0, 1);
  26. }

编译:

  1. gcc -fPIC --shared new.c -ldl -o libnew.so

然后通过加载程序对 target 进程打入热补丁 libnew.so,最后我们对 target 程序打入这个热补丁,观察变化:

  1. # ./target
  2. Hello
  3. Hello
  4. Goodbye
  5. Goodbye

我们发现热补丁确实改变了 print 函数,最后通过 gdb 进一步确认,可以看出 print 函数的入口被修改成 48 b8 dc b6 15 a9 c1 7f 00 00 ff e0,与我们的预期相符:

  1. (gdb) disas /r print
  2. Dump of assembler code for function print:
  3. 0x00007fc1a98f456c <+0>: 48 b8 dc b6 15 a9 c1 7f 00 00 movabs $0x7fc1a915b6dc,%rax
  4. 0x00007fc1a98f4576 <+10>: ff e0 jmpq *%rax # 这里print在入口处跳转到0x7fc1a915b6dc这个地址
  5. (gdb) info symbol 0x7fc1a915b6dc
  6. print_v2 in section .text of /root/process-hotupgrade/test/libnew.so # 0x7f2ea417971c这个地址就是print_v2函数的地址

总结

我们介绍了应用程序热补丁的基本原理,实践了一个应用程序热补丁 demo。此类热补丁适用于动态替换共享链接库中的可见函数,可以修复例如 glibc “GHOST 漏洞”(CVE-2015-0235)等等,在 UCloud 我们利用热补丁修复了若干缺陷,在用户没有感知的情况下把 bug 快速及时的修复。这些热补丁修复程序里,绝大多数代码是通用的,只需少数几行做特殊替换。

上文介绍的热补丁技术对于适用的场景非常理想,简单可靠,但存在几个缺点:

  • 手写热补丁代码门槛较高,特别是被修复函数的依赖函数链较长时手写热补丁很容易出错
  • 无法修复局部函数和局部变量(只能修复全局可见的函数和变量)

后面的文章我们会介绍如一种更加先进的应用程序热补丁技术。

——————

相关阅读推荐:

机器学习进阶笔记之十 | 那些 TensorFlow 上好玩的黑科技

机器学习进阶笔记之九 | 利用 TensorFlow 搞定「倒字验证码」

机器学习进阶笔记之八 | TensorFlow 与中文手写汉字识别

机器学习进阶笔记之七 | MXnet 初体验

机器学习进阶笔记之六 | 深入理解 Fast Neural Style

机器学习进阶笔记之五 | 深入理解 VGG\Residual Network

机器学习进阶笔记之四 | 深入理解 GoogLeNet

机器学习进阶笔记之三 | 深入理解 Alexnet

机器学习进阶笔记之二 | 深入理解 Neural Style

机器学习进阶笔记之一 | TensorFlow 安装与入门

本文由『UCloud 内核团队』提供 ** 作者:王超**

现在使用 UCloud,有首充返现优惠,最高可返 3000 元代金券!活动传送门:用 UCloud!3000 元限量版礼盒等你来拆!

另,欢迎添加 UCloud 运营小妹个人微信号:Surdur,陪聊很专业:)

应用程序热补丁(一):如何用几行代码打造应用程序热补丁 - 知乎 - 图3

「UCloud 机构号」将独家分享云计算领域的技术洞见、行业资讯以及一切你想知道的相关讯息。欢迎提问 & 求关注 o(////▽////)q~

以上。
https://zhuanlan.zhihu.com/p/25752198