Linux Kernel CFI机制简介及测试禁用

PS:要转载请注明出处,本人版权所有。

PS: 这个只是基于《我自己》的理解,

如果和你的原则及想法相冲突,请谅解,勿喷。

环境说明

前言


当我们为android移植linux的驱动程序的时候,总会遇到一些错误,这些错误有一部分就是android 内核开启的安全的机制导致的。本文就会介绍一种内核的安全机制:Kernel Control Flow Integrity(kCFI)。

此外,这里还要说明一下,Control Flow Integrity(CFI)与 Kernel Control Flow Integrity(kCFI)是不一样的,kCFI只检查函数指针,CFI还具备其他很多的检查,详情请参考:https://clang.llvm.org/docs/ControlFlowIntegrity.html

Kernel Control Flow Integrity(kCFI)原理简单介绍


Control Flow Integrity的翻译是控制流完整性,从直译来看,其实就是用一些方法保证来保证我们的指令执行到正确的位置。我们从clang官方文档知道,kCFI只检查函数指针,那么其实kCFI就是保证函数指针跳转到正确的位置,并且返回到正确的位置。

从这里来看,其实我们可以看到对于函数指针来说,我们需要保护两个地方:跳转到正确的位置、返回到正确的位置。这两个地方有两个专有名词:

  • forward-edge
  • backward-edge

此外,我们还应该知道,在编写代码的时候,分为直接函数调用(direct function call),间接函数调用(indirect function call)。他们的示例如下:

c 复制代码
void target(void)
{
    //... ...
}

typedef void(*fn)(void);
int main(int argc, char * argv[])
{
    // direct function call
    target();

    //indirect function call
    fn _id_fn = target;
    _id_fn();
} 

从示例可以知道,indirect function call其实就是函数指针这种调用形式。

此外,我们还要知道,如果我们想破坏代码的执行流,那么我们必须在可写、可读、可执行的内存里面写入shellcode,并跳转到这个shellcode,否则我们的代码是无法工作的。那么显而易见的事情是,通过函数指针来调用函数,我们的目标是明确的,因此我们可以校验这些目标的原型、地址等等信息。

因为我们需要验证目标的原型、地址等等信息,所以,当我们在生成可执行文件的时候,需要知道所有的函数目标的信息,这个时候,就需要一个叫做Link Time Optimization(LTO)的功能,因为只有最终可执行文件链接时,才知道所有的函数目标信息。

kCFI演示示例


首先在qemu中运行一个arm64的linux模拟器,然后为linux内核配置如下内核选项:

txt 复制代码
# General architecture-dependent options -> LTO
CONFIG_CFI_CLANG=y
CONFIG_CFI_PERMISSIVE=y

我们的测试驱动例子:

c 复制代码
#include <linux/module.h>  // 必须的头文件,定义了MODULE_*宏
#include <linux/kernel.h>  // 包含内核信息头文件
#include <linux/init.h>    // 包含 __init 和 __exit 宏

static int param_int = 0;
module_param(param_int, int, 0644);

static void hello_cfi_i(int i){
    printk(KERN_INFO "hello_cfi_i\n");
}
static void hello_cfi_f(float i){
    printk(KERN_INFO "hello_cfi_f\n");
}

typedef void (*hello_cfi_func_i)(int);
typedef void (*hello_cfi_func_f)(float);


struct node {
    hello_cfi_func_i i0[1];
    hello_cfi_func_f f0[1];
    hello_cfi_func_i i1[1];
    hello_cfi_func_f f1[1];
    hello_cfi_func_i i2[1];
    hello_cfi_func_f f2[1];
};
struct node fn_arr = {
    .i0 = {hello_cfi_i},
    .f0 = {hello_cfi_f},
    .i1 = {hello_cfi_i},
    .f1 = {hello_cfi_f},
    .i2 = {hello_cfi_i},
    .f2 = {hello_cfi_f},
};
// 模块初始化函数
static int __init hello_init(void)
{

    fn_arr.i0[param_int](param_int);

    printk(KERN_INFO "Hello, World!\n");
    return 0;  // 返回0表示加载成功
}

// 模块清理函数
static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, World!\n");
}

// 注册模块初始化和清理函数
module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");  // 模块许可证
MODULE_AUTHOR("Your Name");  // 模块作者
MODULE_DESCRIPTION("A simple Hello World Module");  // 模块描述

我们传入参数0,执行fn_arr.i0[0],测试正常跳转

我们传入参数1,执行fn_arr.i0[1],测试传入参数原型不匹配(本来应该调用hello_cfi_i,实际调用hello_cfi_f)

测试数组越界访问

后记


从上面来看,kCFI一般会对调用类型、调用的目标地址进行判断,更多细节,去看CFI的具体原理。

参考文献


打赏、订阅、收藏、丢香蕉、硬币,请关注公众号(攻城狮的搬砖之路)

PS: 请尊重原创,不喜勿喷。

PS: 要转载请注明出处,本人版权所有。

PS: 有问题请留言,看到后我会第一时间回复。