【内核】基于 LSM 框架的 ELF 校验控制

发布时间 2023-12-19 20:12:10作者: _hong

欲实现操作系统对正在加载的 ELF 文件的校验控制,需要借助 LSM 框架。

LSM 框架介绍

LSM 全称 Linux Security MOdule,是 Linux 的一个安全模块框架。LSM 为用户提供了若干用于安全控制的“插桩式”接口,用户自定义的安全控制逻辑能够以hook的方式直接插入到内核代码中运行。

我们熟悉的SELinux就是基于 LSM 框架开发的。

LSM 框架的原理如图所示:

image

LSM 安全控制的 hook 通常被设置在内核企图去访问操作真正的内部资源之前。

这句话不太好理解,在《Linux Security Modules Framework》一文中原文为:" Just before the kernel attempts to access the internal object, an LSM hook makes an out-call to the module posing the question, 'Is this access OK with you?' "可能会说的清楚一些。

以用户对 inode 的访问控制为例,当进程从用户态通过系统调用陷入内核态时,首先会进行内核资源 inode 存在与否的判断(Look up inode),然后进行一些必要的错误检查(error checks),随后需要通过一些经典的 UNIX 目录访问控制,就在内核准备完成请求,企图访问真正的 inode 节点之前,LSM 框架设置了一个挂载点(Linux hook),执行用户编写的自定义控制逻辑。

在较新版本的 Linux 内核中,提供了 150 个 LSM Hook 点,涵盖内核访问资源的各个过程,体现在各个结构体中。常见的有:

过程 结构体
Task/Process task_struct
ELF Program linux_binprm
FileSystem super_block
Pipe/File/Socket inode
Open File file
Network Buffer sk_buff
Network Device net_device
Semaphore/Shared Memory Segment/Message Queue kern_ipc_perm
Individual Message msg_msg

具体的 hook 函数定义在 indluce/linux/lsm_hooks.h中,由一个 union 结构的 security_list_options 对象存储,每一个 hook 对应一个 security_hook_heads 结构。一组 security_list_optionssecurity_hook_heads 结构共同组成了 security_hook_list 结构体,用于一次业务逻辑校验。

具体内容如下:

// indluce/linux/lsm_hooks.h 节选
union security_list_options { 
	// ...
    int (*bprm_check_security)(struct linux_binprm *bprm);
    int (*file_permission)(struct file *file, int mask);
    /...
}

struct security_hook_heads { 
	// ...
    struct hlist_head bprm_check_security;
    struct hlist_head file_permission;
    // ...
} __randomize_layout;

/*
 * Security module hook list structure.
 * For use with generic list macros for common operations.
 */
struct security_hook_list {
	struct hlist_node		list;
	struct hlist_head		*head;
	union security_list_options	hook;
	char				*lsm;
} __randomize_layout;

除此之外, indluce/linux/lsm_hooks.h 中提供了 security_hook_list 的注册方式,如下:

// indluce/linux/lsm_hooks.h 节选
#define LSM_HOOK_INIT(HEAD, HOOK) \
	{ .head = &security_hook_heads.HEAD, .hook = { .HEAD = HOOK } }

extern struct security_hook_heads security_hook_heads;
extern void security_add_hooks(struct security_hook_list *hooks, int count,
				char *lsm);

// 上面两个 extern 变量和函数在 security/security.c 中有实现:(节选)
// security/security.c
struct security_hook_heads security_hook_heads __lsm_ro_after_init;

static void __init do_security_initcalls(void)
{
	initcall_entry_t *ce;
	ce = __security_initcall_start;
	while (ce < __security_initcall_end) {
		call = initcall_from_entry(ce);
		ret = call();
		ce++;
	}
}

int __init security_init(void)
{
	struct hlist_head *list = (struct hlist_head *) &security_hook_heads;
	for (i = 0; i < sizeof(security_hook_heads) / sizeof(struct hlist_head);
	     i++)
		INIT_HLIST_HEAD(&list[i]);
	pr_info("Security Framework initialized\n");
    
	do_security_initcalls();

	return 0;
}

void __init security_add_hooks(struct security_hook_list *hooks, int count,
				char *lsm)
{
	for (i = 0; i < count; i++) {
		hooks[i].lsm = lsm;
		hlist_add_tail_rcu(&hooks[i].list, hooks[i].head);
	}
}

通过上述代码可以得出:

使用 LSM 框架实现访问控制,用户仅需要完成下面三件事情:

1)选择合适的 LSM Hook 点,自定义 hook 函数 A

2)声明一个 security_hook_list 结构,将 Hook 点作为security_hook_heads,将 A 作为security_list_options注册到 security_hook_list 中;

3)将注册好的 security_hook_list 列表通过 security_add_hooks添加到一个指定 name 的 LSM 回调列表中。

如此一来,当内核初始化时,用户所注册的自定义 LSM 模块就会被security_init识别,并加载到内核中。

注意:

网上可以找到 LSM 模块编写示例,但很多都是将 LSM 模块直接编译为普通内核模块(.ko),并且在内核运行时动态加载(insmod/rmmod),实际上是不准确的。

在最早的《Linux Security Modules Framework》论文中,固然提出了这样的阐释:

LSM enables loading enhanced security policies as kernel modules.

但目前主流的内核版本(我用的 4.19 版本)早已不支持动态载入 LSM,仅允许以重新编译内核的方式加入到源码中。kernel.org 上的官方说明如下:

The Linux Security Module (LSM) framework provides a mechanism for various security checks to be hooked by new kernel extensions. The name "module" is a bit of a misnomer since these extensions are not actually loadable kernel modules. Instead, they are selectable at build-time via CONFIG_DEFAULT_SECURITY and can be overridden at boot-time via the "security=..." kernel command line argument, in the case where multiple LSMs were built into a given kernel.

引用自:https://www.kernel.org/doc/html/latest/admin-guide/LSM/

至于为何不能将 LSM 模块直接编译为普通内核模块以求动态加载,在内核源码中就可窥见一二。

security/security.c 中, security_add_hooks 函数被 __init 描述符修饰,在编译时,这段函数会被链接到.init.text数据段中,而这个数据段聚集了初始化的相关操作,在内核初始化kernel_init时执行。

因此,当一个自定义的 LSM 被当做 ko 模块被动态加载时,所调用的security_add_hooks早就不存在了。

ELF 校验方案

一个功能完整的操作系统必须允许用户运行可执行软件(ELF或PE格式等),可执行文件可能还会依赖于某些动态链接库。但运行或引用未知来源或未经验证的可执行文件或动态链接库可能会破坏操作系统的安全机制,造成数据泄漏、系统崩溃或引发其它问题。

以通用Linux操作系统为例,Linux内核支持加载、运行静态或动态链接的ELF文件。可执行文件本身的加载必需由内核完成,而动态连接库的加载则既可以在内核中完成,也可通过解释器( ld-linux.so )在用户空间(glibc)完成。Linux内核和解释器都扮演了ELF文件加载器的角色,可以修改ELF文件加载器,在加载过程中校验可执行文件和动态链接库的合法性和完整性。

请设计并使用代码实现一套完整的流程,确保每个ELF文件(包括可执行文件和共享库)的可靠性和完整性。

以上题目选自 2023全国大学生计算机系统能力大赛操作系统设计赛-功能挑战赛,proj86。欲实现在加载时对 ELF 文件的校验,需要考虑两点内容:

1)ELF文件是可执行文件;

2)ELF是动态链接库文件。

因此题目设计方案应当考虑两种情况

1)ELF 是可执行文件

ELF 可执行文件在加载过程中,当执行到 search_binary_handler(bprm) 时,会首先调用 security_bprm_check(bprm)。(详见 [# ELF 文件的执行流程](##-ELF 文件的执行流程))

此处为 LSM 预设的挂载点,因此可以在此添加校验逻辑。至于校验内容,可以为 ELF 文件新增一个自定义扩展字段,当认定它为可靠时,赋予其一个唯一签名;后面一旦发生修改或其他不安全行为,更改其签名内容。在 LSM 挂载点,对这个已有的签名进行校验。

给一个文件添加扩展属性的命令为:

setfattr -n security.authorization -v kos ${filename}

获取文件的某个扩展属性命令为:

getfattr -n security.authorization ${filename}

若按照以上方案,可以添加自定义 hook 函数:

#define ELF_CHECKER_MAX_ATTR 50
#define ELF_CHECKER_XATTR_NAME "security.authorization"

int systemd_count = 0;
int elf_check_flag = 0;

// 用于校验是否应该开启 ELF 检查功能
// 简单实现:由启动脚本触发
int elf_check_check_flag(struct linux_binprm *bprm) {
	const char* bprm_filename = NULL;

	if (bprm == NULL) {
		return -EPERM;
	}

	bprm_filename = bprm->filename;

	// set flag
	if (strcmp(bprm_filename, "./elf-checker-start.sh") == 0) {
		elf_check_flag = 1;
		printk(KERN_INFO "ELF_CHECKER: Enable ELF checker!");
	} else if (strcmp(bprm_filename, "./elf-checker-stop.sh") == 0) {
		elf_check_flag = 0;
		printk(KERN_INFO "ELF_CHECKER: Disable ELF checker!");
	}

	return 0;
}

// 自定义 hook 函数
int elf_check_bprm_check_security(struct linux_binprm *bprm) {
	struct dentry *dentry = bprm->file->f_path.dentry;
	int size = 0;
	char xattr[ELF_CHECKER_MAX_ATTR];
	const char* bprm_filename = NULL;
	bprm_filename = bprm->filename;
    // (A-1) 
	if (strcmp(bprm_filename, "/usr/lib/systemd/systemd") == 0){
		systemd_count++;
	}
	
	// (B) check flag
	if (elf_check_check_flag(bprm) || !elf_check_flag) {
		return 0;
	}

	// (A-2) ignore initramfs
	if (systemd_count < 1) {
		return 0;
	}

	// initialize xattr
	memset(xattr, '\0', sizeof(xattr));

	// (C) get xattr
	size = vfs_getxattr(dentry, ELF_CHECKER_XATTR_NAME, xattr, ELF_CHECKER_MAX_ATTR);
	if (size == 0) {
		printk(KERN_INFO "ELF_CHECKER: [bprm_check_security] xattr.size = 0\n");
		return -EPERM;
	}
	// validate xattr
	if (strcmp(xattr, "kos") == 0) {
		printk(KERN_INFO "ELF_CHECKER: [bprm_check_security] in file: %s,  check successfully!\n", bprm_filename);
	} else {
		printk(KERN_INFO "ELF_CHECKER: [bprm_check_security] in file: %s  check failed! xattr.security.authorization = %s\n", bprm_filename, xattr);
		return -EPERM;
	}

	return 0;
}

在自定义 hook 函数 elf_check_bprm_check_security() 中,对 ELF 文件的扩展字段进行校验。有一些细节需要注意:

(A)ignore initramfs

由于 LSM 模块需要随内核一起编译,在内核init时启用。因此为了不影响内核的正常引导启动时 initramfs 中 ELF 文件的加载,我们需要跳过 initramfs 挂载的阶段。

内核启动时,systemd 进程会启动两次:

在用户真正使用的根文件系统 sysroot 启动之前,内核用先启动 initramfs(虚根)文件系统 systemd,systemd 完成启动后会挂载 sysroot(逻辑根目录),然后切换到根文件系统,再次执行 systemd。

具体流程(概述):

(1) 执行 /init;       // initramfs
(2) 唤起 systemd[1];     // initramfs
(3) 挂载 /sysroot;      // initramfs
(4) 切换根目录 Root;     // initramfs
(5) 再次执行 systemd[1];   // real fs

所以可以监听 systemd 进程的执行过程,即,监听/usr/lib/systemd/systemd二进制文件的执行次数,当第二次执行(第(5)步)systemd 时,/usr/lib/systemd/systemd第一次正式加载。

同时,这个 LSM 模块正常运行,还需要给当前文件系统中的所有 ELF 文件标记可信签名,否则一旦重新引导新内核,会导致很多问题。可执行以下脚本:

sudo find / -path /proc -prune -o -path /sys -prune -o -path /dev -prune -o -path /run -prune -o -path /boot/efi -prune -o -path /boot/grub2 -prune -o -path /run/user -prune -o -type f -exec setfattr -n security.authorization -v kos {} +

(B)check flag

用户态增加 ELF 校验模块开关,采用了一个简单方式来实现,即,通过运行一个 start.shstop.sh,来控制hook函数是否启用。

注意,hook 函数返回值为 0 代表检测通过(pass),非0 代表检测不通过。

(C)get xattr

vfs_getxattr 是 Linux 内核中用于获取文件或目录扩展属性值的函数,它接收四个参数:

  • dentry:表示要获取扩展属性值的文件或目录的目录项。
  • name:表示要获取的扩展属性的名称。
  • buffer:表示存储扩展属性值的缓冲区。
  • size:表示存储扩展属性值的缓冲区的大小。
2)ELF 是动态链接库

对于动态链接库类型的 ELF 文件,最后都会被解释器访问,并以共享库的形式被其他程序链接执行,无论这个解释器存在于内核态还是用户态,ELF 文件都免不了被访问(access)的命运

LSM 框架刚好提供了对文件访问前的 hook 点,file_permission。它能够对文件读、写、执行等动作做出自定义响应。

因此,基于此hook点,实现以下函数:

int elf_check_file_permission(struct file *file, int mask) {
	struct inode *inode = NULL;
	struct dentry *dentry = NULL;
	int mod;
	int size = 0;
	char xattr[ELF_CHECKER_MAX_ATTR];
	const char *file_name = file->f_path.dentry->d_iname;	// 文件名

	// ignore initramfs and illegal mask
	if (mask < 0 || systemd_count < 1) {
		return 0;
	}

	// check flag
	if (!elf_check_flag) {
		return 0;
	}

	// initialize xattr
	memset(xattr, '\0', sizeof(xattr));

	// check the '.so' ELF files
	if (strstr(file_name, ".so")) {
		// init fields
		inode = file_inode(file);
		dentry = file->f_path.dentry;
		// mod = inode->i_mode;
		size = vfs_getxattr(dentry, ELF_CHECKER_XATTR_NAME, xattr, ELF_CHECKER_MAX_ATTR);

		if (size == 0) {
			printk(KERN_INFO "ELF_CHECKER: [file_permission] xattr.size = 0\n");
			return -EPERM;
		}

		// validate xattr
		if (strcmp(xattr, "kos") == 0) {
			printk(KERN_INFO "ELF_CHECKER: [file_permission] in file: %s,  check successfully!\n", file_name);
		} else {
			printk(KERN_INFO "ELF_CHECKER: [file_permission] in file: %s  check failed! xattr.security.authorization = %s\n", file_name, xattr);
			return -EPERM;
		}
	}
	return 0;
}

逻辑与前面大同小异,不过值得注意的是,这里需要抓取以 .so 结尾的 ELF 文件进行校验。

3)Hook 函数注册

