1. 程式人生 > 其它 >libco原始碼解析---hook機制探究

libco原始碼解析---hook機制探究

引言

  在探究這個機制之前我們先來看看libco為什麼被騰訊的工程師們創造出來。

  如今微信已經是一個月活近12億的現象級軟體,不可否認其背後的技術架構一定是首屈一指的。但是羅馬不是一日建成的。實際在微信執行之初其併發能力並不是像現在一樣。事實上當時大部分模組都採用了半同步半非同步模型。接入層為非同步模型,業務邏輯層則是同步的多程序或多執行緒模型,業務邏輯的併發能力只有幾十到幾百。顯然微信需要一次技術上的革命。

有兩種方案被提出來:

  • A 執行緒非同步化:把所有服務改造成非同步模型,相當於把整個微信的框架重新實現一遍,從技術上來說是合理的,但是風險和代價都太大。
  • B 協程非同步化:對業務邏輯非侵入的非同步化改造,即使用某種技術使得能夠在最小化侵入程式碼的情況下完成非同步改造。

最終騰訊的工程師選擇了後者,libco就是這個方案的最終結果。

以上我們可以看出libco除了非同步以外最重要的功能就是要做到對於使用者程式碼的侵入性小,即最小化修改使用者程式碼,hook機制就是實現這一步最為重要的一點。

靜態連結與動態連結

  靜態連結和動態連結的相關只是,可閱讀這篇部落格:Linux下的靜態連結與動態連結

  我們知道在連結的過程中並不是只有我們的程式碼而已,在絕大多數情況下要用到安全穩定的標準庫(大多數情況),那我們就要把庫的程式碼拿到我們的的程式碼中來,我們知道在連結階段已經得到了一張符號表,所以我們要做的就是把引用與定義相連線,但一個標準庫中檔案顯然是很多的,如果每次引用都會把庫中的程式碼都拷貝了顯然是有些笨重的。

  所以我們的靜態庫採用了把庫中每一個函式都變成一個獨立的可重定位檔案,這樣我們在連結階段就可以把我們需要的部分拷貝,其他的則不同理會,這樣極大的節省了我們的寶貴的記憶體

  如此看來靜態連結已經很好的解決了問題,為什麼還有動態連結呢?

    第一點是因為靜態庫雖然已經很好的節省了記憶體,但在很多情況下還是不夠優秀,比如常用的標準IO庫,我們幾乎每個程式都需要,難到要給每一個程序都拷貝一份嗎,答案是否定的,共享庫的策略是每一個檔案系統系統只有一份庫的資料,每一個庫的程式碼部分可被不同程序共享,也就是說每一個程序的虛擬地址經過翻譯後得到的實體地址相同,共用一份庫,這樣就優化了我們的記憶體佔用。

    第二點就是靜態庫因為是在編譯的時候執行的,所以就算靜態庫有一點點小小的改變都需要重新編譯程式,然後釋出出去(對於玩家來說,可能是一個很小的改動,卻導致整個程式重新下載,全量更新)。所以動態庫其實並不是在編譯時載入而是在執行時載入的,這樣就解決了靜態庫對程式的更新、部署和釋出頁會帶來麻煩,使用者只需要更新動態庫即可。

  具體的實現取決於一個重要的結構,即GOT(Global Offset Table),即全域性偏移量表,因為連結階段可能不知道一些符號的的具體位置,所以我們需要一個數據結構來儲存經過動態載入後找到的符號的真實地址,這就是GOT。

當然動態連結也分為載入時連結和執行時連結,

  • 載入時連結就是在程式載入時重定位GOT中的條目,系統用匯入DLL函式的啟動地址修改函式地址表。DLL將在初始化期間對映到程序的虛地址空間,並僅在需要時載入到實體記憶體中
  • 執行時連結就是對於每一條GOT條目在庫中進行匹配。當應用程式呼叫LoadLibrary 或 LoadLibraryEx 函式時,系統就會嘗試按載入時動態連結搜尋次序(參見載入時動態連結)定位DLL。如果找到,系統就把DLL模組對映到程序的虛地址空間中,並增加引用計數。如果呼叫LoadLibrary或LoadLibraryEx 時指定的DLL其程式碼已經對映到呼叫程序的虛地址空間,函式就會僅返回DLL的控制代碼並增加DLL引用計數。注意:兩個具有相同檔名及副檔名但不在同一目錄的DLL被認為不是同一個DLL。

  所以所謂的hook其實就是在這張表上把原本對應的地址改變,舉個例子,在載入時把我們自己的read函式地址放到表中,並使用dlsym族函式獲取hook前函式的地址,這樣就可以在自己實現的read中回撥原函式,並加上一些額外的邏輯,並且在執行是會呼叫我們的版本了

hook機制

  系統提供給我們的dlopen、dlsym族函式可以用來操作動態連結庫,這也為hook機制提供了技術上的保證。我們來舉一個簡單的例子,我們要實現的功能是在呼叫系統給我們提供的read時列印一句“hello world”,並回調系統的read。

