Linux內核如何替換內核函數並調用原始函數

替換一個已經在內存中的函數,使得執行流流入我們自己的邏輯,然後再調用原始的函數,這是一個很古老的話題了。比如有個函數叫做funcion,而你希望統計一下調用function的次數,最直接的方法就是 如果有誰調用function的時候,調到下面這個就好了 :

<code>

void

new_function() { count++;

return

function

(

); }

/<code>

網上很多文章給出了實現這個思路的Trick,而且一直以來計算機病毒也都採用了這種偷樑換柱的伎倆來實現自己的目的。然而,當你親自去測試時,發現事情並不那麼簡單。

網上給出的許多方法均不再適用了,原因是在早期,這樣做的人比較少,處理器和操作系統大可不必理會一些不符合常規的做法,但是隨著這類Trick開始做壞事影響到正常的業務邏輯時,處理器廠商以及操作系統廠商或者社區便不得不在底層增加一些限制性機制,以防止這類Trick繼續起作用。

常見的措施有兩點:

  • 可執行代碼段不可寫

這個措施便封堵住了你想通過簡單memcpy的方式替換函數指令的方案。

  • 內存buffer不可執行

這個措施便封堵住了你想把執行流jmp到你的一個保存指令的buffer的方案。

  • stack不可執行

別看這些措施都比較low,一看誰都懂,它們卻避免了大量的緩衝區溢出帶來的危害。

那麼如果我們想用替換函數的Trick做正常的事情,怎麼辦?

我來簡單談一下我的方法。首先我不會去HOOK用戶態的進程的函數,因為這樣意義不大,改一下重啟服務會好很多。所以說,本文特指HOOK內核函數的做法。畢竟內核重新編譯,重啟設備代價非常大。

我們知道,我們目前所使用的幾乎所有計算機都是馮諾伊曼式的統一存儲式計算機,即指令和數據是存在一起的,這就意味著我們必然可以在操作系統層面隨意解釋內存空間的含義。

我們在做正當的事情,所以我假設我們已經拿到了系統的root權限並且可以編譯和插入內核模塊。那麼接下來的事情似乎就是一個流程了。

是的,修改頁表項即可,即便無法簡單地通過memcpy來替換函數指令,我們還是可以用以下的步驟來進行指令替換:

  1. 重新將函數地址對應的物理內存映射成可寫;
  2. 用自己的jmp指令替換函數指令;
  3. 解除可寫映射。

非常幸運,內核已經有了現成的 text_poke/text_poke_smp 函數來完成上面的事情。

同樣的,針對一個堆上或者棧上分配的buffer不可執行,我們依然有辦法。辦法如下:

  1. 編寫一個stub函數,實現隨意,其代碼指令和buffer相當;
  2. 用上面重映射函數地址為可寫的方法用buffer重寫stub函數;
  3. 將stub函數保存為要調用的函數指針。

是不是有點意思呢?下面是一個步驟示意圖:

Linux內核如何替換內核函數並調用原始函數

下面是一個代碼,我稍後會針對這個代碼,說幾個細節方面的東西:

<code> 
 
 
 
 
 
 

char

saved_op[OPTSIZE] = {

0

};

char

jump_op[OPTSIZE] = {

0

};

static

unsigned

int

(*ptr_orig_conntrack_in)(

const

struct

nf_hook_ops *ops,

struct

sk_buff *skb,

const

struct

net_device *

in

,

const

struct

net_device *

out

,

const

struct

nf_hook_state *state);

static

unsigned

int

(*ptr_ipv4_conntrack_in)(

const

struct

nf_hook_ops *ops,

struct

sk_buff *skb,

const

struct

net_device *

in

,

const

struct

net_device *

out

,

const

struct

nf_hook_state *state);

static

unsigned

int

stub_ipv4_conntrack_in(

const

struct

nf_hook_ops *ops,

struct

sk_buff *skb,

const

struct

net_device *

in

,

const

struct

net_device *

out

,

const

struct

nf_hook_state *state) { printk(

"hook stub conntrack\n"

);

return

0

; }

static

unsigned

int

hook_ipv4_conntrack_in(

const

struct

nf_hook_ops *ops,

struct

sk_buff *skb,

const

struct

net_device *

in

,

const

struct

net_device *

out

,

const

struct

nf_hook_state *state) { printk(

"hook conntrack\n"

);

return

ptr_orig_conntrack_in(ops, skb,

in

,

out

, state); }

static

void

*(*ptr_poke_smp)(

void

*addr,

const

void

*opcode, size_t len);

static

__init

int