准备好了以上两个 hook函数,现在需要将其注册为 LSM 模块:

#define ELF_CHECKER_MODULE_NAME "elf_check"

static struct security_hook_list elf_check_hooks[] __lsm_ro_after_init = {
    // (A)
	LSM_HOOK_INIT(bprm_check_security, elf_check_bprm_check_security),	
	LSM_HOOK_INIT(file_permission, elf_check_file_permission),
};

static __init int elf_check_init(void) {
	// (B)
	if (!security_module_enable(ELF_CHECKER_MODULE_NAME)) {
		printk(KERN_INFO "ELF_CHECKER:  Initializing error.\n");
		return 0;
	}
	// (C)
	security_add_hooks(elf_check_hooks, ARRAY_SIZE(elf_check_hooks),
                       ELF_CHECKER_MODULE_NAME);
	printk(KERN_INFO "ELF_CHECKER:  Initialized.\n");

	return 0;
}
// (D)
security_initcall(elf_check_init);

以上代码有几点需要注意:

(A)LSM_HOOK_INIT

LSM_HOOK_INIT 函数中,security_hook_heads 名称千万能写错,否则匹配不上。

(B)security_module_enable()

security_module_enable()函数是 security.c 中定义的,用于判断某个模块是否开启。它接收一个字符串lsm_name做参数。

这里有坑:lsm_name 最大长度为 10 个字符,如果超过 10 个字符,模块会注册不上。

(C)security_add_hooks()

该函数必须要有,用来为 lsm_name 模块添加 security_hook_list

(D)security_initcall()

security 模块初始化时的回调。

编译 LSM

LSM 模块需要和内核源码一起编译,在 security/ 目录中新建 elf_check 目录,elf_check 目录下新建文件 elf_check_lsm.c,上述代码写入 elf_check_lsm.c,并在同级目录中创建 KconfigMakefile 文件。

