執行人: rota1001
GitHub
本專題提出一套不依賴 kallsyms 與 kprobe 的 Linux 核心 rootkit 實作手法,達成對多種核心組態的潛伏與操控。透過機器碼直接修改,本 rootkit 可透明地控制 sys_getdents64, procfs 以及 seq_operations 等關鍵介面,達成檔案與行程的隱匿。再者,藉由側錄 bsearch 與 x64_sys_call 函式並進行堆疊追蹤分析,無需仰賴符號表揭露,即可取得 ksymtab 與系統呼叫表的實體地址。本 rootkit 支援核心層級的行程隱藏、網路連線偽裝、權限提升、remote shell 建立,以及核心模組的永續注入等功能,並實作靜態 ELF 程式碼注入,使 rootkit 可藉由 Live USB 植入並於開機階段自動啟動。
現有的 kernel rootkit 在查詢沒有釋放出來的函式地址大部份是使用 kprobe
,而 kprobe
的實作依賴於 kallsyms
。然而,kallsyms
是可以在核心的編譯階段關掉的,所以這裡為了相容於各種核心組態就開始嘗試做出不依賴於 kallsyms 和 kprobe 的 kernel rootkit。
這個專案做的是在核心中去 hook sys_getdents64
來隱藏檔案。
註冊一個 callback function,在執行某個函式之前去修改暫存器的值,以達到 hook 函式的效果,這個方法不需要去改 syscall table,所以不需要去寫 cr0。
首先會用 ftrace_set_filter_ip
篩選出哪些函數要被 hook,然後用 register_ftrace_function
去註冊。
這個 ops
是一個 ftrace_ops
結構體,去設定被 hook 的函式被呼叫的時候要呼叫哪個 callback function,然後設定 flags
,這些 flags
有以下意義:
FTRACE_OPS_FL_SAVE_REGS
:讓 callback function 可以讀寫暫存器FTRACE_OPS_FL_RECURSION
:讓 callback function 中不會遞迴觸發 callback functionFTRACE_OPS_FL_IPMODIFY
:可以修改 ip(就是 program counter)callback function 是以下這個形狀:
其中 parent_ip
是這個函式被從哪裡呼叫的,可以用這個來判斷是不是在這個核心模組被呼叫的:
如何去隱藏檔案呢?一個 linux_dirent
對應到的是一個檔案,裡面有一個元素是 d_reclen
代表這個檔案的長度。在走訪一個目錄下所有檔案的時候會用這個數字來找到下一個檔案,所以只要去修改上一個檔案的 d_reclen
就能讓使用者找不到這個檔案(是在回傳給使用者的地方做修改,而不是真的在硬碟上做修改),如果這個檔案在最前面的話,就會把後面所有的檔案做前移。
就是把內嵌在這個核心模組結構體的 list_head
從 module_list
裡面去除掉。至於 show
的話就是把它重新加回來。
這裡用 VFS 界面來做實驗:
一開始掛載之後,是看得到核心模組的:
然後對它進行 read
之後,會發現看不到了:
再對它進行 write
之後它又出現了:
這裡拿來 hook 函式的方式不是去改 syscall table,而是直接改機器碼。
上面的 n_code
是會被寫入函數開頭的位置的,去進行反組譯會發現:
它其實是把函式指標 push 進去堆疊中,再 ret,所以防寫保護關掉之後在架構正確的情況下是一個很通用的 hook。
以下我在 linux 6.11 成功的把它實作出來。rooty 針對的是 x86 32 位元的架構,而我則是在 64 位元上,再加上新版本有其他分頁機制,所以要稍做修改。
要寫 shellcode 的話必須關掉防寫保護,在 linux 5.3 之後需要自己去寫 cr0,然而我跟著 lkmpg 去做之後發現它會出錯,於是找到了這個 patch,在清除 cr0 的 WP 之前要先清掉 cr4 的 CET,所以我寫了以下幾個函式:
首先初始化 cr4
,然後用 disable_wp
和 enable_wp
把需要關閉防寫保護的操作包住,這樣就能成功關掉防寫保護。
首先用 pwntools 去看一下 x86 要怎麼做到一樣的事情:
所以我們只要在 0102030405060708
的部份填上地址就好,於是修改成這樣的實作:
我建了兩個函式 A
、B
:
並且在 hello_init
裡去做 hook,然後呼叫 A
函式:
掛載之後,成功的呼叫了 B
函式:
這個作法我想現在已經不適用了,原因是因為現在的系統呼叫已經不用 sys_call_table
了。這個方法首先去找 idt table,其中第 0x80 項放的是 syscall handler 的相關資訊,下面是 32 位元的實作,我在 asm/desc_defs.h
中找到一個 gate_offset
函數可以把函數地址求出來,32 位元和 64 位元都適用。接下來,它去尋找機器碼中有沒有 \xff\x14\x85
這個字串,下面去看一下這個字串的意義。
我們使用 pwntools
去反組譯一下:
這看起來就是一個陣列的讀取,合理猜測這裡在讀 syscall table,所以可以直接把地址讀出來。
使用 commit_cred
可以去改變權限,我這裡寫了一個核心模組,透過 character device 去進行提權:
然後在使用者空間執行這樣的程式:
然後就拿到 root 了:
這個方法是使用 flip_open
去取得特定路徑的檔案結構體,並且劫持其中的 VFS 界面,這裡做個簡單的復現。未方便起見,我先把創建 character device 的方式寫成這樣的函式:
首先我創建了兩個 read
函式,並且讓受害者的 read
函式一開始是 victim_read
:
原本 rooty
專案裡面是去劫持 readdir
,這只有在比較舊的版本裡有(我大致看 4.* 就修掉了),現在 file_operations
裡已經沒有這個操作了,於是這裡我去劫持 read
。
然後,我創建叫做 victim
的 character device,獲取他的 read
函式,並且進行 hook:
掛載核心模組之後,進行 cat /dev/victim
,然後用 dmesg
看看:
可以發現 read
被成功 hook 了。
register_keyboard_notifier
可以去註冊一個鍵盤事件的 callback function,用他來進行按鍵側錄。
這個專案在做 fault injection,也就是通過讓系統呼叫機率性回傳錯誤結果來測試軟體,它用的手法和上面提到的差不多。使用 kprobe 去找到 kallsyms_lookup_name
,並且用它去找到 sys_call_table
,hook 的方式是去修改 sys_call_table
,並且使用 VFS 界面去做控制。我想這個方法同樣現在不適用了(因為是改 sys_call_table
),不過可以用 ftrace hook 或是修改機器碼做到同樣事情。
他的 hook 方式是寫機器碼,不一樣的地方是它可以跳回去。要完成這個功能就必須要另外分配一段可執行的區域,這裡使用 set_memroy_x
來設定:
將啟動腳本放到 /etc/init.d
,並請使用 update-rc.d
去做安裝。
在 kmatryoshka.c
中,它先把一段數據解密之後,使用 sys_init_module
去加載核心模組。
這裡根據 linux_kernel_hacking 去使用 init_module
加載核心模組:
使用以下命令可以將核心模組輸出成二進制資料:
於是另外寫一個 user.c
來加載核心模組:
然後在 Makefile
裡面將 wp.ko
行程的二進制資料陣列與 user.c
的程式碼一起編譯:
然後就可以執行 ./user
,並且看到核心模組成功被掛載:
它很酷的點是它寫唯讀區域的方式。
它是得到特定地址的 pte,把它設成可讀寫的,在較新的核心版本也適用:
我用這個去改寫我的 hook_start
函式:
去做上面做的實驗,發現成功的 hook 了:
在 rooty 這個專案可以看到它利用 filep_open
去找到特定路徑的 file_operations
結構體,以劫持對應目錄的 VFS 界面。這個方法的價值是不管對應的檔案操作函式的 symbol 有沒有被釋放出來,只要我知道那個目錄的路徑我都能獲得它所有的檔案操作函式。然而,專案內使用的 readdir
函式在 linux 3.11 之後就已經從 file_operations
裡面移除了。而目前我實驗上,如果我建立一個 process file,使用 filep_open
打開之後,那個 file
結構體裡面的 file_operations
結構體和我用來註冊的 proc_ops
結構體裡面的函式是不一樣的,所以需要去找到新的方法做這件事情。
以下做的事情是去了解 proc_dir_entry
的資料結構,並且利用創建惡意檔案來得到根目錄的地址。另外,在 linux 原始碼中這個結構體有 __randomize_layout
,所以以下會在不知道結構體內部實作的前提下,利用紅黑樹結點的結構去計算出偏移量。
這樣可以做到什麼事情呢?舉個例子,在 /proc
底下有 kallsyms
,可以讀 symbol,那我利用這個方法獲取了 proc_dir_entry
結構體,又計算出 proc_ops
的偏移量,所以我能找到 proc_read
的函式指標,所以就能獲取 symbol。
首先去追蹤 proc_create
的實作,可以發現他是要讓一個結構體 p
變成另一個結構體 parent
的子節點,我們繼續追蹤 p
與 parent
的關係,會發現最後進到了 pde_subdir_insert
這個函式裡面:
會發現,這是一個紅黑樹的插入,使用名字字典序來做大小比較。
這裡統整一下這段程式碼可以看到的事情,proc_dir_entry
中的 subdir
是一個紅黑樹的根,這棵紅黑樹裡面裝的是所有在這個目錄底下的東西(就這一層,目錄底下的目錄中的東西不算),而紅黑樹的節點是內嵌在 proc_dir_entry
中的 subdir_node
。
那所以我創建一個 process file 就能把所有 process file 的資訊都洩漏出來了嗎?去看了 proc_dir_entry
的結構發現他有一個 __randomize_layout
巨集,在某些組態上這個結構體會根據編譯階段決定的種子隨機分佈,所以我應該在不知道結構體內部實作的前提下來做到這件事情。
那我想,我有方法能判斷一個指標是否為有效指標,又能對指標進行讀取,那如果創建一些惡意的檔案與目錄,就能利用樹的結構去枚舉偏移量了。
首先是創建 parent
、child
和 grandchild
這樣的檔案結構:
另外定義了這樣的獲取成員的巨集:
我要去計算以下這些偏移量或地址:
name
這就枚舉看看哪個指標是名字:
parent
在 child 中枚舉看哪個是 parent:
subdir
這是紅黑樹的根。在 parent
和 child
的樹中,現在都只有一個節點,分別是 child
和 grandchild
的 subdir_node
,而他們內嵌在結構體中,所以相對於結構體開頭的偏移量是相同的,可以利用這樣去計算:
subdir_node
直接把前述的那個偏移量拿來用就好:
proc_ops
在創建 process file 的時候,註冊的那個 proc_ops
會被複製一份,所以指標不會一樣,不過裡面的函式指標會是一樣的,只要計算好偏移量去對照函式指標就好了:
proc_root
這是要找到 /proc
這個目錄的 proc_dir_entry
,這樣以後要什麼目錄就能直接用二元搜尋樹的查詢就好。因為之前已經有把結構體中的 parent
的偏移量計算出來了,所以就能簡單的計算:
我首先去計算偏移量與獲取根節點,並且呼叫以下的 test
函式:
這個函式能把這個目錄底下所有東西印出來,去掛載之後用 dmesg
查看:
可以發現在那裡面的東西都被印出來了,且在中序走訪的過程中是按照字典序排列(長度不一樣時以長度優先),下面可以看到確實有 kallsyms
:
另外也可以去寫一個二元搜尋的函式就能找到特定目錄或檔案:
上述方法有嚴重的侷限性,因為 procfs 中有些子系統雖然沿用 proc_dir_entry
,但是仰賴特殊實作,譬如 /proc/net
就是一個很好的例子。如果去觀察 /proc/net
的 PDE(Process Directory Entry) 的話,會發現他的子目錄是空的。實際上,他的 PDE 是另外在其他地方紀錄的,這部份在隱藏網路連線會做詳細的探討。
如果去觀察在 linux/fs/proc/generic.c
中對 proc_readdir
的實作,可以看到他是使用 PDE
這個函式來從一個 inode
找到他的 PDE 的。我們去觀察一下他的實作:
可以看到這個 inode
結構體是被內嵌在 proc_inode
結構體裡面的 vfs_inode
成員,而這個 proc_inode
結構體中有一個 proc_dir_entry
結構體的指標 pde
。
於是,現在我們如果有 inode
的指標,就可以得到 PDE 了。這時,我發現之前我們丟掉的東西有用了,用 filp_open
打開的檔案有 inode
,這個 inode
會不會是一樣的東西呢?答案是會的,於是,我們可以用 filp_open
去獲得 PDE 了。
commit 1e560ab
這可以不用自行編譯核心的方式就獲得一個實驗環境(當然我還是有用 buildroot 去編譯其他版本的核心)。
可以這樣獲得自己拿來開機的 bzImage:
還可以這樣看看現在在運行的作業系統是怎麼開機的:
接下來建立一個 rootfs,下面 建立精簡 Live USB 的部份會提到如何建立它,這裡敘述怎麼把它打包成一個 .ext4
的映像檔(以 ext4 檔案系統為例)。
首先建立空白檔案:
這個意思是從 /dev/zero
輸入,輸出到 rootfs.ext4
,一個單位是 1MB,輸出 300 個單位。於是現在有一個空白檔案,接下來用 mkfs.ext4
把它格式化成 ext4
的格式:
接下來掛載,把該放的東西放進去,最後再 umount
:
然後確保已經安裝 qemu 了,寫一個啟動腳本:
對它 chomd +x
,接下來執行,就會成功開機了。而且這個方法使用原本作業系統的 header 編譯出的核心模組是能直接運行在上面的,因為現在運行的作業系統就是用它來啟動的。
我看現在大部份作法是在 init.d
裡面去放東西,但是這會依賴於發行板。於是我的作法是使用靜態的 ELF code injection 把核心模組與 shellcode 塞進 systemd
裡面(可以去建一個陣列去放所有可能會作為 init 的執行檔)。
這裡的實作參考 drow 專案。
首先簡單講一下下面會用到的一些 ELF 的結構。
ELF 有一個 ELF header,放在整個執行檔的開頭位置,可以用 Elf64_Ehdr
這個結構體去解析。如果 image
是整個執行檔內容的開頭指標,那可以這樣獲得它:
e_entry
是執行檔的進入點,是一個虛擬地址,在後續會設定它。
e_shoff
和 e_phoff
是這個 ELF header 中的兩個元素,可以用來獲得 section headers 和 program headers 兩個陣列:
program header 描述的是 segment,section header 描述的是 section。section 會包含在 segment 裡面。而這兩個 headers 陣列各包含著很多 header,每個 header 描述的就是分別表示一個 segment 或 section。在 ELF header 中有 e_phnum
與 e_shnum
兩個元素,分別代表 program header 和 section header 的數量。
p_flags
是 RWX 的標示,p_offset
是這個區段在這個檔案裡面的偏移量,p_vaddr
是指映射進記憶體時的地址,如果有開 ASLR 的話,那他會是相對偏移量。p_filesz
是他在檔案中佔的大小,p_memsz
是他在記憶體中佔的大小。
sh_addr
是這個 section 在記憶體中的虛擬地址,sh_offset
是他在檔案中的偏移量。
首先找到可執行的 segment(也就是有 PF_X
這個標誌的),接下來在這個 segment 中找到合法的 section,這裡的 section 有以下一些限制。在檔案中與下一個 section 的空隙要大於等於 patch_size
,在虛擬地址中,與下一個 section 的空隙要大於等於 patch_size
。這不難理解,因為不管在檔案還是在記憶體中都不能與下一個 section 重疊。
這裡會遇到一個問題,在不想對執行檔有太大變動的情況下,section 之間的空隙是非常有限的,除非合法的 segment 和 section 都在最尾端(當然是還需要移動一些東西),所以像是核心模組這樣的東西不適合放進去。於是,我的作法是把它塞在檔案的尾端,對齊 PAGE_SIZE
。在 shellcode 裡面將它使用 mmap
映射進記憶體中(對齊 PAGE_SIZE
的目的就在這裡)。
這個想法是很簡單的,接下來是實作的部份。
找合適的區段就只是在解析 ELF 而已,這裡不多贅述:
commit 0ce8e39
怎麼把核心模組轉換成一個陣列並且使用 user program 載入呢?
首先產生一個 object file,然後我們能用 objdump
去看他的內容:
會看到以下訊息:
這些東西可以在 user program 中使用 extern char[]
來獲得(只要編譯時將這個 object file 加入編譯),可以參考 你所不知道的 C 語言:連結器和執行檔資訊。
於是我們可以這樣掛載核心模組:
當然,在注入進去的程式碼要使用 x86 組合語言去達成這件事情。
另外,shellcode 也可以向這樣變成一個可以 extern 的陣列,把編譯出來的部份只有 .text 區段被複製出來,變成一個 binary file,然後再用相同方式去產生一個 object file:
現在我們有辦法寫組合語言,獲得一個由機器碼組成的陣列,也可以把核心模組變成一個陣列,於是可以開始寫 shellcode 了。
首先是進入和退出的部份:
我把它叫做 context switch,因為它做的事情是保存舊的暫存器狀態,且隨後恢復。另外,與原本的 entry point 之間的偏移量資訊也紀錄在這個 shellcode 裡面,會由注入 shellcode 的程式來進行計算與修改,這裡會預留空間,並且以相對 rip
的地址去取得:
在中間做的事情就是分別叫了 open
、mmap
和 init_module
三個系統呼叫:
另外是 systemd
在被執行的時候如果以 O_RDWR
開啟的話會有 Text file busy 錯誤,所以不能直接修改它,但是我發現它可以 unlink
,也就是刪除。所以這裡的作法是先用 O_RDONLY
的標誌打開,將內容存在 buffer 裡面,把檔案 unlink
再重新建立。
這個在 ubuntu 24.04 上實驗過,我把 systemd
備份後用 root 權限執行惡意程式,重新開機。它順利啟動,並且成功自動掛載核心模組。
finit_module
本來以為事情到這裡就解決了,然而發現了它會出現錯誤。下面會使用到 stop_machine
來執行東西,並且在裡面呼叫了 x64_sys_call
,這樣的行為會出現錯誤,原因還沒想到。不過使用 insmod
去掛載卻沒問題,我使用了 strace
去追蹤,發現它使用了 finit_module
系統呼,於是我也改成這樣的實作方式。
finit_module
需要輸入 fd
,而它會去找這個 fd
對應到的檔案,從它開頭位置開始讀。因為這個特性,使用 lseek
去移到特定位置再呼叫 finit_module
和直接呼叫 finit_module
的結果是一樣的(雖然說 insmod 有做這件事,不知道考量是什麼)。而我的核心模組是被塞在檔案後面的,因為上面的特性,我不能使用 lseek
去移動 fd
對應的指針來達到讀取核心模組的效果。所以,我使用了 memfd_create
系統呼叫,它會創建一個匿名檔案,且回傳一個檔案描述子。我們可以對這個檔案描述子進行讀寫操作。在創建這個匿名檔案後,我會把核心模組的內容寫進去,並且使用這個檔案描述子去呼叫 finit_module
。
說起來簡單,接下來就是寫更多的組合語言了:
commit 7f15447
看現在還能動的 kernel rootkit 都是用 ftrace,但我想用改機器碼的方式減少對 ftrace 的依賴是好事。而現在看到的使用這樣方式的 rootkit 都很舊,針對的是 32 bit 的 x86 架構,且對於防寫保護的關閉也不夠全面(清除 cr0
的 WP
以前應該先清除 cr4
的 CET
)。而我這裡的實作方法不是改 cr0
,而是去改 page table entry 中的讀寫權限。
先定義了一個結構體:
org_func
是目標函式,evil_func
是惡意函式。org_code
是目標函式的開頭,evil_func
是要跳到惡意函式的 shellcode。這個 HOOK_SIZE
是 shellcode 長度。我會在目標開頭塞入這個 shellcode 讓它跳進惡意函式中。另外,惡意函式要怎麼呼叫原本的函式呢?它會在呼叫前去呼叫 hook_pause
,這個函式會把原本函式的開頭復原,最後呼叫 hook_resume
將 shellcode 再寫回去。這裡先訂出界面,由於個函式實作概念重複,以下只會敘述一些函式的實作:
commit 6abaaf4
這裡用來修改機器碼的方式不是使用修改 cr0
的,而是去修改 page table entry 的讀寫權限:
在 hook_start
的部份,我會先建立 hook
結構體,將對應資訊寫進去後,開始做 hook 操作。讓目標函式是可以寫的之後,將 shellcode 寫進去:
然後在有一個全域的鏈結串列拿來放所有的 hook
結構體,使用內嵌的 list_head
將他們連起來。
最後,在退出的時候,可以呼叫 hook_release
將所以有的函式都復原:
我設想的使用情境是使用一個精簡的 live usb 去做開機,並且往特定硬碟分區的執行檔中注入惡意程式碼。現有的 rootkit 大致著重在安裝 rootkit 之後能做什麼事,而我想這樣隨開即用的設計是蠻實用的。並且 bzImage 還可以直接用現在這個作業系統拿來開機的 bzImage。
以下會敘述建立精簡 Live USB 的過程,之後會整理成 shell script 整合進來。
先用 lsblk 觀察硬碟分區,這裡是 /dev/sda
。
首先 umount 它:
安裝依賴:
格式化,並且建立分區給 bootloader:
安裝 grub:
建立 /mnt/usb/boot/grub/grub.cfg
,設定成以下東西,UUID
要去觀察以下建立 rootfs 用的那個硬碟分區是什麼:
等下全部結束之後,記得 umount:
建立第二個硬碟分區:
格式化成 ext4:
塞一些東西進去:
建立系統起始腳本,放在 /mnt/rootfs/init
,然後記得 chmod +x
:
然後可以這樣看一下 UUID 是多少,在上面的 grub.cfg
設定會用到:
結束後一樣要 umount:
commit 6e094f7
雖然專題的重點在核心模組,但實作簡單就做一下。
如果使用 Live USB 去植入 rootkit 的話,那安裝的根目錄就不再是開機時的 rootfs 了,所以這裡對於這種情況做了處理,可以指定要以哪個目錄作為根目錄植入 rootkit。
我提供了一個可以簡單使用的使用者界面,它會列出所有偵測到的硬碟分區,和他們的相關資訊,譬如掛載點、檔案系統類型、大小。使用者可以從裡面選擇一個去植入 rootkit。
我對這個 shell script 的期待是依賴最小化,所以訊息的獲得都從預設會有的 procfs 或是 sysfs 獲得,並且可以直接使用 /bin/sh
執行。
另外,在過程中也發現使用 Live USB 開機的時候,動態連結庫也是個需要解決的問題,這裡直接將使用者程式改為靜態連結的方式。
這裡個待處理的問題,btrfs 在掛載上去的時候會有 subvolume 的問題(我自己是使用btrfs,所以發現了這個問題),我等到核心模組的功能比較完善了再回來處理這個問題。
commit 4f79a61
使用 rootkit 案例閱讀 提到的實作,這裡提供一個 ioctl
的控制界面。
commit 9d6b7c0
使用 rootkit 案例閱讀 提到的實作。
commit 3ef6897
使用 rootkit 案例閱讀 提到的實作,但現在未實作隱藏某個使用者擁有的所有檔案與行程,不過可以依據行程的名稱去進行隱藏(因為使用者空間獲得行程訊息是使用 procfs)。
ksymtab
與 ksymtab_gpl
地址這和上面的 proc_dir_entry
一樣,就是發現了新的方法在不依賴 kprobe
的情況下洩漏更多資訊,但是缺乏可以被使用的地方。
從核心中 export 出 symbol 的方式有兩種,一種是 EXPORT_SYMBOL
,一種是 EXPORT_SYMBOL_GPL
。大部分的科普文章是這樣寫的,沒有 GPL
授權的核心模組不能存取有 GPL
標示的 symbol,有 GPL
授權的核心模組可以存取所有 export 的 symbol。然而,在 9011e49
("modules: only allow symbol_get of EXPORT_SYMBOL_GPL modules") 之後,有 GPL
授權的核心模組使用 symbol_get
的話只能存取有 GPL
授權的 symbol 了:
然而,以下我找了一個方式去將裡面所有 symbol 都洩漏出來了(在不依賴 kprobe
和 kallsyms
的情況下)。
我們先實驗一下使用 EXPORT_SYMBOL
來 export 出來的 symbol 不能被有 GPL
授權的核心模組存取,首先是在使用 EXPORT_SYMBOL_GPL
的狀況下:
在掛載後,用 dmesg
會看到以下訊息,可以發現有抓到 symbol:
之所以要去改 THIS_MODULE->state
是因為在 strong_try_module_get
中會對這個核心模組的狀態做檢查。這裡因為在 init
裡面,所以他的狀態是 MODULE_STATE_UNFORMED
,所以會失敗(其實後來發現在 find_symbol
就擋下來,所以實際上不會呼叫下面這個函式,如果進到這個函式的話會被 BUG_ON
偵測到錯誤)。
接下來改為使用 EXPORT_SYMBOL
:
會發現它沒有成功抓到 symbol。
__symbol_get
的實作方式__symbol_get
會呼叫 find_symbol
來找 symbol 對應到的值。他的實作是這樣的:
使用上述兩種方法 export 的 symbol 分成兩種,一種是放在 ksymtab
和 ksymtab_gpl
裡面的,這種是在核心編譯過程中就決定的資訊,名稱由小到大排序放在表裡面。另一種是由掛載的核心模組 export 的,放在核心模組的 syms
、gpl_syms
欄位。
在 12 行到 14 行間他是在前者中去找符號,後面是在後者中去找符號。這裡關注的是前面的部份。可以發現,他們是使用 arr
這個陣列裡面的資訊作為參數去呼叫 find_exported_symbol_in_section
,而裡面放的有上述兩個表的起始和結束。接下來追進去看 find_exported_symbol_in_section
是怎麼實作的:
可以發現,它會使用 bsearch
去進行二分搜尋,找到對應的 kernel_symbol
的位置。
可以發現 find_exported_symbol_in_section
呼叫了 bsearch
,而這個東西是有被釋放出來給核心模組使用的函式,所以我們可以知道他的地址,那我們可以對它進行 hook 就能獲得他的輸入。只要去呼叫一次 __symbol_get
或 __symbol_put
,並且在期間對 bsearch
進行攔截參數的話,就能獲取 __start___ksymtab
和 __start___ksymtab_gpl
了。
首先去對 bsearch
進行 hook,看看結果:
使用 dmesg
可以看到它印出了 3 組地址。第一組地址是 ksymtab
的,第二組地址是 ksymtab_gpl
的,第三組地址是在核心模組的 sym
裡面找的,可以看到尋找的字串都是 fabcd
,也就是一開始設定的函式名稱(這裡使用)。
為了看看他們是不是正確的地址,以下把他們的名字印出來。這裡只是要做個驗證,我把 kaslr 關掉,直接用寫死的地址去看(和上面一樣的地址)。另外,因為 kernel_symbol_name
之類的函式並沒有被釋放出來,我自己實作了一個:
可以發現 symbol 被弄出來了:
commit 7197487
以下的方法只要保證從 x64_sys_call
到核心模組的 init 函式的呼叫路徑(call trace)的長度是某個特定的值就能運作,而如果要追求更多的相容性的情況下可以再經過一些篩選去做到,但在假設使用者不會去更改 linux 程式碼的情況下我沒有做這樣的篩選。
在呼叫 rootkit_init
這個函式的時候,因為他是透過 init_module
這個系統呼叫去掛載的,所以它一定會經過 syscall handler。而在一個函式中,我們可以在 rbp+8
的位置得到這個函式的回傳地址,又可以從 rbp
的位置得到在上一個 stack frame 中的 rbp
值,所以可以找到在呼叫路徑中若干層的回傳地址。從下面的例子我們可以看到,rbp
指向的位置存的是 rbp1
,也就是上一個 stack frame 中的 rbp
,而 rbp1
指向的位置又存著上上個 stack frame 中的 rbp
。那如果我要找到我 return 三次會回到哪個地址,也就是 return address 2,要怎麼做呢?向 rbp
取值得到 rbp1
,再向 rbp1
取值得到 rbp2
,在 rbp2+8
的位置存的值就是 return address 2。
有了這個工具,我們就可以去看看 syscall handler 在這個呼叫路徑上的第幾層,我在實驗環境中去讓它在 rootkit_init
中系統崩潰,可以看到這個呼叫路徑:
可以發現往上 7 層後就會找到 x64_sys_call
函式,然後我們去看看核心中對於這個函式的實作:
其中 nr
是 syscall number,他是用一個 switch-case 去依據 nr
呼叫對應的系統呼叫。
然後我們看看函式的開頭有什麼特徵:
這不難理解,因為在函式呼叫的一開始要把上一個 stack frame 的 rbp
存起來,並且讓 rbp
指向那個位置,我們可以以這個特徵來找到函式的開頭。
接下來觀察 x86-64 的 call
指令的構成,如果以相對 rip
的地址去呼叫的話,那麼機器碼的第一個位元組會是 e8
,接下來 4 個位元組存了一個偏移量,是相對於下一條指令的偏移量。
既然知道了 call
指令的構成,那我們能從函式開頭開始去找到所有的 call
函式,並且知道他們呼叫的地址是什麼。比較麻煩的一件事情是 x86 的指令長短不一,所以需要寫一個簡單的反組譯器去計算當前指令的長度。
接下來我們有了所有它呼叫的系統呼叫函式,但是要怎麼分辨哪個對應到哪個 syscall number 呢?如果去進行反組譯來做靜態的解析的話有幾個缺點:
於是,我希望使用在我可以控制的範圍下模擬執行的方式獲得結果。我的方法是這樣的,先去 hook 所有找到的系統呼叫函式,我在這裡把那個替換過去的函式叫做 evil_func
。在 evil_func
中,我可以知道我從哪裡呼叫他的,於是我可以透過解析它的 call
指令來找到它原本呼叫的函式地址是什麼。那因為我擁有 syscall handler 的地址,所以我可以利用他來呼叫有特定 syscall number 的系統呼叫,於是就能夠利用 evil_func
來獲得它對應的函式地址。
x64_sys_call
首先實作出 find_address_up
這個函式,可以找到向上回傳 level
次會到達的地方,這個函式是以 inline
的方式插入在函式裡面的,所以不用多計算一層:
接下來往上找 7 層,並且對照 push rbp; mov rbp, rsp;
的機器碼去尋找函式的開頭:
首先是從函式頭開始走,如果遇到開頭是 0xe8
的代表他是 call
指令,就解析指令得到地址,對它進行 hook
:
接下來使用 get_instruction_length
去找到這個指令的長度,然後跳到下一個指令的開頭:
這個 get_instruction_length
是在 AI 的幫助下生出來的,我做的是一直看 core dump 去看看它撞到了什麼沒有處理到的指令。之後如果有擴充需求可以去改進這個函式,我想現在市面上已經有很多現成的反組譯器了,所以理論上所有情況都有辦法解決:
接下來我會把每個 syscall 都叫一遍去找到地址,exit_group
和 exit
不去做是因為這兩個系統呼叫是不會回傳的,而且目前想不到什麼狀況會需要 hook 這兩個函式,所以先不去處理它們。我的回傳結果會放在 regs.ax
裡面,這是和 syscall_stealer
的約定:
最後是 syscall_stealer
,這是拿來 hook 系統呼叫函式的邪惡函式,它會做的事情是找到系統呼叫的地址,並且利用 pt_regs
結構體進行回傳。找到的方式是先找到它會回傳到哪裡,並且因為 call
指令的長度是 5,所以在回傳地址的前 4 個位元組是那個系統呼叫函式相對於回傳地址的偏移量,可以透過這個方法去算出系統呼叫函式的地址:
那如果在這之間發生 syscall 怎麼處理呢?原本我的處理方式會是利用傳入的暫存器讓 syscall_stealer
去判斷說是否為正常呼叫,如果是正常呼叫就呼叫原本的系統呼叫函式,然而這個方式在多核且系統呼叫頻繁的系統中會出現問題。於是我找到了一個函式 stop_machine
,它可以保證其他的任務全部暫停,只有它設定的那個 callback 函式在運行,我利用這個函式去進行呼叫。
我將獲得系統呼叫函式地址的方法包裝成了 get_syscall
函式,並且在初始化之後去印出所有的 syscall 地址:
可以看到結果:
總共 461 - 2 個系統呼叫(除了 exit
和 exit_group
)都被我們洩漏出來了。
在不同版本做實驗的時候發現,在不同版本下,甚至是同版本的不同組態下,呼叫路徑的長度有可能會不同,所以這裡使用了一種方式來找到 x64_sys_call
,這個方式同時在 6.11.0
、6.11.0-26-generic
、6.14.0
上成功運行。它是利用 x64_sys_call
的特徵,也就是很多 call
指令,來分辨出在呼叫路徑上的哪個函式是 x64_sys_call
。
commit f9a58cb
seq_operations
地址這是一個 利用 proc_dir_entry 進行資訊洩漏 的明顯運用,畢竟網路有關的訊息都在 /proc/net
底下。然而,實際去做實驗後發現沒那麼單純,依照 /proc/net
對應的 proc_dir_entry
結構體中的資訊而言,這個目錄底下是空的,於是我去閱讀關於網路的 procfs 實作。
現有的 rootkit 對於隱藏 tcp 連線是對 tcp4_seq_show
進行 hook,於是我以此在 linux 核心程式碼中進行搜尋。
在 linux/net/ipv4/tcp_ipv4.c
可以看到 /proc/net/tcp
的 seq_operations
被定義:
同樣繼續往下看,可以看到他是怎麼被註冊的:
可以看到他的親代節點是在一個 net
結構體中的 proc_net
。
然後這個函式是一個拿來 init 的 callback 函式,並且使用 register_pernet_subsys
註冊:
接下來看看哪裡用到了 pernet_operations
這個結構體,在 linux/net/core/net_namespaces.c
找到了這個函式:
這看函式名稱是一個拿來註冊一個鏈結串列裡的所有東西的函式,看函式內容也符合這樣的猜測。值得注意的是 for_each_net
這個巨集,我們看看它怎麼定義的:
可以看到它是在走訪 net_namespace_list
這個鏈結串列,以下畫了一張結構圖:
後來我發現這個鏈結串列是有被 export 的,所以我們能直接使用它。這也等價於我們能直接使用 for_each_net
了。
所以我將 proc_find_by_path
的實作稍作修改整合進來了:
commit 1a4f738
我將開頭是 /proc/net
的路徑分類處理,從所有 net
的 proc_net
作為根目錄去搜尋後面的路徑對應到的結構體。
commit ad6c616
這裡參考了 linux_kernel_hacking 的實作,先去觀察 tcp4_seq_show
在 linux 核心中怎麼實作的:
這個函式拿來把資訊放進 seq
裡面的,以 SEQ_START_TOKEN
標示開頭,除了開頭以外,會把 v
這個指標解析成 sock
結構體的指標,並依照這個 socket 的狀態來決定要用什麼形式放進 seq
裡面。
所以我們要做的事情是解析這個結構體的資訊,判斷有哪些東西要放進去 seq
裡面。看了 linux_kernel_hacking 的實作之後,知道了 sock
其實是一個內嵌在 inet_sock
結構體開頭的結構體,在 inet_sock
中有更多的資訊,譬如說來源的 port。於是我建了一個黑名單來紀錄一些我希望隱藏的 port,並且在發現這些 port 的時候不往 seq
裡面放東西,實際上就是去呼叫原本的函式:
同樣的,udp 的隱藏方法也相似:
commit 908acd6
首先啟動一個 http server:
一開始使用 netstat
看看,發現是看得到的:
接下來掛載核心模組:
再去看一次,發現沒有東西了:
首先啟動一個 udp server:
一開始使用 netstat
看看,發現是看得到的:
接下來掛載核心模組:
再去看一次,發現沒有東西了:
現有的 process 隱藏方式多是去對系統呼叫進行 hook 來達成檔案系統層級的 process 隱藏,然而其實是有作法可以做到在核心層級的隱藏的,也就是使得依賴於 find_pid_ns
或者相似函式的搜尋方法(據我所知是所有方法)皆無法透過這個 pid number 找到對應的任務。
首先看一下在核心中是怎麼透過 pid number 去找到 process 的,可以發現他是透過 pid hash 去找到一個 struct pid
這個 struct pid
裡面會有一個 tasks
陣列,可以把各種 type 的 pid number 對應的任務連起來,詳細的討論可以參考 2025-04-22 討論簡記
一個小結論是 pid hash 是由 radix tree 去實作的,而我們可以直接從裡面把元素做刪除就能做到 process 的隱藏。而它是可以被正常排程的,因為排程器看的是 task_struct
,而不是 pid
。
commit 2a95184
我的 remote shell 的實作是使用 reverse shell,因為這樣被攻擊方就不一定要做 port forwarding,只需要我有一個在外面的 port,他自己連進來就好了。
如果我有很多時間大可以在核心空間中寫一個 shell,這樣的話更隱密,但我想效益不是非常大,這裡想用現成的東西。首先,在核心裡面可以透過 call_usermodehelper
去執行使用者空間中的執行檔,並且因為 bash
有將網路連線抽象化為檔案,所以可以直接用重導向的方式做出一個 reverse shell。於是,我直接把 bash
這個執行檔變成一個字元陣列,並且在執行階段去做寫檔,這樣就在目標機器上生出 bash
了。
我這裡讓它每 5 秒就彈一個 shell 出來:
接下來我用 vmware 開一台靶機來測試一下,我把 SHELL_IP
設成我的 ip,SHELL_PORT
用 1234。在 vmware 上面啟動 ubuntu 24.04,並且載入核心模組。接下來在外面使用 nc
去做監聽:
可以發現,我們得到 shell 了
commit 0749d4e