热升级是什么?
热升级是什么呢?了解 nginx 的同学都知道,nginx 是支持热升级的,可以用老进程服务先前链接的链接,使用新进程服务新的链接,即在不停止服务的情况下完成系统的升级与运行参数修改。那么热升级和热编译是不同的概念,热编译是通过监控文件的变化重新编译,然后重启进程。
共享库的动态替换
为了实现 Linux 系统进程热升级,本文提供了一种底层的实现方法,即在不重启进程的条件下,升级进程的共享库模块。
动态链接的共享库
Linux 环境中的应用依赖相当数量的共享库。通常,为了达到软件模块化的目的,开发人员会把逻辑上紧密相关的功能集中在一起,编译到共享库中。这样做,既有利于代码的管理,也便于模块的复用。同时,共享库的方式也有利于应用升级。许多时候,仅仅更新数个共享库就可以完成整个应用的升级,降低了升级时的开销。
如果应用支持手工触发重新装载共享库,就不需要重启。但如果应用正巧并不支持,那么,更换共享库后仍需要重启应用。本文提供了一种方法可以在应用保持运行状态下,替换共享库。在替换过程中,应用被无缝的切换到新的共享库中。整个过程,应用(进程)无需重启。
基本过程
完成不重启的升级,需要一系列的复杂步骤。一个独立升级程序 U 来负责触发目标应用(进程 T )挂载新的共享库 L 。假设 U , T , L 是它们的名字。基本步骤如下:
- 升级程序 U 要找到进程 T 的 dlopen 函数的入口地址。
- 升级进程 U 执行 attach 系统调用,贴附到进程 T 上。向进程 T 的堆栈里压入新的共享库 L 的名字,再把 dlopen 函数的入口地址赋值给 PC 寄存器。
- 让目标进程 T 继续运行。由于 PC 寄存器保存的是 dlopen 函数的入口地址,这样,在目标进程 T 空间里,dlopen 函数被调用。新的共享库 L 被目标进程 T 装载。
- 新的共享库 L 在被装载时,利用 dlsym 函数在目标进程 T 中找到被替换函数的地址。设置被替换函数的代码空间为可写状态。
- 将汇编指令 0xCC 和 0xC3 写入被替换函数入口。0xCC 是汇编 INT 3 的指令码。0xC3 是汇编 RET 的指令码(注: 0xC3 是 64 位系统的指令码)。显然,由于 INT 3 的存在,当目标进程 T 调用这个被替换函数时,就会触发一次 SIGTRAP 信号。
- 新的共享库 L 在被装载时,调用 sigaction 函数,接管 SIGTRAP 信号。在信号处理函数中,调用用于代替被替换函数的新函数。
- 至此,新的共享库 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. 偏移量](https://i-blog.csdnimg.cn/blog_migrate/9c6476fe80ac3a28126216365aa51d9e.png)
这个偏移量在不同的进程空间里是相同的。因为,不同的进程加载的是相同的 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, ®s); 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, ®s, 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, ®s); 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 内部调用关系](https://i-blog.csdnimg.cn/blog_migrate/46bcc99e16c79a1b30bdc51271eeb505.png)
基于 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, ®s, 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 内部调用关系](https://i-blog.csdnimg.cn/blog_migrate/233da4e7b5ad06f02dbb51108abbb6ad.png)
总结
沿着本文所述方法的思路,可以进一步扩展支持更为广泛的目标进程。比如,本文利用 dlsym 来定位旧函数的入口地址。但 dlsym 无法定位非共享库的函数。这时,就需要对进程的映射文件(外存设备上的 ELF 格式文件)进行解析,计算出在内存空间的的地址。
另外,在 x86 版本中,我们使用了 INT 3 的方法。其实,我们也可以使用 JMP 指令,采用 ARM 版本的方法来实现替换。而且,看起来这种方法更为完美。
总之,进程热升级可以很好的提高系统设备的可靠性和安全性。每当有 hotfix 补丁时,如果用户不希望设备关机升级,产品的开发商可以利用本文的方法对设备升级,避免因为不能停机的缘故,而无法打上安全补丁,而使产品带着漏洞运行。