VFS hooks

文件相关的监控点梳理(eBPF 文件审计)

Posted by pandaychen on October 22, 2025

0x00 前言

本文从eBPF 内核态开发的视角,梳理 Linux VFS(虚拟文件系统)中值得挂载(hook)的监控点:文件的打开、创建、删除、重命名、目录/链接操作、属性与扩展属性修改、读写截断、挂载卸载等,并补齐 inode/dentry 相关操作与 security_* LSM 钩子

本文代码基于:

阅读导引:两套内核在 VFS 层的调用链骨架基本一致,但对 eBPF 开发者影响最大的是函数签名的参数漂移,集中在三处:

  1. mnt_idmap(挂载点 idmapping)在 6.x 版本被引入为多数 vfs_*/security_inode_*首个参数(前身是内核 5.12 版本引入的 struct user_namespace *,内核6.3 改为 struct mnt_idmap *)。这会导致 kprobe hook读取参数时寄存器下标整体后移一位
  2. vfs_rename 在内核 5.12+ 把一长串参数收敛为 struct renamedata * 单个结构体指针
  3. open 路径在内核 5.6+ 引入 openat2/do_sys_openat2,内核6.6 的 open/openat 也统一走该路径
//https://elixir.bootlin.com/linux/v6.6.47/source/fs/mnt_idmapping.c#L12
struct mnt_idmap {
	struct user_namespace *owner;
	refcount_t count;
};

//https://elixir.bootlin.com/linux/v5.4.241/source/include/linux/user_namespace.h#L55
struct user_namespace {
	struct uid_gid_map	uid_map;
	struct uid_gid_map	gid_map;
	struct uid_gid_map	projid_map;
	atomic_t		count;
	struct user_namespace	*parent;
	int			level;
	kuid_t			owner;
	kgid_t			group;
	struct ns_common	ns;
	unsigned long		flags;

#ifdef CONFIG_KEYS
	/* List of joinable keyrings in this namespace.  Modification access of
	 * these pointers is controlled by keyring_sem.  Once
	 * user_keyring_register is set, it won't be changed, so it can be
	 * accessed directly with READ_ONCE().
	 */
	struct list_head	keyring_name_list;
	struct key		*user_keyring_register;
	struct rw_semaphore	keyring_sem;
#endif

	/* Register of per-UID persistent keyrings for this namespace */
#ifdef CONFIG_PERSISTENT_KEYRINGS
	struct key		*persistent_keyring_register;
#endif
	struct work_struct	work;
#ifdef CONFIG_SYSCTL
	struct ctl_table_set	set;
	struct ctl_table_header *sysctls;
#endif
	struct ucounts		*ucounts;
	int ucount_max[UCOUNT_COUNTS];
} __randomize_layout;

说明:下文代码片段均只保留核心/关键路径(省略错误处理、retry、加锁细节等),完整实现请对照 bootlin 源码链接。凡涉及具体行号或细节以内核源码为准

0x01 背景:VFS 分层与 eBPF 挂载点选型

VFS 调用分层

一次文件系统调用大致穿过四层,理解分层是选对 hook 点的前提:

flowchart TD
    subgraph L1 [1.syscall 层]
        SC["SYSCALL_DEFINE: openat/unlinkat/renameat2 ..."]
    end
    subgraph L2 [2.VFS 通用层 vfs_star]
        VFS["vfs_open / vfs_unlink / vfs_rename / vfs_create ..."]
    end
    subgraph L3 [3.LSM 安全钩子层 security_star]
        SEC["security_file_open / security_inode_unlink ..."]
    end
    subgraph L4 [4.具体文件系统 i_op/f_op]
        FS["ext4_create / xfs_rename / overlayfs ..."]
    end

    SC -->|"getname 取路径, 路径解析 link_path_walk"| VFS
    VFS -->|"权限与策略检查"| SEC
    SEC -->|"允许后回调"| FS

