背景

用户总是希望服务进程能保持稳定。如果可以 7*24 小时的工作,那就永远不要重启它。但是,软件产品的功能总是在不断的丰富。当用户发现一些新的功能正是他所需要的,他也许会主动要求进行一次升级。而当严重的安全问题出现时,用户就不得不接受强制的升级了。

不停机升级,也被称为热升级。通常实现热升级,需要用户部署两套业务系统。至少,被升级的关键模块是两块以上的。这一般是通过硬件方式支持的。由此而产生的成本压力,不是每个用户都可以接受的。

对于小型业务系统,频繁的升级总是不可避免的。如果升级过程中,业务进程不用重启,那么,升级将不再是一个令用户烦恼的事情了。


动态链接的共享库

Linux 环境中的应用依赖相当数量的共享库。通常,为了达到软件模块化的目的,开发人员会把逻辑上紧密相关的功能集中在一起,编译到共享库中。这样做,既有利于代码的管理,也便于模块的复用。同时,共享库的方式也有利于应用升级。许多时候,仅仅更新数个共享库就可以完成整个应用的升级,降低了升级时的开销。

如果应用支持手工触发重新装载共享库,就不需要重启。但如果应用正巧并不支持,那么,更换共享库后仍需要重启应用。本文提供了一种方法可以在应用保持运行状态下,替换共享库。在替换过程中,应用被无缝的切换到新的共享库中。整个过程,应用(进程)无需重启。


基本过程

完成不重启的升级,需要一系列的复杂步骤。一个独立升级程序 U 来负责触发目标应用(进程 T )挂载新的共享库 L 。假设 U , T , L 是它们的名字。基本步骤如下:

  1. 升级程序 U 要找到进程 T 的 dlopen 函数的入口地址。
  2. 升级进程 U 执行 attach 系统调用,贴附到进程 T 上。向进程 T 的堆栈里压入新的共享库 L 的名字,再把 dlopen 函数的入口地址赋值给 PC 寄存器。
  3. 让目标进程 T 继续运行。由于 PC 寄存器保存的是 dlopen 函数的入口地址,这样,在目标进程 T 空间里,dlopen 函数被调用。新的共享库 L 被目标进程 T 装载。
  4. 新的共享库 L 在被装载时,利用 dlsym 函数在目标进程 T 中找到被替换函数的地址。设置被替换函数的代码空间为可写状态。
  5. 将汇编指令 0xCC 和 0xC3 写入被替换函数入口。0xCC 是汇编 INT 3 的指令码。0xC3 是汇编 RET 的指令码(注: 0xC3 是 64 位系统的指令码)。显然,由于 INT 3 的存在,当目标进程 T 调用这个被替换函数时,就会触发一次 SIGTRAP 信号。
  6. 新的共享库 L 在被装载时,调用 sigaction 函数,接管 SIGTRAP 信号。在信号处理函数中,调用用于代替被替换函数的新函数。
  7. 至此,新的共享库 L 的函数替代了目标进程 T 中原先使用的旧函数。每当目标进程 T 试图调用被替换函数时,都会触发 SIGTRAP 信号。然后,信号处理函数调用新的函数。这个过程将一直存在于进程 T 的整个生存周期中。

从上面的步骤可以得知,本方法适用于共享库的升级。通过替换旧的共享库中函数,实现升级。在上面的步骤的实施前,可以先对文件系统中共享库进行替换。这样,在无鏠升级后,当目标进程 T 有机会进行重启,再度启动的应用将直接加载新的共享库,而不再需要上面的复杂升级过程了。

本方法在底层对进程的内存数据进行了修改。由于不同体系,不同位数的 CPU ,指令码,寄存器,以及函数调用的栈帧结构都是不同的,因此,不同的硬件条件,升级程序将会有所差别。但是,基本原理是相同的。下面,分别详细介绍 x86 和 ARM 版本的实现细节。


基于 x86 的实现

本节根据前一节的基本步骤所述的内容,展示在 x86_64 CPU 体系上的实现步骤和关键代码,并对代码给予详细的说明。本章所列出的步骤将更为详细。

得到 dlopen 函数在目标进程 T 的地址。

假设升级程序 U 已经得到目标进程 T 的 PID。PID 为 t_pid。

:我们的目标进程 T 是 ELF 格式的程序。在 glibc 中,完成共享库加载的函数是 __libc_dlopen_mode。详情可参见 glibc 的相关资料和代码。


