编写可堆叠文件系统
编写文件系统,或任何内核代码,都是很困难的。内核是一个需要精通的复杂环境,小错误都可能导致严重的数据损坏。然而,文件系统提供了一种对用户应用程序透明的干净数据访问机制,这就是为什么开发人员总是希望向文件系统添加新功能。在本文中,我们将提供一个快速入门介绍,以便您可以在不必成为内核或文件系统专家的情况下,向现有文件系统添加新功能。
尽管 Linux 支持许多文件系统,但它们非常相似:基于磁盘的文件系统、基于网络的文件系统等。使文件系统稳定高效需要多年的努力,一旦它稳定且工作正常,您就不希望通过加入新功能来破坏它。此外,文件系统的维护者很少接受对其稳定文件系统的功能增强补丁。因此,目前使用最流行的文件系统多年来没有发生根本性变化也就不足为奇了。
假设您想编写一个简单的加密文件系统,该文件系统使用单个固定密码密钥来加密文件数据。获取各种密码的可移植 C 代码很容易。接下来,您必须将加密和解密数据缓冲区的调用与文件系统联系起来。从概念上讲,问题很简单:加密来自 write 系统调用的任何数据,然后再将其写入磁盘;解密来自磁盘的任何数据,然后再将其传递回调用 read 系统调用的用户进程。
您的第一个想法可能是复制 ext2 的 5,000 多行源代码,对其进行研究,然后在其中添加您的密码调用。您应该抵制复制整个其他文件系统作为起点的冲动。虽然只有 5,000 多行代码,但内核代码的开发复杂度至少比用户级代码高一个数量级。如果您最终确实将密码调用放在了这个新文件系统中的正确位置,您会发现您大部分时间都花在了研究它上面,只是在某些地方添加了少量代码行。即便如此,现在您也拥有了一个单一的加密 ext2 文件系统。如果您想要一个加密的 NFS 文件系统或任何其他大量的 Linux 文件系统呢?
与大多数操作系统一样,Linux 将其文件系统代码分为两个组件:原生文件系统(ext2、NFS 等)和一个名为虚拟文件系统 (VFS) 的通用层。VFS 是位于系统调用入口点和原生文件系统之间的层。VFS 提供了一种统一的文件系统访问机制,无需了解这些文件系统的详细信息。当文件系统在内核中初始化时,它们会安装一组函数指针(面向对象术语中的方法)供 VFS 使用。VFS 反过来会通用地调用这些指针函数,而无需知道指针代表哪个特定文件系统。例如,unlink 系统调用被转换为服务例程 sys_unlink,后者调用 vfs_unlink VFS 函数,后者通过使用其安装的函数指针来调用文件系统特定的方法:ext2 的 ext2_unlink,NFS 的 nfs_unlink 或其他文件系统的相应函数。在本文中,我们使用 -> 来指代特定的文件系统方法,例如 ->unlink()。
为了解决如何快速开发我们的加密文件系统的问题,我们采用了以下格言:“计算机科学中的任何问题都可以通过添加另一层间接性来解决。” 幸运的是,Linux VFS 允许在 VFS 和另一个文件系统之间插入另一个文件系统。图 1 显示了这样一个名为 Cryptfs 的可堆叠加密文件系统。Cryptfs 被称为可堆叠的,因为它堆叠在另一个文件系统(ext2)之上。在这里,VFS 调用 Cryptfs 的 ->write() 方法 (cryptfs_write);Cryptfs 加密它接收到的用户数据,并通过调用下面的 ->write() 方法 (ext2_write) 将其传递下去。