各层的审计取舍:

  • syscall 层:能直接拿到用户态参数(路径字符串、flags),但参数是 __user 指针、易受 TOCTOU 影响,且不同架构/系统调用入口(__x64_sys_*__arm64_sys_*、syscall wrapper)差异大。
  • VFS 通用层(vfs_*:路径已解析为 struct dentry/struct path,语义稳定、覆盖所有文件系统,是审计的首选层;缺点是拿不到原始用户态路径字符串,需要靠 dentry 回溯。
  • LSM 层(security_*:语义最稳定、且可拦截(返回非 0 即拒绝),配合 BPF_PROG_TYPE_LSM 是现代审计/防护的最佳落点;缺点是需要内核开启 CONFIG_BPF_LSM(5.7+)。
  • 具体文件系统层(i_op->create 等):实现分散、随文件系统而变,通常不作为通用审计点。

eBPF 挂载点类型对比

类型 典型附着点 参数稳定性 可否拦截 备注
kprobe/kretprobe 任意内核函数(如 vfs_unlink 依赖函数签名,跨版本易漂移 覆盖面最广,最通用
fentry/fexit(BTF trampoline) 有 BTF 的内核函数 同上,但读参更简洁(直接类型化) 需 5.5+ 与 BTF,开销更低
tracepoint syscalls:sys_enter_openat ABI 稳定 稳定但只在 syscall 边界,拿的是原始参数
raw_tracepoint/btf raw_tp 内核静态 tracepoint 稳定 比 tracepoint 开销更低
LSM-BPF bpf_lsm_inode_unlink 语义最稳定 CONFIG_BPF_LSM,5.7+

一句话选型建议:审计优先挂 vfs_*/security_* 层,而非易变的 syscall 参数结构;需要“阻断”能力时用 LSM-BPF。

路径/文件名提取

在 VFS 层拿到的是 dentry/path,eBPF 中常用三种方式还原路径:

  • bpf_d_path(&file->f_path, buf, sz):最方便,但仅允许在内核 allowlist 内的 hook 使用(如 security_*、部分 vfs_*),且需 5.10+
  • 手写 dentry 回溯:沿 dentry->d_namedentry->d_parent 向上循环拼接,配合 bpf_probe_read_kernel_str 读取 d_name.name,适用范围最广
  • bpf_probe_read_user_str:在 syscall 层直接读用户态路径字符串(注意 TOCTOU)
// 手写回溯(示意):从 dentry 逐级向上取 d_name,拼出路径
#pragma unroll
for (int i = 0; i < MAX_DEPTH; i++) {
    struct qstr d_name = BPF_CORE_READ(dentry, d_name);
    bpf_probe_read_kernel_str(&comp, sizeof(comp), d_name.name);
    struct dentry *parent = BPF_CORE_READ(dentry, d_parent);
    if (parent == dentry)          // 到达挂载点根
        break;
    dentry = parent;
}

核心 hook 参数约定:如何区分父目录与子节点?

大量命名空间类 VFS 函数(vfs_create/vfs_unlink/vfs_mkdir/vfs_rmdir/vfs_rename/vfs_mknod 等)都采用固定搭配 (struct inode *dir, struct dentry *dentry)。一个较常见的困惑就是:哪个参数是「父目录」,哪个是「被操作的子节点」。而判据其实很简单,类型即角色

  • struct inode *dir父目录的 inode,表示操作发生在哪个目录下。它是 inode 类型,本身不携带名字信息(inode 结构没有名字,名字在 dentry 结构中定义)
  • struct dentry *dentry被操作的子节点(目标)的目录项。它携带叶子名 d_name,并通过 d_parent 指回父目录 dentry

也就是说:参数里凡是 struct inode *(通常命名 dir/old_dir/new_dir)一律是父目录;凡是 struct dentry *dentry/old_dentry/new_dentry)一律是要创建/删除/改名的子项

它们之间存在可用于 eBPF 中交叉校验的固定关系:

dentry->d_parent            // 父目录的 dentry
dentry->d_parent->d_inode   // 恒等于入参 dir(同一个父目录 inode)
dentry->d_name              // 子节点名(叶子组件)
dentry->d_inode             // 子节点自身 inode:
                            //   创建前为 NULL(负 dentry, negative dentry)
                            //   unlink/rmdir/rename 等目标已存在时非 NULL

关于负目录项(negative dentry)create/mkdir/mknod/symlink 场景下传入的 dentry负 dentry(d_inode == NULL,因为此时对象尚未创建。此时子节点只有名字(d_name)可用,inode 号需要等创建完成后(用 kretprobe/fexit,或改挂 security_inode_* 之后再在返回点补齐)才能拿到;而 unlink/rmdir/rename 的目标已存在,dentry->d_inode 直接有效

这里以 vfs_unlink 为例(6.x 带 idmap,dir=arg1、dentry=arg2)提取并交叉校验:

SEC("kprobe/vfs_unlink")                    // 6.x:(idmap, dir, dentry, ...)
int BPF_KPROBE(kp_vfs_unlink, struct mnt_idmap *idmap,
               struct inode *dir, struct dentry *dentry)
{
    u64 parent_ino = BPF_CORE_READ(dir, i_ino);            // 父目录 inode 号
    struct qstr dn = BPF_CORE_READ(dentry, d_name);        // 子节点叶子名
    char name[64];
    bpf_probe_read_kernel_str(name, sizeof(name), dn.name);
    u64 child_ino = BPF_CORE_READ(dentry, d_inode, i_ino); // 子节点 inode(unlink 时有效)

    // 交叉校验:dentry 的父目录 inode 应等于入参 dir
    struct inode *pino = BPF_CORE_READ(dentry, d_parent, d_inode);
    if (pino != dir) { /* 极少见:并发 rename 导致关系已变,谨慎采信 */ }
    return 0;
}

两个易错点(务必注意):

  1. vfs_link(old_dentry, [idmap,] dir, new_dentry, ...) 里三个路径类参数含义各不相同:
    • old_dentry已存在的源文件目录项,它的父目录不是 dir
    • dir新硬链接所在的父目录 inode
    • new_dentry:新硬链接的子节点(是 dir 的孩子,创建前为负 dentry) 即=dir 的孩子是 new_dentry 而非 old_dentry,不能想当然地用 d_parent == dir 去套 old_dentry
  2. vfs_rename两组 (dir, dentry)(old_dir, old_dentry) 为源,(new_dir, new_dentry) 为目的;跨目录移动时 old_dir != new_dir

各核心函数中父目录 / 子节点对应关系汇总:

函数 父目录(struct inode * 子节点/目标(struct dentry * 备注
vfs_create dir dentry(负 dentry) want_excl 对应 O_EXCL
vfs_mkdir dir dentry(负 dentry) 目录
vfs_mknod dir dentry(负 dentry) dev 为设备号
vfs_symlink dir dentry(负 dentry) oldname 是链接目标串
vfs_unlink dir dentryd_inode 有效) 目标已存在
vfs_rmdir dir dentryd_inode 有效) 目标已存在
vfs_link dir(新链接父目录) new_dentry(负 dentry) old_dentry 是已存在源,父目录非 dir
vfs_rename old_dir / new_dir old_dentry / new_dentry 两组,跨目录时两者不同

小结:inode *dir = 父目录、dentry = 子节点/目标;需要父目录 inode 号读 dir->i_ino,需要子节点名读 dentry->d_name,需要子节点 inode 读 dentry->d_inode(负 dentry 场景要放到返回点取)。下文各节的参数说明表在此基础上逐个标注

0x02 文件打开 open

对应系统调用及参数

系统调用 原型 参数含义
open open(const char *filename, int flags, umode_t mode) filename 路径;flags 打开标志(O_RDONLY/O_WRONLY/O_CREAT/O_TRUNC/O_EXCL...);mode 新建时权限
openat openat(int dfd, const char *filename, int flags, umode_t mode) dfd 相对目录 fd(AT_FDCWD 表示 cwd)
openat2 openat2(int dfd, const char *filename, struct open_how *how, size_t size) how 结构化打开参数(flags/mode/resolve),5.6+

内核核心调用链

openat -> do_sys_open -> do_sys_openat2 -> do_filp_open -> path_openat -> vfs_open -> do_dentry_open

// v6.6:open/openat 统一走 openat2 路径
static long do_sys_openat2(int dfd, const char __user *filename,
			   struct open_how *how)
{
	struct open_flags op;
	int fd = build_open_flags(how, &op);
	struct filename *tmp = getname(filename);
	fd = get_unused_fd_flags(how->flags);
	struct file *f = do_filp_open(dfd, tmp, &op);   // 核心:解析+打开
	fd_install(fd, f);
	return fd;
}

// 打开的最终点:所有路径都会到 do_dentry_open
static int do_dentry_open(struct file *f, struct inode *inode,
			  int (*open)(struct inode *, struct file *))
{
	f->f_inode = inode;
	error = security_file_open(f);           // <== LSM 审计/拦截点
	...
	if (!open)
		open = f->f_op->open;
	error = open ? open(inode, f) : 0;       // 具体文件系统 .open
	...
}

核心 hook 参数说明

open 路径的目标文件在进入 hook 时已解析完成,因此没有独立的 dir 参数,父目录需要从目标 dentry 的 d_parent 得到:

hook 函数 参数 类型 含义 / 父目录 vs 子节点
security_file_open f struct file * 被打开的目标文件对象;子节点 dentry = f->f_path.dentry,父目录 = f->f_path.dentry->d_parent,目标 inode = f->f_inode
do_filp_open(返回值) ret struct file * 返回已打开的 filekretprobe/fexit 取),失败为 ERR_PTR
do_dentry_open f / inode struct file * / struct inode * f 目标文件;inode目标文件 inode(非父目录);open 为具体文件系统 .open 回调

可提取字段为f->f_flagsO_* 打开标志)、f->f_pathbpf_d_path 取完整路径)、f->f_inode->i_inoBPF_CORE_READ(f, f_path.dentry, d_parent, d_inode, i_ino)(父目录 inode 号)