清单 1. 得到 dlopen 函数地址

				
 snprintf(path, sizeof(path), "/proc/%d/maps", my_pid); 

 if ((f = fopen(path, "r")) == NULL) 
 return -1; 

 for (;;) { 
    Read a line form maps file 
 Look for a line with “r-xp” and libc- substring 
 If found { 
 addr = the first field of line; 
 break; 
    } 
 } 

 fclose(f); 

 dlopen_entry = dlsym(NULL, "__libc_dlopen_mode"); 
 if (!dlopen_mode) { 
 printf("Unable to locate dlopen address.\n"); 
 return -1; 
 } 

 dlopen_offset = dlopen_entry – addr; /* calc offset */ 

 t_libc = begin of libc of target process T; /* get from maps file of target T */ 
 if (!t_libc) { 
 printf("Unable to locate begin of target's libc.\n"); 
 return -1; 
 } 
 dlopen_entry = t_libc + dlopen_offset; 

 

升级程序 U 在启动后,调用 getpid 系统调用,得到自己的 PID (变量 my_pid ),进而确定 proc 目录下的 maps 文件的路径。打开 maps 文件,该文件描述了不同的 section 在进程空间里的分配情况。形式如下:

 2b779cdbf000-2b779cdc1000 r-xp 00000000 08:01 1446923 /lib64/libc-2.5.so 

 

文件由多行组成。每行则由多个字段组成。字段间用空格分隔。

第一列描述了 section 的起始和结束地址:2b779cdbf000-2b779cdc1000。

第二列描述了 section 的权限: r-xp 。每个缩写字符的含义为 :

r=read,w=write,x=execute,s=shared,p=private(copy on write) 。

最后一列描述了被映射文件的文件名: /lib64/libc-2.5.so 。

升级程序 U 在 maps 文件中查找权限字段为“ r-xp ”和最后字段为“ libc-* ”的行。找到后,取出第一字段,存入 addr 变量中。

调用 dlsym 函数得到 __libc_dlopen_mode 函数在进程空间的入口地址。将其减入 addr ,得到与 __libc_dlopen_mode 函数在 libc 中的偏移量。


图 1. 偏移量
图 1. 偏移量
 

这个偏移量在不同的进程空间里是相同的。因为,不同的进程加载的是相同的 libc 库。所以,打开目标进程 T 的 maps 文件。采用相同的方法得到 libc 在目标进程 T 的起始地址。这个起始地址加上偏移量,升级程序 U 就得到了 __libc_dlopen_mode 函数在目标进程 T 的入口地址。

Attach 目标进程 T ,备份现场数据。


清单 2. attach 目标进程

				
 struct my_user_regs { 
	 unsigned long r15; 
	 unsigned long r14; 
	 unsigned long r13; 
	 unsigned long r12; 
	 unsigned long rbp; 
	 unsigned long rbx; 
	 unsigned long r11; 
	 unsigned long r10; 
	 unsigned long r9; 
	 unsigned long r8; 
	 unsigned long rax; 
	 unsigned long rcx; 
	 unsigned long rdx; 
	 unsigned long rsi; 
	 unsigned long rdi; 
	 unsigned long orig_rax; 
	 unsigned long rip; 
	 unsigned long cs; 
	 unsigned long eflags; 
	 unsigned long rsp; 
	 unsigned long ss; 
	 unsigned long fs_base; 
	 unsigned long gs_base; 
	 unsigned long ds; 
	 unsigned long es; 
	 unsigned long fs; 
	 unsigned long gs; 
 }; 
 char sbuf1[512], sbuf2[512]; 
 struct my_user_regs regs, saved_regs, aregs; 

 if (ptrace(PTRACE_ATTACH, t_pid, NULL, NULL) < 0) 
 return -1; 

 waitpid(t_pid, &status, 0); 
 ptrace(PTRACE_GETREGS, t_pid, NULL, &regs); 

 peek_text(t_pid, regs.rsp + 512, sbuf1, sizeof(sbuf1)); 
 peek_text(t_pid, regs.rsp, sbuf2, sizeof(sbuf2)); 

 

调用 ptrace 函数 attach 到目标进程。成功后,获取寄存器组。根据栈寄存器 rsp ,备份栈内共计 1024 字节的数据。这些工作都是为了最后恢复现场做准备。

: peek_text 函数是自定义的。它对 ptrace(PTRACE_PEEKTEXT … ) 做了封装,以支持多字节的数据块的读取。系统调用 ptrace(PTRACE_PEEKTEXT … ) 调用一次只能读取一个字。函数 peek_text 根据入参指明的长度,多次调用 ptrace 读取多个字节。后文将提到的 poke_text 是对 ptrace(PTRACE_POKETEST … ) 的封装,以支持写入多字节的数据块。

