如何用幾行代碼免重啟修復(fù)應(yīng)用程序BUG?(一)
引言
千呼萬喚始出來,從今天起,《UCloud技術(shù)大觀園》系列正式開張,撒花╭(●`∀´●)╯!
UCloud生而為云,一直專注在云計(jì)算的泥潭里摸爬滾打,踩過數(shù)不清的坑,寫過數(shù)不清的BUG。所幸,在不斷的試錯(cuò)中,也錘煉出一些能在江湖傍身的大殺器。這些經(jīng)過千錘百煉的大殺器和寶貴的踩坑經(jīng)驗(yàn),一起成為今天UCloud的核心科技。
現(xiàn)在,我們將在《UCloud技術(shù)大觀園》系列里,把這些核心科技全部開放出來,毫無保留,逐一為大家講解,哪些坑是我們已經(jīng)踩過的,引以為誡,哪些是優(yōu)質(zhì)的技術(shù)實(shí)踐經(jīng)驗(yàn),值得借鑒。
我們始終相信——開放,才是技術(shù)的本心。
本篇作為《UCloud技術(shù)大觀園》系列的開篇,聚焦UCloud應(yīng)用程序熱補(bǔ)丁技術(shù),將介紹一種簡(jiǎn)單實(shí)用的應(yīng)用程序熱補(bǔ)丁技術(shù)。不少場(chǎng)景下,用該方法編寫幾行代碼即可免修復(fù)應(yīng)用程序BUG!
那,我們開始吧~
前言
應(yīng)用程序,作為核心業(yè)務(wù)組件,每天都面臨著嚴(yán)峻的高可用挑戰(zhàn),每次重啟,都會(huì)導(dǎo)致服務(wù)受損。尤其是單點(diǎn)的虛擬化組件和有狀態(tài)的應(yīng)用程序,一旦重啟,影響更甚。
熱補(bǔ)丁,一種在程序運(yùn)行時(shí)動(dòng)態(tài)修復(fù)內(nèi)存中代碼bug的技術(shù),能避免系統(tǒng)重啟導(dǎo)致的業(yè)務(wù)中斷、有效保證操作系統(tǒng)的可用性。
經(jīng)過大量的研究和實(shí)踐,UCloud從0到1,自研了一套應(yīng)用程序熱補(bǔ)丁技術(shù)。千錘百煉出真金,經(jīng)過內(nèi)部數(shù)十萬臺(tái)次修復(fù)驗(yàn)證,UCloud應(yīng)用程序熱補(bǔ)丁技術(shù)已自成體系,成為UCloud核心黑科技之一。
原理
一般來說,應(yīng)用程序熱補(bǔ)丁的流程是,首先通過編譯器將熱補(bǔ)丁源碼制作成可加載的動(dòng)態(tài)鏈接庫,然后通過加載程序?qū)嵫a(bǔ)丁加載到目標(biāo)進(jìn)程的地址空間,***在進(jìn)行一致性模型檢查確認(rèn)安全的情況下,把原始代碼替換成新的代碼,完成在線修復(fù)的過程。
下面我們分別介紹熱補(bǔ)丁本身和熱補(bǔ)丁加載程序,熱補(bǔ)丁本身是因patch而異的,加載程序是通用的。
假設(shè)我們有熱補(bǔ)丁加載程序Loader、目標(biāo)進(jìn)程T、熱補(bǔ)丁patch.so,目標(biāo)程序的func函數(shù)替換為func_v2。
熱補(bǔ)丁
- 編寫熱補(bǔ)丁源碼,編譯成動(dòng)態(tài)鏈接庫的格式的熱補(bǔ)丁patch.so,patch.so中包含func和func_v2的信息。
- 熱補(bǔ)丁patch.so在被加載程序Loader加載到目標(biāo)進(jìn)程T地址空間的過程中,通過dlsym調(diào)用找到func的地址,并將func的入口指令改為可寫,同時(shí)改變?yōu)樘D(zhuǎn)到func_v2。
- 至此,所有對(duì)func的調(diào)用都會(huì)被重定向到func_v2,func_v2執(zhí)行完畢后返回,程序繼續(xù)運(yùn)行。
- 如圖所示:
熱補(bǔ)丁加載程序
- 加載程序Loader找到目標(biāo)進(jìn)程T的dlopen函數(shù)入口地址。
- Loader通過ptrace依附到目標(biāo)進(jìn)程T,Loader將熱補(bǔ)丁的名字放入放入目標(biāo)進(jìn)程T的堆棧,將IP寄存器設(shè)置為dlopen函數(shù)的地址。
- Loader使目標(biāo)進(jìn)程T繼續(xù)運(yùn)行。因?yàn)镮P寄存器已經(jīng)設(shè)置為dlopen函數(shù)的入口,目標(biāo)進(jìn)程T會(huì)調(diào)用dlopen把熱補(bǔ)丁加載到T的地址空間中。
- 如圖所示:
了解原理之后,我們一步步實(shí)現(xiàn)一種簡(jiǎn)單的基于x86_64的熱補(bǔ)丁。
(對(duì)于需要制作熱補(bǔ)丁的同學(xué),只需自己編寫patch.so,而Loader是通用的。patch.so編寫可以參考下面的例子,往往只需幾行代碼做相應(yīng)替換。)
實(shí)現(xiàn)
熱補(bǔ)丁
1.目標(biāo)進(jìn)程T執(zhí)行dlopen的過程中,通過預(yù)先在熱補(bǔ)丁(動(dòng)態(tài)鏈接庫)中寫入的constructor函數(shù),在加載過程中函數(shù)func_v1替換函數(shù)func。
- static void __attribute__((constructor)) init(void)
- {
- int numpages;
- void *old_func_entry, *new_func_entry;
- old_func_entry = dlsym(NULL, "func");
- new_func_entry = dlsym(NULL, "func_v2");
- #define PAGE_SHIFT 12
- #define PAGE_SIZE (1UL << PAGE_SHIFT)
- #define PAGE_MASK (~(PAGE_SIZE-1))
- numpages = (PAGE_SIZE - (old_func_entry & ~PAGE_MASK) >= size) ? 1 : 2;
- mprotect((void *)(old_func_entry & PAGE_MASK), numpages * PAGE_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC);
- /*
- * Translate the following instructions
- *
- * mov $new_func_entry, %rax
- * jmp %rax
- *
- * into machine code
- *
- * 48 b8 xx xx xx xx xx xx xx xx
- * ff e0
- */
- memset(old_func_entry, 0x48, 1);
- memset(old_func_entry + 1, 0xb8, 1);
- memcpy(old_func_entry + 2, &new_func_entry, 8);
- memset(old_func_entry + 10, 0xff, 1);
- memset(old_func_entry + 11, 0xe0, 1);
- }
熱補(bǔ)丁加載程序
1.Loader得到目標(biāo)進(jìn)程T地址空間中dlopen入口地址
1.1. dlopen函數(shù)有l(wèi)ibdl提供,并不是所有的程序都加載libdl,幸運(yùn)的是,libc中提供了同樣功能的函數(shù)libc_dlopen_mode,并且接受的參數(shù)和dlopen相同。除非特殊情況,所有程序都會(huì)加載libc。所以我們需要找到libc_dlopen_mode在目標(biāo)進(jìn)程T地址空間中的函數(shù)入口地址。
1.2. 我們知道,不同進(jìn)程中l(wèi)ibc會(huì)被加載到不同的基地址,但是libc中函數(shù)的地址相對(duì)基地址的偏移是不變的。
1.3. 通過Loader和目標(biāo)進(jìn)程T的/proc/pid/maps,我們可以得到libc在Loader和目標(biāo)進(jìn)程T中加載的基地址。通過Loader運(yùn)行dlsym,我們可以得到Loader中的libc_dlopen_mode的地址。這樣我們可以得到目標(biāo)進(jìn)程T中l(wèi)ibc_dlopen_mode的地址(Loader_dlopen - Loader_libc + T_libc)。
- / Take a hint and find start addr in /proc/pid/maps /
- static unsigned long find_lib_base(pid_t pid, char *so_hint)
- {
- FILE *fp;
- char maps[4096], mapbuf[4096], perms[32], libpath[4096];
- char *libname;
- unsigned long start, end, file_offset, inode, dev_major, dev_minor;
- sprintf(maps, "/proc/%d/maps", pid);
- fp = fopen(maps, "rb");
- if (!fp) {
- fprintf(stderr, "Failed to open %s: %s\n", maps, strerror(errno));
- return 0;
- }
- while (fgets(mapbuf, sizeof(mapbuf), fp)) {
- sscanf(mapbuf, "%lx-%lx %s %lx %lx:%lx %lu %s", &start,
- &end, perms, &file_offset, &dev_major, &dev_minor, &inode, libpath);
- libname = strrchr(libpath, '/');
- if (libname)
- libname++;
- else
- continue;
- if (!strncmp(perms, "r-xp", 4) && strstr(libname, so_hint)) {
- fclose(fp);
- return start;
- }
- }
- fclose(fp); return 0;
- }
- loader_libc = find_lib_base(getpid(), “libc-c”);
- T_libc = find_lib_base(T_pid, “libc-“);
- Loader_dlopen = (unsigned long)dlsym(NULL, “__libc_dlopen_mode”);
- T_dlopen = T_libc + (Loader_dlopen - Loader_libc);
2.Loader對(duì)目標(biāo)進(jìn)程T使用ptrace attach,并保存T此時(shí)的寄存器信息。
- static int ptrace_attach(pid_t pid)
- {
- int status;
- if (ptrace(PTRACE_ATTACH, pid, NULL, NULL)) {
- fprintf(stderr, "Failed to ptrace_attach: %s\n", strerror(errno));
- return 1;
- }
- if (waitpid(pid, &status, __WALL) < 0) {
- fprintf(stderr, "Failed to wait for PID %d, %s\n", pid, strerror(errno));
- return 1;
- }
- return 0;
- }
- static int ptrace_call(pid_t pid, unsigned long func_addr, unsigned long arg1, unsigned long arg2, unsigned long *func_ret)
- {
- …
- memset(&saved_regs, 0, sizeof(struct user_regs_struct));
- ptrace_getregs(pid, &saved_regs);
- …
- }
3.將目標(biāo)進(jìn)程T的%RIP指向dlopen,熱補(bǔ)丁的名字的字符串放入堆棧,字符串的地址寫入%rdi,RTLD_NOW的值寫入%rsi作為dlopen的flag。同時(shí)把dlopen返回地址設(shè)置為非法地址0x0(把0x0壓入棧中),這樣Loader可以捕獲目標(biāo)進(jìn)程T產(chǎn)生的SIGSEGV信號(hào)進(jìn)而重新獲得T的控制權(quán)。
- unsigned long invalid = 0x0;
- regs.rsp -= sizeof(invalid);
- ptrace_poketext(pid, regs.rsp, ((void *)&invalid), sizeof(invalid));
- ptrace_poketext(pid, regs.rsp + 512, filename, strlen(filename) + 1);
- regs.rip = dlopen_addr;
- regs.rdi = regs.rsp + 512;
- regs.rsi = RTLD_NOW;
- ptrace_setregs(pid, ®s);
4.Loader使目標(biāo)進(jìn)程T繼續(xù)運(yùn)行。當(dāng)T執(zhí)行完dlopen之后,T產(chǎn)生的SIGSEGV信號(hào)被Loader捕獲,Loader重新獲得T進(jìn)程的控制權(quán)。
- static int ptrace_cont(pid_t pid)
- {int status;
- if (ptrace(PTRACE_CONT, pid, NULL, 0)) {
- fprintf(stderr, "Failed to ptrace_cont: %s\n", strerror(errno));return 1;
- }
- if (waitpid(pid, &status, __WALL) < 0) {fprintf(stderr, "Failed to wait for PID %d, %s\n", pid, strerror(errno));
- return 1;}
- return 0;}
5. Loader通過讀取目標(biāo)進(jìn)程T此時(shí)的%rax寄存器得到dlopen的返回值,恢復(fù)T最開始的執(zhí)行狀態(tài),***釋放對(duì)T的控制
- ptrace_getregs(pid, ®s);
- dlopen_ret = regs.rax;
- ptrace_setregs(pid, &saved_regs);
- ptrace_detach(pid);
至此對(duì)目標(biāo)進(jìn)程T的熱補(bǔ)丁就完成了。下面我們看一個(gè)例子。
驗(yàn)證
假設(shè)我們運(yùn)行target程序,每隔一秒打印Hello一次:
- # ./target
- Hello
- Hello
- …
- target程序由tar
target程序由target本身和libold.so組成,分別代碼如下:
- /* target.c */
- #include <unistd.h>
- #include "old.h"
- int main() {
- for (;;) {
- print();
- sleep(1);
- }
- }
- /* old.c */
- #include <stdio.h>
- void print(void)
- {
- printf("Hello\n");
- }
編譯
- gcc -fPIC --shared old.c -o libold.so
- gcc target.c ./libold.so -o target
我們想要修改print函數(shù),變成打印“Goodbye”。我們需要編寫熱補(bǔ)丁new.c,并添加新函數(shù)和constructor:
- /* new.c */
- #include <stdio.h>
- #include <string.h>
- #include <sys/mman.h>
- #include <dlfcn.h>
- print_v2(void)
- {
- printf("Goodbye\n");
- }
- static void __attribute__((constructor)) init(void)
- {
- int numpages;
- void *old_func_entry, *new_func_entry;
- old_func_entry = dlsym(NULL, print);
- new_func_entry = dlsym(NULL, print_v2);
- #define PAGE_SHIFT 12
- #define PAGE_SIZE (1UL << PAGE_SHIFT)
- #define PAGE_MASK (~(PAGE_SIZE-1))
- numpages = (PAGE_SIZE - (old_func_entry & ~PAGE_MASK) >= size) ? 1 : 2;
- mprotect((void *)(old_func_entry & PAGE_MASK), numpages * PAGE_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC);
- memset(old_func_entry, 0x48, 1);
- memset(old_func_entry + 1, 0xb8, 1);
- memcpy(old_func_entry + 2, &new_func_entry, 8);
- memset(old_func_entry + 10, 0xff, 1);
- memset(old_func_entry + 11, 0xe0, 1);
- }
編譯:
- gcc -fPIC --shared new.c -ldl -o libnew.so
然后通過加載程序?qū)arget進(jìn)程打入熱補(bǔ)丁libnew.so,***我們對(duì)target程序打入這個(gè)熱補(bǔ)丁,觀察變化:
- # ./target
- Hello
- Hello
- Goodbye
- Goodbye
- …
我們發(fā)現(xiàn)熱補(bǔ)丁確實(shí)改變了print函數(shù),***通過gdb進(jìn)一步確認(rèn),可以看出print函數(shù)的入口被修改成48 b8 dc b6 15 a9 c1 7f 00 00 ff e0,與我們的預(yù)期相符:
- (gdb) disas /r print
- Dump of assembler code for function print:
- 0x00007fc1a98f456c <+0>: 48 b8 dc b6 15 a9 c1 7f 00 00 movabs $0x7fc1a915b6dc,%rax
- 0x00007fc1a98f4576 <+10>: ff e0 jmpq *%rax # 這里print在入口處跳轉(zhuǎn)到0x7fc1a915b6dc這個(gè)地址
- …
- (gdb) info symbol 0x7fc1a915b6dc
- print_v2 in section .text of /root/process-hotupgrade/test/libnew.so # 0x7f2ea417971c這個(gè)地址就是print_v2函數(shù)的地址
總結(jié)
我們介紹了應(yīng)用程序熱補(bǔ)丁的基本原理,實(shí)踐了一個(gè)應(yīng)用程序熱補(bǔ)丁demo。此類熱補(bǔ)丁適用于動(dòng)態(tài)替換共享鏈接庫中的可見函數(shù),可以修復(fù)例如glibc “GHOST漏洞”(CVE-2015-0235)等等,在UCloud我們利用熱補(bǔ)丁修復(fù)了若干缺陷,在用戶沒有感知的情況下把bug快速及時(shí)的修復(fù)。這些熱補(bǔ)丁修復(fù)程序里,絕大多數(shù)代碼是通用的,只需少數(shù)幾行做特殊替換。
上文介紹的熱補(bǔ)丁技術(shù)對(duì)于適用的場(chǎng)景非常理想,簡(jiǎn)單可靠,但存在幾個(gè)缺點(diǎn):
- 手寫熱補(bǔ)丁代碼門檻較高,特別是被修復(fù)函數(shù)的依賴函數(shù)鏈較長(zhǎng)時(shí)手寫熱補(bǔ)丁很容易出錯(cuò)
- 無法修復(fù)局部函數(shù)和局部變量(只能修復(fù)全局可見的函數(shù)和變量)
【本文是51CTO專欄機(jī)構(gòu)作者“大U的技術(shù)課堂”的原創(chuàng)文章,轉(zhuǎn)載請(qǐng)通過微信公眾號(hào)(ucloud2012)聯(lián)系作者】