版本差异(v5.4 vs v6.6)

  • v5.4:do_sys_open() 直接构造 open_flags 后调 do_filp_open()(无 openat2)。
  • v6.6:open/openatdo_sys_open()do_sys_openat2();另有独立 openat2 系统调用。
  • 公共骨架 do_filp_open -> path_openat -> vfs_open -> do_dentry_open 两版一致;6.x 里 path_openat 内部把 do_last() 拆成了 open_last_lookups() + do_open()

eBPF 挂载点建议

  • 推荐 security_file_open(struct file *f):可用 bpf_d_path(&f->f_path, ...) 取完整路径,且能拦截。
  • 通用可挂 do_filp_open(返回 struct file*)或 vfs_open(const struct path *path, struct file *file)
  • 可提取:f->f_flags(打开标志)、f->f_path(路径)、f->f_inode->i_ino、进程上下文(bpf_get_current_pid_tgidcomm)。

0x03 文件创建 create/mknod

PS:现在Linux系统编程中,creat 在功能上已经被 open 完全替代。在底层,creat 实际上只是 open 的一个语法糖。调用 creat(pathname, mode) 完全等价于:

open(pathname, O_CREAT | O_WRONLY | O_TRUNC, mode);

对应系统调用及参数

系统调用 原型 参数含义
creat creat(const char *pathname, umode_t mode) 等价 open(pathname, O_CREAT\|O_WRONLY\|O_TRUNC, mode)
open(O_CREAT) 见 0x02 O_CREAT 时若文件不存在则创建
mknod/mknodat mknodat(int dfd, const char *filename, umode_t mode, dev_t dev) 创建普通文件/设备/FIFO/socket 节点;dev 设备号

内核核心调用链

普通文件创建走 open 路径的“最后一步”:path_openat -> do_last/open_last_lookups -> lookup_open -> vfs_createmknoddo_mknodat -> vfs_mknod

// v5.4
int vfs_create(struct inode *dir, struct dentry *dentry, umode_t mode,
	       bool want_excl)
{
	int error = may_create(dir, dentry);
	...
	error = security_inode_create(dir, dentry, mode);   // <== LSM 点
	if (error)
		return error;
	error = dir->i_op->create(dir, dentry, mode, want_excl);  // ext4_create ...
	...
}

// v6.6:首参新增 struct mnt_idmap *idmap
int vfs_create(struct mnt_idmap *idmap, struct inode *dir,
	       struct dentry *dentry, umode_t mode, bool want_excl)
{
	...
	error = security_inode_create(dir, dentry, mode);
	...
	error = dir->i_op->create(idmap, dir, dentry, mode, want_excl);
	...
}

核心 hook 参数说明

参数 类型 含义 / 父目录 vs 子节点
idmap(仅 6.x) struct mnt_idmap * 挂载点 idmapping,用于 uid/gid 映射;kprobe 中占 arg0
dir struct inode * 父目录 inode(在哪个目录下创建);父目录 inode 号 = dir->i_ino
dentry struct dentry * 待创建的子节点(负 dentry,d_inode == NULL);子节点名 = dentry->d_name,inode 号需在返回点(kretprobe/fexit)读 dentry->d_inode->i_ino
mode umode_t 文件类型与权限位(S_IFREG 等 + rwx)
want_excl bool 是否 O_EXCL(已存在则失败)

security_inode_create(dir, dentry, mode) 两版参数一致(无 idmap),dir/dentry 含义同上。vfs_mknod 多一个 dev_t dev(设备号)

版本差异

  • vfs_create/vfs_mknod 在 6.x 首参新增 struct mnt_idmap *(5.12~6.2 曾是 struct user_namespace *)。
  • security_inode_create(dir, dentry, mode) 两版参数一致,故审计更推荐挂 security_inode_create,规避参数漂移。

eBPF 挂载点建议

  • 推荐 security_inode_create:参数稳定,可从 dentry->d_name 取新建文件名,从 dir->i_ino 取父目录 inode。
  • vfs_create:注意 6.x 需按“idmap 在前”的顺序读参。

对应系统调用及参数

