把过滤推进内核:eBPF 为什么正在取代 auditd
摘要:本文是「命令审计」系列的收官篇,从 auditd 在容器时代的两大结构性痛点(无容器元数据、过滤在用户态)切入,系统讲解 eBPF 是什么、过滤位置为何决定性能数量级、五件套工作原理(程序类型 / Map / Verifier / BTF 与 CO-RE / 用户态框架),以及它为何天生适合容器。随后梳理 Falco、Tetragon 等成熟生态与内核态阻断能力,并逐条拆解内核版本门槛、Verifier 拒绝、事件静默丢弃、eBPF 自身攻击面四类坑。最后给出根本边界------内存马不产生 execve,四层采集全部失效,需靠网络层、Web 日志、内存检测补位,并附选型决策表与一分钟自查清单。
------命令审计系列之四(收官篇):当采集本身变成可编程的
系列前作:
- 之一《TTY 为空的命令,为什么最危险?》------ 能被伪造的字段只能做线索
- 之二《实时 ≠ 可靠:为什么 PROMPT_COMMAND 挡不住一行 unset》------ 能被一条命令关掉的采集源只能做线索
- 之三《内核里的黑匣子:auditd 如何看见 root 也改不掉的事》------ 分层让线索升级为证据
这篇是收官。前三篇从字段、采集源、层级三个角度逼近同一个结论,这一篇给出它的工程终局:如果内核层才是正确答案,那为什么不干脆把判断逻辑也放进内核?
一个消失在容器里的进程
告警:某 K8s 节点上有个容器在对外挖矿。
值班同学立刻去查宿主机的 auditd 日志,很快找到了那条命令:
type=SYSCALL ... pid=28471 ppid=28450 uid=0 comm="xmrig" exe="/tmp/.x/xmrig"
type=CWD ... cwd="/app"
PID 有了,命令有了,路径有了。然后卡住了两个问题:
第一个问题:这个 PID 属于哪个 Pod?
auditd 在宿主机视角工作,日志里只有宿主机 PID。要反查得手工去 /proc/28471/cgroup 里抠容器 ID,再拿容器 ID 去问 containerd,再拿 container ID 去问 K8s 查 Pod 名和命名空间。
等他走完这套流程,容器已经被销毁重建了。 攻击者用的是短生命周期的 Pod,跑完就删,PID 28471 早就不存在,/proc/28471/ 打不开,链路断死。
第二个问题:这个集群有一千个节点,为什么只在出事之后才查?
因为 auditd 的日志量扛不住------全集群开 execve 全量审计,日均几十亿条,日志平台先崩。所以实际只开了关键节点。而这次出事的,恰好不在"关键节点"名单里。
这个困境是 auditd 在容器时代的结构性痛点,不是配置问题:
- 没有容器元数据 ------ 它不知道 Pod 是谁、镜像是哪个、命名空间是什么
- 过滤在用户态 ------ 内核全量吐,扛不住就只能在覆盖面上妥协
而这两点,恰恰是 eBPF 的全部优势所在。
一、eBPF 是什么:内核里的可编程沙箱
eBPF = extended Berkeley Packet Filter。 名字里还留着 "Packet Filter",但它早就和网络抓包没什么关系了。
一句话定义:
eBPF 让你在不改内核源码、不加载内核模块的前提下,把一段自己写的程序安全地送进内核去执行。
这个能力有多颠覆?在它之前,想在内核里加点自己的逻辑只有两条路:
| 老路子 | 问题 |
|---|---|
| 改内核源码重新编译 | 不可能,每家公司的内核都不一样 |
| 写内核模块(LKM) | 写错一行直接 kernel panic,整个系统崩给你看 |
eBPF 解决了这个死结------它给内核加了一个沙箱虚拟机 :你的程序进去之前,先由一个叫 Verifier(验证器) 的组件做静态分析,确认它绝对不会死循环、绝对不会越界访问、绝对不会把内核搞崩。通不过就拒绝加载。
所以 eBPF 的本质是:把"内核可编程"从高危操作变成了安全操作。
这也解释了它在安全领域的爆发------安全恰恰是最需要在内核里做观测、又最不敢动内核的领域。
二、核心差异:过滤在哪一层
回到系列第三篇留下的那个结论:auditd 的阿喀琉斯之踵是过滤在用户态。
对比一下数据流向:
auditd 的路径:
系统调用发生
→ 内核埋点,收集全部上下文(UID/PID/路径/参数...)
→ 格式化成文本
→ netlink 传到用户态
→ auditd 收到,再按规则过滤
→ 写盘
代价发生在前三步。 哪怕这条事件最后被规则丢弃了,内核也已经为它付出了采集、格式化、传输的全部开销。
eBPF 的路径:
系统调用发生
→ 内核埋点,执行你的 eBPF 程序
→ 【在内核里就判断:是不是监控 agent 自己?是不是系统服务?要不要?】
→ 不要 → 直接丢弃,零后续开销
→ 要 → 只把需要的字段放进 map / ringbuf
→ 用户态读取
差别就在那个中括号。 判断发生在内核态,不想要的事件连格式化和传输的成本都不会产生。
这是性能差一个数量级的根本原因,也是整个系列的工程终局。
三、工作原理:五件套
理解 eBPF 落地,需要认识五个东西:
① 程序类型(挂在哪)
| 类型 | 挂载点 | 特点 |
|---|---|---|
| kprobe / kretprobe | 任意内核函数的入口/返回 | 最灵活,但依赖函数名,内核升级可能失效 |
| tracepoint | 内核预定义的静态埋点 | 稳定,推荐 |
| raw_tracepoint | tracepoint 的裸参数版 | 开销更小 |
| fentry / fexit (5.5+) | 基于 BTF 的函数入口/出口 | 性能最好,现代内核首选 |
| LSM hook (5.7+) | security_* 安全钩子 |
安全审计的理想挂载点 |
做命令审计,常用的三个埋点:
sys_enter_execve ← 系统调用进入时(拿不到返回值)
sched_process_exec ← 进程成功 exec 后触发(信息最全,推荐)
security_bprm_check ← LSM 钩子,权限检查点
生产上首推 sched_process_exec------它在 exec 成功后触发,能拿到完整的进程信息和参数,且是稳定 tracepoint,不依赖具体函数名。
② Map(数据怎么出来)
eBPF 程序不能直接往用户态写,必须通过一个叫 map 的内核态数据结构交互:
| map 类型 | 用途 |
|---|---|
BPF_MAP_TYPE_HASH |
存状态(比如进程树、黑名单) |
BPF_MAP_TYPE_PERF_EVENT_ARRAY |
事件流(老方案,每 CPU 一个缓冲,可能乱序) |
BPF_MAP_TYPE_RINGBUF (5.8+) |
事件流(新方案,全局共享,保序,推荐) |
ringbuf 是现在的主流选择,它解决了 perf buffer 的两个老问题:每 CPU 独立缓冲导致的乱序、以及内存浪费。
③ Verifier(为什么它不会搞崩内核)
加载前做静态分析,检查:
- 有没有不可达指令
- 会不会越界访问内存
- 循环是否有明确上界(5.3+ 支持 bounded loop)
- 栈使用是否超限(512 字节)
- 指令数是否超限(早期 4096,新内核已大幅放宽)
这是 eBPF 敢用在生产内核上的全部底气。
④ BTF 与 CO-RE(解决"换个内核就跑不了")
这是 eBPF 从"玩具"变成"生产可用"的关键一步。
老问题:eBPF 程序要读内核结构体(比如 task_struct),但不同内核版本的结构体字段偏移不一样。用 kprobe 的老方案,换个内核版本就得重新编译,甚至直接失效。
BTF(BPF Type Format) 把内核的类型信息直接编译进内核镜像,就像一个自带的内核头文件。CO-RE(Compile Once, Run Everywhere) 基于 BTF 做重定位------一次编译,到处能跑。
要求内核 5.2+ 带 BTF,现在主流发行版基本都满足。
⑤ 用户态框架
| 框架 | 语言 | 特点 |
|---|---|---|
| BCC | Python + C | 上手快,自带大量工具,但运行时编译,部署重 |
| bpftrace | 自有 DSL | 一行命令搞定简单观测,排查神器 |
| libbpf / Cilium ebpf | C / Go | 生产级,CO-RE,无运行时依赖 |
| cilium/ebpf (Go) | Go | 安全 Agent 的主流选择 |
生产环境的安全 Agent 一般用 cilium/ebpf(Go),因为可以编译成单个静态二进制,不依赖目标机器的编译器和头文件。
四、为什么它天生适合容器
这是 eBPF 在云原生时代碾压 auditd 的地方------它能在内核里直接拿到容器的上下文。
auditd 的困境
auditd 日志里给你一个宿主机 PID,然后你得自己找:
bash
cat /proc/28471/cgroup
# → 12:pids:/kubepods/burstable/pod3f2a.../a1b2c3d4...
# 然后拿 a1b2c3d4 去 crictl 查,再拿 container id 去 kubectl 查 Pod
而且这个反查是"事后"的 ------容器一旦销毁,/proc/<pid>/ 就没了,链路断死(就是开篇那个场景)。
eBPF 的做法
在内核里,通过 bpf_get_current_task() 拿到当前进程的 task_struct,可以直接读出:
- cgroup ID → 反查容器
- PID namespace → 判断在哪个 namespace 里
- 配合用户态程序预加载的容器元数据(通过 informer 监听 K8s API 拿 Pod 列表,写进 map)
于是事件产生时,Pod 名、命名空间、容器名、镜像名就已经富化在事件里了。
{
"event": "execve",
"cmd": "/tmp/.x/xmrig",
"pid": 28471,
"pod": "payment-api-7d9f8",
"namespace": "prod",
"container": "app",
"image": "registry.internal/payment-api:v2.1.3",
"node": "node-07"
}
注意最后一行的价值 :这已经不是一条"日志",而是一条带完整上下文的可行动告警。分析师不需要做任何关联查询,直接就能判断"这是 prod 环境的支付服务在跑挖矿"。
从"记录"到"可行动",这一步富化是关键。 这正是系列第一篇强调的"字段上限决定分析上限"的终极体现。
五、生态:不要从零写
除非你是做安全产品,否则不要自己写 eBPF 探针,直接用成熟项目:
| 项目 | 出品方 | 特点 |
|---|---|---|
| Falco | Sysdig → CNCF(已毕业) | 事实标准,规则语言强大,容器元数据原生支持 |
| Tetragon | Isovalent / Cilium | 基于 BTF,性能极好,K8s 场景设计,可做到内核态阻断 |
| Tracee | Aqua Security | 事件覆盖全面,取证友好 |
| Pixie | New Relic | 偏可观测性 |
Falco 规则长什么样
yaml
- rule: 容器中执行 shell
desc: 检测容器内启动了交互式 shell
condition: >
spawned_process and container and
proc.name in (bash, sh, zsh, dash) and
not proc.pname in (entrypoint, run.sh)
output: >
容器内启动 shell (user=%user.name pod=%k8s.pod.name
ns=%k8s.ns.name image=%container.image.repository cmd=%proc.cmdline)
priority: WARNING
注意 output 里那些字段 :%k8s.pod.name、%k8s.ns.name、%container.image.repository------这些是 Falco 从内核拿到 cgroup 后,自动关联 K8s API 富化出来的。
这整段配置,auditd 一句话都做不到。
Tetragon 的独门能力:内核态阻断
Falco 主要是检测 ,Tetragon 能把 LSM hook 用起来做内核态阻断:
yaml
# 直接阻止某个进程执行,而不是"执行完再告警"
- call: "security_bprm_check"
args:
- index: 0
type: "linux_binprm"
selectors:
- matchBinaries:
- operator: "In"
values: ["/usr/bin/nc", "/usr/bin/ncat"]
matchActions:
- action: "Sigkill" # ← 内核里直接杀掉
这是前几篇所有方案都做不到的:在命令真正执行之前,在内核里就把它拦下来。
这呼应了系列第二篇那个痛点------
PROMPT_COMMAND在"命令执行完"才记录,trap DEBUG虽然能在执行前记录但拦不住。而 Tetragon 是在内核里执行前拦截。
六、限制与坑:别把它当银弹
eBPF 很强,但有几个必须知道的边界。
坑一:内核版本门槛
| 能力 | 最低内核 |
|---|---|
| 基础 eBPF | 4.x |
| BTF / CO-RE | 5.2+ |
| ringbuf | 5.8+ |
| fentry/fexit | 5.5+ |
| LSM hook | 5.7+ |
CentOS 7 的 3.10 内核基本用不了------这正是 auditd 至今无可替代的现实原因。
所以选型结论很朴素:新机器(Rocky 9、Ubuntu 22.04+)上 eBPF,老机器上 auditd,两者并存不是妥协,是现实。
坑二:Verifier 会拒绝你的程序
写复杂逻辑时最常遇到的挫败:
libbpf: prog 'my_prog': BPF program load failed: Permission denied
...
R2 invalid mem access 'scalar'
原因通常是:栈超限(512 字节)、循环无上界、指针未校验就解引用。
解法:逻辑拆成多个程序、用 map 存中间状态、把复杂判断下放到用户态。
坑三:事件也会丢
这是最危险的坑,和 auditd 的 backlog 溢出是同一类问题。
ringbuf / perf buffer 满了会丢事件。而且默认不告警------你以为在审计,其实在漏。
必须监控丢弃计数,Tetragon 和 Falco 都暴露了相关 metrics:
promql
# 看丢事件速率
rate(tetragon_events_total{type="lost"}[5m]) > 0
这跟 auditd 的 -b 缓冲区是同一个道理:任何采集系统,丢事件都是静默的,必须主动监控。
坑四:eBPF 自己也是攻击面
这一条最少有人讲,但很重要。
既然 eBPF 能在内核里执行代码,那攻击者当然也能用它:
- 拿到
CAP_BPF或CAP_SYS_ADMIN后,加载恶意 eBPF 程序 - 用来隐藏进程、隐藏网络连接、劫持系统调用------也就是 eBPF rootkit
- 现实中已经出现利用 eBPF 的恶意软件
更现实的威胁是:攻击者可以直接卸载你的探针。
应对:
bash
# 1. 定期盘点内核里加载了哪些 eBPF 程序
bpftool prog list
bpftool prog show
# 2. 异常的程序(非本 Agent 加载的)要能发现
# 建议做成基线:正常状态有 N 个程序,多出来就告警
# 3. 开启内核 lockdown
# /sys/kernel/security/lockdown → 阻止未签名程序加载
# 4. 收紧权限:不要给业务容器 CAP_BPF / CAP_SYS_ADMIN
# K8s 里就是 securityContext.capabilities.drop: [ALL]
这恰恰说明了一个贯穿系列的道理 :没有哪一层是绝对安全的。内核层比应用层可靠,但内核层自己也有攻击面。所以永远要多源对账。
七、根本边界:内存马不产生 execve
这是整个系列的根本边界,必须在收官篇讲清楚。
前面四篇讲的所有采集源------bash_history、PROMPT_COMMAND、auditd、eBPF------盯的都是 execve 这个动作。
那么问题来了:
如果攻击根本不创建新进程呢?
Java 内存马就是典型:攻击者利用反序列化或注入漏洞,在已运行的 JVM 进程内 注册一个恶意 Filter 或 Controller。此后所有攻击行为都在这个进程内完成,一次 execve 都不产生。
| 采集源 | 能看见内存马吗 |
|---|---|
| bash_history | ❌ |
| PROMPT_COMMAND | ❌ |
| auditd (execve) | ❌ |
| eBPF (execve) | ❌ |
四层采集,全部失效。
这不是某一层的失败,是这条技术路线的边界------只要你的检测建立在"进程创建"这个信号上,绕过方式就是"不创建进程"。
所以命令审计永远不能是唯一的检测手段,必须配:
- 网络层:内存马再隐蔽也要通信,网络流量镜像 / NetFlow / DNS 日志
- Web 访问日志:内存马必然体现在异常的 URL 访问模式上
- 文件完整性:如果它落了文件
- 内存检测:JVM 内的 Filter/类扫描(比如专门的 Java 内存马检测工具)
- K8s API Server 审计日志:独立证据源,攻击者拿单个节点权限改不了
回到系列第三篇那句话:任何一个单一数据源都可能有盲区,交叉验证才有韧性。
这句话在收官篇得到了最终验证------连最强的内核层采集都有盲区。
八、选型决策表
什么时候用什么,一张表说清楚:
| 场景 | 选择 | 理由 |
|---|---|---|
| 内核 5.8+、容器化环境 | eBPF(Tetragon/Falco) | 性能好、容器元数据原生、可阻断 |
| 内核 3.10(CentOS 7) | auditd | eBPF 跑不了 |
| 等保测评要求 | auditd(可并存) | 测评师认它 |
| 轻量临时排查 | bpftrace | 一行命令,不用部署 |
| eBPF 装不了又想实时 | PROMPT_COMMAND | 降级兜底(见第二篇) |
| 老系统实在没招 | bash_history | 有胜于无 |
生产推荐组合:
内核层(eBPF 或 auditd)为主
+ PROMPT_COMMAND 走 syslog 独立通道做交叉验证(见第二篇)
+ 全部日志秒级外送到 WORM
注意第二行的价值 :它不提供新信息,但它提供独立通道。攻击者要同时干掉两条路径才能让日志干净,这显著提高了成本和暴露概率。
九、eBPF内核可编程采集一句话总括
"eBPF 的价值分两块。
第一是性能:它把过滤推进了内核。auditd 是内核全采、用户态再筛,不想要的事件也已经付过采集和传输的开销了;eBPF 在内核里就判断要不要,不要的直接丢弃。这是差一个数量级的根本原因。
第二是容器元数据 :它能在内核里通过
task_struct直接拿到 cgroup 和 PID namespace,事件产生时就富化出 Pod 名、命名空间、镜像名,不用像 auditd 那样事后反查------而容器销毁后反查就断链了。生产上我不自己写探针,用 Falco 或 Tetragon。Tetragon 还能挂 LSM hook 做内核态阻断,这个能力是应用层方案完全做不到的。
但它有边界:一是内核门槛,CentOS 7 的 3.10 用不了,所以 auditd 还得留着;二是eBPF 本身也是攻击面 ,拿到 CAP_BPF 就能加载恶意程序,所以要定期
bpftool prog list做基线盘点;三是最根本的边界------内存马不产生 execve,四层采集全部失效,必须靠网络层、Web 日志、内存检测补位。"
总结:四篇收官,三句话
-
四层采集的演进,本质是"记录者离攻击者越来越远"。 bash_history 在同一个进程里、PROMPT_COMMAND 在同一个 shell 里、auditd 在内核里、eBPF 在内核里且可编程。离得越远,越难被篡改;可编程,才能把判断逻辑也搬进去。
-
性能优势的根源是过滤位置,不是埋点位置。 auditd 和 eBPF 都埋在内核,但一个在用户态筛、一个在内核态筛------这一个位置的差别,决定了数量级的差距。
-
没有银弹,连内核层都有盲区。 内存马不产生 execve,四层采集全军覆没。所以命令审计的定位是纵深感的一部分,不是全景。
系列四篇的完整主线:
- 之一:能被伪造的字段,只能做线索(TTY)
- 之二:能被一条命令关掉的采集源,只能做线索(PROMPT_COMMAND)
- 之三:分层让线索升级为证据(auditd / auid)
- 之四:把判断逻辑本身也推进内核,但依然要承认边界(eBPF)
四句话合起来,是 Linux 命令审计完整的设计哲学:
从"记录发生了什么",到"让记录的过程独立于被审计者的意志",再到"接受自己看不见的部分,用别的手段去覆盖它"。
最后这半句才是成熟------知道自己看不见什么,比看见什么更重要。
附录:一分钟自查清单
选型
- 内核版本是多少?5.8+ 走 eBPF,3.10 走 auditd
- 有没有等保要求?(有就 auditd 得留着)
- 是否容器化?是则强烈倾向 eBPF
部署
- 用的是成熟项目(Falco/Tetragon/Tracee)还是自研?(建议前者)
- 探针是不是 DaemonSet 部署在每个节点?
- 有没有开启 BTF/CO-RE?(避免内核升级后失效)
可靠性
- 有没有监控丢事件计数?(ringbuf 满了会静默丢弃)
- 日志有没有秒级外送到 WORM?
- 有没有心跳断流检测?(Agent 被杀了要能发现)
自身安全
- 业务容器有没有 drop 掉
CAP_BPF/CAP_SYS_ADMIN? - 有没有做
bpftool prog list基线盘点?(发现非预期的 eBPF 程序) - 有没有考虑内核 lockdown?
边界认知
- 有没有意识到内存马不产生 execve?
- 有没有网络层、Web 访问日志、K8s apiserver 审计日志做补位?
- 有没有在授权靶场真打一遍? 包括:普通反弹 shell、
pty.spawn升级、unset PROMPT_COMMAND、auditctl -D、Java 内存马------看看哪些能抓到、哪些是盲区
最后这条最实在 :把这张清单里的攻击手法,在授权环境里全部打一遍,然后列一张表------哪些告警了、哪些没告警、没告警的靠什么补。这张表就是你这套体系的真实能力地图。