天天看點

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

關注騰訊雲大學,了解行業最新技術動态    

騰訊雲大學知識分享月已經開幕了

為了讓大家沉澱知識,

我們邀請了

趙昕講師

将直播内容整理成了文章

話不多說讓我們再來回顧一下課程内容吧

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

直 播 回 顧

簡介

動态連結庫(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關系圖,通過名字查找函數了。具體的查找關系圖如下:

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

簡單講解一下上圖

  • 首先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的函數位址。

這裡派生出幾個問題

  1. 為什麼要動态連結?

        為了解決重複代碼、更新難的問題,把代碼按子產品分開。(實際上linux各種運作時庫的版本也很難受)

  2. 為什麼不做成機器碼直接jmp就好了?

        機器碼裡直接jmp,但是事先不知道目标位址,是以隻能填空,這樣又不好與正常代碼區分。是以搞一個plt的地方,來做這個事情。

  3. plt怎麼運作的?

        寫一個so,這個so隻是調用了下puts函數,然後objdump觀察機器碼。

        可以看到調用puts的地方,實際上是調用了[email protected],即plt的某個位置

    .so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

                往上找一找,找到[email protected]的定義,即0x580的位置,可以看到機器碼如下:

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

    第一行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是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

使用者函數調用:

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

到了這裡,我們已經完成了使用者函數調用,也即完成了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;}
           

圖示

兩種替換的示意圖如下:

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

函數指針綁定熱更新

前面我們已經完成了常見的函數熱更新,對于某些項目,比如Lua,會将函數位址與字元串做一個映射,然後存到一個map中。

這種情況,修改got已經不能滿足了,因為map存放的是最終位址,隻能修改函數機器碼jmp。

假如有100個函數,那麼就要修改100次,對于導出lua函數比較多的so來說,會很麻煩,特别是類成員函數的名字還很複雜。

是以最好能直接注入一個新的so,重新綁定一下,将map中的位址替換為新的函數位址。

隐含問題

這裡有幾個問題:

  1. 如何拿到 lua_State  * L?

        衆所周知,Lua的資料都是儲存在L中,除非搞一個全局變量,不然我們調用綁定函數的時候,需要指定L,如rebind(lua_State * L)

  2. rebind函數調用時機?

        因為我們ptrace的時候,目标程序會處于任意狀态,如果直接調用rebind,會導緻Lua的重入,要麼死鎖要麼core掉。選擇一個調用時機很重要。

解決方案

  1. 如何拿到  lua_State  * L?

        關于拿到L的問題,我們隻需要讓目标程序在執行某個Lua函數的時候斷住,然後擷取它的參數,就能拿到L。至于如何斷住,和前面的函數調用類似,直接在目标函數的入口寫入一個int3即可。

  2. rebind函數調用時機?

        調用時機也可以順便在這裡一起解決,我們做一個觸發的機制,當目标程序執行某個函數的時候,讓他停住,先執行一個新的函數。

具體示意圖如下:

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

最終效果,我們在lua_settop的地方斷住,此時可以認為Lua的棧是穩定的,我們隻要保證執行後,Lua棧一緻即可。當斷住後,拿到第一個參數L,執行rebind(lua_State * L)完成重新綁定。

.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧
.so是什麼檔案_linux的so注入與熱更新原理 | 直播回顧

繼續閱讀