最近遇到一个客户环境运行的虚拟机环境时间不准的问题,虽然环境中都部署了 ntp/chronyd 进行时间同步,校准了时间,但是隔一段时间系统时间仍会被意外调慢约 2 分钟。于是做了一个分析。大概包含下面这些内容:
- 如何使用 eBPF LSM 编程拦截特定的操作
- 如何修改 grub 内核启动参数
- grub 修改后未生效怎么办
- grub bls 技术是什么,grubby 命令使用
- rust eBPF 编程实践
万事开头难:使用 eBPF LSM 进行行为监控
为了精确定位修改系统时间的肇事者进程,利用 eBPF LSM(Linux Security Module)钩子函数进行监控,观测是谁在调用 settime,收集调用进程的详细信息,包括进程 ID、进程名称,以便后面做拦截。这里依然使用 rust aya 进行 eBPF 编程。
#[lsm]
pub fn hook_settime(ctx: LsmContext) -> i32 {
let comm_bytes = match ctx.command() {
Ok(bytes) => { bytes }
Err(_) => { return 0; }
};
let len = comm_bytes.iter()
.position(|&x| x == 0)
.unwrap_or(comm_bytes.len());
if len > 0 {
let comm_str = unsafe { core::str::from_utf8_unchecked(&comm_bytes[..len]) };
info!(&ctx, "lsm called: settime {}/{}", ctx.pid(), comm_str);
}
0
}
但是运行以后的 eBPF 程序以后,手动调用命令设置系统时间,eBPF 程序没有任何输出。经过一番调试,发现问题出在 LSM(Linux Security Module)的配置上。通过检查 LSM 配置状态:
cat /sys/kernel/security/lsm
capability,selinux
可以到当前系统仅启用了 capability 和 selinux 模块,缺少必需的 bpf 模块支持。正确配置下的 LSM 输出应该包含:
cat /sys/kernel/security/lsm
capability,yama,bpf
按常规步骤修改了 GRUB 配置 /etc/default/grub,在 GRUB_CMDLINE_LINUX 末尾新增 lsm 配置选项
lsm=ndlock,lockdown,yama,integrity,apparmor,bpf
接下来调用 grub2-mkconfig 命令更新 grub 配置,随后重启
grub2-mkconfig -o /boot/grub2/grub.cfg
但是一顿操作下来,重启后发现 /sys/kernel/security/lsm 还是没有 bpf 选项。查看 /proc/cmdline 也没有对应的内核启动时传递的启动参数。
# 检查当前 LSM 模块状态
$ cat /sys/kernel/security/lsm
capability,selinux # 显示配置未生效
# 检查内核启动参数
$ cat /proc/cmdline # 未发现新增的 LSM 参数
BOOT_IMAGE=(hd0,msdos1)/vmlinuz-5.10.134-16.2.an8.x86_64 root=UUID=7b851053-3729-47c6-a73e-aec3083f4a82 ro resume=UUID=8c6bad2a-6a76-491d-99a2-90cbccf2ba33 rhgb quiet cgroup.memory=nokmem crashkernel=0M-2G:0M,2G-8G:192M,8G-:256M
从 dmesg 中启动日志也可以同步确认。
$ dmesg -T | less
...
[三 11月 6 08:45:01 2024] Linux version 5.10.134-16.2.an8.x86_64 (mockbuild@anolis-build-02.openanolis.cn) (gcc (GCC) 8.5.0 20210514 (Anolis 8.5.0-18.0.4), GNU ld version 2.30-119.0.2.an8.2) #1 SMP Mon Mar 4 16:14:16 CST 2024
[三 11月 6 08:45:01 2024] Command line: BOOT_IMAGE=(hd0,msdos1)/vmlinuz-5.10.134-16.2.an8.x86_64 root=UUID=7b851053-3729-47c6-a73e-aec3083f4a82 ro resume=UUID=8c6bad2a-6a76-491d-99a2-90cbccf2ba33 rhgb quiet cgroup.memory=nokmem crashkernel=0M-2G:0M,2G-8G:192M,8G-:256M
...
为什么修改了 /etc/default/grub 未按预期生效
这里暂时不知道为什么没有生效,去 /boot 目录搜索相关的关键字,看下是不是有其它的配置文件
图片
找到了 /boot/loader/entries 文件,经过确认,这是因为这个 Linux 发行版比较新,引入了 BLS(Boot Loader Specification)功能。
BLS 是一个新的启动加载项配置规范,旨在统一和简化 Linux 系统的启动配置管理。它由 Fedora/Red Hat 推出,现已被多个主流 Linux 发行版采用,配置文件位于 /boot/loader/entries/ 目录。它可以不再需要手动编辑 grub.cfg 文件,同时支持多个内核版本的独立配置。
可以使用 grubby 工具进行修改,常见的操作如下:
# 查看默认内核
grubby --default-kernel
# 查看所有内核信息
grubby --info=ALL
# 修改默认内核参数
grubby --args="xxx=xxx" --update-kernel=DEFAULT
# 删除内核参数
grubby --update-kernel=ALL --remove-args="quiet"
比如 grubby info 命令可以看到当前的 grub 列表
grubby --info=ALL
index=0
kernel="/boot/vmlinuz-5.10.134-16.2.an8.x86_64"
args="ro resume=UUID=8c6bad2a-6a76-491d-99a2-90cbccf2ba33 rhgb quiet $tuned_params cgroup.memory=nokmem crashkernel=0M-2G:0M,2G-8G:192M,8G-:256M"
root="UUID=7b851053-3729-47c6-a73e-aec3083f4a82"
initrd="/boot/initramfs-5.10.134-16.2.an8.x86_64.img $tuned_initrd"
title="Anolis OS (5.10.134-16.2.an8.x86_64) 8"
id="de564eb41d9d4440b67b167404b867a4-5.10.134-16.2.an8.x86_64"
index=1
kernel="/boot/vmlinuz-0-rescue-de564eb41d9d4440b67b167404b867a4"
args="ro crashkernel=auto resume=UUID=8c6bad2a-6a76-491d-99a2-90cbccf2ba33 rhgb quiet"
root="UUID=7b851053-3729-47c6-a73e-aec3083f4a82"
initrd="/boot/initramfs-0-rescue-de564eb41d9d4440b67b167404b867a4.img"
title="Anolis OS (0-rescue-de564eb41d9d4440b67b167404b867a4) 8"
id="de564eb41d9d4440b67b167404b867a4-0-rescue"
有了这些知识,更新 grub 就变简单了
# 使用 grubby 工具更新内核参数:
grubby --args="lsm=lockdown,capability,landlock,yama,apparmor,bpf" --update-kernel=DEFAULT
系统重启后,通过检查 LSM 配置确认更改已生效:
cat /sys/kernel/security/lsm
capability,yama,bpf
现在我们可以继续使用 eBPF 程序来监控系统时间修改行为。
使用 bpf 拦截
在成功启用 LSM BPF 支持后,我们快速定位到了时间异常调整的源头,是因为虚拟机的 vm-agent 进程在定时做修改。(图中的另外一个 date 是我在手动调用 date 修改时间)
图片
接下来要做的就是匹配 vm-agent,然后拦截它。
const VM_AGENT_BYTES: &'static [u8] = b"vm-agent";
#[lsm]
pub fn hook_settime(ctx: LsmContext) -> i32 {
let comm_bytes = match ctx.command() {
Ok(bytes) => { bytes }
Err(_) => { return 0; }
};
let len = comm_bytes.iter()
.position(|&x| x == 0)
.unwrap_or(comm_bytes.len());
if len == VM_AGENT_BYTES.len() && &comm_bytes[..len] == VM_AGENT_BYTES {
info!(&ctx, "match vm-agent, return -1");
return -1;
}
0
}
userspace 端的代码比较简单,加载 eBPF 程序,然后永久等待。
#[tokio::main]
async fn main() -> anyhow::Result<()> {
std::env::set_var("RUST_LOG", "debug");
env_logger::init();
let mut ebpf = aya::Ebpf::load(aya::include_bytes_aligned!(concat!(
env!("OUT_DIR"),
"/time-backward"
)))?;
if let Err(e) = aya_log::EbpfLogger::init(&mut ebpf) {
warn!("failed to initialize eBPF logger: {}", e);
}
let btf = Btf::from_sys_fs()?;
let program: &mut Lsm = ebpf.program_mut("hook_settime").unwrap().try_into()?;
program.load("settime", &btf)?;
program.attach()?;
let ctrl_c = signal::ctrl_c();
println!("Waiting for Ctrl-C...");
ctrl_c.await?;
println!("Exiting...");
Ok(())
}
这样实现以后,就有效阻止了 vm-agent 的修改时间操作(下面是一个模拟的、假的名为 vm-agent 的程序去设置时间的操作被拒绝)。
# 拦截效果验证
./vm-agent +%T -s "10:07:48"
./vm-agent: cannot set date: Operation not permitted
10:07:48
把程序放到虚拟机中运行,就可以拦截掉搞事情的 vm-agent 了。
图片
由于没有 vm-agent 乱改时间,现在系统的时间就正常了,经过长时间的观测,时钟非常准确。
图片
小结
vm-agent 应该是虚拟机的一个管理进程,可能的原因是物理机的时间慢 2min 左右,然后通过 vm-agent 来调整了当前主机所有虚拟机的时间。
ebpf 代码很简单,但是往往是它运行的环境需要比较小心的去准备。ps: 又学到了一点 grub 的好像没什么用的知识。