一套使用注入和Hook技术托管入口函数的方案
? ? ? ? 工作中,我們可能會經常使用開源項目解決一些領域中的問題。這種“拿來主義”是一種“專業人干專業事”的思想,非常實用。(轉載請指明出于breaksoftware的csdn博客)
? ? ? ? 一般場景下,我們都是把開源項目代碼編譯到我們自己的項目中。這樣的“融合”,就相當于讓兩個項目進行了“基因重組”,最終產出一個“基因戰士”。在進行“基因重組”中,需要“專業人員”對開源項目中每個“基因序列”有足夠的理解,這樣才能正確挑選出適合的“序列”;還需要知曉這些“序列”的組合關系,才能將其正確“剪切”且“組裝”到我們項目的“基因”中。
? ? ? ? 這對“專業人員”有著比較高的要求,因為研究透一套開源項目并不容易。
? ? ? ? 然而對于急需解決問題的人們來說,“拿來主義”應該足夠簡單且穩定。如果需要花很多時間去熟悉研究一個開源項目,并且不能保證使用的正確性,也不能保證開源項目內部實現的穩定性,就可能比較不值得。可以想象下,假如我們“基因重組”出來的“物種”總是夭折(頻繁崩潰),是對項目多么大的打擊。
? ? ? ? 這樣的例子并不少,比較常見的是開源項目curl。它幫我們實現很多下載、上傳等網絡業務。幾年前,我曾經花費了不少時間研究過它的使用。可以參見《實現HTTP協議Get、Post和文件上傳功能——使用libcurl接口實現》,大家只管直接往后翻閱,就會發現這套“基因”并不是那么容易組裝的。
? ? ? ? 再稍微復雜點,像視頻領域的開源項目ffmpeg。之前為了熟悉它的“基因序列”,我也花費了不少時間,才摸清了套路。大家同樣可以參見《ffmpeg api的應用——提取視頻圖片》,就會發現這套系統也有其獨特的設計思路。而且比較悲劇的是,我們可能并不能將去各個方面都摸透。比如如何防止超時?如果防止啟動過多線程?如果要解決這些關鍵而又“專業”的問題,可能就需要花費更多的時間。對于“拿來主義”來說,這可能就變味了。
? ? ? ? 而且,在實踐中,我們發現一些開源項目自身的穩定性不是十分可靠(比如ffmpeg)。如果的在線服務是“重組”了這些項目的“基因”,就會面臨著很大的穩定性風險。
? ? ? ? 這么看來,“基因重組”是需要“非常專業”的人員花費大量的時間來“組裝”出一個不是十分理想產品的方案。
? ? ? ? 但是有些時候,我們出于“定制”、“高效”等原因不得不使用“基因重組”,那么就需要花很多金錢和時間去把上述問題都攻破。
? ? ? ? 可是,在實際場景中,并不是每個項目都“苛刻”到必須使用“基因重組”。
? ? ? ? 有些開源項目,是以工具的形式發布的,其公布的源碼或者開放接口只是副產品。比如curl或者ffmpeg,我們可以使用這些可執行文件完成大部分業務。這就像人體需要糖分,我們可以直接食用飽含糖的香蕉。而不需要去把香蕉的基因和人類的基因“融合”,產生出一個只要曬曬太陽喝喝水就能產生糖的“轉基因人”。
? ? ? ? 說了這么多,可能有人會說,不就是直接啟動工具并接管輸出么?不錯,是這樣的。但是我們追求可以更高點。
? ? ? ? 在最近工作中,我們就遇到這樣場景。我提出一種“進程池”模型,即:這些工具是以獨立進程運行的;這些進程組成一個動態可管理的池子。
? ? ? ? 這相對于“線程池”來說,就是新瓶裝舊酒,沒什么新意。但是從實現的角度來說,還是存在一些可以挖掘的技術點。比如一般工具都是在運行一次后就退出了,那就意味著工具進程頻繁的生死。怎么解決這個問題?這就是本文要探討的一個技術方案。
? ? ? ? 目前我想到的一個方案就是托管工具的主函數,然后替換成我們的函數。我們的函數負責和父進程通信傳遞請求(之前是通過命令行的方式)和結果,并且調用原來的主函數。
? ? ? ? 這個方案一個基礎的技術點便是:如何托管工具的主函數?
? ? ? ? 首先我們需要明確一些基礎知識。
- Main函數是主函數么?不一定。Main函數只是一種約定,我們的程序并不一定需要一個叫做main的函數才能運行。這塊可以參見編譯鏈接等知識。
- 主函數是進程運行的第一個函數么?不是。在調用主函數之前,系統還要做很多預分配等工作。這塊可以參見進程啟動的原理等知識。
- 哪些我們可以定制的行為可以在主函數之前執行?這個問題如果換做一道經典的面試題就是“全局變量是在什么時候被構建的”?我想大家已經知道了答案了。
? ? ? ? 因為只是一個方案的調研,我們先把問題簡化。不求這個方案可以滿足所有場景,但求大部分場景可以覆蓋。于是本文的方案將基于一個假設:工具的主函數就是main。對于例外的場景,只要替換方案中尋找主函數的邏輯即可。
? ? ? ? 在linux系統中,我們啟動另外一個可執行文件是通過fork和exec系列函數實現的。fork完之后,進程的代碼空間還和主進程一樣。exec系列函數被執行后,進程的代碼空間就變成目標文件的了。這段割裂讓我們無法常規的使用主進程中的代碼去干預子進程。然而干預必須存在,否則怎么替換子進程的主函數?
? ? ? ? 這就需要使用注入技術了。注入分為提前注入和普通注入,提前注入要求在主函數執行之前注入。很明顯我們需要提前注入,因為子進程主函數執行起來后,我們如何找到時機將流程切換到我們的“替換的主函數”中就是個比較困難的問題。關于這塊的技術方案,我曾經寫過一個windows下的系列。感興趣的同學可以參見《VC下提前注入進程的一些方法1——遠線程不帶參數》,《VC下提前注入進程的一些方法2——遠線程帶參數》,《VC下提前注入進程的一些方法3——修改程序入口點》,《VC提前注入.net軟件的方法》。
? ? ? ? 在linux下,一種常見的方案是使用ptrace。這塊方案已經比較成熟,我就不再展開。除了這個之外,還有種比較簡單的方案,就是使用LD_PRELOAD。使用過gperftools的同學應該對這個方案有點熟悉,它可以讓我們檢測沒有“基因重組”gperftools的庫的程序。但是它有個限制,要求進程動態加載libc.so。
? ? ? ? 在方案確定可行的情況下,我選用LD_PRELOAD。因為這只是調研,先把整體流程走通再說。最終我們會替換到一些終極方案,比如ptrace。
? ? ? ? 我們直接看主進程的代碼
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>int main(int argc, char * argv[]) {std::cout << "parent main start" << std::endl;pid_t child = 0;int status = 0;child = fork();if (child == -1) {std::cerr << "fork error" << std::endl;return -1;}if (child == 0) {//in child processchar* newargv[] = { "./child", "hello", "world", nullptr };char* newenviron[] = { "LD_PRELOAD=./inject.so", nullptr };execve("./child", newargv, newenviron);}else {//in parent processwait(&status);std::cout << "child status=" << WEXITSTATUS(status) << std::endl;;}return 0;
}
? ? ? ? ?主進程邏輯很簡單,就是啟動同錄下一個叫做child的程序,并傳遞hello world這兩個參數。同時使用LD_PRELOAD讓子進程提前加載同目錄下的inject.so文件。其編譯指令是
g++ parent.cpp -ldl -o parent
? ? ? ? 子進程更簡單,只是輸出輸入的參數,然后退出。
#include <iostream>
#include <dlfcn.h>int main(int argc, char** argv) {std::cout << "client main start" << std::endl;for (int i = 0; i < argc; i++) {std::cout << "argv[" << i << "]\t" << argv[i] << std::endl;}std::cout << "client main end" << std::endl;return 0;
}
? ? ? ? 但是其編譯指令有點特別,需要額外加入-rdynamic。這是為了方便在注入模塊中比較簡單的獲取主函數——main的地址。此時需要指出的是,這只是一個便捷的方案,而不是必要條件。因為如果我們限制了工具的編譯方式,將極大限制這套方案的適用性。當然不可否認的是,尋找一個普遍適用的主函數地址并不是一件容易的事。目前我可能想到的替代方案是,通過hook libc庫中的__libc_start_main,從其第一個參數中獲取主函數地址。
g++ child.cpp -ldl -rdynamic -o child
? ? ? ? 現在我們看下注入的模塊的代碼
#include <iostream>
#include <dlfcn.h>
#include <limits.h>
#include <unistd.h>
#include <stdio.h>#include <iostream>
#include <dlfcn.h>
#include <limits.h>
#include <unistd.h>
#include <stdio.h>extern "C" {
#include "funchook.h"
}typedef int (*main_func)(int, char**);
main_func g_main_ori = nullptr;int main_stub(int argc, char** argv) {std::cout << "main_stub start" << std::endl;for (int i = 0; i < argc; i++) {std::cout << "main_stub argv[" << i << "]\t" << argv[i] << std::endl;}while (true) {sleep(1);g_main_ori(argc, argv);}return 0;
}class Inject {
public:Inject() {std::cout << "inject OK" << std::endl;void *handle = dlopen(NULL, RTLD_NOW|RTLD_GLOBAL);if (!handle) {std::cerr << "dlopen error" << std::endl;}else {std::cout << "cur module address:" << handle << std::endl;}void *ptr = dlsym(handle, "main");if (ptr) {std::cout << "inject main ptr:" << ptr << std::endl;}else {std::cerr << "inject get main ptr error" << std::endl;}funchook_t *funchook = funchook_create();g_main_ori = (main_func)ptr;int rv = funchook_prepare(funchook, (void**)&g_main_ori, (void*)main_stub);if (rv) {std::cerr << "funchook_prepare error " << rv << std::endl;}rv = funchook_install(funchook, 0);if (rv) {std::cerr << "funchook_install error " << rv << std::endl;}}
};class InjectHolder {
public:InjectHolder() {};
private:Inject _inject;
};static InjectHolder g_inject_holder;
? ? ? ? 第76行,我們定義了一個InjectHolder的全局變量。它在被注入進程的main函數之前被初始化。由于其包含Inject類的對象也將被初始化,這將觸發其構造函數的執行。在Inject的構造函數中,我們將完成Hook主函數的功能。
? ? ? ? 第38到52行,我們試圖從當前進程空間中獲取main函數地址。使用dlsym只是一個簡便方案,它需要子進程編譯時使用-rdynamic。當然我們可以找到比較終極的尋找方案以去掉該限制。
? ? ? ? 第54到64行,我們試圖使用自定義的main_stub函數替換原來的main函數。
? ? ? ? 第20到31行,我們定義的main_stub函數輸出主進程傳遞過來的參數后,在一個死循環中調用原來的main函數。讓main函數成為我們的一個子函數,并且可以保證進程不退出。
? ? ? ? hook方案的選擇我折騰了一段時間。首先我選用的是subhook庫(https://github.com/Zeex/subhook.git)。很不幸。我發現這個庫有著嚴重的問題,特別是處理64位程序時,基本可以認為是不可用。經過調試和對比內存變化,我發現其本質的缺陷是64位下,部分地址偏移算的有問題。我并不打算去研究這塊,所以放棄尋找修復的方案。
? ? ? ? 最終,我找到funchook(https://github.com/kubo/funchook.git)。經驗證,它在64位系統下是可用的。由于它編譯產出是一個so文件,而我并不希望我們項目最終發布時需要發布多個so,于是就通過修改其Makefile文件,讓其編譯出一個靜態庫。然后inject.so“基因重組”這個libfunchook。
g++ inject.cpp -lfunchook -Wl,-rpath,../funchook/lib -L../funchook/lib -I../funchook/include -ldl -fPIC -rdynamic -shared -o inject.so
? ? ? ? 最后我們看下執行的效果
? ? ? ? 子進程main函數被我們托管了,從而子進程不再退出。這樣我們就實現了進程池的基礎關鍵技術。
? ? ? ? 作為對比,我們嘗試在child編譯時去掉-rdynamic參數,以使hook失敗。這樣的執行結果是:子進程執行一次主函數后便退出了。
? ? ? ? 最后說一下,這個托管主函數的方案不是十全十美。因為有些程序通過注冊信號回調干了很多事,而這套方案只適用于那些相對正常的程序。
? ? ? ? 大家可以從https://github.com/f304646673/hookmain.git獲取測試的代碼。
總結
以上是生活随笔為你收集整理的一套使用注入和Hook技术托管入口函数的方案的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: ffmpeg api的应用——提取视频图
- 下一篇: bug诞生记——隐蔽的指针偏移计算导致的