Skip to content

探针和打桩

此节的实现和函数约定密不可分

本文来谈一谈我们小内核已实现的两种简单技术:探针和打桩

我们实现的功能特别少,很简单,功能如下:

  • 探针:在被探函数A前执行我们自己的函数B,返回后继续执行A

一般可用于监控函数A,函数B会收到和函数A一样的参数,同时我们保证了B返回后寄存器都是原来的值。也就是说,B对于A是透明的,A察觉不到自己前面还有一个窃听者

当然,如果愿意,只需要稍改代码,不恢复寄存器的值也行,甚至还可以故意修改寄存器,这样A收到的参数就是被改过的参数,这样的B很类似于网络安全中的中间人

  • 打桩:我们采用破坏性侵入代码,替换A该而执行我们自己的函数B。当其他地方函数C调用函数A时,其实会运行我们的函数B,且函数B返回后直接返回C

由于我们已经实现了简单的内核模块,实现这个仅仅是为了允许我们运行时动态监控或修改内核(当然,内核自身也可以用。不过。。。为什么需要没事找事呢,哈哈)。

在我们的实现中,这两个东西都要手改内核代码。由于代码段的东西照常来说不允许修改,为了修改,我们需要临时对修改页面页表添加写权限。由于是在动态运行过程中修改,难免此处正在有代码运行,若同时也在写,我也不知道发生什么乱七八糟的事情(不过概率应该极低)。

因此,有风险的喔。。。

1. 探针

有个A->B,在编译后,A中的call B会直接编译成对 B的地址跳转。

如果不做任何处理,编译后的汇编代码都是紧紧挨在一起,密不可分,也就是说 B 函数前前后后都是代码,严丝合缝(牛角尖就不要来了哈,什么开头一个和最后一个函数啥的,在链接脚本中单独配置函数分布啥的拉都不考虑。。。。)。那么如何让A先跳到我们自己插入的函数呢?

一种办法是在运行过程中直接修改A的二进制代码,显然是不可取的。先不论大家A,Z,X,V,B,N,M...调用B最后汇编代码的实现都不一样,就算都一样,其他大家伙也还是没有变啊,就算大家都能变,你能知道有哪些地方调用了这个B吗,不好说嘀。。

所以为了实现,只能在被调用者B上面下功夫。

此外,我们还受限于这两个下面的两个条件:

  1. 不能破坏B(或者先破坏后复原)
  2. 所有代码密不可分

A->B,也就是,我们至少是先跳到B,但是在B之前又需要执行我们自己的代码,但是又不允许破坏B,属于是既要马儿跑,又不让马儿吃草

方法1

如果需要启用该功能,需要添加配置编译选项后重新编译

GCC 编译器提供了一个编译选项,可以在每个函数前面插入一定数量的 nop 指令。也就是说,比如 仍是A->B,但是编译后函数B 的起始地址开始处其实是可以有若干个 nop 指令,过后才是 B 函数真正执行代码的地方。

c
CFLAGS += -fpatchable-function-entry=N,M

具体的使用可以去查看文档,我们使用插入4个nop一共8B,我们便利用这个来做文章。

c
CFLAGS += -fpatchable-function-entry=4,0

形如:

c
0000000080017524 <kmalloc>:
    80017524:	0001            nop
    80017526:	0001            nop
    80017528:	0001            nop
    8001752a:	0001            nop

void *kmalloc(int size, uint32_t flags)
{
    8001752c:	7179            addi	sp,sp,-48
    8001752e:	f406            sd	ra,40(sp)
    ...

利用这 8B 内存,跳转到一个汇编函数,在这个汇编函数内,保存调用者保存寄存器(caller save)(以免执行我们自己的函数后参数发生不必要的修改),然后执行我们自己的函数,最后在保持 ra 不能变的环境下,通过其他寄存器跳回去。

被替换的8B内容会被手动修改为:

assembly
auipc t0, imm1
jalr t0,imm2(t0)

这个跳转到的地址就是我们下面即将介绍的kprobe_exec_entry。由于是相对跳转,这个修改的内容还需要视代码位置而定。

我们在跳转到汇编函数前把返回地址放在了t0jalr语句),在保存t0后然后调用函数,然后恢复t0后,使用t0的值给跳出。由于 t0 属于caller save,且不作为传参,因此后续没有任何影响。

代码如下:

assembly
# kernel/kprobe/kprobe_asm.S

kprobe_start:
# 这里是用
#  auipc t0, imm1
# jalr t0,imm2(t0)
# 跳转过来的,返回值保存在 t0 寄存器中
# 我们后面需要通过 t0 返回
# 根据ABI约定,作为调用者,我们需要保存 t0 后才能跳转
kprobe_exec_entry:
# save caller-saved registers.
    addi    sp, sp, -144
    sd      ra, 0(sp)
    ...
    sd      t6, 136(sp)

