文章目录
- 前言
- 1、概述
- 2、使用方法
- 3、测试用例
- 3.1、检测加载的内核模块
- 3.2、检测调用的内核模块
- 3.3、通过系统调用检测
- 3.4、检测编译到Linux内核中的内核模块
- 4、工作原理
- 4.1、影子内存(Shadow Memory)
- 4.2、内存状态(Memory States)
- 4.3、红色区域(Redzones)
- 4.4、KASan的实现
- 5、参考文献
- 总结
前言
本博客的主要内容为KASan的部署、使用与原理分析。本博文内容较长,因为涵盖了KASan的几乎全部内容,从部署的详细过程到如何使用KASan对Linux内核中的内存错误进行检测,以及其对Linux内核中的内存错误进行检测的原理分析,相信认真读完本博文,各位读者一定会对KASan有更深的了解。以下就是本篇博客的全部内容了。
1、概述
KASan(Kernel Address Sanitizer)是Linux内核中的一种内存错误检测工具,旨在帮助开发者发现和修复内核空间的内存访问错误。下面是关于KASan的一些基本介绍:
- 功能和目的
- 内存错误检测:KASan主要用于检测内核空间代码中的内存错误,包括但不限于内存越界访问、使用未初始化的内存、以及一些特定的内存使用后释放问题。
- 开发者工具:作为开发者工具,KASan帮助开发者在早期发现并修复潜在的内存错误,提高内核代码的稳定性和安全性。
- 工作原理
- 内存影子(Shadow Memory):KASan使用内存影子技术来实现内存错误检测。它为每个分配的内存区域维护一个影子内存地图,记录了每个内存字节的分配状态和元数据信息。
- 访问检查:当程序访问内存时,KASan会同时访问影子内存地图,检查对应的内存访问是否有效。例如,检测是否存在未分配或已释放的内存访问,以及是否发生了内存越界访问。
- 报告和跟踪:当检测到内存错误时,KASan会生成报告并记录相关信息,帮助开发者追踪和修复问题。
- 使用方法
- 配置和编译:KASan的启用需要在编译内核时进行配置,通常通过修改内核配置文件(如“.config”)来启用相关选项(如
CONFIG_KASAN=y
)。 - 运行时检测:一旦内核加载并运行,KASan在检测到内存错误时会向系统日志(如dmesg)输出相应的错误信息,包括错误类型、位置和堆栈跟踪等。
- 配置和编译:KASan的启用需要在编译内核时进行配置,通常通过修改内核配置文件(如“.config”)来启用相关选项(如
- 适用性和限制
- 适用范围:KASan主要用于内核空间的内存错误检测,适用于内核代码本身、驱动程序和内核模块等。
- 性能开销:KASan在运行时会增加一定的内存和CPU开销,特别是在大规模的内核代码或高频率的内存访问场景下可能会有显著的性能影响。
总之,KASan是Linux内核中的一种工具,专用于检测和报告内核空间代码中的内存错误。通过影子内存技术,KASan能够有效地检测内存访问越界、未初始化的内存使用等问题,并生成详细的报告帮助开发者迅速定位和修复这些问题。此外KASan工具基于C语言和汇编语言开发。
2、使用方法
由于KASan是Linux内核自带的一个工具,所以需要在编译内核时开启该工具,故我们会首先编译一个新的内核已开启KASan工具的功能,最后验证其是否开启成功。
- 首先使用如下命令查看当前系统的Linux内核版本:
$ uname -r
-
可以发现,当前系统的Linux内核版本为4.15.0-45-generic:
-
然后使用如下命令查看当前系统的版本:
$ lsb_release -a
-
可以发现,当前系统的版本为Ubuntu 16.04.6 LTS:
-
然后下载安装编译内核所需要的软件:
$ sudo apt-get update
$ sudo apt-get install g++ gcc make build-essential libncurses-dev bison flex libssl-dev libelf-dev -y
$ sudo apt-get install openssl zlibc minizip libidn11-dev libidn11 libncurses5-dev -y
- 然后下载一个新的Linux内核(在这里我采用的Linux内核版本为4.9.3)源码压缩包,并将其解压,最后进入解压好的Linux内核源码目录:
$ cd /usr/src/
$ sudo wget https://mirrors.aliyun.com/linux-kernel/v4.x/linux-4.9.3.tar.gz
$ sudo tar -zxvf linux-4.9.3.tar.gz
$ cd linux-4.9.3/
- 然后顺序执行如下命令来进行编译内核之前的清理工作:
$ sudo make mrproper
$ sudo make clean
- 然后执行如下命令来启动内核的编译配置界面:
$ sudo make menuconfig
-
然后按如下图所示进行选择,最后按一下“Enter”键:
-
然后按如下图所示进行选择,最后按一下“Enter”键:
-
然后按如下图所示进行选择,最后按一下“Enter”键:
-
然后按如下图所示进行选择,最后按一下“Enter”键:
-
然后按如下图所示进行选择,最后按一下“Space”键:
-
然后连续按“Esc”键直到出现如下图所示的界面,然后按如下图所示进行选择,最后按一下“Enter”键,这样就开启了当前待编译内核的KASan功能:
-
然后执行如下命令来编译内核:
$ sudo make -j$(nproc)
- 然后执行如下命令来安装编译好的内核模块:
$ sudo make modules_install
- 然后执行如下命令来安装编译好的内核:
$ sudo make install
- 然后重启系统:
$ reboot
-
在重启系统的过程中按“Esc”键,即可进入如下图所示界面,我们只需要如下图红框和红箭头处所示进行选择即可:
-
然后按如下图所示进行选择,然后按一下“Enter”键,目的是使用我们刚刚编译好的Linux内核加载以进入系统:
-
然后执行如下命令来查看系统内核是否更换成功:
$ uname -r
-
可以发现,当前系统的内核已经更换为4.9.3:
-
然后执行如下命令来查看KASan是否成功开启:
$ grep CONFIG_KASAN /boot/config-$(uname -r)
- 执行上面的命令后,出现如下图所示的内容即代表KASan开启成功:
3、测试用例
3.1、检测加载的内核模块
在本章节,我们将通过编写和运行一个简单的内核模块,故意触发一个内存越界访问来对KASan进行测试(注意:该测试用例使用的Linux内核版本为4.4.252)。
- 首先来到当前用户的根目录,并新建一个代码文件,然后将其打开:
$ cd ~
$ touch kasan_test.c
$ gedit kasan_test.c
- 然后在打开的文件中输入如下内容,最后保存修改后退出:
#include <linux/module.h>
#include <linux/kallsyms.h>
#include <asm/uaccess.h>
#include <linux/syscalls.h>
#include <linux/kernel.h>
#include <linux/slab.h>
static int __init kasan_test_init(void)
{
char *ptr;
size_t size = 124;
printk(KERN_INFO "KASan test module loaded\n");
ptr = kmalloc(size, GFP_KERNEL);
if (!ptr) {
printk(KERN_ERR "Allocation failed\n");
return -1;
}
printk(KERN_INFO "ptr address: %p\n", ptr);
ptr[size] = 'x';
printk(KERN_INFO "Accessing out-of-bounds memory: ptr[%zu] address: %p\n", size, ptr + size);
kfree(ptr);
return 0;
}
static void __exit kasan_test_exit(void)
{
printk(KERN_INFO "KASan test module unloaded\n");
}
module_init(kasan_test_init);
module_exit(kasan_test_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("IronmanJay");
MODULE_DESCRIPTION("KASan test module");
该内核模块包含一个初始化函数和一个退出函数。在初始化函数kasan_test_init
中,分配了一块大小为124
字节的内存,然后故意访问越界位置(第124
字节,即ptr[size]
)。函数会打印内存地址及越界访问地址,最后释放分配的内存。退出函数kasan_test_exit
仅打印一个模块卸载信息。通过这些步骤,可以测试KASan(Kernel Address Sanitizer)是否能够检测到内存越界访问。
- 然后再在当前目录下创建一个Makefile文件,并将其打开:
$ touch Makefile
$ gedit Makefile
- 在打开的文件中输入如下内容,最后保存修改后退出:
obj-m += kasan_test.o
# 添加调试信息和警告
EXTRA_CFLAGS += -g -Wall
# 指定内核源码路径
KERNEL_SOURCE := /lib/modules/$(shell uname -r)/build
# 默认目标
all:
make -C $(KERNEL_SOURCE) M=$(PWD) modules
# 清理目标
clean:
make -C $(KERNEL_SOURCE) M=$(PWD) clean
- 然后顺序执行下面两条命令,以编译和加载该内核模块:
$ make
$ sudo insmod kasan_test.ko
- 然后执行如下命令来查看内核日志以确认KASan是否捕捉到我们设计好的内存越界访问:
$ dmesg -T
- 执行上面的命令后,会在命令行窗口打印如下图所示的信息,这就说明我们自定义的内核模块中的内存越界错误已经被KASan捕获到了:
3.2、检测调用的内核模块
在本章节,我们将使用用户态的二进制程序来调用存在内存越界错误的内核模块,从而故意触发一个内存越界访问来对KASan进行测试,测试KASan能否检测到调用的内核模块中的内存越界错误(注意:该测试用例使用的Linux内核版本为4.9.3)。
- 首先来到当前用户的根目录,并新建一个代码文件,然后将其打开:
$ cd ~
$ touch kasan_trigger.c
$ gedit kasan_trigger.c
- 然后在打开的文件中输入如下内容,最后保存修改后退出:
#include <linux/module.h>
#include <linux/proc_fs.h>
#include <linux/uaccess.h>
#include <linux/slab.h>
#define PROC_NAME "kasan_trigger"
static ssize_t proc_write(struct file *file, const char __user *buffer, size_t count, loff_t *pos) {
char *kbuf;
int *p;
// 分配内核缓冲区并从用户态复制数据
kbuf = kmalloc(count, GFP_KERNEL);
if (!kbuf)
return -ENOMEM;
if (copy_from_user(kbuf, buffer, count)) {
kfree(kbuf);
return -EFAULT;
}
// 故意引发内存越界错误
p = kmalloc(sizeof(int) * 10, GFP_KERNEL);
if (!p) {
kfree(kbuf);
return -ENOMEM;
}
p[10] = 0; // 越界写入
kfree(p);
kfree(kbuf);
return count;
}
static const struct file_operations proc_file_ops = {
.write = proc_write,
};
static int __init kasan_trigger_init(void) {
if (!proc_create(PROC_NAME, 0666, NULL, &proc_file_ops)) {
printk(KERN_ERR "Error: Could not initialize /proc/%s\n", PROC_NAME);
return -ENOMEM;
}
printk(KERN_INFO "/proc/%s created\n", PROC_NAME);
return 0;
}
static void __exit kasan_trigger_exit(void) {
remove_proc_entry(PROC_NAME, NULL);
printk(KERN_INFO "/proc/%s removed\n", PROC_NAME);
}
MODULE_LICENSE("GPL");
MODULE_AUTHOR("IronmanJay");
MODULE_DESCRIPTION("A module to trigger KASAN error via proc file");
module_init(kasan_trigger_init);
module_exit(kasan_trigger_exit);
该内核模块通过在“/proc”文件系统中创建一个名为“kasan_trigger”的文件。写入该文件时,模块故意引发内存越界错误,以触发KASAN(Kernel Address Sanitizer)检测内存问题。模块的初始化和退出函数分别创建和删除该“/proc”文件。
- 然后再在当前目录下创建一个Makefile文件,并将其打开:
$ touch Makefile
$ gedit Makefile
- 在打开的文件中输入如下内容,最后保存修改后退出:
obj-m += kasan_trigger.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
- 然后顺序执行下面两条命令,以编译和加载该内核模块:
$ make
$ sudo insmod kasan_trigger.ko
- 然后创建一个用户态程序来触发上面写好的内存越界错误:
$ touch trigger_kasan.c
$ gedit trigger_kasan.c
- 在打开的文件中输入如下内容,然后保存修改后退出:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd;
char buffer[] = "trigger";
fd = open("/proc/kasan_trigger", O_WRONLY);
if (fd < 0) {
perror("open");
return 1;
}
if (write(fd, buffer, strlen(buffer)) < 0) {
perror("write");
close(fd);
return 1;
}
close(fd);
return 0;
}
- 然后编译并运行写好的用户态程序:
$ gcc -o trigger_kasan trigger_kasan.c
$ ./trigger_kasan
- 然后执行如下命令来查看内核日志以确认KASan是否捕捉到我们设计好的内存越界访问:
$ dmesg -T
- 执行上面的命令后,会在命令行窗口打印如下图所示的信息,这就说明我们自定义的内核模块中的内存越界错误已经被KASan捕获到了:
3.3、通过系统调用检测
在本章节,我们将使用用户态的二进制程序来调用某个系统调用,从而故意触发一个内存越界访问来对KASan进行测试,测试KASan能否检测到通过系统调用引发的内核中的内存越界错误(注意:该测试用例使用的Linux内核版本为4.9.3)。
- 首先来到当前用户的根目录,并新建一个代码文件,然后将其打开:
$ cd ~
$ touch kasan_syscall.c
$ gedit kasan_syscall.c
- 然后在打开的文件中输入如下内容,最后保存修改后退出:
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int main() {
// 分配内存映射区域
size_t length = 4096;
void *addr = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (addr == MAP_FAILED) {
perror("mmap failed");
return 1;
}
// 写入数据到内存区域内
strcpy((char *)addr, "hello, KASAN!");
// 尝试访问映射区域外的内存(引发越界错误)
// 这个操作应当引发KASan检测到内存越界错误
*((char *)addr + length) = 'x'; // 越界写入
// 清理内存映射区域
munmap(addr, length);
return 0;
}
这段代码是一个用户态程序,通过内存映射分配一块内存区域,并向其中写入数据。随后,它尝试访问并写入该内存区域的边界之外,故意引发内存越界错误,以触发KASAN(Kernel Address Sanitizer)的检测。最后,程序清理内存映射区域并退出。
- 然后编译并运行写好的用户态程序:
$ gcc -o kasan_syscall kasan_syscall.c
$ ./kasan_syscall
-
执行上面的命令后,会打印如下图所示的内容:
-
然后执行如下命令来查看内核日志以确认KASan是否捕捉到我们设计好的内存越界访问:
$ dmesg | grep -i kasan
- 执行上面的命令后,会在命令行窗口打印如下图所示的信息,这就说明我们自定义的内核模块中的内存越界错误已经被KASan捕获到了:
3.4、检测编译到Linux内核中的内核模块
在前面章节的测试中,我们使用了十分明显的内存错误测试用例,然而在大多数情况下,内存错误是不易被察觉且难以发现的,所以本章节将模拟正常使用的情况,通过比较复杂的内核模块代码来模拟用户正常使用内核模块时的情况来测试KASan能否检测到我们设置的内存错误(注意:该测试用例使用的Linux内核版本为4.9.3)。
- 首先来到当前用户的根目录中,并创建测试目录,最后进入该目录:
$ cd ~
$ mkdir test
$ cd test/
- 然后创建下面的文件,并编辑:
$ touch mem_error_module.c
$ gedit mem_error_module.c
- 在打开的文件中输入下面的内容,最后保存修改后退出:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/init.h>
#include <linux/device.h> // 用于 class_create 和相关函数
#include <linux/string.h>
#define DEVICE_NAME "mem_error"
#define CLASS_NAME "mem_error_class"
#define BUFFER_SIZE 1024
#define TRIGGER_CONDITION 1024
static int majorNumber;
static char kernel_buffer[BUFFER_SIZE];
static struct class* memErrorClass = NULL;
static struct device* memErrorDevice = NULL;
static ssize_t dev_write(struct file *filep, const char __user *buffer, size_t len, loff_t *offset) {
// 复杂的条件判断
if (len > TRIGGER_CONDITION) {
// 只有当len大于TRIGGER_CONDITION时,才会导致缓冲区溢出
printk(KERN_WARNING "mem_error: Buffer overflow condition met\n");
// 使用不安全的内存操作来制造溢出
memcpy(kernel_buffer, buffer, len); // 不安全的内存操作
return len;
} else {
// 安全地复制数据
if (copy_from_user(kernel_buffer, buffer, len)) {
return -EFAULT;
}
return len;
}
}
static int dev_open(struct inode *inodep, struct file *filep) {
printk(KERN_INFO "mem_error: Device has been opened\n");
return 0;
}
static int dev_release(struct inode *inodep, struct file *filep) {
printk(KERN_INFO "mem_error: Device successfully closed\n");
return 0;
}
static struct file_operations fops = {
.open = dev_open,
.write = dev_write,
.release = dev_release,
};
static int __init mem_error_init(void) {
printk(KERN_INFO "mem_error: Initializing the mem_error module\n");
// 动态分配主设备号
majorNumber = register_chrdev(0, DEVICE_NAME, &fops);
if (majorNumber < 0) {
printk(KERN_ALERT "mem_error: Failed to register a major number\n");
return majorNumber;
}
printk(KERN_INFO "mem_error: Registered correctly with major number %d\n", majorNumber);
// 注册设备类
memErrorClass = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(memErrorClass)) {
unregister_chrdev(majorNumber, DEVICE_NAME);
printk(KERN_ALERT "mem_error: Failed to register device class\n");
return PTR_ERR(memErrorClass);
}
printk(KERN_INFO "mem_error: Device class registered correctly\n");
// 注册设备驱动
memErrorDevice = device_create(memErrorClass, NULL, MKDEV(majorNumber, 0), NULL, DEVICE_NAME);
if (IS_ERR(memErrorDevice)) {
class_destroy(memErrorClass);
unregister_chrdev(majorNumber, DEVICE_NAME);
printk(KERN_ALERT "mem_error: Failed to create the device\n");
return PTR_ERR(memErrorDevice);
}
printk(KERN_INFO "mem_error: Device class created correctly\n");
return 0;
}
static void __exit mem_error_exit(void) {
device_destroy(memErrorClass, MKDEV(majorNumber, 0));
class_unregister(memErrorClass);
class_destroy(memErrorClass);
unregister_chrdev(majorNumber, DEVICE_NAME);
printk(KERN_INFO "mem_error: Goodbye from the mem_error module!\n");
}
module_init(mem_error_init);
module_exit(mem_error_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("IronmanJay");
MODULE_DESCRIPTION("A simple Linux char driver with a conditional buffer overflow");
MODULE_VERSION("1.0");
- 然后创建下面的文件,并编辑:
$ touch Makefile
$ gedit Makefile
- 在打开的文件中输入下面的内容,最后保存修改后退出:
obj-m += mem_error_module.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
- 然后执行下面的命令来编译该内核模块:
$ make
- 然后执行下面的命令来加载编译好的内核模块:
$ sudo insmod mem_error_module.ko
- 然后创建下面的文件,并编辑:
$ touch test_mem_error.c
$ gedit test_mem_error.c
- 在打开的文件中输入下面的内容,最后保存修改后退出:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#define DEVICE_PATH "/dev/mem_error"
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <input_string>\n", argv[0]);
return 1;
}
const char *input = argv[1];
int fd = open(DEVICE_PATH, O_WRONLY);
if (fd < 0) {
perror("Failed to open the device");
return 1;
}
ssize_t bytes_written = write(fd, input, strlen(input));
if (bytes_written < 0) {
perror("Failed to write to the device");
return 1;
}
printf("Wrote %zd bytes to the device\n", bytes_written);
close(fd);
return 0;
}
- 然后执行下面的命令来编译我们刚刚写好的代码:
$ gcc -o test_mem_error test_mem_error.c
- 然后打开一个新的命令行终端,并执行下面的命令来实时检测KASan捕获到的信息:
$ sudo dmesg -Tw
- 然后执行下面的命令来进行第一次测试,本次的测试用例并不会触发内存错误:
$ sudo ./test_mem_error "This is a normal situation"
-
由于该测试用例并不会触发内存错误,所以可以发现KASan并没有捕获到相关信息:
-
然后执行下面的命令来进行第二次测试,本次的测试用例会触发我们手动设置的内存错误:
$ sudo ./test_mem_error $(perl -e 'print "A" x 2000')
- 由于该测试用例会触发我们手动设置的内存错误,所以可以发现KASan成功捕获到了相关信息:
4、工作原理
KASan(Kernel Address Sanitizer)是用于检测内核内存错误的工具,主要用于检测内存越界访问和使用已释放的内存。它通过在内存周围插入红色区域(称为“毒区”或“红色区域”)并在内存分配和释放时检查这些区域的状态来工作。它可以检测以下类型的内存错误:
- 内存越界访问(Out-of-bounds access)
- 使用未初始化的内存(Use-after-free)
- 使用未初始化的堆内存(Use-after-poison)
- 重复释放内存(Double-free)
KASan的实现依赖于影子内存(Shadow Memory)和红色区域(Redzones)来跟踪内存分配和访问情况。
4.1、影子内存(Shadow Memory)
影子内存是KASan用于记录每个内存字节状态的内存区域。每个实际内存字节对应一个影子字节,影子字节的值表示相应内存字节的状态。影子内存的布局如下:
- 1字节影子内存对应8字节实际内存
- 每个影子字节可以表示8个实际内存字节的状态
影子内存的计算公式如下:
S
h
a
d
o
w
A
d
d
r
=
(
A
d
d
r
>
>
3
)
+
S
H
A
D
O
W
_
O
F
F
S
E
T
ShadowAddr = (Addr >> 3) + SHADOW\_OFFSET
ShadowAddr=(Addr>>3)+SHADOW_OFFSET
其中 A d d r Addr Addr是实际内存地址, S H A D O W _ O F F S E T SHADOW\_OFFSET SHADOW_OFFSET是影子内存的起始偏移。
4.2、内存状态(Memory States)
影子内存字节的值表示相应内存字节的状态:
- 0:对应内存字节是安全的
- 负数(-1到-8):对应内存字节在红色区域内,不可访问
- 正数(1到7):对应内存字节部分有效
4.3、红色区域(Redzones)
红色区域是在每个分配的内存块前后插入的未分配区域。这些区域用于检测越界访问。当访问红色区域时,影子内存会标记这些访问为非法。
4.4、KASan的实现
KASan通过以下步骤实现内存错误检测:
- 内存分配
- 在内存块前后插入红色区域
- 更新影子内存以标记红色区域和实际分配的内存块
- 内存释放
- 将整个内存块及其红色区域标记为中毒状态
- 更新影子内存
- 内存访问
- 检查影子内存对应的值,以确定内存访问是否合法
- 如果发现非法访问,记录错误信息并触发内核警告
5、参考文献
- linux之kasan原理及解析
- linux内核(5.4.81)—KASAN - povcfe’s blog
- 编译内核报错 No rule to make target ‘debian/canonical-certs.pem‘ 或 ‘canonical-revoked-certs.pem‘ 的解决方法
- warning: the frame size of 1040 bytes is larger than 1024 bytes
- ubuntu22.04:使用时遇到的问题_missing symbol table
- 编译内核报错——Failed to generate BTF for vmlinux
- 编译内核启用KASan动态检测内核内存错误功能(ubuntu16.04 4.4.0内核编译升级到linux-4.4.252版本)_module for testing kasan for bug detection
总结
以上就是本篇博文的全部内容,可以发现,KASan的部署与使用过程并不复杂,我们本篇博客对其进行了详细的分析。相信读完本篇博客,各位读者一定对KASan有了更深的了解。