
關注騰訊雲大學,了解行業最新技術動态
騰訊雲大學知識分享月已經開幕了
為了讓大家沉澱知識,
我們邀請了
趙昕講師
将直播内容整理成了文章
話不多說讓我們再來回顧一下課程内容吧
直 播 回 顧
簡介
動态連結庫(SO檔案)在Linux中使用非常廣泛,對于背景開發來說,伺服器程序往往加載和使用了很多的SO檔案,當需要更新某個SO時往往需要重新開機程序。本課程将講述如何做到不重新開機程序,而将so的修改熱更新生效!
原理
不管是熱更新so還是其他方式操作so,都要先注入才行。是以先考慮如何注入so。
其實往一個程序注入so的方法,很簡單,讓程序自己調用一下dlopen即可。這個就是基本原理,剩下的事情,就是如何讓他調用。
那麼如何操作?這裡要介紹一下linux的ptrace函數。
ptrace
ptrace很多人也用過,大緻意思就是拿來控制其他程序的,讀寫記憶體,讀寫寄存器,下斷點,追蹤系統調用,相當于可程式設計版gdb,實際上gdb就是基于ptrace實作。
ptrace的定義如下:
#include long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
通過設定request的值,來實作具體的操作,本文用到的大部分如下:
PTRACE_ATTACH:關聯上目标程序
PTRACE_GETREGS:讀目标程序寄存器
PTRACE_SETREGS:寫目标程序寄存器
PTRACE_PEEKTEXT:讀目标程序記憶體資料
PTRACE_POKETEXT:寫目标程序記憶體資料
PTRACE_CONT:目标程序繼續
PTRACE_DETACH:斷聯目标程序
比如設定PTRACE_PEEKTEXT,就能把目标程序的某個位址的記憶體讀到本程序。
使用者函數調用
前面說到,我們希望讓目标程序調用dlopen(target.so),來實作target.so的注入。抽象出來,就是如何讓目标程序調用一個使用者函數(即,非系統調用的函數)。
那麼如何調用?可以拆分為兩步,第一步找到目标函數的位址,第二步調用它。
函數查找
我們知道,linux的可執行檔案是elf檔案格式,動态連結庫其實也是elf格式。關于elf,有很多資料,這裡簡單講一下elf結構。
elf
elf全名Executable and Linkable Format,存在的主要目的,就是為了程式的執行。而程式的執行,需要哪些資訊呢?
- 具體做事情的代碼,也即代碼段,當我們調用到了int add()函數,進去的就是這個地方
- 為了友善調試或者查找,會把add名字記錄下來,與代碼段對應上,這樣就知道是哪個函數了
- 對于動态連結庫,有的函數是在執行的時候,才能知道位址在哪裡,比如main使用了一個頭檔案定義的函數int add(),最後編譯成了main.out與add.so兩個elf檔案。兩個elf之前是互相獨立的,那麼就需要在main.out記錄引用了外部的add函數,add.so裡記錄導出了add函數
最後這些資訊,加上一些亂七八糟的,以一塊一塊(section)的形式組合而成,就是elf檔案了。
查找過程
查找函數位址的過程也分為兩步,查找so起始記憶體位址,查找函數所在so偏移,兩者相加就是函數的位址
查找so起始位址
首先,我們要查找某個so的函數,就得先找到so所在的記憶體位置才行。
例如要調用dlopen,而dlopen是在libc.so中,那麼我們第一步就是要找到libc.so所在記憶體的位址。
好在linux很友善的提供了方法,通過cat /proc/程序id/maps,可以看到記憶體布局
7f3a9a270000-7f3a9a42a000 r-xp 00000000 fc:01 25054 /usr/lib64/libc-2.17.so
7f3a9a42a000-7f3a9a629000 ---p 001ba000 fc:01 25054 /usr/lib64/libc-2.17.so
7f3a9a629000-7f3a9a62d000 r--p 001b9000 fc:01 25054 /usr/lib64/libc-2.17.so
7f3a9a62d000-7f3a9a62f000 rw-p 001bd000 fc:01 25054 /usr/lib64/libc-2.17.so
這裡第一排的7f3a9a270000,就是libc-2.17.so在記憶體的起始位址。
這裡我們先假定elf是完整映射到了記憶體中,那麼隻需要分析記憶體中的elf結構就可以了。實際上某些比較大的如libstdc++.so并不是,對于這種情況,就需要指定具體so的檔案路徑,解析好函數在檔案中的偏移,再加上so記憶體位址就是函數位址了。
查找函數所在so偏移
前面我們找到了so的起始位址,也分析了elf格式,剩下的就是照着elf關系圖,通過名字查找函數了。具體的查找關系圖如下:
簡單講解一下上圖
- 首先elf頭裡,記錄了存着section name table(節頭字元表)在哪裡。
- 找到節頭字元表,就能知道這些section具體的類型。
- 接着找到dynsym(動态連結符号表),即導出給外部用的函數資訊,跟着用dynstr定位這些符号的名字,這一步就能定位有沒有想找的函數了,比如在libc裡找到dlopen(實際上是__libc_dlopen_mode)。
- 如果找的是foo1,那麼就能通過dynsym裡的st_shndx字段,找到代碼所在的section,那麼可以算出,這個函數的位址=elf加載的位址+section所在elf的偏移。
- 如果找的是foo2,foo2是在另一個elf中定義的,例如之前提到的,調用add.so函數的add函數。那麼就需要左邊的rela.plt(重定向資訊)以及got.plt(位置偏移資訊)。
- 當發現foo2在dynsym裡的st_shndx字段是undef時,通過index定位到rela.plt中的位置,進一步取到偏移表的位置,這個位置的值,指向了foo2的函數位址。
這裡派生出幾個問題
-
為什麼要動态連結?
為了解決重複代碼、更新難的問題,把代碼按子產品分開。(實際上linux各種運作時庫的版本也很難受)
-
為什麼不做成機器碼直接jmp就好了?
機器碼裡直接jmp,但是事先不知道目标位址,是以隻能填空,這樣又不好與正常代碼區分。是以搞一個plt的地方,來做這個事情。
-
plt怎麼運作的?
寫一個so,這個so隻是調用了下puts函數,然後objdump觀察機器碼。
可以看到調用puts的地方,實際上是調用了[email protected],即plt的某個位置
.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧
往上找一找,找到[email protected]的定義,即0x580的位置,可以看到機器碼如下:
第一行jmpq通過got的值跳轉,在初始時got的值直接為下一行,即0x586,于是開始執行第二行。第二行和 第三行傳參調用libc完成了綁定puts的過程,并且更新got。
後續再調用第一行,就直接跳轉到了目标函數了。
4、為什麼plt裡不直接存放位址,要搞個got?
理論上是可以got裡的每個位址拆分放到plt中,可能是出于邏輯與資料分離考慮,并且分開後記憶體頁的讀寫 權限更好管理,畢竟一個是可執行,一個是可寫。
到這裡,函數的位址就拿到了,如果是外部函數,還知道了存放函數位址的指針在哪,即got的位置,這個後面做替換的時候會用到。
函數調用
在前面我們已經拿到了函數的位址了,剩下的就是修改目标程序的寄存器與記憶體。
通過查閱資料可知,linux amd64調用函數,用到的寄存器及含義如下:
- rdi:參數1
- rsi:參數2
- rdx:參數3
- rcx:參數4
- r8:參數5
- r9:參數6
- rax:函數位址
- rbp:棧底位址
- rsp:棧頂位址
- rip:執行代碼位址
比如我們要調用dlopen("target.so"),dlopen的位址前面我們已經拿到,但是參數"target.so"是一個字元串,寄存器裡存放的是字元串的位址,而目标程序中并沒有這個記憶體,怎麼辦?同時函數運作需要的棧空間,也需要記憶體,怎麼擷取?
解決方法是調用一下系統調用mmap來申請記憶體,抽象一下,就是如何讓目标程序調用系統調用
系統調用
系統調用比較簡單,查閱相關資料,系統調用的寄存器及含義如下:
- rdi:參數1
- rsi:參數2
- rdx:參數3
- r10:參數4(注意這裡和使用者函數不一樣)
- r8:參數5
- r9:參數6
- rax:系統調用号(如write是1)
- rip:執行代碼位址
mmap的定義為
void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
第一個參數填0表示系統配置設定,其他都是int,參數很好解決。系統調用号填9。都準備好,讓目标程序執行一個syscall指令就開始調用了。
剩下的問題就是rip怎麼處理?以及如何拿到傳回值?
函數執行
我們期望函數能夠跑某段機器碼,即設定一個rip。如前所述,申請記憶體的方式要調用函數,陷入了雞生蛋的輪回。
有個方法是直接修改elf的某段可執行記憶體,改完再複原。
這裡可以取巧,使用elf頭部的8位元組無用記憶體,定義為
Elf64_Ehdr e_ident[8-16]
是以我們就用這8個位元組,來作為函數調用需要的機器碼存放位址。在數組裡寫入一個syscall指令
[0x0f, 0x05]
函數傳回值
當目标程序執行完syscall後,如何斷住,能讓本程序拿到傳回值,比較簡單,直接在前面的code空間裡,寫入int3斷點指令,再填滿無用指令nop
[0x0f, 0x05, 0xcc, 0x90, 0x90, 0x90, 0x90, 0x90]
這樣當目标程序執行到0xcc時,會發出SIGTRAP信号,ptrace它的本程序就會收到信号斷住,類似于gdb的斷點。
這時候就可以擷取寄存器的rax值,拿到傳回值。對于mmap,就是實際的記憶體位址。
函數調用尾聲
在前面,我們找到了函數位址,一系列系統調用,準備好了執行環境,剩下的事情就是調用我們想要的函數了。
這裡調用方式與傳回值獲得,其實和系統調用沒啥差別,就不再贅述。
總結系統調用與使用者函數調用,如下圖所示:
系統調用:
使用者函數調用:
到了這裡,我們已經完成了使用者函數調用,也即完成了so的注入。下一步就開始具體的熱更新操作了。
使用者函數熱更新
如前所述,我們可以随意注入so到某個程序,也能找到某個so的某個函數的位址。那麼熱更新其實比較簡單。這裡分為了兩種,分别是内部函數、外部函數。
内部函數
還是回到前面的例子,例如main加載了add.so,執行add.so的add函數,我們期望以後調用add都變成addnew.so的addnew函數。這種add在add.so内部定義,這種替換方式就叫内部函數替換。
那麼如何替換呢?很簡單,注入addnew.so,找到addnew.so的addnew函數位址。然後修改add函數的機器碼,寫一個jmp到addnew函數。
替換的代碼如下:
int offset = (int) ((uint64_t) new_funcaddr - ((uint64_t) old_funcaddr + 5));char code[8] = {0};code[0] = 0xe9;memcpy(&code[1], &offset, sizeof(offset));
外部函數
剛才的例子,假設add.so的add函數,調用了c标準庫libc.so的puts函數列印結果,我們期望不要調用puts,改為自定義的putsnew。這種puts在add.so外部定義,這種替換方式就叫外部函數替換。
那麼如何替換呢?很簡單,注入查找新的函數位址,直接把新的函數位址寫入got即可。
注意這裡的修改隻對add.so生效,其他so調用puts還是不變。
代碼如下:
// func out .soret = remote_process_write(pid, old_funcaddr_plt, &new_funcaddr, sizeof(new_funcaddr));if (ret != 0) { close_so(pid, handle); return -1;}
圖示
兩種替換的示意圖如下:
函數指針綁定熱更新
前面我們已經完成了常見的函數熱更新,對于某些項目,比如Lua,會将函數位址與字元串做一個映射,然後存到一個map中。
這種情況,修改got已經不能滿足了,因為map存放的是最終位址,隻能修改函數機器碼jmp。
假如有100個函數,那麼就要修改100次,對于導出lua函數比較多的so來說,會很麻煩,特别是類成員函數的名字還很複雜。
是以最好能直接注入一個新的so,重新綁定一下,将map中的位址替換為新的函數位址。
隐含問題
這裡有幾個問題:
-
如何拿到 lua_State * L?
衆所周知,Lua的資料都是儲存在L中,除非搞一個全局變量,不然我們調用綁定函數的時候,需要指定L,如rebind(lua_State * L)
-
rebind函數調用時機?
因為我們ptrace的時候,目标程序會處于任意狀态,如果直接調用rebind,會導緻Lua的重入,要麼死鎖要麼core掉。選擇一個調用時機很重要。
解決方案
-
如何拿到 lua_State * L?
關于拿到L的問題,我們隻需要讓目标程序在執行某個Lua函數的時候斷住,然後擷取它的參數,就能拿到L。至于如何斷住,和前面的函數調用類似,直接在目标函數的入口寫入一個int3即可。
-
rebind函數調用時機?
調用時機也可以順便在這裡一起解決,我們做一個觸發的機制,當目标程序執行某個函數的時候,讓他停住,先執行一個新的函數。
具體示意圖如下:
最終效果,我們在lua_settop的地方斷住,此時可以認為Lua的棧是穩定的,我們隻要保證執行後,Lua棧一緻即可。當斷住後,拿到第一個參數L,執行rebind(lua_State * L)完成重新綁定。