系统调用 原型 参数含义
unlink unlink(const char *pathname) 删除文件/减少硬链接计数
unlinkat unlinkat(int dfd, const char *pathname, int flag) flagAT_REMOVEDIR(此时等价 rmdir

内核核心调用链

unlinkat -> do_unlinkat -> vfs_unlinkAT_REMOVEDIR 分流到 do_rmdir):

// v5.4
int vfs_unlink(struct inode *dir, struct dentry *dentry,
	       struct inode **delegated_inode)
{
	struct inode *target = dentry->d_inode;
	error = may_delete(dir, dentry, 0);
	...
	error = security_inode_unlink(dir, dentry);   // <== LSM 点
	if (!error) {
		error = try_break_deleg(target, delegated_inode);
		if (error)
			goto out;
		error = dir->i_op->unlink(dir, dentry);   // ext4_unlink ...
	}
	...
}

// v6.6:首参新增 idmap
int vfs_unlink(struct mnt_idmap *idmap, struct inode *dir,
	       struct dentry *dentry, struct inode **delegated_inode)
{ ... error = security_inode_unlink(dir, dentry); ... }

核心 hook 参数说明

参数 类型 含义 / 父目录 vs 子节点
idmap(仅 6.x) struct mnt_idmap * idmapping;6.x 中占 arg0,导致 dir/dentry 从 arg0/arg1 后移到 arg1/arg2
dir struct inode * 父目录 inode(从哪个目录删);dir->i_ino
dentry struct dentry * 被删的子节点(目标已存在,d_inode 有效);名 = dentry->d_name,目标 inode = dentry->d_inode,删除前链接数 = dentry->d_inode->i_nlink
delegated_inode struct inode ** NFS 委托回收用,审计一般忽略

security_inode_unlink(dir, dentry) 两版一致;判定“真正删除文件”可看 i_nlink == 1(删除后归零)

版本差异

  • vfs_unlink 6.x 新增首参 mnt_idmapkprobe 中 dir/dentry 分别从 arg0/arg1 后移到 arg1/arg2——这是最容易踩坑的地方。
  • security_inode_unlink(dir, dentry) 两版一致。

eBPF 挂载点建议

  • 推荐 security_inode_unlink(参数稳定)或 vfs_unlink(注意版本下标)。
  • 可提取:被删 dentry(回溯路径)、dentry->d_inode->i_inoi_nlink(删除前链接数)。

0x05 重命名 rename

对应系统调用及参数

系统调用 原型 参数含义
rename rename(const char *oldname, const char *newname) 移动/改名
renameat renameat(int olddfd, const char *oldname, int newdfd, const char *newname) 支持相对目录 fd
renameat2 renameat2(..., unsigned int flags) flagsRENAME_NOREPLACE(目标存在则失败)、RENAME_EXCHANGE(原子交换)、RENAME_WHITEOUT(overlayfs)

内核核心调用链

renameat2 -> do_renameat2 -> vfs_rename

// v5.4:一长串参数
int vfs_rename(struct inode *old_dir, struct dentry *old_dentry,
	       struct inode *new_dir, struct dentry *new_dentry,
	       struct inode **delegated_inode, unsigned int flags)
{
	error = security_inode_rename(old_dir, old_dentry,
				      new_dir, new_dentry, flags);   // <== LSM 点
	...
	error = old_dir->i_op->rename(old_dir, old_dentry,
				      new_dir, new_dentry, flags);
	...
}

// v6.6:收敛为 struct renamedata *
struct renamedata {
	struct mnt_idmap *old_mnt_idmap;
	struct inode *old_dir;
	struct dentry *old_dentry;
	struct mnt_idmap *new_mnt_idmap;
	struct inode *new_dir;
	struct dentry *new_dentry;
	struct inode **delegated_inode;
	unsigned int flags;
};
int vfs_rename(struct renamedata *rd)
{
	error = security_inode_rename(rd->old_dir, rd->old_dentry,
				      rd->new_dir, rd->new_dentry, rd->flags);
	...
}

核心 hook 参数说明

rename 有两组 (dir, dentry),分别描述源与目的;跨目录移动时 old_dir != new_dir

参数 类型 含义 / 父目录 vs 子节点
old_dir struct inode * 源父目录 inode
old_dentry struct dentry * 源子节点(已存在,d_inode 有效);原名 = old_dentry->d_name
new_dir struct inode * 目的父目录 inode
new_dentry struct dentry * 目的子节点(若 RENAME_NOREPLACE 则为负 dentry;否则 d_inode 为被覆盖目标);新名 = new_dentry->d_name
flags unsigned int RENAME_NOREPLACE / RENAME_EXCHANGE / RENAME_WHITEOUT
  • 5.4 上述为 6 个独立参数;6.6 收敛进 struct renamedata *rd,需 BPF_CORE_READ(rd, old_dir) / rd->old_dentry 等按结构体成员取
  • security_inode_rename(old_dir, old_dentry, new_dir, new_dentry, flags) 两版一致,建议直接挂它避免 renamedata 差异

版本差异

  • 这是跨版本差异最大的函数:5.4 是 6 个独立参数;5.12+/6.6 收敛为单个 struct renamedata *。eBPF 中 6.x 需先读 renamedata 指针,再按结构体偏移取 old_dentry/new_dentry
  • security_inode_rename(old_dir, old_dentry, new_dir, new_dentry, flags) 两版参数一致,强烈推荐用它规避 renamedata 差异

eBPF 挂载点建议

  • 首选 security_inode_rename(参数稳定、可拦截)。
  • 可提取:old_dentry/new_dentry(回溯源/目的路径)、flags(识别 RENAME_EXCHANGE 等)。

0x06 目录 mkdir/rmdir

对应系统调用及参数

系统调用 原型 参数含义
mkdir mkdir(const char *pathname, umode_t mode) 创建目录,mode 受 umask 影响
mkdirat mkdirat(int dfd, const char *pathname, umode_t mode) 相对目录 fd 版
rmdir rmdir(const char *pathname) 删除空目录

内核核心调用链

mkdirat -> do_mkdirat -> vfs_mkdirrmdir -> do_rmdir -> vfs_rmdir

// v5.4
int vfs_mkdir(struct inode *dir, struct dentry *dentry, umode_t mode)
{
	error = may_create(dir, dentry);
	error = security_inode_mkdir(dir, dentry, mode);   // <== LSM 点
	error = dir->i_op->mkdir(dir, dentry, mode);
	...
}

// v6.6:首参新增 idmap(6.6 中 vfs_mkdir 仍返回 int)
int vfs_mkdir(struct mnt_idmap *idmap, struct inode *dir,
	      struct dentry *dentry, umode_t mode) { ... }

核心 hook 参数说明

参数 类型 含义 / 父目录 vs 子节点
idmap(仅 6.x) struct mnt_idmap * idmapping,占 arg0
dir struct inode * 父目录 inode(在哪个目录下建/删子目录);dir->i_ino
dentry struct dentry * 子目录节点vfs_mkdir 时为负 dentry;vfs_rmdir 时目标已存在(d_inode 有效);名 = dentry->d_name
mode(仅 mkdir) umode_t 目录权限位(受 umask 影响)

security_inode_mkdir(dir, dentry, mode) / security_inode_rmdir(dir, dentry) 两版一致

版本差异

  • vfs_mkdir/vfs_rmdir 6.x 首参新增 mnt_idmap
  • security_inode_mkdir(dir, dentry, mode)security_inode_rmdir(dir, dentry) 两版一致。

eBPF 挂载点建议

  • 推荐 security_inode_mkdir/security_inode_rmdir;可从 dentry 取目录名、dir->i_ino 取父目录。

对应系统调用及参数

系统调用 原型 参数含义
link/linkat linkat(int olddfd, const char *oldname, int newdfd, const char *newname, int flags) 创建硬链接flagsAT_SYMLINK_FOLLOWAT_EMPTY_PATH
symlink/symlinkat symlinkat(const char *oldname, int newdfd, const char *newname) 创建软链接oldname 为链接目标(可不存在)

内核核心调用链

linkat -> do_linkat -> vfs_linksymlinkat -> do_symlinkat -> vfs_symlink

// v5.4
int vfs_link(struct dentry *old_dentry, struct inode *dir,
	     struct dentry *new_dentry, struct inode **delegated_inode)
{
	error = security_inode_link(old_dentry, dir, new_dentry);  // <== LSM 点
	error = dir->i_op->link(old_dentry, dir, new_dentry);
	...
}

// v6.6:在 old_dentry 之后插入 idmap
int vfs_link(struct dentry *old_dentry, struct mnt_idmap *idmap,
	     struct inode *dir, struct dentry *new_dentry,
	     struct inode **delegated_inode) { ... }

// v5.4
int vfs_symlink(struct inode *dir, struct dentry *dentry, const char *oldname)
{
	error = security_inode_symlink(dir, dentry, oldname);   // <== LSM 点
	error = dir->i_op->symlink(dir, dentry, oldname);
	...
}
// v6.6:首参新增 idmap
int vfs_symlink(struct mnt_idmap *idmap, struct inode *dir,
		struct dentry *dentry, const char *oldname) { ... }

核心 hook 参数说明

vfs_link(硬链接)——注意 old_dentry 不是 dir 的孩子

参数 类型 含义 / 父目录 vs 子节点
old_dentry struct dentry * 已存在的源文件目录项(被指向的原文件),其父目录不是 dir;源 inode = old_dentry->d_inode
idmap(仅 6.x) struct mnt_idmap * idmapping;插在 old_dentry 之后(非首参)
dir struct inode * 新硬链接所在的父目录 inode
new_dentry struct dentry * 新硬链接子节点dir 的孩子,负 dentry);链接名 = new_dentry->d_name
delegated_inode struct inode ** NFS 委托,审计可忽略

vfs_symlink(软链接):

参数 类型 含义 / 父目录 vs 子节点
idmap(仅 6.x) struct mnt_idmap * idmapping,占 arg0
dir struct inode * 父目录 inode(软链接建在哪个目录下)
dentry struct dentry * 软链接子节点(负 dentry);链接名 = dentry->d_name
oldname const char * 链接指向的目标字符串(可指向不存在的路径,用 bpf_probe_read_kernel_str 读)

security_inode_link(old_dentry, dir, new_dentry) / security_inode_symlink(dir, dentry, oldname) 两版一致

版本差异

  • vfs_symlink 6.x 首参新增 idmapvfs_link 6.x 在 old_dentry 之后插入 idmap(注意不是首参)。
  • security_inode_link/security_inode_symlink 两版参数一致。

eBPF 挂载点建议

  • 推荐 security_inode_link/security_inode_symlink
  • 硬链接审计尤为重要(常被用于绕过基于路径的策略):可提取 old_dentry(源)、new_dentry(新链接名)。

0x08 属性修改 chmod/chown/setattr/utimes

对应系统调用及参数

系统调用 原型 参数含义
chmod/fchmodat fchmodat(int dfd, const char *filename, umode_t mode) 改权限位
chown/fchownat fchownat(int dfd, const char *filename, uid_t user, gid_t group, int flag) 改属主/属组;flagAT_SYMLINK_NOFOLLOW
utimensat utimensat(int dfd, const char *filename, const struct timespec times[2], int flags) 改 atime/mtime
truncate 属性侧 见 0x0A 改 size 也是一次 setattr

chmodchown 最终都汇聚到 notify_change() -> inode->i_op->setattr(),这是属性变更的统一收敛点

chmod/fchmodat -> do_fchmodat -> chmod_common

int do_fchmodat(int dfd, const char __user *filename, umode_t mode)
{
	struct path path;
	int error = user_path_at(dfd, filename, LOOKUP_FOLLOW, &path);
	if (!error) {
		error = chmod_common(&path, mode);
		path_put(&path);
	}
	return error;
}

static int chmod_common(const struct path *path, umode_t mode)
{
	struct inode *inode = path->dentry->d_inode;
	struct iattr newattrs;
	newattrs.ia_mode = (mode & S_IALLUGO) | (inode->i_mode & ~S_IALLUGO);
	newattrs.ia_valid = ATTR_MODE | ATTR_CTIME;
	error = security_path_chmod(path, mode);              // <== LSM 点
	if (!error)
		error = notify_change(path->dentry, &newattrs, ...);  // 统一收敛
	...
}

