1. 程式人生 > >Linux 程序熱升級(共享庫的動態替換)

Linux 程序熱升級(共享庫的動態替換)

背景

使用者總是希望服務程序能保持穩定。如果可以 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 補丁時,如果使用者不希望裝置關機升級,產品的開發商可以利用本文的方法對裝置升級,避免因為不能停機的緣故,而無法打上安全補丁,而使產品帶著漏洞執行。