第一種方法是使用LD_PRELOAD環境變數。

  LD_PRELOAD是Linux系統的一個環境變數,它可以影響程式的執行時的連結(Runtime linker),它允許你定義在程式執行前優先載入的動態連結庫。這個功能主要就是用來有選擇性的載入不同動態連結庫中的相同函式。通過這個環境變數,我們可以再主程式和其動態連結庫的中間載入別的動態連結庫,甚至覆蓋正常的函式庫。一方面,我們可以以此功能來使用自己的或是更好的函式(無需別人的原始碼),而另一方面,我們也可以向別人的程式注入程式,從而達到特定的目的.

  系統一般會去LD_LIBRARY_PATH下尋找,但如果使用了這個變數,系統會優先去這個路徑下尋找,如果找到了就返回,不在往下找了,動態庫的載入順序為LD_PRELOAD>LD_LIBRARY_PATH>/etc/ld.so.cache>/lib>/usr/lib。

我們來看一個小小的例子:

 1 //hookread.cpp
 2 #include <dlfcn.h>
 3 #include <unistd.h>
 4 
 5 #include <iostream>
 6 
 7 typedef ssize_t (*read_pfn_t)(int fildes, void *buf, size_t nbyte);
 8 
 9 static read_pfn_t g_sys_read_func = (read_pfn_t)dlsym(RTLD_NEXT,"read");
10 
11 ssize_t read( int fd, void *buf, size_t nbyte ){
12     std::cout << "進入 hook read\n";
13     return g_sys_read_func(fd, buf, nbyte);
14 }
15 
16 void co_enable_hook_sys(){
17     std::cout << "可 hook\n";
18 }
 1 #include <bits/stdc++.h>
 2 #include <sys/socket.h>
 3 #include <netinet/in.h>
 4 #include <arpa/inet.h>
 5 #include <unistd.h>
 6 
 7 using namespace std;
 8 
 9 int main(){
10     int fd = socket(PF_INET, SOCK_STREAM, 0);
11     char buffer[10000];
12     
13     int res = read(fd, buffer ,10000);
14     return 0;
15 }

執行以下命令:

1 g++ -o main main.cpp
2 g++ -o hookread.so -fPIC -shared -D_GNU_SOURCE hookread.cpp -ldl
3 LD_PRELOAD=./hookread.so ./main

輸出為:

1 進入 hook read

這樣就成功實現了hook!

libco如何做

  但是libco並不是這樣做的,整個libco中你都看不到LD_PRELOAD,libco使用了一種特殊的方法,祕密都在於co_enable_hook_sys函式,通過在使用者程式碼中包含這個函式,可以把整個hookread.cpp中的符號表匯入我們的專案中,這樣也可以做到使用我們自己的庫去替換系統的庫。我們來看看如何做到吧:

hookread.cpp檔案

 1 //hookread.cpp
 2 #include <dlfcn.h>
 3 #include <unistd.h>
 4 
 5 #include <iostream>
 6 
 7 #include "hookread.h"
 8 
 9 typedef ssize_t (*read_pfn_t)(int fildes, void *buf, size_t nbyte);
10 
11 static read_pfn_t g_sys_read_func = (read_pfn_t)dlsym(RTLD_NEXT,"read");
12 
13 ssize_t read( int fd, void *buf, size_t nbyte ){
14     std::cout << "進入 hook read\n";
15     return g_sys_read_func(fd, buf, nbyte);
16 }
17 
18 void co_enable_hook_sys(){
19     std::cout << "可 hook\n";
20 }

hookread.h檔案

1 // hookread.h
2 void co_enable_hook_sys();

main.cpp檔案

 1 // main.cpp
 2 #include <bits/stdc++.h>
 3 #include <sys/socket.h>
 4 #include <netinet/in.h>
 5 #include <arpa/inet.h>
 6 #include "hookread.h"
 7 #include <unistd.h>
 8 
 9 using namespace std;
10 
11 int main(){
12     co_enable_hook_sys();
13     int fd = socket(PF_INET, SOCK_STREAM, 0);
14     char buffer[10000];
15     
16     int res = read(fd, buffer ,10000);
17     return 0;
18 }

執行以下命令

1 g++ hookread.cpp -o hookread.i -E
2 g++ hookread.i -o hookread.s -S
3 g++ hookread.s -o hookread.o -c
4 g++ main.cpp -ldl hookread.o
5 ./a.out

輸出為

1 可 hook
2 進入 hook read

  我們可以看到這樣也可以達到hook的目的,雖然相比於上一種方法,會對使用者程式碼造成侵入,但是好處是不需要使用者自己去配置環境變數降低使用難度。

總結

  重點都是動態連結相關的知識點,只有懂了動態連結,這些都不是神祕什麼問題啦。也就是通過hook機制,libco可以達到使用者無感的情況下把同步的程式碼替換為非同步,這也是騰訊工程師寫出libco的目的。實際上就是用我們自己實現的將函式地址去代替標準函式地址,將其放到GOT中。

參考文章

https://blog.csdn.net/dtdn/article/details/307718

https://blog.csdn.net/weixin_43705457/article/details/106895038