# auipc t0, imm20 # t0 ← PC + imm20 << 12
# jalr ra, imm12(t0) # 跳转到 t0 + imm12,ra 保存返回地址
kprobe_exec_entry_set:		# 跳转到我们自己的函数,后续还需要改这里
    auipc   t0,0
    jalr    ra,0(t0)

# restore registers.
    ld      ra, 0(sp)
    ...
    ld      t6, 136(sp)
    addi    sp, sp, 144

    # 根据 t0 的值跳转回去
    jalr    zero, t0, 0

然后我们手动修改kprobe_exec_entry_set处的内容,使用auipc + jalr来跳转到我们自己的函数。

这里给出部分代码:

c
// kernel/probe/kprobe.c

static void generate_jump_instruction(uint32_t *patch, void *target, 
           uint8_t reg, uint8_t opcode_auipc,uint8_t opcode_jalr)
{
    uint64_t offset = (uint64_t)target - (uint64_t)patch;
    uint64_t hi = (offset + 0x800) >> 12;
    uint32_t imm_hi = hi & 0xFFFFF;

    // 生成 auipc 指令
    patch[0] = (imm_hi << 12) | (reg << 7) | opcode_auipc;

    // 生成 jalr 指令
    int32_t imm_lo = offset - (hi << 12);
    patch[1] = (imm_lo << 20) | (reg << 15) | (0 << 12) | (reg << 7) | opcode_jalr;
}

//  auipc ra
//  jalr ra xxx(t0)
static void create_ra_jump(void *from, void *to)
{
    generate_jump_instruction((uint32_t *)from, to, 1, 0x17, 0x67);
}

方法二

不需要配置额外的编译选项,也就是上文的:

CFLAGS += -fpatchable-function-entry=N,M

很接近方法一,只不过我们采取直接霸占B的头8个字节,然后B的头8个字节换个地方执行

而已。

因此,我们就不需要编译的时候采取这么笨拙的方法。但是这原来8B仍是需要执行的,我们把这8字节放在了kprobe_exec_origin这里。具体代码如下:

assembly
# kernel/kprobe/kprobe_asm.S

kprobe_start:
kprobe_exec_entry:
	...
kprobe_exec_entry_set:
	...
    addi    sp, sp, 144
    

# kprobe.h INSTRUCTION_LEN 字节(8B)占位,后面填充
.align 2
kprobe_exec_origin: 
    .word 0x00000013   # 4B
    .word 0x00000013   # 4B

    # 根据 t0 的值跳转回去
    jalr    zero, t0, 0

注:实测大多数函数的头8字节都是压栈保存 ra,这里的ra也就是正常的ra

我们最开始的跳转是用的t0ra被压栈后,采用ra跳到我们自己的处理函数,回来后ra出栈恢复,因此不会造成问题。

到了这里,kprobe就差不多完成了,我们开始下一个吧。

2. 打桩

使用“打桩”可以不用加上文的编译选项,我们直接利用现有的函数空间内存侵入。

打桩的目的是替换原有的函数,执行我自己的函数。那么原函数本身的空间我们可以直接给鸠占鹊巢了。同样,我们把开头8字节直接抠出来,废弃,改为跳转到我们的某个函数。由于此时的ra是正确的,我们直接保存下来,后续通过ra直接ret就回去了(探针不能直接回去,需要回到后面继续的函数)。

跳转的8B代码改动类型和上面一样,不过是改到另外一个汇编函数这里,代码如下:

assembly
# 这里跳转方式类上
# 不过我们后面不需要保存 t0 的值
# 原函数的返回值保存在 ra(注意我们跳转方式并没有修改成新的 ra)
# 我们直接返回,这样原函数就不会被执行了
kprobe_attch_entry:
    addi    sp, sp, -8
    sd      ra, 0(sp)

# auipc t0, imm20 # t0 ← PC + imm20 << 12
# jalr ra, imm12(t0) # 跳转到 t0 + imm12,ra 保存返回地址
kprobe_attch_entry_set:
    auipc   t0,0
    jalr    ra,0(t0)

    ld      ra, 0(sp)
    addi    sp, sp,8
    ret

如果看过上面的话,基本上就没啥好讲的了,因为这个简单很多。

3. 清理

我们在替换信息的时候都有保存原来当前位置的字节信息,恢复的时候直接覆盖回去即可。

参见kernel/kprobe.c->kprobe_clear函数。

4. 实例

module/syscall_probe.c 文件,我们实现了一个可以监控当前系统中发出的所有系统调用,并输出相关信息等。