LSM 安全模块开发——文件打开 2FA
这个标题看起来很高大上,其实也只是一个功能比较简陋的小内核模块,这篇文章看完我想你也能做一个类似的甚至更好的东西。
如果你也想尝试,建议使用 VMware Workstation 虚拟机,不推荐使用 Windows 自带的 Hyper-V 虚拟机,因为用现成的内核编译配置文件直接编译同版本内核在 Hyper-V 中都跑不起来,需要调整很多驱动,还不能保证成功运行。
代码仓库:https://github.com/ytf4425/lsm_totp_2fa
初步构思
这个模块想要实现的功能很简单,就是在文件访问之前添加 2FA。
由于 LSM 是在内核态默默地做验证,验证时并不能与用户态进行交互,因此并不能在程序访问到某个文件的时候做实时的认证,因此需要在访问文件前就通过一个程序来解锁,设定 unlocked 状态,这样在 LSM 检查访问权限时才能通过。当然在文件访问结束后可以根据意愿重新加锁。一个文件可能在一个程序中需要被重复打开,因此没有做打开后自动加锁的功能(不过也许以后可以加上,事实上大多数情况只打开一次就够了)。
所有文件名(这是个坑,最后再说)和对应的 TOTP 密钥及其他信息需要保存在一个普通用户无法读取的地方,因为知道 TOTP 的密钥后就可以直接计算并解锁。
同时在内核内存中应该要有一个解锁状态表(用户-文件信息-解锁状态
),普通用户是不能直接读写的,但是应该要能通过一个程序,告诉它文件信息和解锁密钥,它来修改解锁状态,也可以向它请求某文件的解锁状态。这个程序需要运行在内核态,才能修改内核的内存信息,但又需要能与用户态交互。经过与 [@mapl](https://www.york.moe) 的讨论,选择使用 procfs 来实现。
LSM 框架简介与接口选择
Linux LSM(Linux Security Modules) 框架是什么在这里就不介绍了,网上的文章相当多,可以随便搜一下。
LSM 框架在系统的很多资源访问接口上预留了插桩的点位,对于每一个插桩的点位采用哈希链表的形式绑定用于检查权限的函数,每当执行到插桩点位时会将绑定的所有函数执行一遍。
由于 LSM 插桩的点位都在 DAC 检查之后,因此 LSM 是不能做 DAC 的,至少应该不能绕过被 DAC 禁止的访问,只能是在已有的 DAC 上做补充,通常是用来做 MAC 的。
LSM 的所有 Hook 接口以宏的形式写在 include/linux/lsm_hook_defs.h
中,每个 Hook 接口都会通过参数列表给出与对应的检查项目相关的必要信息,定义Hook 接口的宏采用 LSM_HOOK(<return_type>, <default_value>, <hook_name>, args...)
的形式来定义。
对于文件访问来说,有 file_permission
, file_open
, file_ioctl
等等的众多接口,他们都会通过参数列表传递一个指向 file
结构体的指针 struct file *file
,用来检查当前文件的信息,以检查是否有相应的权限。下面随便截取一段:
1 | // include/linux/lsm_hook_defs.h |
以 file_open
为例,想要编写一个通过 file_open
接口来检查权限的函数,它的返回值需要是 int
类型,参数列表是 struct file *file
。
不同的 Hook 接口给的参数列表不同,这些参数都会在具体执行到 Hook 点位时被传递给所有绑定在这之上的具体实现函数(通过 call_void_hook
和 call_int_hook
宏),因此基于 LSM 框架编写相关内核模块的时候需要将权限检查函数的函数签名保持和接口一致。
LSM 框架通过权限检查函数的返回值来判断是否通过检查,若返回值为 0 则表明通过检查,允许访问,否则就不允许访问。
关于 LSM 框架的具体实现机制可以参考 Linux Security Module 框架介绍 - liwugang(存档)。
最开始是想使用 file_permission
接口来实现对文件访问的控制的,但是后来发现这个接口会对文件的所有操作都进行检查,不仅包括打开文件,还包括读写文件,比如说如果一个文件很大,访问的时候读了很多次,那么就会相应地做这么多次的检查,感觉开销太大(其实也是开发的时候调试比较麻烦,debug 信息不是很好打印)。后来选择使用 file_open
接口来实现,这样权限控制函数只在文件打开的时候才会检查,后续的读写不会再检查。
开始前的小提示
从某个 Linux 内核版本开始,LSM “模块”就不能作为模块在内核启动后再加载了,因此需要和完整内核一起编译,这使得编译和调试起来真的非常耗时。
为了有更快的编译速度,可以使用 ccache 工具来缓存内核编译过程中的中间文件。ccache 的使用请自行搜索,参考资料中也给出了一个参考链接。
LSM 框架初上手
先来尝试编译一个内核吧。
配置的环境是 VMware Workstation + Ubuntu Server 22.04.1,内核版本为 Linux ubuntu 5.15.0-56-generic
首先安装与当前内核版本相同的 Linux 内核的源码包。
1 | sudo apt update |
随后就能在 /usr/src
中找到下载的源码包 linux-source-5.15.0.tar.bz2
,将其复制到主目录并解压。
1 | neko@neko:/usr/src$ ls |
直接复制当前内核的编译配置到源码目录,开始编译内核。
1 | cp /boot/config-$(uname -r) .config |
编译安装完成后,重启系统,选择刚编译的内核(通常默认就是)启动,检查能否正常启动。若不能正常启动,请根据错误输出解决。
正常启动后,使用 LSM安全模块开发 - Real Own(存档) 中的代码进行测试,尝试实现一个最基础的 LSM 模块。
需要注意的是文章中使用的内核有些旧,security_add_hooks
的参数与 Linux 5.15.0 有所差异,参考如下代码修改:
1 | static struct lsm_id lsm_2fa_lsmid __lsm_ro_after_init = { |
其中函数与变量的命名自行修改,其余代码和操作不变,看看编译成功后内核启动时能否输出相应的信息。
LSM 模块代码简单介绍
因为 LSM安全模块开发 - Real Own(存档) 中的代码和一些注释有些变更和错误,这里简单介绍一下 LSM 模块的代码。以下是一个简单的 LSM 模块的代码框架:
1 |
|
其中 mylsm_check()
函数是我们写的权限检测函数,该函数返回 0 表示权限检查通过。
DEFINE_LSM(mylsm)
宏实际上是定义了一个名为 mylsm
的 static struct lsm_info
类型的结构体变量。struct lsm_info
结构体要求必须提供的成员变量只有 name
和 init
。 name
中的 mylsm
就是在 LSM 框架的安全模块的标识。在编译选项中填写要开启的 LSM 模块时用到的就是这个标识(用于 LSM 的 ordered_lsm_parse
函数加载各 LSM 模块)。init
需要赋值为初始化函数,在这里就是 mylsm_init()
。
然后还需要两个结构体 static struct lsm_id
和 static struct security_hook_list
。
- 前者描述整个 LSM 模块,
lsm
成员变量指向模块名,slot
是指在 lsmblob 中指定的 slot,如果不需要可以使用LSMBLOB_NOT_NEEDED
。 - 后者以数组的形式描述将某个权限检查函数绑定到某个 LSM Hook 点位上,
LSM_HOOK_INIT(HEAD, HOOK)
宏展开就是{ .head = &security_hook_heads.HEAD, .hook = { .HEAD = HOOK } }
,即将 HOOK 所指的函数绑定到 HEAD 所指的点位上。LSM_HOOK_INIT(file_open,mylsm)
就是将mylsm
函数绑定到file_open
点位上。
随后在初始化函数 mylsm_init()
中将上述两个结构体在 LSM 初始化的时候添加进 LSM 中。
如果模块允许加载,就会在内核启动时执行初始化函数 mylsm_init()
。若初始化正确,则每次在执行 file_open
时就会执行 mylsm_check()
。
内核编译(Kbuild)配置简单介绍
LSM安全模块开发 - Real Own(存档) 中提供的两个 Kconfig
文件与 Makefile
文件没有问题。
Linux 内核是一个非常庞大而复杂的工程项目,模块众多,代码量大,各个功能分布在不同的文件中,而管理它们有序编译、构建的工具就是 make。为简化编译配置的编写,内核采用了一套叫 Kernel Build System 的构建系统,依靠 make 的功能提供了简化的模板,只要按照模板为自己添加的模块编写配套的 Kconfig
与 Makefile
文件就能很快将自己的模块加入内核
初步学习一下 Kconfig
与 Makefile
文件的编写可以让内核代码的目录层级更整洁,尤其是某个功能由多个文件构成的时候。
Makefile 编写
提醒:Makefile
文件中的缩进只能使用 \t
。
模块目录的编写以 security/2fa/Makefile
为例:
1 | obj-$(CONFIG_SECURITY_LSM2FA) := 2faall.o |
这个文件只有两行,第一行是目标定义,也是 Kbuild 的 Makefile 文件中最重要的一个部分,特征是以 obj-
开头,它定义了要构建的文件、特殊的编译选项以及要递归输入的子目录。$(CONFIG_SECURITY_LSM2FA)
是一个变量,被定义在 Kconfig
文件中,以 Kconfig
文件中的模块的标识前面加上 CONFIG_
命名,可以在内核根目录的 .config
文件中定义为 y
, m
和其他,也即可在后期不修改内部的 Makefile
文件而是通过调整 .config
文件来调整模块的开关。
obj-y
表示要把目标文件内建到内核中,可以直接后跟多个目标文件(注意链接顺序,这将决定模块加载顺序),它们会被合并到built-in.a
,然后被链接到vmlinux
中。obj-m
表示以模块形式编译。如果需要多个目标文件,不能和obj-y
一样直接跟在后面,而是需要使用如下形式编写(这种形式也适用于obj-y
):1
2
3obj-$(CONFIG_MODULE_A) += module_a.o
module_a-y := module_a_1.o module_a_2.o module_a_3.o
module_a-$(CONFIG_MODULE_A_EXTFUNC) := module_a_ext.o其中
module_a-y
中的y
也是可以使用变量后期配置的,可以为这一模块选择需要的功能。如上所示,可以在后期通过.config
文件选择module_a_ext.o
是否添加进该模块。其他形式表示不编译链接这些目标文件。
fs/proc/2fa/Makefile
同理。如果想要脱离完整的内核编译而单独编译模块,可以添加下面几行方便直接使用 make 编译:
1 | all: |
上级目录的编写较为简单,以 security/Makefile
为例,只需要在在合适的位置(如 *# Object file lists*
下方)插入 obj-$(CONFIG_SECURITY_LSM2FA) += 2fa/
即可,其中 $(CONFIG_SECURITY_LSM2FA)
与 security/2fa/Makefile
中的变量对应,而 2fa/
是模块目录的路径,具体解释同上。
Kconfig 编写
模块目录的编写以 security/2fa/Kconfig
为例:
1 | config SECURITY_LSM2FA |
- 第一行是模块标识,用于 Kconfig 的配置中,也用于
Makefile
文件中作为变量名(前加CONFIG_
)。 - 第二行的
bool
表示该模块可选内建(y)或不编译(n),可以替换为tristate
,表示该模块可选内建(y)或作为模块编译(m)或不编译(n)。后面的字符串是make menuconfig
中显示的模块名称。 - 第三行是依赖的模块,若依赖模块未启用,该模块也不可以启用。
- 第四行是默认值(y/m/n)。
- 第五行是模块的帮助信息。
上级目录的编写与 Makefile
一样,都是将子目录包含进来即可,只是语法上的区别,以为 security/Kconfig
例,只需在合适的位置插入 source "security/2fa/Kconfig"
。
最后在 make menuconfig
界面或 .config
文件中启用对应的模块即可。LSM 模块还需要参考 LSM安全模块开发 - Real Own(存档) 修改 .config
中的 CONFIG_LSM
配置或 make menuconfig
界面中的 Ordered list of enable LSMs
。
关于 Makefile 文件编写的通用语法可以参考 跟我一起写Makefile - Ubuntu中文。
更详细的内核编译选项与 Kconfig
与 Makefile
文件编写的介绍可以参考 Kernel Build System -- The Linux Kernel documentation。
TOTP 简介
TOTP 是一种动态认证方式,全称是基于时间的一次性密码算法 (Time-based one-time password),通常用来做二步验证,类似用用户名密码登陆后还需要使用短信验证,其中短信验证就是一种实时动态生成的二步验证密码。实际使用上 TOTP 更接近于 QQ 令牌之类的东西。
具体算法描述与二维码 URL 这里不详细介绍了,可以参考 动态令牌是怎么生成的?(OTP & TOTP 简单介绍) - 知乎(存档)。
本模块的 TOTP 计算代码采用的是 fmount/c_otp: HOTP / TOTP pure C implementation 并做了一些修改以便于在内核态使用。Linux 内核自带有 HMAC(SHA1) 加密算法,使用方法可以参考 Code Examples — The Linux Kernel documentation 与 linux内核hmac-sha1使用_奔跑的码仔的博客-CSDN博客(存档)。
LSM 模块设计
首先考虑内核中的解锁状态表应该用何种形式存取,这里选择使用内核内置的 Hashtable 来实现,这样在表的条目数量很多的时候也能保持比较高的检索效率。相关使用方法可以参考 hash / hashtable(linux kernel 哈希表) - 知乎(存档)、linux kernel中HList和Hashtable - L(存档)。
1 |
可以直接返回状态值,而不用再多一层判断。
procfs 模块设计
procfs 简单介绍
在 Linux 下有很多伪文件系统,它们是启动时动态生成的文件系统,以文件系统的形式提供一些系统功能。procfs 就是其中之一,procfs 是进程文件系统的缩写,挂载于 /proc
下,最初是用于通过内核访问进程信息,但现在的 procfs 还包含系统的状态信息和配置信息。
最重要的是这种伪文件系统提供了一个用户态和内核交互的接口。用户态可以读写这些文件系统的文件,在发出 I/O 请求时陷入内核态,进入伪文件系统具体实现的读写接口,而这些读写接口就可以通过用户态下发出的不同的 I/O 请求提供不同的功能。
procfs 就提供了这样的接口,而且提供了内核读写处理的接口,也即可以通过编写一个内核模块,来指定当用户态读写指定文件时内核做的操作。
具体来说,procfs 使用 proc_create
新建文件,每个 procfs 文件都对应了一个 struct proc_dir_entry
结构体,里面包含了 struct proc_ops
,可以绑定该文件的读写操作函数上去。比如当需要读取该文件时,就会使用 proc_read
所绑定的函数将数据填入缓冲区,随后内核从缓冲区中读取数据。当需要写入该文件时,内核会将内容写入缓冲区,proc_write
所绑定的函数会读取缓冲区并做处理。
具体使用可以参考 对/proc文件系统进行读写操作 » edsionte's TechBlog(存档)。注:这篇文章严重过时,介绍的一些接口和函数和当前最新版内核提供的接口不一致,请根据新版内核提供的接口编写,初步了解工作原理还是可以的。一个较新的模板可以参考 一个可读可写的procfs模板,基于kernel-5.12 | Yi颗烂樱桃(存档)(用了 seq_file
,可以自行搜索相关用法)。
// 未完待续
踩过的一些坑
echo 输出自带一个换行,不带\0
,不能直接 strcmp,使用 sscanf
内核接口动不动就改:
- procfs、
- current_kernel_time、getnstimeofday、ktime_get_real_seconds
- vfs_read、vfs_write
内核读写文件也没有绕过 DAC
内核加载顺序
内核中符号的链接
pip 不同用户不同目录
IS_ERR、PTR_ERR
对配置文件的重复读取,一次不能读完就会导致重复读取,读完第一条配置文件就被锁上了,不能继续被读取。 primary code
卡 loading initial ramfs
和内核加载顺序有关,加载lsm时还没加载fs
之所以卡在 loading initial ramfs 可能是因为当时还没有加载出显示驱动,从grub进入内核log时不是从[0] 开始的
proc_lseek
在使用 python 读写 procfs 里的文件的时候会被 Killed,见得多了知道肯定又是内核模块出错了。
1 | [ 459.222269] BUG: kernel NULL pointer dereference, address: 0000000000000000 |
用 strace
跟踪一下系统调用情况:
1 | execve("/usr/bin/python3", ["python3", "tools/2fa.py", "unlock", "/etc/security/2fa.conf", "938425", "-1"], 0x7ffe352e2bf8 /* 36 vars */) = 0 |
没有 proc_lseek
,网上找了一通都是用 seq_file
实现的,但是因为实在没看懂 seq_file
这个东西,所以还是对此很有抵触。
后来还是在网上盲目寻找的时候找到了一个内核里自带的 proc_lseek
的实现 noop_llseek
,来自 fs/read_write.c
:
1 | /** |
后来在这个文件里面随手一翻发现底下有很多直接能用的 lseek
的实现,还有这种更简陋的:
1 | loff_t no_llseek(struct file *file, loff_t offset, int whence) |
感觉非常有意思。
据 [@mapl](https://www.york.moe) 称,用户程序是总喜欢调和折中的,proc_lseek
不能空着,但可以返回错误。内核的那个空指针错误就是空着 proc_lseek
的问题,如果不想实现可以返回个错误代码。
当然最后我还是没懂这是个啥东西,不过能用就行(笑)。
后记
提供了隐蔽信道(笑
注意:模块加载时不能清空 hashtable,不然可能会因为配置文件未解锁无法读取而无法加载配置文件,导致所有加锁失效。
改进:从文件路径改到 inode 号(struct file -> *f_inode -> i_ino
),或者写在 inode 的 i_security
中(基于列的访问控制矩阵信息),靠文件路径只要改个文件名就能绕过了
致谢
[@mapl](https://www.york.moe)
参考资料
LSM:
Linux Security Module 框架介绍 - liwugang(存档)
procfs:
一个可读可写的procfs模板,基于kernel-5.12 | Yi颗烂樱桃(存档)
对/proc文件系统进行读写操作 » edsionte's TechBlog(存档)
TOTP:
fmount/c_otp: HOTP / TOTP pure C implementation
动态令牌是怎么生成的?(OTP & TOTP 简单介绍) - 知乎(存档)
HMAC(SHA1):
Code Examples — The Linux Kernel documentation
linux内核hmac-sha1使用_奔跑的码仔的博客-CSDN博客(存档)
Hashtable:
hash / hashtable(linux kernel 哈希表) - 知乎(存档)
linux kernel中HList和Hashtable - L(存档)
内核态读写文件:
https://blog.csdn.net/qq_43519779/article/details/116715350
https://xuanxuanblingbling.github.io/ctf/pwn/2021/08/05/kernel/
scanf:
https://www.cnblogs.com/zjuhaohaoxuexi/p/16260922.html
内核模块加载顺序:
https://www.cnblogs.com/chaozhu/p/6410271.html
https://www.cnblogs.com/zxc2man/p/7766967.html
内核模块导出符号:
https://www.kernel.org/doc/html/latest/kernel-hacking/hacking.html#export-symbol
ccache:
Linux 5.10.20 上使用 ccache 加快内核编译速度(安装配置及使用方法)clleng3399的博客
IS_ERR、PTR_ERR:
https://www.cnblogs.com/muahao/p/8528780.html
内核模块自动加载:
https://blog.csdn.net/kunyus/article/details/104989979
内核模块添加:
https://www.cnblogs.com/schips/p/linux-driver-add-exfat-module-in-kernel.html
内核编译选项与配置文件编写:
Kernel Build System -- The Linux Kernel documentation
Makefile: