Hooking linux核心函式(三):Ftrace的主要優缺點

G1733發表於2024-05-22

本文是《Hooking Linux Kernel Functions, Part 3: What Are the Main Pros and Cons of Ftrace?》的翻譯文章。

前言

Ftrace是一個Linux例項程式,通常用於跟蹤核心函式。 但是,當我們尋找一個有用的解決方案,允許我們啟用系統活動監控和阻止可疑程序時,發現Linux ftrace也可用於鉤子函式呼叫。

這是本系列文章的最後一部分,本系列共分三部分,主要討論如何使用ftrace來hook Linux核心函式。在本文中,我們將重點討論ftrace的主要優缺點,並描述在用ftrace hooking Linux核心函式時所遇到的一些意外情況。閱讀本系列文章的第一部分,瞭解其他四種可用於hooking Linux核心函式呼叫的方法。你在想:什麼是ftrace?ftrace是如何工作的?那麼請參閱本系列的第二部分,以獲得這些問題的答案,並瞭解關於如何使用ftrace來hooking Linux核心函式。

使用ftrace的利弊

Ftrace使Linux核心函式更容易hook,並具有幾個關鍵優勢:

  • 一個成熟的API和簡單的程式碼。在核心中利用現成的介面大大降低了程式碼的複雜性。只需要進行兩個函式呼叫,填充兩個結構欄位,並在回撥中新增一些magic,就可以用ftrace來hook核心函式。剩下的程式碼只是圍繞跟蹤函式執行的事件邏輯。
  • 能夠根據名稱跟蹤任何函式。使用ftrace跟蹤Linux核心是一個相當簡單的過程——用常規字串編寫函式名就足夠指向你需要的函式名了。不需要糾結於連結器、掃描記憶體或研究內部核心資料結構。只要知道它們的名稱,就可以使用ftrace跟蹤核心函式,即使這些函式沒有為模組匯出。

但就像我們在本系列中描述的其他方法一樣,ftrace有一些缺點。

  • 核心配置要求。 確保成功進行Linux核心跟蹤需要幾個核心要求:
    -- 用於按名稱搜尋功能的kallsyms符號列表
    -- 用於執行跟蹤的整個ftrace框架
    -- Ftrace選項對鉤子函式來說至關重要

所有這些功能都可以在核心配置中禁用,因為它們對系統的執行並不重要。 但是,通常流行發行版使用的核心仍然包含所有這些核心選項,因為它們不會顯著影響系統效能,並且可能對除錯很有用。 但是,如果你需要支援某些特定的核心,最好還是記住這些要求。

  • 開銷成本。因為ftrace不使用斷點,所以它的開銷比kprobes低。但是,這種方法的開銷比手工拼接要高。實際上,動態ftrace是拼接的變體,它執行了不必要的ftrace程式碼和其他回撥。
  • 函式被包裝成一個整體。與通常的拼接一樣,ftrace將函式包裝為一個整體。雖然從技術上講,拼接可以在函式的任何部分執行,但ftrace只在入口點工作。你可以將這種限制視為一種缺點,但通常它不會引起任何併發症。
  • 雙重呼叫ftrace。正如我們之前解釋過的,使用parent_ip指標進行分析會導致對同一個鉤子函式呼叫兩次ftrace。這增加了一些間接成本,並可能干擾其他跟蹤的讀取,因為他們將看到兩次的呼叫。這個問題可以透過將原來的函式地址移動5個位元組(呼叫指令的長度)來解決,這樣基本上就可以在ftrace上跳轉了。

讓我們仔細分析這些缺點。

核心配置要求

核心必須同時支援ftrace和kallsyms。這需要啟用兩個配置選項:

  • CONFIG_FTRACE
  • CONFIG_KALLSYMS

接下來,ftrace必須支援動態暫存器修改,開啟以下選項:

  • CONFIG_DYNAMIC_FTRACE_WITH_REGS

要訪問FTRACE_OPS_FL_IPMODIFY標誌,你使用的核心必須基於版本3.19或更高版本。舊的核心版本仍然可以修改%rip暫存器,但是在版本3.19中,只有在設定標誌之後才能修改這個暫存器。在較老版本的核心中,出現此標誌將導致編譯錯誤。在較新的版本中,缺失這個標誌意味著一個non-operating hook。

最後但並非最不重要的是,我們需要注意函式內部的ftrace呼叫位置。 ftrace呼叫必須位於函式的開頭,在函式序言之前(形成堆疊幀並分配區域性變數的空間)。 以下選項考慮了此功能:

  • CONFIG_HAVE_FENTRY

雖然x86_64架構支援這個選項,但i386架構不支援。由於i386架構的ABI限制,編譯器不能在函式序言之前插入ftrace呼叫。因此,當你執行ftrace呼叫時,函式堆疊已經被修改了,並且更改暫存器的值不足以hook函式。並且還需要撤消在序言中執行的操作,這些操作在不同的函式中有所不同。