hook_conn_init(

void

) { s32 hook_offset, orig_offset; ptr_poke_smp = kallsyms_lookup_name(

"text_poke_smp"

);

if

(!ptr_poke_smp) { printk(

"err"

);

return

-1

; } ptr_ipv4_conntrack_in = kallsyms_lookup_name(

"ipv4_conntrack_in"

);

if

(!ptr_ipv4_conntrack_in) { printk(

"err"

);

return

-1

; } jump_op[

0

] =

0xe9

; hook_offset = (s32)((

long

)hook_ipv4_conntrack_in - (

long

)ptr_ipv4_conntrack_in - OPTSIZE); (*(s32*)(&jump_op[

1

])) = hook_offset; saved_op[

0

] =

0xe9

; orig_offset = (s32)((

long

)ptr_ipv4_conntrack_in + OPTSIZE - ((

long

)stub_ipv4_conntrack_in + OPTSIZE)); (*(s32*)(&saved_op[

1

])) = orig_offset; get_online_cpus(); ptr_poke_smp(stub_ipv4_conntrack_in, saved_op, OPTSIZE); ptr_orig_conntrack_in = stub_ipv4_conntrack_in; barrier(); ptr_poke_smp(ptr_ipv4_conntrack_in, jump_op, OPTSIZE); put_online_cpus();

return

0

; } module_init(hook_conn_init);

static

__exit

void

hook_conn_exit(

void

) { get_online_cpus(); ptr_poke_smp(ptr_ipv4_conntrack_in, saved_op, OPTSIZE); ptr_poke_smp(stub_ipv4_conntrack_in, stub_op, OPTSIZE); barrier(); put_online_cpus(); } module_exit(hook_conn_exit); MODULE_DESCRIPTION(

"hook test"

); MODULE_LICENSE(

"GPL"

); MODULE_VERSION(

"1.1"

);/<code>

測試是OK的。

在上面的代碼中,saved_op中為什麼沒有old inst呢?直接就是一個jmp y,這豈不是將原始函數中的頭幾個字節的指令給遺漏了嗎?

其實說到這裡,還真有個不好玩的Trick,起初我真的就是老老實實保存了前5個自己的指令,然後當需要調用原始ipv4_conntrack_in時,就先執行那5個保存的指令,也是OK的。隨後我objdump這個函數發現了下面的代碼:

<code>     

0000000000000380

:

380:

e8

00

00

00

00

callq

385

385:

55

push

%rbp

386:

49

8b

40

18

mov

0x18

(%r8),%rax

38a:

48

89

f1

mov

%rsi,%rcx

38d:

8b

57

2c

mov

0x2c

(%rdi),%edx

390:

be

02

00

00

00

mov

$0x2,%esi

395:

48

89

e5

mov

%rsp,%rbp

398:

48

8b

b8

e8

03

00

00

mov

0x3e8

(%rax),%rdi

39f:

e8

00

00

00

00

callq

3a4

3a4:

5d

pop

%rbp

3a5:

c3

retq

3a6:

66

2e

0f

1f

84

00

00

nopw

%cs:0x0(%rax,%rax,1)

3ad:

00

00

00

/<code>

注意前5個指令: e8 00 00 00 00 callq 385

可以看到,這個是可以忽略的。因為不管怎麼說都是緊接著執行下面的指令。所以說,我就省去了inst的保存。

如果按照我的圖示中常規的方法的話,代碼稍微改一下即可:

<code>

char

saved_op[OPTSIZE+OPTSIZE] = {

0

}; ...

memcpy

(saved_op, (

unsigned

char

*)ptr_ipv4_conntrack_in, OPTSIZE); saved_op[OPTSIZE] =

0xe9

; orig_offset = (s32)((

long

)ptr_ipv4_conntrack_in + OPTSIZE - ((

long

)stub_ipv4_conntrack_in + OPTSIZE + OPTSIZE)); (*(s32*)(&saved_op[OPTSIZE+

1

])) = orig_offset; .../<code>

但是以上的只是玩具。

有個非常現實的問題。在我保存原始函數的頭n條指令的時候,n到底是多少呢?在本例中,顯然n是5,符合如今Linux內核函數第一條指令幾乎都是callq xxx的慣例。

然而,如果一個函數的第一條指令是下面的樣子:

<code>

op

d1 d2 d3 d4 d5/<code>

即一個操作碼需要5個操作數,我要是隻保存5個字節,最後在stub中的指令將會是下面的樣子:

<code>

op

d1 d2 d3 d4 0xe9

off

1

off

2

off

3

off

4/<code>

這顯然是錯誤的,op操作碼會將jmp指令0xe9解釋成操作數。

解藥呢?當然有咯。

我們不能魯莽地備份固定長度的指令,而是應該這樣做:

<code>

curr

=

0

if

orig[0] 為單字節操作碼

=

orig[curr];

curr++;

else

if orig[0] 攜帶1個1字節操作數

orig, 2);

curr

+= 2;

else

if orig[0] 攜帶2字節操作數

orig, 3);

curr

+= 3;

...

=

0xe9; // jmp

offset

=

...

=

offset;

/<code>

這是正確的做法。

需要C/C++ Linux服務器架構師學習資料私信“資料”(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等),免費分享

Linux內核如何替換內核函數並調用原始函數


分享到:


相關文章: