0x00 前言
本文从eBPF 内核态开发的视角,梳理 Linux VFS(虚拟文件系统)中值得挂载(hook)的监控点:文件的打开、创建、删除、重命名、目录/链接操作、属性与扩展属性修改、读写截断、挂载卸载等,并补齐 inode/dentry 相关操作与 security_* LSM 钩子
本文代码基于:
阅读导引:两套内核在 VFS 层的调用链骨架基本一致,但对 eBPF 开发者影响最大的是函数签名的参数漂移,集中在三处:
mnt_idmap(挂载点 idmapping)在 6.x 版本被引入为多数vfs_*/security_inode_*的首个参数(前身是内核 5.12 版本引入的struct user_namespace *,内核6.3 改为struct mnt_idmap *)。这会导致 kprobe hook读取参数时寄存器下标整体后移一位vfs_rename在内核 5.12+ 把一长串参数收敛为struct renamedata *单个结构体指针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_name与dentry->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;
}
两个易错点(务必注意):
vfs_link(old_dentry, [idmap,] dir, new_dentry, ...)里三个路径类参数含义各不相同:old_dentry:已存在的源文件目录项,它的父目录不是dirdir:新硬链接所在的父目录 inodenew_dentry:新硬链接的子节点(是dir的孩子,创建前为负 dentry) 即=dir的孩子是new_dentry而非old_dentry,不能想当然地用d_parent == dir去套old_dentry
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 |
dentry(d_inode 有效) |
目标已存在 |
vfs_rmdir |
dir |
dentry(d_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 * |
返回已打开的 file(kretprobe/fexit 取),失败为 ERR_PTR |
do_dentry_open |
f / inode |
struct file * / struct inode * |
f 目标文件;inode 即目标文件 inode(非父目录);open 为具体文件系统 .open 回调 |
可提取字段为f->f_flags(O_* 打开标志)、f->f_path(bpf_d_path 取完整路径)、f->f_inode->i_ino、BPF_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/openat→do_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_tgid、comm)。
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_create;mknod 走 do_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 在前”的顺序读参。
0x04 删除 unlink
对应系统调用及参数
| 系统调用 | 原型 | 参数含义 |
|---|---|---|
unlink |
unlink(const char *pathname) |
删除文件/减少硬链接计数 |
unlinkat |
unlinkat(int dfd, const char *pathname, int flag) |
flag 含 AT_REMOVEDIR(此时等价 rmdir) |
内核核心调用链
unlinkat -> do_unlinkat -> vfs_unlink(AT_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_unlink6.x 新增首参mnt_idmap,kprobe 中dir/dentry分别从 arg0/arg1 后移到 arg1/arg2——这是最容易踩坑的地方。security_inode_unlink(dir, dentry)两版一致。
eBPF 挂载点建议
- 推荐
security_inode_unlink(参数稳定)或vfs_unlink(注意版本下标)。 - 可提取:被删
dentry(回溯路径)、dentry->d_inode->i_ino、i_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) |
flags:RENAME_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_mkdir;rmdir -> 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_rmdir6.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取父目录。
0x07 链接 link/symlink
对应系统调用及参数
| 系统调用 | 原型 | 参数含义 |
|---|---|---|
link/linkat |
linkat(int olddfd, const char *oldname, int newdfd, const char *newname, int flags) |
创建硬链接;flags 含 AT_SYMLINK_FOLLOW、AT_EMPTY_PATH |
symlink/symlinkat |
symlinkat(const char *oldname, int newdfd, const char *newname) |
创建软链接,oldname 为链接目标(可不存在) |
内核核心调用链
linkat -> do_linkat -> vfs_link;symlinkat -> 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_symlink6.x 首参新增idmap;vfs_link6.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) |
改属主/属组;flag 含 AT_SYMLINK_NOFOLLOW |
utimensat |
utimensat(int dfd, const char *filename, const struct timespec times[2], int flags) |
改 atime/mtime |
truncate 属性侧 |
见 0x0A | 改 size 也是一次 setattr |
chmod 与 chown 最终都汇聚到 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_MODE→ia_mode(chmod)、ATTR_UID→ia_uid、ATTR_GID→ia_gid(chown)、ATTR_SIZE→ia_size(truncate)、ATTR_MTIME/ATTR_ATIME(utimes)。security_inode_setattr 6.x 也带 idmap,其后 (dentry, attr) 同上
版本差异
notify_change及i_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 iattr:ia_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.*;flags:XATTR_CREATE/XATTR_REPLACE |
getxattr 系列 |
getxattr(path, name, value, size) |
读取扩展属性(审计可选) |
removexattr 系列 |
removexattr(path, name) |
删除扩展属性 |
审计重点:
security.capability(file capabilities)、security.selinux、system.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.capability、security.selinux 等敏感属性名
版本差异
vfs_setxattr/vfs_removexattr6.x 首参新增idmap。security_inode_setxattr在 6.x 也新增idmap首参(5.4 无)。- 6.x 对 xattr 路径做了重构:
setxattr系统调用侧多了do_setxattr()中间层,核心仍落到vfs_setxattr。
eBPF 挂载点建议
- 推荐
vfs_setxattr/vfs_removexattr或security_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_read;write -> ksys_write -> vfs_write;truncate -> 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_truncate6.x 首参新增idmap;但截断最终仍收敛到notify_change(... ATTR_SIZE ...)。
eBPF 挂载点建议
- 读写路径频率极高,全量 hook
vfs_read/vfs_write会带来显著开销与事件洪泛。审计场景建议:- 只对敏感文件/目录(按 inode 或路径前缀过滤)上报;
- 或采样、或聚合计数后周期上报;
- 或用 LSM
security_file_permission(在打开后首次读写权限校验时触发,语义更“动作级”)。
- 截断推荐
do_truncate或统一的notify_change(ATTR_SIZE)。
0x0B 挂载 mount/umount
对应系统调用及参数
| 系统调用 | 原型 | 参数含义 |
|---|---|---|
mount |
mount(const char *dev_name, const char *dir_name, const char *type, unsigned long flags, void *data) |
flags:MS_RDONLY/MS_BIND/MS_REMOUNT/MS_MOVE...;data 文件系统私有选项 |
umount2 |
umount2(const char *target, int flags) |
flags:MNT_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)、flags(MS_*)。
0x0C inode 操作函数
struct inode_operations(inode->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_operations、xfs_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_operations(dentry->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_name(struct 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_create、security_inode_unlink、security_inode_rename、security_inode_mkdir、security_inode_rmdir、security_inode_mknod。 - 链接:
security_inode_link、security_inode_symlink。 - 属性:
security_inode_setattr、security_inode_getattr、security_inode_permission。 - 扩展属性:
security_inode_setxattr、security_inode_getxattr、security_inode_removexattr、security_inode_listxattr。
- 创建/删除/改名:
- 路径级
security_path_*(依赖CONFIG_SECURITY_PATH,如 AppArmor/TOMOYO)security_path_chmod、security_path_chown、security_path_mknod、security_path_unlink、security_path_rename、security_path_link/symlink、security_path_truncate。
- 超级块/挂载
security_sb_*security_sb_mount、security_sb_umount、security_sb_remount、security_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_rename的struct renamedata *。建议改挂security_inode_rename以规避。open路径:5.6+ 引入openat2/do_sys_openat2,6.6 的 open/openat 也走此路径;6.xpath_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 并区分内核版本处理参数下标。