在目标进程 T 的堆栈里准备好 dlopen 函数的数据,触发目标进程 T 执行 dlopen 函数。


清单 3. 触发目标进程 T 执行 dlopen 函数

				
 z=0; 
 strcpy(filename_new_so, “/usr/lib/libnew.so”); 

 poke_text(t_pid, regs.rsp, (char *)&z, sizeof(z)); 
 poke_text(t_pid, regs.rsp + 512, filename_new_so, strlen(filename_new_so) + 1); 

 memcpy(&saved_regs, &regs, sizeof(regs)); 

 regs.rdi = regs.rsp + 512; 
 regs.rsi = RTLD_NOW|RTLD_GLOBAL|RTLD_NODELETE; 
 regs.rip = dlopen_entry + 2; 

 ptrace(PTRACE_SETREGS, t_pid, NULL, &regs); 
 ptrace(PTRACE_CONT, t_pid, NULL, NULL); 

 waitpid(t_pid, &status, 0); 

 

首先,将 0 压栈,这个数据将成为从 dlopen 函数退出时的返回地址。这个非法地址将触发一个异常。这使得升级程序 U 可以在目标进程调用完 dlopen 函数后,重新获得对它的控制。

保存文件名的变量 filename_new_so 是在升级程序 U 的进程空间中,所以,需要把它放入目标进程 T 的堆栈里。regs.rsp + 512 开始的空间已经备份过,可以把文件名存放在这里。

然后,为 dlopen 函数准备入参。dlopen 函数的函数声明是

 void *dlopen(const char *filename, int flag) 

 

在 64 位 CPU 中,函数参数的传递是使用寄存器。因此,在这里, rdi 寄存器保存了文件名的地址。它对应入参 filename 。寄存器 rsi 保存了标志,对应入参 flag 。

:在 32 位 CPU 中,函数参数是通常栈空间完成。与上面的示例是完全不同的。

最后,将指令执行地址寄存器 rip 设定为 dlopen 函数的入口地址,调用 ptrace 函数将控制权交回给目标进程。

由于在上一步中,预置了非法的返回地址 0, SIGSEGV 信号将会发生。升级程序 U 将再次获得控制权。在本步骤执行结束后,新的共享库 L 将被目标进程 T 加载。用户可以通过执行

 $cat /proc/t_pid/maps 

 

查看新的共享库 L 是否已经被加载。

升级程序 U 恢复现场

当新的共享库被加载后,升级程序 U 必须恢复目标进程 T 至 attach 前的时刻。


清单 4. 恢复目标进程的现场

				
 ptrace(PTRACE_SETREGS, t_pid, 0, &saved_regs); 

 poke_text(t_pid, saved_regs.rsp + 512, sbuf1, sizeof(sbuf1)); 
 poke_text(t_pid, saved_regs.rsp, sbuf2, sizeof(sbuf2)); 

 ptrace(PTRACE_DETACH, t_pid, NULL, NULL); 

 

在第一次执行 ptrace 进行 attach 后,升级程序 U 就备份了目标进程 T 的堆栈空间和寄存器。在新的共享库 L 加载成功后,升级程序 U 将目标进程 T 的堆栈和寄存器恢复到 attach 前的状态。

升级程序 U 的任务到这里就完成了。为了替代目标进程 T 中的函数,新加载的共享库 L 需要执行一系列特定的步骤。下面的各节描述了新的共享库 L 里的实现细节。

将 INT 3 和 RET 指令写入要替代的函数

假设要替换的函数声明为:void old_func(void)。

该函数无入参和返回值。这里是为了简化问题,便于说明基本原理。带有入参和返回值会使处理代码更为复杂。


清单 5. 写入 INT3 和 RET 指令

				
 void _init() 
 { 
 unsigned char *aligned = NULL; 
 struct sigaction sa; 
 unsigned char * entrys [32] = {0, 0}; 

 void *handle=dlopen(NULL, RTLD_LAZY); 
 if (handle == NULL) 
        return ; 

 if ((entrys[0] = dlsym(handle, "old_func")) == NULL){ 
 return; 
 } 

 memset(&sa, 0, sizeof(sa)); 
 sa.sa_sigaction = sigtrap; 
 sa.sa_flags = SA_RESTART|SA_SIGINFO; 
 sigaction(SIGTRAP, &sa, NULL); 

 aligned = (unsigned char *)(((size_t)hooks[0]) & ~4095); 
 if (mprotect(aligned, 4096, PROT_READ|PROT_WRITE|PROT_EXEC) != 0) { 
 return; 
 } 

 entrys [0][0] = 0xcc;   /* int 3 */ 
 entrys [0][1] = 0xc3;   /* 64bit ret instruction */ 
 } 

 

代码清单 4 描述的是新的共享库的代码。

函数 _init 将在共享库被加载时隐式执行。值得注意的是,函数 _init 是在目标进程 T 的空间中运行。

首先,它调用入参为 NULL 的 dlopen 函数得到全局符号句柄。依靠全局符号句柄,再调用 dlsym 函数获得 old_func 函数的入口地址。

然后,设置信号 SIGTRAP 的处理函数为自已的 sigtrap 函数。

最后,它将 old_func 函数的内存空间修改为可读写执行模式。将 old_func 函数的第一个指令设置为 0xCC ;第二个指令设置为 0xC3 。 0xCC 是汇编指令 INT 3 的指令码。 0xC3 是汇编指令 RET 的指令码。由于被替换函数 old_func 是一个无入参,无返回值的函数,所以,在修改这个函数时,无需堆栈处理。但在实际应用中,如果函数有入参和返回值,就不可以直接使用 RET 指令,而是需要对堆栈进行精确的处理,保证目标进程 T 的堆栈的正确。

:代码段是可读,可执行,但不可写的。所以,为了写入新的指令,必须将代码段设为可写模式。

在 sigtrap 信号处理函数里,调用 new_func 函数。

在上一步中,函数 _init 对信号 SIGTRAP 设置了处理函数。本节介绍这个处理函数的细节。该函数的实现代码属于新的共享库 L 。


清单 6. sigtrap 函数

				
 void new_func(void) 
 { 
 printf(">> this is new function\n"); 
 return ; 
 } 

 static void sigtrap(int x, siginfo_t *si, void *vp) 
 { 
 new_func(); 
 return; 
 } 

 

信号处理函数异常简单,仅仅是调用新的函数 new_func 。这个函数正是用于替换函数 old_func 的。

到此,旧的函数 old_func 就完全被替代了。每当目标进程 T 调用 old_func 函数时,由于 old_func 函数第一个指令为 INT 3 ,这将触发一个 SIGTRAP 信号。导致 sigtap 信号处理函数被调用。在信号处理函数内部,用来替代 old_func 的 new_func 函数被调用。从 sigtrap 函数返回后,由于第二个指令是 RET ,目标进程 T 对 old_func 的调用完成。对于目标进程 T 来说,虽然它调用的是 old_func 函数,但实际得到执行的却是 new_func 。它根本无法查觉到 old_func 函数已经被替换成了 new_func 函数。

值得一提的是,在升级程序 U 执行热升级任务之前,可以先对磁盘上的共享库文件升级覆盖。在新的共享库文件中, old_func 函数已经被去除, new_func 函数已经编译在程序中。这样,当目标进程 T 重启后, new_func 函数将经由正常的启动途径被加载,而无需上面的复杂机制。下面的示意图可以帮助我们更好的理解新旧共享库,函数之间的关系:


图 2. 升级后,目标进程 T 内部调用关系
图 2. 升级后,目标进程 T 内部调用关系
 

基于 ARM 的实现

本文所述方法也适用于 ARM 体系。但是,一些与 CPU 有关的地方,则有所不同。本节详细说明不同之处。其余部分完全相同。

第一个不同之处是“触发目标进程 T 执行 dlopen 函数”。而获取 dlopen 函数的方法与 x86 相同。


清单 7. 触发目标进程 T 执行 dlopen 函数(ARM)

				
 peek_text(t_pid, regs.ARM_sp + 512, sbuf1, sizeof(sbuf1)); 
 peek_text(t_pid, regs.ARM_sp, sbuf2, sizeof(sbuf2)); 

 strcpy(filename_new_so, “/usr/lib/libnew.so”); 

 poke_text(t_pid, regs.ARM_sp + 512, filename_new_so, strlen(filename_new_so) + 1); 

 memcpy(&saved_regs, &regs, sizeof(regs)); 

 regs.ARM_r0 = regs.ARM_sp + 512; 
 regs.ARM_r1 = RTLD_NOW|RTLD_GLOBAL|RTLD_NODELETE; 
 regs.ARM_lr = 0; 
 regs.ARM_pc = (size_t)dlopen_entry; 

 ptrace(PTRACE_SETREGS, t_pid, NULL, &callso_regs); 
 ptrace(PTRACE_CONT, t_pid, NULL, NULL); 

 waitpid(ph->pid, &status, 0); 

 