图 1. 可堆叠加密文件系统示例
一般来说,可堆叠文件系统可以独立存在,并可以挂载在任何其他现有文件系统挂载点之上;这意味着您只需开发一次(可堆叠)文件系统,它就可以与任何其他原生(底层)文件系统(如 ext2、NFS 等)一起使用。此外,从 Linux 2.4.20 开始,可堆叠文件系统甚至可以安全地导出(通过 nfs-utils-1.0 或更高版本)到远程 NFS 客户端。
可堆叠文件系统的基本功能是将操作及其参数传递给底层文件系统。以下精简的代码片段显示了名为 Wrapfs 的可堆叠空模式直通文件系统如何处理 ->unlink() 操作
int wrapfs_unlink(struct inode *dir, struct dentry *dentry) { int err = 0; struct inode *lower_dir; struct dentry *lower_dentry; lower_dir = get_lower_inode(dir); lower_dentry = get_lower_dentry(dentry); /* pre-call code can go here */ err = lower_dir->i_op->unlink(lower_dir, lower_dentry); /* post-call code can go here */ return err; }
当 VFS 需要在 Wrapfs 文件系统中取消链接文件时,它会调用 wrapfs_unlink,并将要删除的文件所在的目录的 inode (dir) 和要删除的条目的名称(封装在 dentry 中)传递给它。
每个文件系统都维护一组属于它的对象,包括 inode、目录项和打开的文件。当使用堆叠时,多个对象表示同一个文件——只是在不同的层。例如,图 1 中的 Cryptfs 可能必须保留一个目录项 (dentry) 对象,其中包含文件名的明文版本,而 ext2 将保留另一个 dentry,其中包含相同名称的密文(加密)版本。为了对 VFS 和其他文件系统真正透明,可堆叠文件系统在每一层都保留多个对象。
这就是 wrapfs_unlink 采取的前几个操作的原因,它是从它获得的参数中定位 inode 和 dentry,它们对应于相同的对象,只是在下面挂载的文件系统中。这些 get_lower_* 函数本质上是跟踪之前在创建这些对象时存储在 Wrapfs 对象私有字段中的指针。一旦定位到较低的对象,堆叠的主要魔法就发生了。我们通过较低级别的目录 inode 调用较低级别文件系统自己的 ->unlink() 方法,并将两个较低级别的对象传递给它。
Wrapfs 是一个功能齐全的可堆叠空层(或环回)文件系统,它只是在 VFS 和较低级别的文件系统之间传递所有操作和对象(未修改)。然而,Wrapfs 本身并不容易编写,主要原因之一是;它必须将较低级别的文件系统视为 VFS,同时对真正的 Linux VFS 表现为较低级别的文件系统。这种双重角色需要仔细处理锁、引用计数、已分配的内存等等。幸运的是,已经有人编写并维护了 Wrapfs。因此,Wrapfs 可以作为您修改和添加新功能的绝佳模板。
现在您已经了解了堆叠的工作原理,接下来该做什么?首先,我们必须探索可以将代码插入 Wrapfs 的位置。回顾之前显示的 wrapfs_unlink 代码,有三个这样的位置,它们对应于在调用较低级别 ->unlink() 方法之前、代替或之后。
1) 预调用:您可以在调用较低级别 ->unlink() 之前插入代码。例如,您可以检查用户是否尝试删除重要文件并阻止这种情况发生
if (strcmp(dentry->d_name.name, "vmlinuz") == 0) return -EACCES;
2) 调用:您可以替换整个调用本身。例如,您可以重命名文件而不是删除文件,作为简单的撤消文件系统的一部分(我们都经历过意外的 rm -f 命令)。
3) 后调用:在这里,我们可以在主要操作从较低级别的文件系统返回后执行操作。例如,假设恶意用户尝试删除 /etc/passwd,但正常的 UNIX 权限检查阻止了它。管理员可能希望记录此类操作(使用 syslogd),如下所示
if (err == -EACCES && strcmp(dentry->d_name.name, "passwd") == 0) printk("uid %d tried to delete passwd", current->fsuid);
其中 current 是一个全局变量,始终指向当前正在执行的任务(进程),而 ->fsuid 是该进程的有效 UID,供文件系统使用。
这些示例以及接下来的示例在某种程度上都经过了简化,以节省空间并传达其本质。例如,d_name.name 组件不是以 null 结尾的,因此必须使用具有适当长度的 memcmp;或者,要检查 dentry 引用的文件是否确实是真正的 /etc/passwd,代码必须检查文件系统是否是根文件系统,或者使用 d_path() 与绝对路径名进行比较。有关在 2.4.20 下测试的完整示例,请参阅 FiST 主页 (www.cs.sunysb.edu/~ezk/research/fist)。
UNIX 尝试保护文件免受未经授权用户的访问。当用户尝试打开他们无权访问的文件时,UNIX 会立即返回权限拒绝错误。一些用户喜欢窥探其他人的文件,有时会寻找错误地未受保护的文件,或者试图猜测可能存在于仅可搜索目录中的文件名。不幸的是,即使这些窥探用户没有成功,此类窥探的受害者通常也不知道发生了什么。
最常见的文件系统操作之一是 ->lookup(),每当系统调用使用文件名时就会发生这种情况。内核必须将该(字符串)名称转换为实际的 VFS 对象,例如 inode、dentry 或文件。为了检测窥探用户,我们将以下代码放在 snoopfs_lookup 或 snoopfs_permission 中,紧接在它调用较低级别文件系统上的 ->lookup() 之后
if ((err == -EACCES || err == -ENOENT) && dir->i_uid != current->fsuid && current->fsuid != 0) printk("snoop uid=%d pid=%d file=%s", current->fsuid, current->pid, dentry->d_name.name);
在这里,我们检查从调用较低级别 ->lookup() 返回的代码 (err)。如果状态是 EACCES(权限被拒绝)或 ENOENT(没有这样的文件或目录),并且如果目录的所有者 (dir->i_uid) 与运行当前任务的用户的 UID (current->fsuid) 不同,并且当前用户不是超级用户(因为 root 用户可以做任何事情),那么它会打印一条描述性消息,标识窥探用户。此消息通常由 syslogd 记录。
包装器技术特别适用于与安全相关的应用程序,在这些应用程序中,包装或监视通常很有用。毫不奇怪,从 FiST 开发的最流行的应用程序是加密文件系统。在本例中,我们演示了一个使用 rot13 密码的简单加密文件系统。
在这个文件系统中,我们想要使用一个名为 rot13 的函数(假定已经编写)来加密所有文件数据,该函数接受一个输入缓冲区、输出缓冲区及其长度。但是,与之前的示例不同,没有一个单一的方法可以在其中放置 rot13() 函数来加密文件的数据。事实上,在任何文件系统中操作文件数据都相当复杂,因为它涉及多种方法,以及两种形式的访问文件数据的方式,即 read 和 write 系统调用,它们可以在任何文件偏移量处工作,以及 mmap,它在整个页面上工作。为了使可堆叠文件系统开发人员的生活更轻松,Wrapfs 将所有这些方法整合为两个简单的调用:一个用于编码文件数据,一个用于解码文件数据,两者都在整个页面对齐的数据页面上工作(例如,IA-32 系统上的 4KB)。使用 Wrapfs 模板,您只需编写以下代码即可生成基于 rot13 的加密文件系统
int encode_block(void *in, void *out, int len) { rot13(in, out, len); return len; } int decode_block(void *in, void *out, int len) { rot13(in, out, len); return len; }
Wrapfs 已经包含了处理混合读取、写入和内存映射操作的所有复杂代码。Wrapfs 调用 encode_block 来加密数据页,调用 decode_block 来解密数据页(在本例中它们是相同的)。
当然,rot13 几乎不是一种实用的密码,但鉴于这个简单的示例,您可以构建更强大的加密文件系统。在此之后,我们最近构建了一个功能强大的加密文件系统,名为 NCryptfs(Cryptfs 的后继者)。NCryptfs 支持多种密码;每个用户、进程或组的多个密钥;多种身份验证方案;密钥超时和撤销;委托权限等等——所有这些都具有可忽略不计的性能开销。
Wrapfs 还支持使用两个额外的例程来编码和解码文件名来操作文件名。加密文件名时需要注意的一件事是,文件名在加密后必须保持有效。换句话说,它们不能包含 null 或“/”字符。常见的解决方案是在加密后对文件名进行 uuencode 编码。
在 wrapfs_unlink 示例中,我们建议您可以重命名文件而不是删除文件,从而保存已删除文件的单个备份。假设我们将此文件系统称为 unrmfs,其中已删除的文件将从其原始名称 F 重命名为 F.unrm。如果所有这些 .unrm 文件开始出现在您的目录中,尤其是在您期望那里什么都没有的情况下,这可能会很烦人。此外,这种功能还可以用来欺骗试图删除可能用于跟踪其操作的日志文件的攻击者。然而,为了实现这一点,默认情况下 .unrm 文件必须对用户不可见或不可访问。
要隐藏文件系统中的某些文件,您必须做两件事。首先,阻止文件出现在 ->readdir() 中。这通过在 wrapfs_filldir 中编写代码来完成,该代码检查传递给 ->filldir() 的每个文件名,并为您不想列出的文件返回 NULL。其次,阻止用户通过文件名直接查找文件;这通过在 wrapfs_lookup 的开头检查 .unrm 文件来完成。
当然,对所有用户隐藏这些文件并不是很有用。合法用户必须能够在某些条件下访问这些文件。一种简单的方法可能是检查调用进程的 UID,并且仅对某些用户隐藏 .unrm 文件。更好的方法是使用所有系统调用的母系统调用 ioctl。在 Wrapfs 中,您可以定义任意数量的新 ioctl,然后编写小型用户级程序来使用这些 ioctl。例如,这是我们在加密文件系统中使用的机制,用于用户级工具将用户的密码密钥传递给内核。
对于我们的 unrmfs,您可以编写一个 restore ioctl,它接受文件名 F,检查文件 F.unrm 是否存在,然后将 F.unrm 重命名回 F,从而有效地从 unrmfs 中取消隐藏它。以下示例显示了此代码的草图
/* len: length of source file */ newname = kmalloc(len+6, GFP_KERNEL); strncpy_from_user(newname, ioctl_arg, len); strcat(newname, ".unrm"); lower_dir = get_lower_inode(dir); src = lookup_one_len(lower_dir, newname); if (IS_ERR(src)) return PTR_ERR(src); dst = lookup_one_len(lower_dir, name); vfs_rename(lower_dir, src, lower_dir, dst);
文件系统开发不必很困难。使用可堆叠文件系统,您可以快速创建新的、有用的和高效的文件系统——所有这些都无需更改内核或现有文件系统。本文中的示例有望展示堆叠的强大功能,您可以从中逐步构建更复杂的文件系统。您可以从 www.cs.sunysb.edu/~ezk/research/fist 获取 FiST 软件、文档和更多示例。祝您堆叠愉快。

Erez Zadok (ezk@cs.stonybrook.edu) 是石溪大学计算机科学系的教员,Linux NFS 和 Automounter Administration (Sybex, 2001) 的作者,FiST 可堆叠模板系统的创建者和维护者,以及 Am-utils (又名 Amd) 自动挂载器的主要维护者。Erez 从事操作系统研究,重点是文件系统、安全和网络。