如何替換一個Linux內核函數的實現-熱補丁原理

昨晚發過誓了。不會再接着寫二進制hook的手藝了,今天有網友諮詢技術細節,終於又忍不住了…

爲了不違背即便是胡亂說出口誓言,今天不寫二進制hook,今天用C語言寫,二進制只是沾點邊兒!

看題目, 替換Linux內核函數的實現 ,what?這不就是kpatch嘛!也就是我們所謂的 熱補丁 。我們爲內核做熱補丁的時候,沒人用匯編寫吧,沒人用二進制指令碼去拼邏輯吧,我們一般都是直接修改內核函數的C代碼的,然後形成一個patch文件,然後…然後…去讀kpatch的Documents吧。

本文我將要描述的是熱補丁的原理,而不是一個如何使用kpatch的Howto,更不是關於任何kpatch技術的源碼分析。

以一個實際的3.10內核的Bugfix熱補丁爲例開始我們的故事。

在該實例中,我們修改了set_next_buddy的實現:

diff --git a/kernel/sched/fair.c b/kernel/sched/fair.c
...
@@ -4537,8 +4540,11 @@ static void set_next_buddy(struct sched_entity *se)
    if (entity_is_task(se) && unlikely(task_of(se)->policy == SCHED_IDLE))
        return;
-   for_each_sched_entity(se)
+   for_each_sched_entity(se) {
+       if (!se->on_rq)
+           return;
        cfs_rq_of(se)->next = se;
+   }
 }

看來,爲了Fix一個已知的Bug,我們需要爲set_next_buddy函數加幾行代碼,很顯然,這很容易。

增加了這幾行代碼後,便形成了一個新的set_next_buddy函數,爲了能讓新的函數run起來,現在我們面臨三個問題:

  • 我們如何可以將這個新的set_next_buddy函數編譯成二進制?

  • 我們如何將這個新的set_next_buddy函數二進制碼注入到正在運行的內核?

  • 我們如何用新的set_next_buddy二進制替換老的set_next_buddy函數?

我們一個一個問題看。

首先,第一個問題非常容易解決。

我們修改了一個C文件kernel/sched/fair.c,爲了解決編譯時的依賴問題,只需要將修改後形成的patch文件打入當前運行內核的源碼樹中就可以編譯了,通過objdump之類的機制,我們可以把編譯好的set_next_buddy二進制摳出來形成一個obj文件,然後組成一個ko就不是什麼難事了。這便形成了一個內核模塊,類似 kpatch-y8u59dkv.ko

接下來看第二個問題,如何將第一個問題中形成的ko文件中set_next_buddy二進制注入到內核呢?

這也不難,kpatch的模塊加載機制就是幹這個的。打入熱補丁的內核就會出現兩個set_next_buddy函數:

crash> dis set_next_buddy
dis: set_next_buddy: duplicate text symbols found:
// 老的set_next_buddy
ffffffff810b9450 (t) set_next_buddy /usr/src/debug/kernel-3.10.0/linux-3.10.0.x86_64/kernel/sched/fair.c: 4536
// 新的set_next_buddy
ffffffffa0382410 (t) set_next_buddy [kpatch_y8u59dkv]

到了第三個問題,有點麻煩。新的set_next_buddy二進制如何替換老的set_next_buddy二進制呢?

顯然,不能採用覆蓋的方式,因爲內核函數的佈局是非常緊湊的且連續的,每個函數的空間在內核形成的時候就確定了,如果新函數比老函數大很多,就會越界覆蓋掉其它的函數。

採用我前面文章裏描述的二進制hook技術是可行的,比如下面文章裏的方法:
https://blog.csdn.net/dog250/article/details/105206753
通過二進制diff,然後緊湊地poke需要修改的地方,這無疑是一種妙招!然而這種方法並不優雅,充滿了奇技淫巧,它最大的問題就是逆經理。

最正規的方法就是使用ftrace的hook,即 修改老函數的開頭5個字節的ftrace stub,將其修改成“jmp/call 新函數”的指令,並且在stub函數中skip老函數的棧幀。 如此一來徹底繞過老的函數。

我們來看上面提到的兩個set_next_buddy的二進制:

// 老的set_next_buddy:
crash> dis ffffffff810b9450 4
// 注意,老函數的ftrace stub已經被替換
0xffffffff810b9450 <set_next_buddy>:    callq  0xffffffff81646df0 <ftrace_regs_caller>
// 後面這些如何被繞過呢?ftrace_regs_caller返回後如何被skip掉呢?這需要平衡堆棧的技巧!
// 後面通過實例來講如何平衡堆棧,繞過老的函數。
0xffffffff810b9455 <set_next_buddy+5>:  push   %rbp
0xffffffff810b9456 <set_next_buddy+6>:  cmpq   $0x0,0x150(%rdi)
0xffffffff810b945e <set_next_buddy+14>: mov    %rsp,%rbp
// 新的set_next_buddy:
crash> dis ffffffffa0382410 4
// 新函數則是ftrace_regs_caller最終要調用的函數
0xffffffffa0382410 <set_next_buddy>:    nopl   0x0(%rax,%rax,1) [FTRACE NOP]
0xffffffffa0382415 <set_next_buddy+5>:  push   %rbp
0xffffffffa0382416 <set_next_buddy+6>:  cmpq   $0x0,0x150(%rdi)
0xffffffffa038241e <set_next_buddy+14>: mov    %rsp,%rbp

這就是熱補丁的原理了。

本文到這裏都是紙上的高談闊論,就此結束未免尷尬且遺憾,接下來我要用一個實際的例子來說明這一切。這個例子非常簡單,隨便擺置幾下就能run起來看到效果。

我比較討厭源碼分析,所以我不會去走讀註釋ftrace_regs_caller的源碼,我用我自己的方式來實現類似的需求,並且要簡單的多,這非常有利於咱們工人理解事情的本質。

我的例子不會去patch內核中既有的函數,我的例子patch的是我編寫的一個簡單的內核模塊裏的函數,該模塊代碼如下:

#include <linux/module.h>
#include <linux/proc_fs.h>
// 下面的sample_read就是我將要patch的函數
static ssize_t sample_read(struct file *file, char __user *ubuf, size_t count, loff_t *ppos)
{
    int n = 0;
    char kb[16];
    if (*ppos != 0) {
        return 0;
    }
    n = sprintf(kb, "%d\n", 1234);
    memcpy(ubuf, kb, n);
    *ppos += n;
    return n;
}
static struct file_operations sample_ops = {
    .owner = THIS_MODULE,
    .read = sample_read,
};
static struct proc_dir_entry *ent;
static int __init sample_init(void)
{
    ent = proc_create("test", 0660, NULL, &sample_ops);
    if (!ent)
        return -1;
    return 0;
}
static void __exit sample_exit(void)
{
    proc_remove(ent);
}
module_init(sample_init);
module_exit(sample_exit);
MODULE_LICENSE("GPL");

我們加載它,然後去read一下/proc/test:

[root@localhost test]# insmod sample.ko
[root@localhost test]# cat /proc/test
1234

OK,一切如願。此時,我們看看sample_read的前面的5個字節:

crash> dis sample_read 1
0xffffffffa038c000 <sample_read>:       nopl   0x0(%rax,%rax,1) [FTRACE NOP]

來來來,在已經加載了sample.ko的前提下,我們現在patch它。我的目標是,Fix掉sample_read函數,使得它返回4321而不是1234。

以下是全部的代碼,要點都在註釋裏:

