如何用几行代码打造应用程序热补丁?(一)

企业动态
本篇将介绍一种简单实用的应用程序热补丁技术。不少场景下,用该方法编写几行代码即可免重启修复应用程序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. 加载程序Loader找到目标进程T的dlopen函数入口地址。

2. Loader通过ptrace依附到目标进程T,Loader将热补丁的名字放入放入目标进程T的堆栈,将IP寄存器设置为dlopen函数的地址。

3. Loader使目标进程T继续运行。因为IP寄存器已经设置为dlopen函数的入口,目标进程T会调用dlopen把热补丁加载到T的地址空间中。

4. 如图所示:

热补丁加载程序

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

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

五、实现

1. 热补丁

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

2. 热补丁加载程序

1) Loader得到目标进程T地址空间中dlopen入口地址

  • dlopen函数有libdl提供,并不是所有的程序都加载libdl,幸运的是,libc中提供了同样功能的函数libc_dlopen_mode,并且接受的参数和dlopen相同。除非特殊情况,所有程序都会加载libc。所以我们需要找到libc_dlopen_mode在目标进程T地址空间中的函数入口地址。
  • 我们知道,不同进程中libc会被加载到不同的基地址,但是libc中函数的地址相对基地址的偏移是不变的。
  • 通过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.  
  9.   sprintf(maps, "/proc/%d/maps", pid); 
  10.   fp = fopen(maps, "rb"); 
  11.   if (!fp) { 
  12.           fprintf(stderr, "Failed to open %s: %s\n", maps, strerror(errno)); 
  13.           return 0; 
  14.   } 
  15.  
  16.   while (fgets(mapbuf, sizeof(mapbuf), fp)) { 
  17.           sscanf(mapbuf, "%lx-%lx %s %lx %lx:%lx %lu %s", &start, 
  18.                   &end, perms, &file_offset, &dev_major, &dev_minor, &inode, libpath); 
  19.  
  20.           libname = strrchr(libpath, '/'); 
  21.           if (libname) 
  22.                   libname++; 
  23.           else 
  24.                   continue; 
  25.  
  26.           if (!strncmp(perms, "r-xp", 4) && strstr(libname, so_hint)) { 
  27.                   fclose(fp); 
  28.                   return start; 
  29.           } 
  30.   } 
  31.  
  32.   fclose(fp);   return 0;  
  33.   } 
  34.   loader_libc = find_lib_base(getpid(), “libc-c”); 
  35.   T_libc = find_lib_base(T_pid, “libc-“); 
  36.   Loader_dlopen = (unsigned long)dlsym(NULL, “__libc_dlopen_mode”); 
  37.   T_dlopen = T_libc + (Loader_dlopen - Loader_libc); 

2) Loader对目标进程T使用ptrace attach,并保存T此时的寄存器信息。

  1. static int ptrace_attach(pid_t pid) 
  2.   { 
  3.   int status; 
  4.  
  5.   if (ptrace(PTRACE_ATTACH, pid, NULL, NULL)) { 
  6.           fprintf(stderr, "Failed to ptrace_attach: %s\n", strerror(errno)); 
  7.           return 1; 
  8.   } 
  9.  
  10.   if (waitpid(pid, &status, __WALL) < 0) { 
  11.           fprintf(stderr, "Failed to wait for PID %d, %s\n", pid, strerror(errno)); 
  12.           return 1; 
  13.   } 
  14.   return 0; 
  15.   static int ptrace_call(pid_t pid, unsigned long func_addr, unsigned long arg1, unsigned long arg2, unsigned long *func_ret) 
  16.   { 
  17.   … 
  18.   memset(&saved_regs, 0, sizeof(struct user_regs_struct)); 
  19.   ptrace_getregs(pid, &saved_regs); 
  20.  
  21.   … 
  22.   } 

3) 将目标进程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.  regsregs.rdi = regs.rsp + 512; 
  7.  regs.rsi = RTLD_NOW
  8.  ptrace_setregs(pid, &regs); 

4) Loader使目标进程T继续运行。当T执行完dlopen之后,T产生的SIGSEGV信号被Loader捕获,Loader重新获得T进程的控制权。

  1. static int ptrace_cont(pid_t pid) 
  2.  
  3. {int status; 
  4.  
  5. if (ptrace(PTRACE_CONT, pid, NULL, 0)) { 
  6.  
  7. fprintf(stderr, "Failed to ptrace_cont: %s\n", strerror(errno));return 1; 
  8.  
  9.  
  10. if (waitpid(pid, &status, __WALL) < 0) {fprintf(stderr, "Failed to wait for PID %d, %s\n", pid, strerror(errno)); 
  11.  
  12. return 1;} 
  13. return 0;} 

5) 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一次:

  1. # ./target 
  2. Hello 
  3. Hello 
  4. … 

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

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

编译

  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.  
  7. print_v2(void) 
  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.  
  15.     old_func_entry = dlsym(NULL, print); 
  16.     new_func_entry = dlsym(NULL, print_v2); 
  17.  
  18.     #define PAGE_SHIFT              12  
  19.     #define PAGE_SIZE               (1UL << PAGE_SHIFT)  
  20.     #define PAGE_MASK               (~(PAGE_SIZE-1)) 
  21.  
  22.     numpages = (PAGE_SIZE - (old_func_entry & ~PAGE_MASK) >= size) ? 1 : 2; 
  23.     mprotect((void *)(old_func_entry & PAGE_MASK), numpages * PAGE_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC); 
  24.     memset(old_func_entry, 0x48, 1); 
  25.     memset(old_func_entry + 1, 0xb8, 1);  
  26.     memcpy(old_func_entry + 2, &new_func_entry, 8);  
  27.     memset(old_func_entry + 10, 0xff, 1); 
  28.     memset(old_func_entry + 11, 0xe0, 1); 
  29. }  

编译:

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

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

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

我们发现热补丁确实改变了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. … 
  6. (gdb) info symbol 0x7fc1a915b6dc 
  7. print_v2 in section .text of /root/process-hotupgrade/test/libnew.so # 0x7f2ea417971c这个地址就是print_v2函数的地址 

七、总结

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

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

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

下一篇文章我会介绍一种更加先进的应用程序热补丁技术。

【本文是51CTO专栏机构作者“大U的技术课堂”的原创文章,转载请通过微信公众号(ucloud2012)联系作者】

 戳这里,看该作者更多好文

责任编辑:赵宁宁 来源: 51CTO专栏
相关推荐

2017-06-07 23:33:01

应用程序热补丁代码

2017-03-15 17:57:04

代码免重启BUG

2013-02-22 09:28:45

MEAP软件移动应用开发HTML5

2023-12-21 08:00:00

ChatGPT人工智能大型语言模型

2009-10-19 14:14:19

OSGi Web应用

2024-10-10 13:30:00

2011-07-21 16:19:30

iOS Twitter

2011-06-07 09:36:41

BlackBerry 应用程序

2022-09-19 00:37:13

SaaS云计算开发

2015-11-05 10:16:33

2021-05-17 07:45:06

Linux系统程序

2013-02-21 14:14:40

开发Tizen

2018-09-18 09:30:17

微信热补丁Android

2019-01-16 10:33:41

Linux

2010-01-18 17:32:03

2013-11-19 15:35:01

2012-05-04 08:28:10

2021-10-11 09:00:00

云原生Kubernetes安全

2018-02-27 13:45:01

2018-12-11 11:41:14

物联网应用程序IOT
点赞
收藏

51CTO技术栈公众号