Linux命令执行监控:auditd、eBPF与进程审计实践 凌晨三点一台对外服务的机器上短暂出现过几个进程等天亮去看/proc里早就没影子了shell 的history也干干净净。这种场面但凡碰上一次就会明白一件事Linux 执行命令监控不是装个软件就完事的东西它取决于你把观测点放在哪一层以及你心里清楚这一层能看到什么、看不到什么。很多人一上来就找工具结果要么日志量爆炸把磁盘写满要么回头发现关键时刻一条记录都没有。这篇内容聊三件事命令在内核里到底走了哪条路、几个主流监控手段分别挂在哪、以及在生产环境里怎么组合着用才不翻车。适合负责机器审计、故障溯源、安全排查的运维和开发同学看新手能照着抄配置老手可以重点看后面几节关于性能开销和绕过场景的部分。1. 监控点选错了后面全是白忙活1.1 一条命令从敲下回车到真正跑起来中间隔着几道关很多人默认命令执行是一个原子事件实际上它是一串动作。用户在终端敲下curl -s http://example.com之后shell 先做一轮文本层面的处理别名展开、变量替换、通配符匹配、管道和重定向拆解。这一步做完shell 才判断这是内建命令还是外部程序cd、echo、export、test、pwd这类内建命令直接在当前 shell 进程里执行根本不会产生新进程任何基于新进程的监控都抓不到它们。外部命令走的是另一条路shell 调fork()复制自己子进程再调execve()把新的 ELF 映像加载进来。execve才是真正的分界线进入内核后走到fs/exec.c里的do_execveat_common内核在这个函数里做权限校验、打开可执行文件、解析 ELF 头、重建虚拟地址空间、把argv和envp从旧进程的栈上拷贝到新栈最后返回用户态从 ELF 的入口地址开始跑。监控手段基本都挂在这条链路上差别只是挂在入口、挂在出口还是挂在内核内部函数上。这里有个实操中特别容易踩到的细节带 shebang 的脚本会被执行两次。你跑./deploy.sh内核先为这个脚本本身触发一次execve发现文件头是#!/bin/bash于是内核内部再递归调一次execve去加载/bin/bash把脚本路径作为参数传进去。结果就是一次执行脚本的动作在审计日志里落下两条EXECVE记录第一条的exe是脚本路径第二条的exe是/bin/bash。而如果你写成bash deploy.sh就只会有一次。做日志统计和告警规则的时候如果不把这类重复考虑进去告警数量会凭空翻倍排查的时候能把人绕晕。另外execveat这个系统调用值得单独提一句它是后来加入的支持通过文件描述符加相对路径来执行fexecve、以及一些语言的posix_spawn封装最终都会落到它上面。只监控execve会漏掉这一类规则里两个都得写上。1.2 三个挂载层次决定了你能拿到多少信息把可用的观测点按深度排一下能力差异非常明显选型的时候对着这张表看能省很多时间。观测层次典型手段能拿到拿不到系统调用入口auditd 规则、eBPF 挂sys_enter_execve完整 argv、envp、父进程、登录用户、工作目录、成败需要自己做日志治理全量开启开销大内核内部函数kprobe 挂do_execveat_commonbprm结构里的完整信息包括路径解析前的原始参数内核版本变化时函数签名可能变可移植性差进程生命周期事件sched_process_exectracepoint、proc connector进程 PID、comm、已解析的可执行路径argv 拿不到此时映像已经换完了记账文件psacct / acct谁、什么时候、执行了哪个二进制参数完全看不到Shell 历史~/.bash_history交互式 shell 里敲过的文本可被随手绕过非交互执行完全不记从这张表能看出一个基本事实越靠近内核信息越完整但治理成本越高越靠近应用层部署越简单但可信度越低。真正要拿来做审计证据的只能是最上面两层下面几层适合做辅助定位不适合单独作为依据。还有一个经常被忽略的字段auidaudit uid。它是用户最初登录时由登录程序设置并一路继承下来的sudo提权之后uid变成 0、euid变成 0但auid还是原来那个普通用户。这个字段是判断这条命令到底是谁发起的的关键系统服务进程的auid通常是未设置状态值是4294967295也就是0xFFFFFFFF。审计规则里过滤掉这个值能直接甩掉一多半的系统噪音这一条后面还会细说。2. auditd内核审计框架里那条 execve 规则2.1 规则怎么写才对字段和过滤顺序是关键auditd 是内核审计子系统在用户态的门面它的优势是内核原生支持、不依赖额外模块、记录格式统一、能被集中采集缺点是全量开启时开销不客气。所有规则通过auditctl动态下发或者写进/etc/audit/rules.d/下的.rules文件再加载。最直接的一条全量监控规则长这样-a always,exit -F archb64 -S execve,execveat -k exec_cmd拆开看每个部分的意图。-a always,exit表示在系统调用退出时无条件记录之所以选退出而不是入口是因为退出时success、exit字段才有值能直接区分成功和失败。-F archb64限定 64 位调用约定如果机器上还会跑 32 位兼容程序得再补一条-F archb32否则那一部分会静默漏掉——这是我在实际环境里见过最多的漏记原因之一。-S指定系统调用名-k给规则打一个 key后面用ausearch -k就能把这条规则命中的所有事件捞出来。全量规则的问题在于吵。一个跑容器的宿主机光是execve一天就可能几百万条。实际场景里更常用的是带过滤的版本# 只记录真实登录用户发起的命令过滤系统服务 -a always,exit -F archb64 -S execve,execveat \ -F auid1000 -F auid!4294967295 -k user_exec # 盯住某个高风险二进制的每一次执行 -a always,exit -F archb64 -S execve \ -F exe/usr/bin/curl -k curl_exec # 只关心失败尝试比如权限被拒的执行 -a always,exit -F archb64 -S execve \ -F success0 -k exec_fail-F exe匹配的是/proc/pid/exe解析出来的绝对路径注意它不匹配argv[0]你写-F execurl是不生效的必须写全路径。这一点坑过不少人写错之后规则能加载、auditctl -l也看得见就是不触发查半天才发现是路径没写全。再有一个反直觉的地方-F auid1000这种范围过滤在execve规则上是有效的但很多人习惯性地想加-F path来限制目录而这个字段对execve规则没有意义——path是给文件访问监控用的。想按目录限制执行只能靠exe字段一个个列或者干脆在用户态日志处理阶段做过滤。2.2 一次执行在 audit.log 里到底是什么样规则生效后一次命令执行会在/var/log/audit/audit.log里落下一组记录用同一个事件 ID 关联。原始日志非常不友好ausearch -i能把大部分字段解析成人能看的形式。ausearch -i -k user_exec -ts today一条典型的记录展开后包含这几类typeSYSCALL ... syscall59 successyes ppid12034 pid12098 auid1001 uid1001 euid0 commcurl exe/usr/bin/curl keyuser_exec typeEXECVE ... argc3 a0curl a1-s a2http://example.com typeCWD ... cwd/home/operator/deploy typePATH ... item0 name/usr/bin/curl inode... mode0100755 typePROCTITLE ... proctitle6375726C002D730068747470...SYSCALL那条是骨架ppid能把父子关系串起来auid是真实发起人comm是进程名。EXECVE那条是拆开的参数数组argc是参数个数a0到an依次对应。CWD记录执行时的工作目录做溯源时这个字段价值极高很多问题只有结合在哪执行的才能还原出意图。PROCTITLE是把完整命令行用十六进制编码成一整串空格替换成\0ausearch -i会自动解码。需要注意的是参数长度有上限内核里单个参数超过一定字节数会被截断实际的限制跟内核版本和 audit buffer 有关。所以如果你要监控的命令经常带很长的参数比如 base64 编码的载荷原始日志里看到的可能是残缺的别急着怀疑规则写错了。参数里的非打印字符同样是十六进制形式用a12D2D66696C65这种样子出现aureport解析不出来就得自己转。我一般的做法是写一个小脚本把EXECVE段落里的十六进制字段批量解码拼成完整命令行再写进分析库。2.3 规则持久化、缓冲区调优和 immutable 模式auditctl下发的规则重启就没了持久化要写文件。主流发行版用的是augenrules它会按文件名排序把/etc/audit/rules.d/*.rules拼成最终的/etc/audit/audit.rules。# 写规则 cat /etc/audit/rules.d/50-exec-monitor.rules EOF -a always,exit -F archb64 -S execve,execveat -F auid1000 -F auid!4294967295 -k user_exec EOF # 加载并验证 augenrules --load auditctl -l加载完必须用auditctl -s看一眼状态重点盯lost这个计数。缓冲区不够的时候事件会直接在内核里被丢弃lost就开始涨日志里也会出现lostN的标记。这意味着监控存在盲区而且是最危险的那种——你不知道丢的是什么。auditctl -s # enabled 1 # failure 1 # pid 1024 # backlog_limit 8192 - 缓冲区上限 # lost 0 - 必须是 0 # backlog_wait_time 60000调大缓冲区的办法是-b参数单位是 4KB 块默认值偏保守。在execve高频的机器上把它调到8192甚至更高是常规操作。另一个参数是-r限制每秒写入的最大消息数超过就丢弃。这个参数是把双刃剑设了能防止日志把磁盘写爆但同时也在主动放弃记录观测要求高的场景不建议设。还有一个容易被忽略的开关immutable 模式。在规则文件最后加一行-e 2规则就被锁死运行期任何auditctl修改都会被拒绝必须重启才能改。这个设计的目的很明确——防止有人在入侵后顺手把审计规则清掉。# 放在所有规则的最后 -e 2代价是调整规则必须重启机器所以我的习惯是先在测试机上把规则跑稳确认 backlog 和日志量都正常了再加-e 2上线。生产环境上来就开 immutable后面想加一条规则要排窗口非常难受。3. eBPF 路线execsnoop 跑起来只要一条命令但坑在后面3.1 tracepoint 和 kprobe 两种挂法能力不一样eBPF 的吸引力在于不改内核、不重启、开销可控而且能拿到比 audit 更细的信息。挂execve有两条路。第一条是挂 tracepointbpftrace -e tracepoint:syscalls:sys_enter_execve { printf(%-6d %-16s %s\n, pid, comm, str(args-filename)); }tracepoint 是内核预埋的稳定探针接口不会随版本乱变可移植性最好。但sys_enter_execve的参数是原始系统调用入参args-argv是个指向用户态指针数组的地址要读里面的字符串得自己解引用。上面的写法只拿到了第一个参数也就是可执行文件路径拿不到后续参数。第二条路是挂 kprobe直接怼do_execveat_commonbpftrace -e kprobe:do_execveat_common { printf(%-6d %-16s\n, pid, comm); } 2/dev/nullkprobe 能看到的内核结构更完整linux_binprm里不但有filename还有已经解析好的参数信息。代价是函数签名在不同内核版本上可能变跨大版本部署时要逐个验证。真要拿完整命令行tracepoint 也能做到只是要手动遍历指针数组bpftrace -e tracepoint:syscalls:sys_enter_execve { printf(%d %s:, pid, comm); $argv (uint64 *)args-argv; unroll(8) { if ($i 8) { $p $argv[$i]; if ($p ! 0) { printf( %s, str($p)); } } $i; } printf(\n); }unroll是为了让 eBPF 校验器能确认循环有界最大展开次数要提前定死超出的参数会被丢掉。这个限制没法绕只能根据业务实际情况把数值调大一点。现成的工具里bcc 套件中的execsnoop是上手最快的execsnoop-bpfcc -t -U-t带时间戳-U打印 UID默认只显示被截断的comm。它的实现思路就是上面那套只是把argv拼接和格式美化都封装好了。想按用户过滤加-u 1001想按进程名过滤加-n nginx想看执行失败的加-x。sysdig 是另一个选择命令行风格不太一样适合已经用它做容器排查的团队sysdig -p%evt.time %user.uid %proc.pid %proc.name %proc.cmdline evt.typeexecve它的好处是输出模板灵活%proc.cmdline直接给出完整命令行配合-w还能把一段时间的原始事件落成文件事后用-r重放分析。代价是常驻运行需要额外资源不适合长期开着。3.2 容器里 PID 对不上是这类方案最常翻车的地方宿主机上开 eBPF 监控容器里跑的命令一样能看到但看到的是宿主机视角的 PID跟容器内ps显示的号码完全不是一回事。排查的时候拿着容器内的 PID 去宿主机日志里搜搜不到就会误以为没记录下来。解决办法有两个。一是在程序里顺手把 cgroup id 打出来事后按 cgroup 反查容器bpftrace -e tracepoint:syscalls:sys_enter_execve { printf(%d %d %s\n, pid, cgroup, str(args-filename)); }cgroup是 bpftrace 的内置变量输出的是当前进程所属 cgroup 的 inode 号。宿主机上ls -i /sys/fs/cgroup/...或者查看容器运行时目录能把 inode 和容器对应起来。第二个办法是读/proc/host_pid/cgroup里面的路径通常带着容器标识。写个后处理脚本把 eBPF 输出的 PID 逐个映射到容器 ID是最笨但最不容易错的方式。还需要知道一个结构性限制/proc和 eBPF 看到的都是宿主机命名空间下的视图容器内的pid命名空间做了映射两边对不上是设计如此。有些监控方案号称自动关联容器本质也是在后台做这层映射并不是魔法。另外eBPF 在容器密集的机器上还有个开销问题。每个execve都触发一次程序执行容器一多事件频率上去之后 CPU 占用会明显起来。我一般会在程序里加一层过滤只对特定 cgroup 或者特定comm做完整参数采集其余只计数不输出。3.3 权限和内核版本是绕不开的前置条件跑 eBPF 需要 root 或者一组 capabilityCAP_BPF、CAP_PERFMON、CAP_SYS_ADMIN具体哪个够用跟内核版本有关。新内核上CAP_BPF加CAP_PERFMON能覆盖大部分场景老内核基本还是得给 root。这就带来一个矛盾监控程序本身权限很高一旦被攻破反而是个大口子所以这类程序的部署最好收紧到单机、限定可执行文件权限、系统调用层面做白名单。内核版本方面4.9 以上基本能跑通基础功能但要享受 CO-RE编译一次到处运行的便利需要 5.2 以上并且开启 BTF。老内核上得针对每个版本编译对应的目标文件跨机器分发会麻烦不少。上线前务必在目标机器上验证# 是否支持 BTF ls /sys/kernel/btf/vmlinux # 内核版本和架构 uname -r uname -m/sys/kernel/btf/vmlinux不存在就意味着用不了 CO-RE工具链要提前适配别等到部署当天才发现。4. 进程记账与 /proc 轮询轻量方案的真实能力边界4.1 acct 只记二进制路径但它的开销低到可以忽略进程记账process accounting是内核里另一套独立机制配置项CONFIG_BSD_PROCESS_ACCT打开后每个进程退出时内核往指定文件追加一条固定长度的二进制记录。用户态工具accton负责开启sa、lastcomm、dump-acct负责读取。# 开启记账 accton /var/log/pacct # 查看最近执行的命令 lastcomm --user operator | head -20 # 按命令统计资源使用 sa -m它的记录内容是命令名、用户、时间戳、CPU 时间、退出状态。没有参数没有完整的绝对路径记录的是comm也就是 15 字符以内的进程名。这就是它的能力天花板别指望用它还原出curl到底请求了哪个地址。但低开销是它最大的价值。内核写一条定长记录几乎不影响性能长期开着完全没问题。我的用法是把它当作一条兜底线索当其他监控手段因为各种原因失效时记账文件里至少还能看到某个时间点有某个进程被执行过。事后配合其他日志交叉比对往往能补上关键一环。要注意记账文件本身是追加写的二进制文件会一直涨。得配一个定时轮转轮转前用dump-acct转成文本归档否则时间长了既占空间又不好读。另外accton的开启状态在重启后不保留得写进开机服务里。4.2 /proc 轮询为什么一定会漏有些人图省事写个脚本每秒扫一遍/proc下所有数字目录比对前后两次的差异新出现的 PID 就去读cmdline。这个方法在实验环境能跑在生产环境基本不可用原因有三。第一是时间窗口。脚本扫一轮可能就要几十毫秒到几百毫秒在这期间起来又退出的进程两次扫描之间就没了。ls、grep、cut这类短命命令大量工作都会漏掉。想靠提高扫描频率解决很快就撞上 CPU 占用和锁竞争的天花板。第二是读取时机。进程退出后/proc/pid立刻消失你在遍历过程中拿到的 PID 有可能已经不存在了open()直接返回ENOENT脚本得处理大量这种异常否则会频繁报错。第三是权限。/proc/pid/cmdline和environ读别人的进程往往需要 root通过hidepid挂载选项还能让普通用户完全看不到。监控程序必须以高权限运行又回到权限治理的老问题上。真正能用/proc做的是另一类事情针对存活时间长的进程做周期性快照比如每五分钟记录一次当前所有进程的完整命令行形成一个粗粒度的时间线。这种快照配合其他手段使用是有价值的但拿它当命令监控的主力一定会丢掉最关键的、最短命的那部分事件。顺带说一句sched_process_exec这个 tracepoint它挂在进程映像切换完成的时刻能拿到新进程的pid和comm。它比/proc轮询靠谱得多因为它是由内核事件驱动的不会漏。但它拿不到argv因为触发时参数已经从用户栈上重建完成了要拿参数得回到系统调用入口那一层。5. shell 历史记录为什么不能当审计证据5.1 五种随手就能绕过的写法每一种都很常见~/.bash_history看起来是最顺手的命令记录但它本质是个交互式 shell 的便利功能从头到尾没打算做成审计工具。下面这几种操作任何一个会用终端的人都能随手做出来# 1. 直接改输出目标 export HISTFILE/dev/null # 2. 临时关掉记录 set o history # 3. 命令前面加一个空格如果 HISTCONTROL 含 ignorespace 就不会记 export HISTFILE/dev/null # 4. 清空当前会话历史 history -c # 5. 不走交互式 shell ssh host some-command除了这些还有更隐蔽的情况bash 是退出时才把内存中的历史写回文件的进程被kill -9掉、终端意外断开、容器直接销毁这段历史就没了。很多线上事故排查时发现历史里缺了最后几条命令就是这个原因——机器是被强行关掉的。ssh host cmd这种非交互执行压根不经过readline历史文件里一个字符都不会留。这也是为什么很多异常操作在历史记录里完全看不到痕迹并不是有人刻意清理而是本来就没记。5.2 历史记录的正确用法定位线索不作结论把历史记录判死刑也不对它在排查里仍然有位置。我的用法是把它当成线索生成器先看历史里最近执行过什么形成几个假设再去 audit 日志或者 eBPF 数据里找对应的证据链。顺序不能反。如果确实想让它稍微靠谱一点有几个设置可以加固# 加时间戳不然一堆命令挤在一起根本看不出先后 export HISTTIMEFORMAT%F %T # 记录条数调大 export HISTSIZE100000 export HISTFILESIZE200000 # 多个终端追加而不是覆盖 shopt -s histappendHISTTIMEFORMAT这条最值得加没有时间戳的历史记录在溯源里价值接近于零一屏命令看不出哪条在前哪条在后还原不了操作顺序。histappend也很有必要默认行为是每个 shell 退出时覆盖整个文件开三个终端干活最后只剩最后一个的历史。但这些加固只是提高可用性改变不了可被主动关闭这个本质。任何需要作为证据的场景都不应该以它为准。6. 把这些手段串起来一份可落地的采集与告警编排6.1 先算开销再决定采多少在动手配规则之前我习惯先花十分钟摸一下这台机器的命令执行密度因为它直接决定了后面所有方案能不能扛住。# 粗略估算采样 60 秒内 execve 的触发次数 bpftrace -e tracepoint:syscalls:sys_enter_execve { count(); } interval:s:60 { print(); exit(); }拿到这个数字之后就好判断了。几百次每分钟的机器auditd 全量开着基本没感觉几万次每分钟的 CI 构建机或者容器宿主全量规则会在几分钟内把backlog打满lost一路往上涨。这类机器必须做过滤思路有这么几条按auid过滤-F auid1000 -F auid!4294967295把系统服务和守护进程全部甩掉这一刀通常能砍掉 70% 以上的量按exe定向只盯高风险二进制比如下载类、远程连接类、解释器类按success过滤只关心失败尝试时加-F success0量会小一个数量级分层采样核心机器全量边缘机器只记失败和特定命令过滤策略要和业务场景对齐不能一刀切。运维用的跳板机和跑批的计算节点关注点完全不同前者关心谁登录进来干了什么后者关心批处理任务调了哪些外部程序。6.2 日志完整性比日志量重要得多审计日志一旦被改被删整套监控就失去意义。有几个措施是必须做的。第一是远程转发。本机日志无论怎么保护root 权限总能清掉把日志实时推到另一台机器上才能保证事后可查。auditd 自带的audisp-remote插件就是干这个的配置在/etc/audisp/plugins.d/下把active改成yes填上远端地址就行。用通用日志采集器走 syslog 也可以效果类似。第二是轮转策略。/etc/audit/auditd.conf里的几个参数要按磁盘容量算清楚参数含义建议max_log_file单个日志文件大小上限MB按日增量估算留 2 倍余量num_logs保留几个轮转文件至少 5配合远端转发可减少max_log_file_action写满之后的动作keep_logs或rotate别用ignorespace_left剩余空间告警阈值MB按磁盘总量定一般留 5%space_left_action触发阈值后的动作先syslog告警别直接停服admin_space_left_action紧急阈值动作single进单用户模式保命max_log_file_action设成ignore是个很危险的配置磁盘写满之后 auditd 直接停止工作后面所有事件全部丢失而且表面上看起来一切正常。第三是规则锁定。前面提过的-e 2就是为此设计的加上之后运行期无法改规则只能重启。代价是灵活性适合规则已经稳定的生产机器。6.3 告警规则怎么写才不吵日志采上来了下一步是做告警。这里最容易犯的错是一上来就写一堆看到某命令就报警结果一天几百条很快就没人看了。我的一般做法是分三档。第一档是明确的高风险动作比如提权后立刻执行下载类程序或者从临时目录执行可执行文件——这类规则条件要写得具体宁可漏也别误报太多# 从 /tmp 或 /dev/shm 下执行二进制这在正常运维里极少出现 -a always,exit -F archb64 -S execve \ -F dir/tmp -F permx -F auid1000 -k exec_from_tmp注意这条用的是-F dir加-F permx这是文件访问监控的写法用来捕捉在某个目录下执行的行为跟前面的-F exe是两套不同的匹配方式。两者别混着用混着写规则加载会失败。第二档是行为基线偏离比如某个账号平时只跑几条固定命令突然开始大量调用网络工具。这类规则没法用 auditd 的原生语法表达得把日志导到分析侧做统计。auditd 的aureport能出一些粗粒度的汇总配合定时任务做阈值判断也能用# 每天统计一次各用户的命令执行次数超过阈值就告警 aureport --summary -k user_exec -ts yesterday -te now第三档是纯记录不告警只在需要排查的时候能查得到。大部分事件应该落在这一档比如日常的ls、cat、grep。想从这一档里挖出东西靠的是检索能力和时间范围收敛而不是事先定好规则。告警内容本身也有讲究。只写一句检测到敏感命令执行没有意义需要带上谁执行的auid、在哪台机器上、什么时间、完整命令行、父进程是谁。父进程这个字段尤其关键同样是执行curl父进程是cron还是交互式bash性质完全不同。把这几项拼进告警消息收到的人才有判断依据否则还得自己去查一堆日志。我自己踩过最深的一个坑是在容器宿主机上全量开execve审计规则上线当天磁盘就红了而且因为backlog被打满lost计数一路往上涨真正想看的那段时间反而一条记录都没有。后来改成按auid过滤加exe白名单日志量降到原来的十分之一backlog 稳定在 0才算能正常用。所以每次要在新环境开监控我现在都会先在旁路机器上跑一天看量再决定规则怎么写。