chown 侧核心(do_fchownat -> chown_common):

static int chown_common(const struct path *path, uid_t user, gid_t group)
{
	struct inode *inode = path->dentry->d_inode;
	struct iattr newattrs;
	...
	newattrs.ia_valid = ATTR_CTIME;
	if (user != (uid_t)-1) { newattrs.ia_valid |= ATTR_UID; newattrs.ia_uid = uid; }
	if (group != (gid_t)-1) { newattrs.ia_valid |= ATTR_GID; newattrs.ia_gid = gid; }
	error = security_path_chown(path, uid, gid);          // <== LSM 点
	if (!error)
		error = notify_change(path->dentry, &newattrs, &delegated_inode);
	...
}

统一收敛点 notify_change

// v5.4
int notify_change(struct dentry *dentry, struct iattr *attr,
		  struct inode **delegated_inode)
{
	error = security_inode_setattr(dentry, attr);   // <== LSM 点
	...
	if (inode->i_op->setattr)
		error = inode->i_op->setattr(dentry, attr);
	...
}

// v6.6:首参新增 idmap;setattr 回调也带 idmap
int notify_change(struct mnt_idmap *idmap, struct dentry *dentry,
		  struct iattr *attr, struct inode **delegated_inode) { ... }

核心 hook 参数说明

属性变更类 hook 只作用在目标本身,没有 dir 父目录参数(目标由 dentry/path 直接给出,父目录如需可从 dentry->d_parent 得到):

参数 类型 含义 / 父目录 vs 子节点
idmap(仅 6.x) struct mnt_idmap * idmapping,占 arg0
dentry struct dentry * 被修改属性的目标节点(子节点本身);目标 inode = dentry->d_inode,父目录 = dentry->d_parent->d_inode
attr struct iattr * 变更集attr->ia_valid 位掩码指明改了哪些字段,再取对应值
delegated_inode struct inode ** NFS 委托,可忽略

struct iattr 关键字段(据 ia_valid 判断):ATTR_MODEia_mode(chmod)、ATTR_UIDia_uidATTR_GIDia_gid(chown)、ATTR_SIZEia_size(truncate)、ATTR_MTIME/ATTR_ATIME(utimes)。security_inode_setattr 6.x 也带 idmap,其后 (dentry, attr) 同上

版本差异

  • notify_changei_op->setattr 回调在 6.x 首参新增 mnt_idmap
  • security_inode_setattr 在 6.x 也新增 idmap 首参(security_path_chmod/security_path_chown 参数保持稳定)。

eBPF 挂载点建议

  • 推荐 notify_change/security_inode_setattr一处覆盖 chmod/chown/utimes/truncate 全部属性变更
  • 关键提取 struct iattria_valid 位掩码判断改了什么(ATTR_MODE/ATTR_UID/ATTR_GID/ATTR_SIZE/ATTR_MTIME),再取 ia_mode/ia_uid/ia_gid/ia_size

0x09 扩展属性 xattr

对应系统调用及参数

系统调用 原型 参数含义
setxattr/lsetxattr/fsetxattr setxattr(const char *path, const char *name, const void *value, size_t size, int flags) 设置扩展属性;name 形如 user.*/security.*/trusted.*flagsXATTR_CREATE/XATTR_REPLACE
getxattr 系列 getxattr(path, name, value, size) 读取扩展属性(审计可选)
removexattr 系列 removexattr(path, name) 删除扩展属性

审计重点:security.capability(file capabilities)、security.selinuxsystem.posix_acl_access(ACL)等属于安全敏感的 xattr。

内核核心调用链

setxattr -> path_setxattr -> setxattr/do_setxattr -> vfs_setxattr

// v5.4
int vfs_setxattr(struct dentry *dentry, const char *name, const void *value,
		 size_t size, int flags)
{
	error = security_inode_setxattr(dentry, name, value, size, flags);  // <== LSM 点
	if (!error)
		error = __vfs_setxattr_noperm(dentry, name, value, size, flags);
	...
}

// v6.6:首参新增 idmap
int vfs_setxattr(struct mnt_idmap *idmap, struct dentry *dentry,
		 const char *name, const void *value, size_t size, int flags)
{
	error = security_inode_setxattr(idmap, dentry, name, value, size, flags);
	...
}

核心 hook 参数说明

同属性类,目标由 dentry 直接给出,无 dir 父目录参数

参数 类型 含义 / 父目录 vs 子节点
idmap(仅 6.x) struct mnt_idmap * idmapping,占 arg0
dentry struct dentry * 被设置扩展属性的目标节点;目标 inode = dentry->d_inode
name const char * 属性名(user.*/security.*/trusted.*/system.*,用 bpf_probe_read_kernel_str 读)
value / size const void * / size_t 属性值缓冲与长度
flags int XATTR_CREATE(不存在才建)/ XATTR_REPLACE(存在才改)

vfs_removexattr(idmap, dentry, name) 少了 value/size/flags;审计重点关注 security.capabilitysecurity.selinux 等敏感属性名

版本差异

  • vfs_setxattr/vfs_removexattr 6.x 首参新增 idmap
  • security_inode_setxattr 在 6.x 也新增 idmap 首参(5.4 无)。
  • 6.x 对 xattr 路径做了重构:setxattr 系统调用侧多了 do_setxattr() 中间层,核心仍落到 vfs_setxattr

eBPF 挂载点建议

  • 推荐 vfs_setxattr/vfs_removexattrsecurity_inode_setxattr
  • 可提取:name(属性名字符串,用 bpf_probe_read_kernel_str)、value/size、目标 dentry

0x0A 读写与截断 read/write/truncate

对应系统调用及参数

系统调用 原型 参数含义
read read(unsigned int fd, char *buf, size_t count) count 字节到 buf
write write(unsigned int fd, const char *buf, size_t count) count 字节
pread64/pwrite64 pread64(fd, buf, count, loff_t pos) 带偏移读写,不改文件位置
truncate/ftruncate truncate(const char *path, off_t length) 把文件截断/扩展到 length 字节