1)编译配置

security/ 目录下重要的目录结构如下:

security/
	|-> Kconfig					// A
    |-> Makefile				// B
    |-> elf_check				
    	|-> elf_check_lsm.c		// C
    	|-> Kconfig				// D
    	|-> Makefile			// E

1)E-Makefile 配置

#
# Makefile for building the ELF Checker.
#
obj-$(CONFIG_SECURITY_ELF_CHECK) := elf_check.o
elf_check-y := elf_check_lsm.o

2)D-Kconfig 配置

config SECURITY_ELF_CHECK
        bool "ELF Check"
        default n
        help
          This selects ELF Check.

config SECURITY_ELF_CHECK_BOOTPARAM_VALUE
        int "ELF Check boot parameter default value"
        depends on SECURITY_ELF_CHECK
        range 0 1
        default 1
        help
          This option sets the default value for the kernel parameter
          'ELF Check', which allows ELF Check to be disabled at boot.
          If this option is set to 0 (zero), the ELF Check kernel
          parameter will default to 0, disabling ELF Check at bootup.
          If this option is set to 1 (one), the ELF Check kernel
          parameter will default to 1, enabling ELF Check at bootup.

这里定义了两个配置项:SECURITY_ELF_CHECKconfig SECURITY_ELF_CHECK_BOOTPARAM_VALUE

前者是本 LSM 的配置项名称,后者是本 LSM 的默认引导参数值。

3)C-elf_check_lsm.c

这里定义了本模块的名称为 elf_check

4)B-Makefile 配置

# SPDX-License-Identifier: GPL-2.0
#
# Makefile for the kernel security code
#

obj-$(CONFIG_KEYS)          += keys/
subdir-$(CONFIG_SECURITY_SELINUX)   += selinux
subdir-$(CONFIG_SECURITY_ELF_CHECK)  += elf_check		# ------- 源码路径
subdir-$(CONFIG_SECURITY_SMACK)     += smack
subdir-$(CONFIG_SECURITY_TOMOYO)        += tomoyo
subdir-$(CONFIG_SECURITY_APPARMOR)  += apparmor
subdir-$(CONFIG_SECURITY_YAMA)      += yama
subdir-$(CONFIG_SECURITY_LOADPIN)   += loadpin

# always enable default capabilities
obj-y                   += commoncap.o
obj-$(CONFIG_MMU)           += min_addr.o

# Object file lists
obj-$(CONFIG_SECURITY)          += security.o
obj-$(CONFIG_SECURITYFS)        += inode.o
obj-$(CONFIG_SECURITY_SELINUX)      += selinux/
obj-$(CONFIG_SECURITY_ELF_CHECK)    += elf_check/		# ------- 源码路径
obj-$(CONFIG_SECURITY_SMACK)        += smack/
obj-$(CONFIG_AUDIT)         += lsm_audit.o
obj-$(CONFIG_SECURITY_TOMOYO)       += tomoyo/
obj-$(CONFIG_SECURITY_APPARMOR)     += apparmor/
obj-$(CONFIG_SECURITY_YAMA)     += yama/
obj-$(CONFIG_SECURITY_LOADPIN)      += loadpin/
obj-$(CONFIG_CGROUP_DEVICE)     += device_cgroup.o

# Object integrity file lists
subdir-$(CONFIG_INTEGRITY)      += integrity
obj-$(CONFIG_INTEGRITY)         += integrity/

5)A-Kconfig 配置

# 省略部分内容
source security/selinux/Kconfig
source security/elf_check/Kconfig		# 指定 elf_check Kconfig 路径
source security/smack/Kconfig
source security/tomoyo/Kconfig
source security/apparmor/Kconfig
source security/loadpin/Kconfig
source security/yama/Kconfig

source security/integrity/Kconfig