// hijack.c
#include <linux/module.h>
#include <linux/kallsyms.h>
#include <linux/cpu.h>
char *stub;
char *addr = NULL;
// 可以用JMP模式,也可以用CALL模式
//#define JMP    1
// 和sample模塊裏同名的sample_read函數
static ssize_t sample_read(struct file *file, char __user *ubuf, size_t count, loff_t *ppos)
{
    int n = 0;
    char kb[16];
    if (*ppos != 0) {
        return 0;
    }
    // 這裏我們把1234的輸出給fix成4321的輸出
    n = sprintf(kb, "%d\n", 4321);
    memcpy(ubuf, kb, n);
    *ppos += n;
    return n;
}
// hijack_stub的作用就類似於ftrace kpatch裏的ftrace_regs_caller
static ssize_t hijack_stub(struct file *file, char __user *ubuf, size_t count, loff_t *ppos)
{
    // 用nop佔位,加上C編譯器自動生成的函數header代碼,這麼大的函數來容納stub應該夠了。
    asm ("nop; nop; nop; nop; nop; nop; nop; nop;");
    return 0;
}
#define FTRACE_SIZE       5
#define POKE_OFFSET        0
#define POKE_LENGTH        5
#define SKIP_LENGTH        8
static unsigned long *(*_mod_find_symname)(struct module *mod, const char *name);
static void *(*_text_poke_smp)(void *addr, const void *opcode, size_t len);
static struct mutex *_text_mutex;
unsigned char saved_inst[POKE_LENGTH];
struct module *mod;
static int __init hotfix_init(void)
{
    unsigned char jmp_call[POKE_LENGTH];
    unsigned char e8_skip_stack[SKIP_LENGTH];
    s32 offset, i = 5;
    mod = find_module("sample");
    if (!mod) {
        printk("沒加載sample模塊,你要patch個啥?\n");
        return -1;
    }
    _mod_find_symname = (void *)kallsyms_lookup_name("mod_find_symname");
    if (!_mod_find_symname) {
        printk("還沒開始,就已經結束。");
        return -1;
    }
    addr = (void *)_mod_find_symname(mod, "sample_read");
    if (!addr) {
        printk("一切還沒有準備好!請先加載sample模塊。\n");
        return -1;
    }
    _text_poke_smp = (void *)kallsyms_lookup_name("text_poke_smp");
    _text_mutex = (void *)kallsyms_lookup_name("text_mutex");
    if (!_text_poke_smp || !_text_mutex) {
        printk("還沒開始,就已經結束。");
        return -1;
    }
    stub = (void *)hijack_stub;
    offset = (s32)((long)sample_read - (long)stub - FTRACE_SIZE);
    // 下面的代碼就是stub函數的最終填充,它類似於ftrace_regs_caller的作用!
    e8_skip_stack[0] = 0xe8;
    (*(s32 *)(&e8_skip_stack[1])) = offset;
#ifndef JMP    // 如果是call模式,則需要手工平衡堆棧,跳過原始函數的棧幀
    e8_skip_stack[i++] = 0x41; // pop %r11
    e8_skip_stack[i++] = 0x5b; // r11寄存器爲臨時使用寄存器,遵循調用者自行保護原則
#endif
    e8_skip_stack[i++] = 0xc3;
    _text_poke_smp(&stub[0], e8_skip_stack, SKIP_LENGTH);
    offset = (s32)((long)stub - (long)addr - FTRACE_SIZE);
    memcpy(&saved_inst[0], addr, POKE_LENGTH);
#ifndef JMP
    jmp_call[0] = 0xe8;
#else
    jmp_call[0] = 0xe9;
#endif
    (*(s32 *)(&jmp_call[1])) = offset;
    get_online_cpus();
    mutex_lock(_text_mutex);
    _text_poke_smp(&addr[POKE_OFFSET], jmp_call, POKE_LENGTH);
    mutex_unlock(_text_mutex);
    put_online_cpus();
    return 0;
}
static void __exit hotfix_exit(void)
{
    mod = find_module("sample");
    if (!mod) {
        printk("一切已經結束!\n");
        return;
    }
    addr = (void *)_mod_find_symname(mod, "sample_read");
    if (!addr) {
        printk("一切已經結束!\n");
        return;
    }
    get_online_cpus();
    mutex_lock(_text_mutex);
    _text_poke_smp(&addr[POKE_OFFSET], &saved_inst[0], POKE_LENGTH);
    mutex_unlock(_text_mutex);
    put_online_cpus();
}
module_init(hotfix_init);
module_exit(hotfix_exit);
MODULE_LICENSE("GPL");

OK,我們載入它吧,然後重新read一下/proc/test:

[root@localhost test]# insmod ./hijack.ko
[root@localhost test]# cat /proc/test
4321

可以看到,已經patch成功。到底發生了什麼?我們看下反彙編:

crash> dis sample_read
dis: sample_read: duplicate text symbols found:
ffffffffa039d000 (t) sample_read [sample]
ffffffffa03a2020 (t) sample_read [hijack]
crash>

嗯,已經有兩個同名的sample_read函數符號了,sample模塊裏的是老的函數,而hijack模塊裏的是新的fix後的函數。我們分別看一下:

// 先看老的sample_read,它的ftrace stub已經被改成了call hijack_stub
crash> dis ffffffffa039d000 1
0xffffffffa039d000 <sample_read>:       callq  0xffffffffa03a2000 <hijack_stub>
// 再看新的sample_read,它就是最終被執行的函數
crash> dis ffffffffa03a2020 1
0xffffffffa03a2020 <sample_read>:       nopl   0x0(%rax,%rax,1) [FTRACE NOP]
crash>

當新的sample_read執行完畢,返回hijack_stub後,如果是CALL模式,此時需要skip掉老的sample_read函數的棧幀,所以一個pop %r11來完成它,之後直接ret即可,如果是JMP模式,則直接ret,不需要skip棧幀,因爲JMP指令根本就不會壓棧。

好了,這就是我要講的故事。說白了,本文描述的依然是一個手藝活,我只是希望用大家都能理解的最簡單的方式,來展示相對比較複雜的熱補丁的實現原理。我覺得工友們有必要對底層的原理有深刻的認知。

經理也愛吃辣椒,但不很,不過顯而易見的是,經理灑不了水。


浙江溫州皮鞋溼,下雨進水不會胖!

版權聲明:本文爲CSDN博主「dog250」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:

https://blog.csdn.net/dog250/article/details/105254739

(END)

Linux閱碼場原創精華文章彙總

更多精彩,盡在"Linux閱碼場",掃描下方二維碼關注

別忘了點一下“在看”哦~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章