内核核心调用链

read -> ksys_read -> vfs_readwrite -> ksys_write -> vfs_writetruncate -> do_sys_truncate -> do_truncate -> notify_change

ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
	if (!(file->f_mode & FMODE_READ)) return -EBADF;
	ret = rw_verify_area(READ, file, pos, count);
	...
	if (file->f_op->read)          ret = file->f_op->read(file, buf, count, pos);
	else if (file->f_op->read_iter) ret = new_sync_read(file, buf, count, pos);
	...
}

// v5.4
int do_truncate(struct dentry *dentry, loff_t length, unsigned int time_attrs,
		struct file *filp)
{
	struct iattr newattrs;
	newattrs.ia_size = length;
	newattrs.ia_valid = ATTR_SIZE | time_attrs;
	...
	ret = notify_change(dentry, &newattrs, NULL);   // 截断本质是 setattr(ATTR_SIZE)
}
// v6.6:do_truncate 首参新增 idmap
int do_truncate(struct mnt_idmap *idmap, struct dentry *dentry, loff_t length,
		unsigned int time_attrs, struct file *filp) { ... }

版本差异

  • vfs_read/vfs_write 两版签名稳定(不带 idmap),是少数“跨版本友好”的点。
  • do_truncate 6.x 首参新增 idmap;但截断最终仍收敛到 notify_change(... ATTR_SIZE ...)

eBPF 挂载点建议

  • 读写路径频率极高,全量 hook vfs_read/vfs_write 会带来显著开销与事件洪泛。审计场景建议:
    • 只对敏感文件/目录(按 inode 或路径前缀过滤)上报;
    • 或采样、或聚合计数后周期上报;
    • 或用 LSM security_file_permission(在打开后首次读写权限校验时触发,语义更“动作级”)。
  • 截断推荐 do_truncate 或统一的 notify_changeATTR_SIZE)。

0x0B 挂载 mount/umount

对应系统调用及参数

系统调用 原型 参数含义
mount mount(const char *dev_name, const char *dir_name, const char *type, unsigned long flags, void *data) flagsMS_RDONLY/MS_BIND/MS_REMOUNT/MS_MOVE...data 文件系统私有选项
umount2 umount2(const char *target, int flags) flagsMNT_FORCE/MNT_DETACH/MNT_EXPIRE
新挂载 API fsopen/fsconfig/fsmount/move_mount 5.2+ 引入的 fd 化挂载流程

内核核心调用链

// v5.4:do_mount 内部直接分派
long do_mount(const char *dev_name, const char __user *dir_name,
	      const char *type_page, unsigned long flags, void *data_page)
{
	struct path path = ...;                    // 解析挂载点
	retval = security_sb_mount(dev_name, &path, type_page, flags, data_page); // LSM
	if (flags & MS_REMOUNT) retval = do_remount(...);
	else if (flags & MS_BIND) retval = do_loopback(...);
	else if (flags & MS_MOVE) retval = do_move_mount_old(...);
	else retval = do_new_mount(...);
	...
}

// v6.6:拆出 path_mount(挂载点已解析为 struct path)
int path_mount(const char *dev_name, struct path *path, const char *type_page,
	       unsigned long flags, void *data_page)
{
	ret = security_sb_mount(dev_name, path, type_page, flags, data_page);  // LSM
	...
	if (flags & MS_REMOUNT) return do_remount(...);
	if (flags & MS_BIND)    return do_loopback(...);
	if (flags & MS_MOVE)    return do_move_mount_old(...);
	return do_new_mount(...);
}

卸载:v5.4 走 ksys_umount(name, flags);v6.6 走 SYSCALL_DEFINE2(umount, ...) -> path_umount(&path, flags)path_umount 为 5.9+ 重构产物)。

版本差异

  • 5.9 前后挂载路径重构:v5.4 分派逻辑在 do_mount;v6.6 挂载点解析后交给 path_mount,卸载有独立 path_umount
  • 新旧两套挂载 API 并存;move_mount 有独立 security_move_mount

eBPF 挂载点建议

  • 推荐 security_sb_mount(两版参数稳定、可拦截);path_mount(6.x)/do_mount(5.4)作为通用点需分版本。
  • 可提取:dev_name(设备/源)、path(挂载点,bpf_d_path)、flagsMS_*)。

0x0C inode 操作函数

struct inode_operationsinode->i_op)是具体文件系统实现命名空间语义的回调表,前面各 vfs_* 在完成通用校验与 LSM 后,最终都调用它:

struct inode_operations {
	struct dentry *(*lookup)(struct inode *, struct dentry *, unsigned int);
	int (*permission)(struct mnt_idmap *, struct inode *, int);   // 6.x 带 idmap
	int (*create)(struct mnt_idmap *, struct inode *, struct dentry *, umode_t, bool);
	int (*link)(struct dentry *, struct inode *, struct dentry *);
	int (*unlink)(struct inode *, struct dentry *);
	int (*symlink)(struct mnt_idmap *, struct inode *, struct dentry *, const char *);
	int (*mkdir)(struct mnt_idmap *, struct inode *, struct dentry *, umode_t);
	int (*rmdir)(struct inode *, struct dentry *);
	int (*rename)(struct mnt_idmap *, struct inode *, struct dentry *,
		      struct inode *, struct dentry *, unsigned int);
	int (*setattr)(struct mnt_idmap *, struct dentry *, struct iattr *);
	int (*getattr)(struct mnt_idmap *, const struct path *, struct kstat *, u32, unsigned int);
	...
};

审计视角要点:

  • i_op 的具体实现随文件系统而异ext4_dir_inode_operationsxfs_dir_inode_operations、overlayfs 等),直接 hook 某个 ext4_create漏掉其他文件系统;因此通用审计应挂上层 vfs_*/security_inode_*,而非 i_op 回调。
  • 仅在需要针对特定文件系统做深度分析(如 overlayfs 的 copy-up 行为)时,才 hook 具体 i_op 实现。
  • 版本差异:6.x 中 create/mkdir/symlink/rename/setattr/getattr/permission 等回调普遍新增 mnt_idmap 首参;lookup/unlink/rmdir/link 不带 idmap。