這就是為什麼ftrace函式hooking不支援32位x86體系結構。從理論上講,你仍然可以透過生成和執行反序言來實現此方法,但是它將顯著提高技術複雜性。

使用ftrace時的意外情況

在測試階段,我們面臨一個特殊的特性:在某些發行版上hook函式會導致系統永久掛起。當然,這個問題只發生在與開發人員使用的系統不同的系統上。我們也無法在任何發行版或核心版本上重現初始hooking原型的問題。

經除錯,系統斷在了鉤子函式里。由於一些未知的原因,當在ftrace回撥中呼叫原函式時,parent_ip仍然指向核心而不是函式包裝器。這就啟動了一個死迴圈,ftrace一遍又一遍地呼叫我們的包裝器,而沒有做任何有用的事情。

幸運的是,我們有錯誤的和有效的程式碼,最終發現了問題的原因。我們統一了程式碼並去掉了我們現在不需要的部分,並使包裝器函式程式碼的兩個版本之間的差異縮小了。

這是穩定的程式碼:

static asmlinkage long fh_sys_execve(const char __user *filename,
                const char __user *const __user *argv,
                const char __user *const __user *envp)
{
        long ret;

        pr_debug("execve() called: filename=%p argv=%p envp=%p\n",
                filename, argv, envp);

        ret = real_sys_execve(filename, argv, envp);

        pr_debug("execve() returns: %ld\n", ret);

        return ret;
}

這是導致系統掛起的程式碼:

static asmlinkage long fh_sys_execve(const char __user *filename,
                const char __user *const __user *argv,
                const char __user *const __user *envp)
{
        long ret;

        pr_devel("execve() called: filename=%p argv=%p envp=%p\n",
                filename, argv, envp);

        ret = real_sys_execve(filename, argv, envp);

        pr_devel("execve() returns: %ld\n", ret);

        return ret;
}

日誌級別如何影響系統行為? 令人驚訝的是,當我們仔細研究這兩個函式的機器程式碼時,我們發現這些問題背後的原因是編譯器。

結果是,pr_devel()呼叫被擴充套件為no-op。這個printk-macro版本用於開發階段的日誌記錄。由於這些日誌在操作階段沒有任何意義,系統會自動將它們從程式碼中刪除,除非你啟用了DEBUG宏。之後,編譯器會看到這樣的函式:

static asmlinkage long fh_sys_execve(const char __user *filename,
                const char __user *const __user *argv,
                const char __user *const __user *envp)
{
        return real_sys_execve(filename, argv, envp);
}

這就是最佳化的階段。在我們的示例中,啟用了所謂的尾部呼叫最佳化。如果一個函式呼叫另一個函式並立即返回它的值,這種最佳化讓編譯器可以用更直接的跳轉到函式的主體來替換函式呼叫指令。這就是這個呼叫在機器程式碼中的樣子:

0000000000000000 <fh_sys_execve>:
   0: e8 00 00 00 00 callq 5 <fh_sys_execve+0x5>
   5: ff 15 00 00 00 00 callq *0x0(%rip)
   b: f3 c3 repz retq </fh_sys_execve>

這是一個失敗呼叫的例子:

0000000000000000 <fh_sys_execve>:
   0: e8 00 00 00 00 callq 5 <fh_sys_execve+0x5>
   5: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax
   c: ff e0 jmpq *%rax </fh_sys_execve>

第一個呼叫指令與編譯器在所有函式的開頭插入的fentry()呼叫完全相同。但在那之後,壞程式碼和穩定程式碼的行為就不同了。在穩定的程式碼中,我們可以看到呼叫指令執行的real_sys_execve呼叫(透過儲存在記憶體中的指標),在RET指令的幫助下,後面是fh_sys_execve()。然而,在壞程式碼中,直接跳轉到JMP執行的real_sys_execve()函式。

尾部呼叫最佳化允許你透過不分配包含call指令儲存在堆疊中的返回地址的無用堆疊幀來節省一些時間。但是,由於我們使用parent_ip來決定是否需要hook,因此返回地址的準確性對我們來說至關重要。經過最佳化後,fh_sys_execve()函式不再將新地址儲存在堆疊中,因此只有舊地址指向核心。這就是為什麼parent_ip一直指向核心內部,而那個死迴圈一開始就出現了。

這也是問題僅出現在某些發行版上的主要原因。 不同的發行版使用不同的編譯標誌集來編譯模組。 在所有問題分佈中,尾呼叫最佳化預設是開啟的。

我們透過使用包裝器函式關閉整個檔案的尾部呼叫最佳化來解決這個問題:

# pragma GCC最佳化(“-fno-optimize-sibling-calls”)

至於進一步的hooking實驗,你可以使用GitHub的完整核心模組程式碼。

結論

雖然開發人員通常使用ftrace來跟蹤Linux核心函式呼叫,但這個例項程式本身對於Hooking Linux核心函式也非常有用。儘管這種方法有一些缺點,但它給了你一個關鍵的好處:程式碼和hook過程的整體簡單性。

相關文章