choice
        prompt "Default security module"
        default DEFAULT_SECURITY_SELINUX if SECURITY_SELINUX
        default DEFAULT_SECURITY_ELF_CHECK if SECURITY_ELF_CHECK	# 配置选项
        default DEFAULT_SECURITY_SMACK if SECURITY_SMACK
        default DEFAULT_SECURITY_TOMOYO if SECURITY_TOMOYO
        default DEFAULT_SECURITY_APPARMOR if SECURITY_APPARMOR
        default DEFAULT_SECURITY_DAC

        help
          Select the security module that will be used by default if the
          kernel parameter security= is not specified.

        config DEFAULT_SECURITY_SELINUX
                bool "SELinux" if SECURITY_SELINUX=y

		# 引入模块名 elf_check
        config DEFAULT_SECURITY_ELF_CHECK
                bool "elf_check" if SECURITY_ELF_CHECK=y

        config DEFAULT_SECURITY_SMACK
                bool "Simplified Mandatory Access Control" if SECURITY_SMACK=y

        config DEFAULT_SECURITY_TOMOYO
                bool "TOMOYO" if SECURITY_TOMOYO=y

        config DEFAULT_SECURITY_APPARMOR
                bool "AppArmor" if SECURITY_APPARMOR=y

        config DEFAULT_SECURITY_DAC
                bool "Unix Discretionary Access Controls"

endchoice

config DEFAULT_SECURITY
        string
        default "selinux" if DEFAULT_SECURITY_SELINUX
        default "elf_check" if DEFAULT_SECURITY_ELF_CHECK		# 配置 menu
        default "smack" if DEFAULT_SECURITY_SMACK
        default "tomoyo" if DEFAULT_SECURITY_TOMOYO
        default "apparmor" if DEFAULT_SECURITY_APPARMOR
        default "" if DEFAULT_SECURITY_DAC

endmenu

注意:

以上配置过程,security/Kconfigsecurity/elf_check/Kconfig 中的配置项名称一定要相对应。否则在编译配置菜单安全模块选项中找不到。

2)编译内核

进入内核源码根目录:

# 设定编译配置
make menucofnig

image

选择进入 Security options,关掉 NSA SELinux Support,开启自定义 LSM 模块ELF Check

image

拉到最后,选择 Default security module ()elf_check

image

选择完毕后,Load,退出,再走正常内核编译流程。

ELF 校验用户态程序

最后,编写 elf_checker 用户态程序,用于流程控制,代码比较简单,直接摘录如下:

filename=""
auth_key="security.authorization"
auth_value="kos"

usage() {
    echo "elf-checker: A helper used to validate the legality of an ELF file during its loading process."
    echo "options: "
    echo "  -h      show help information."
    echo "  -e      enable elf-checker."
    echo "  -i      init: grant permissions for all files."
    echo "  -c [filename]   check if this file has been granted permissions."
    echo "  -g [filename]   grant permissions for this file."
    echo "  -r [filename]   revoke the permissions to this file."
    echo "  -d      disable elf-checker."
}

init() {
    find / -path /proc -prune -o -path /sys -prune -o -path /dev -prune -o -path /run -prune -o -path /boot/efi -prune -o -path /boot/grub2 -prune -o -path /run/user -prune -o -type f -exec setfattr -n ${auth_key} -v ${auth_value} {} + 2>/dev/null
    echo "init successfully!"
}

check_value() {
    local result=$(getfattr -n ${auth_key} $1 2>/dev/null)
    local value="none"

    if [ $? -eq 0 ]; then
        value=$(echo "$result" | awk -F '"' '{print $2}' | tr -d '\n')
    fi

    # echo "$value"
    if [ "$value" = "kos" ]; then
        echo "The file '$filename' has been granted permissions."
    else
        echo "The file '$filename' has NOT been granted permissions."
    fi
}

while getopts 'c:g:r:edhi' OPT; do
    case $OPT in
        'h')
            usage
            exit 0
            ;;
        'e')
            ./elf-checker-start.sh
            exit 0
            ;;
        'i')
            init
            exit 0
            ;;
        'c')
            filename=$OPTARG
            check_value $filename
            exit 0
            ;;
        'g')
            filename=$OPTARG
            setfattr -n $auth_key -v $auth_value $filename
            echo "Grant permission for $filename successfully!"
            exit 0
            ;;
        'r')
            filename=$OPTARG
            setfattr -x $auth_key $filename
            echo "Permissions of $filename has been revoked successfully!"
            exit 0
            ;;
        'd')
            ./elf-checker-stop.sh
            exit 0
            ;;
        *)
            usage
            exit 0
            ;;
    esac
done

仓库地址

本项目代码已经开源,仓库地址:

内核态:https://gitee.com/hemist/elf_check

用户态:https://gitee.com/hemist/elf-checker