0x0D dentry 操作函数

struct dentry(目录项)是 VFS 的路径缓存节点,struct dentry_operationsdentry->d_op)定义其行为:

struct dentry_operations {
	int (*d_revalidate)(struct dentry *, unsigned int);   // 缓存有效性校验
	int (*d_hash)(const struct dentry *, struct qstr *);
	int (*d_compare)(const struct dentry *, unsigned int, const char *, const struct qstr *);
	int (*d_delete)(const struct dentry *);               // 最后一个引用释放时是否立即销毁
	void (*d_release)(struct dentry *);
	void (*d_iput)(struct dentry *, struct inode *);
	char *(*d_dname)(struct dentry *, char *, int);       // 特殊文件系统自定义 d_path 显示
	...
};

对审计最有价值的是从 dentry 还原绝对路径,这也是 eBPF 文件审计的核心难点:

  • dentry->d_namestruct qstr{ hash_len; const unsigned char *name; })取当前组件名。
  • dentry->d_parent 指向父目录 dentry,循环向上直到 d_parent == d_self(挂载点根),再借 vfsmount/mnt->mnt_mountpoint 跨挂载点拼接。
  • 内核态有 d_path()/dentry_path_raw();eBPF 侧优先 bpf_d_path(allowlist 限制),否则手写回溯(见 0x01)。
// 内核 d_path 思路(简化):从 dentry 向上回溯拼路径
char *d_path(const struct path *path, char *buf, int buflen)
{
	// prepend_path -> 循环: prepend d_name; dentry = d_parent;
	// 跨挂载点时切换到父 mount 的 mountpoint
	...
}

审计注意:dentry 可能因 rename 而 d_parent/d_name 变化,采集时要在事件发生的同步上下文读取,避免异步读到已变更的缓存。

0x0E security 系列 LSM 钩子汇总

前面各节反复出现的 security_*LSM(Linux Security Module)框架暴露的策略挂钩点。相较 kprobe,它们对 eBPF 审计有三大优势:参数语义稳定、位于动作发生前、返回非 0 即可拦截(阻断能力)

与文件审计相关的钩子分组

  • 文件级 security_file_*
    • security_file_open(struct file *file):打开(含权限最终确认)。
    • security_file_permission(struct file *file, int mask):读写等访问前校验(MAY_READ/MAY_WRITE)。
    • security_mmap_file(struct file *file, unsigned long prot, unsigned long flags):映射可执行文件常用于审计。
  • inode 级 security_inode_*
    • 创建/删除/改名:security_inode_createsecurity_inode_unlinksecurity_inode_renamesecurity_inode_mkdirsecurity_inode_rmdirsecurity_inode_mknod
    • 链接:security_inode_linksecurity_inode_symlink
    • 属性:security_inode_setattrsecurity_inode_getattrsecurity_inode_permission
    • 扩展属性:security_inode_setxattrsecurity_inode_getxattrsecurity_inode_removexattrsecurity_inode_listxattr
  • 路径级 security_path_*(依赖 CONFIG_SECURITY_PATH,如 AppArmor/TOMOYO)
    • security_path_chmodsecurity_path_chownsecurity_path_mknodsecurity_path_unlinksecurity_path_renamesecurity_path_link/symlinksecurity_path_truncate
  • 超级块/挂载 security_sb_*
    • security_sb_mountsecurity_sb_umountsecurity_sb_remountsecurity_move_mount

LSM-BPF 用法

内核 5.7+ 开启 CONFIG_BPF_LSM 后,可用 BPF_PROG_TYPE_LSM 程序附着到 bpf_lsm_<hook>,libbpf 中用 SEC("lsm/<hook>")

SEC("lsm/inode_unlink")
int BPF_PROG(audit_unlink, struct inode *dir, struct dentry *dentry, int ret)
{
    // ret 为前序 LSM 的返回值;若已被拒绝则透传
    if (ret != 0)
        return ret;
    // ... 采集/判定 ...
    return 0;      // 返回 -EPERM 即可阻断该次 unlink
}

要点:

  • LSM-BPF hook 的参数与内核 security_* 钩子签名一一对应,且随 CO-RE/BTF 稳定,跨版本迁移成本低。
  • 返回值直接决定放行/拒绝,是实现主动防护(而非仅审计)的关键。
  • 依赖:CONFIG_BPF_LSM=y、启动参数 lsm=...,bpf(或内核默认已含 bpf)、5.7+。

0x0F 跨版本差异小结

面向 eBPF 开发者,两套内核在本文涉及函数上的关键差异汇总如下(重点是参数下标漂移):

  • mnt_idmap 引入:6.x 中 vfs_create/vfs_mknod/vfs_mkdir/vfs_rmdir/vfs_symlink/vfs_setxattr/vfs_removexattr/notify_change/do_truncate首参新增 struct mnt_idmap *vfs_link 则在 old_dentry 之后插入。演进:5.12 引入 struct user_namespace * → 6.3 改为 struct mnt_idmap *。kprobe 读参时对应寄存器下标整体后移一位。
  • vfs_rename:5.4 为 6 个独立参数;5.12+/6.6 收敛为 struct vfs_renamestruct renamedata *建议改挂 security_inode_rename 以规避。
  • open 路径:5.6+ 引入 openat2/do_sys_openat2,6.6 的 open/openat 也走此路径;6.x path_openat 内部把 do_last 拆为 open_last_lookups + do_open
  • mount:5.9 重构出 path_mount/path_umount;v5.4 分派逻辑仍在 do_mount
  • vfs_read/vfs_write:两版签名稳定,跨版本友好。
  • 总结:审计代码优先挂 security_*(尤其 LSM-BPF),其签名对参数漂移最鲁棒;若必须挂 vfs_*,务必用 BTF/CO-RE 并区分内核版本处理参数下标。

0x10 参考