同样的,首先备份栈内数据。 ARM 的栈寄存器是 sp 。代码中记为 ARM_sp 。准备 dlopen 函数的入参的步骤与 x86 有很大的不同。这是因为 x86 使用栈来传递参数,而 ARM 则使用 R0~R3 寄存器来传递参数。如果参数个数大于 4 ,再使用栈空间。因此,这里, ARM_r0 寄存器指向新的共享库的文件名。 ARM_R1 寄存器保存了标志。 ARM 的函数返回地址是保存在 lr 寄存中的,为了触发异常,而使升级程序 U 在加载了新的共享库后,重新得到控制权,在这里,我们为 lr 寄存器设置了无效的返回值 0 。这与 x86 中的向栈内压入值为 0 的变量 z 是一样的目的。最后,为 pc 寄存器设置 dlopen 函数的入口地址。

第二处不同是向被替换函数写入的指令不同。

在 x86 里,我们使用 INT 3 来发出 SIGTRAP 信号。然后在信号函数里调用新的函数,以达到替换的目的。但是,利用 ARM 指令来实现 SIGTRAP 信号的触发,较为繁琐。故改用跳转指令。代码如下所示。


清单 8. 写入无条件转移指令

				
 void _init() 
 { 
 unsigned char *aligned = NULL; 
 struct sigaction sa; 
 unsigned char * entrys [32] = {0, 0}; 

 void *handle=dlopen(NULL, RTLD_LAZY); 
 if (handle == NULL) 
        return ; 

 if ((entrys[0] = dlsym(handle, "old_func")) == NULL){ 
 return; 
 } 
    
 aligned = (unsigned char *)(((size_t)hooks[0]) & ~4095); 
 if (mprotect(aligned, 4096, PROT_READ|PROT_WRITE|PROT_EXEC) != 0) { 
 return; 
 } 

 entrys [0][0] = 0xe59ff008;;     /* ldr pc, [pc, #8] */ 
 entrys [0][1] = (int)new_func;   /* data */ 
 entrys [0][2] = (int)new_func; 
 entrys [0][3] = (int)new_func; 
 entrys [0][4] = (int)new_func; 
 } 

 

ARM 里的无条件跳转指令有 B 、 BL 、 BX 、。但是它们都有 32MB 跳转范围的限制。ARM 可以通过直接修改 PC 寄存器,实现 4GB 空间的无条件跳转。在向 PC 寄存器存入地址时,不能直接使用 MOV 指令存入绝对地址,像下面的指令:

mov pc, #40200000;

是无法通过编译的。因此,我们在这里使用 ldr 指令,在指令后面的内存空间里存放跳转地址。entrys[0][1]~[0][3] 是用于填充空间,并无实际意义。 ldr 指令实际是从 entrys[0][4] 中取出地址。这个地址正是新的函数的入口地址。

当目标进程 T 调用 old_func 函数时,该函数的入口是一条跳转到 new_func 函数的指令。函数 new_func 被调用,而函数 old_func 就被绕过。函数 new_func 的入参和返回值 old_func 保持一致,实现了无缝升级。下面的示意图可以帮助我们更好的理解:


图 3. 升级后,目标进程 T 内部调用关系
图 3. 升级后,目标进程 T 内部调用关系
 


总结

沿着本文所述方法的思路,可以进一步扩展支持更为广泛的目标进程。比如,本文利用 dlsym 来定位旧函数的入口地址。但 dlsym 无法定位非共享库的函数。这时,就需要对进程的映射文件(外存设备上的 ELF 格式文件)进行解析,计算出在内存空间的的地址。

另外,在 x86 版本中,我们使用了 INT 3 的方法。其实,我们也可以使用 JMP 指令,采用 ARM 版本的方法来实现替换。而且,看起来这种方法更为完美。

总之,进程热升级可以很好的提高系统设备的可靠性和安全性。每当有 hotfix 补丁时,如果用户不希望设备关机升级,产品的开发商可以利用本文的方法对设备升级,避免因为不能停机的缘故,而无法打上安全补丁,而使产品带着漏洞运行。


 

参考资料

学习

  • 参考"程序员杂志 104 期"的一篇文章:杨广翔,2008. ELF 格式可执行程序的代码嵌入技术。

     
  • 如果希望了解链接和装载方面的信息,请参考: John R. Levine, October 1999. Linkers and Loaders, Morgan Kaufmann

     
  • 杜春蕾,2003,《ARM 体系结构与编程》,清华大学出版社

     
  • developerWorks Linux 专区 寻找为 Linux 开发人员(包括 Linux 新手入门)准备的更多参考资料,查阅我们 最受欢迎的文章和教程

     
  • 在 developerWorks 上查阅所有 Linux 技巧Linux 教程

     
  • 随时关注 developerWorks 技术活动网络广播