☰
Linux命令执行监控:shell钩子、auditd与eBPF全链路解析
2026/10/26 3:35:10 网站建设 项目流程

1. 命令监控这件事,到底在解决什么问题

线上机器被登录、被敲了几个命令、跑了什么脚本、谁在什么时间动了哪个目录,这些信息在事故复盘的时候价值极高,但绝大多数团队都是事后才想起来要。我自己第一次被要求“把命令执行记录做起来”,是因为一台跑批任务的机器半夜磁盘被打满,登录账号是个共用账号,五个人用同一个口令,日志里只有 sudo 和 sshd 的登录记录,没人承认自己干了什么。那次之后我才认真去把 Linux 上“命令执行监控”这条链路从头到尾捋了一遍,包括 shell 层钩子、内核审计、eBPF 探针这几套完全不同量级的方案,以及 auditd、bpftrace、acct 这些工具各自适合什么场合。

所谓Linux 执行命令监控,说白了就是在一条命令真正落地执行的瞬间,把“谁、在哪个终端、哪个目录、以什么身份、敲了什么、什么时候敲的”这六个要素抓下来落盘。注意这里的“执行”是个很关键的分界线:用户敲进去的字符流(键盘输入)和最终内核真正 fork+exec 出来的进程,中间隔了 shell 解析、别名展开、变量替换、管道拆分、子 shell 派生等一堆环节,两者往往并不等价。你要监控的是哪一层,直接决定了方案的复杂度、性能和可信度。

这套东西适合谁看?我大概分三类。第一类是运维和 SRE,需要给生产机器加一层可回溯的操作记录,出事能定位到具体命令;第二类是安全审计方向的同学,需要满足内部合规要求,能证明“特权操作有记录、可追查”;第三类是开发或者折腾爱好者,想搞清楚 shell 和内核之间到底发生了什么,顺便做个小工具记录自己一天敲了什么。三类的深度需求差很远,但底层原理是同一套,所以我打算把原理、实操、工具选型、踩坑这四块全都摊开讲,你按自己的需要取用。

先给一个结论性的判断,省得你看到最后才发现方向选错:如果你要的是“可信、防篡改、能作为审计证据”,走内核层的 auditd 或 eBPF;如果你要的是“贴近人的操作语义、能看清复合命令和管道”,走 shell 层钩子;两者不是二选一,成熟的做法是组合,各管一段。

1.1 几个绕不开的典型场景

场景一,故障归因。服务突然 502,上去一看是配置文件被人改了。这时候你需要回答:谁改的、什么时候改的、改之前是什么内容。光有文件 mtime 不够,因为 mtime 可以被 touch 伪造,而且 mtime 不告诉你改的人是谁。

场景二,权限滥用排查。某个普通账号被加了 sudo 权限,理论上只允许重启服务,结果有人拿它去读别的目录。sudo 日志能记录“执行了 sudo 某命令”,但记录粒度取决于配置,默认的 sudoers log_output 是不开的,很多细节抓不到。

场景三,共用账号的责任划分。这是最恶心的。团队为了省事共用 root 或者共用某个运维账号,出问题的时候日志里全是同一个 uid。这时候唯一能把“人”和“命令”对上的线索,就是登录会话(session)和终端(tty/pts),所以监控方案必须把这两项带上,否则记录等于白记。

场景四,入侵后的行为重建。机器被拿下了,攻击者做了什么、下载了什么、横向到哪台机器。这时候你需要的不是“命令文本”,而是“进程执行链”——父进程是谁、子进程是谁、可执行文件的绝对路径是什么。这也是为什么纯 shell 钩子方案在应急场景下不够用,因为攻击者可以直接起一个不带钩子的 shell。

1.2 粒度选择:为什么不能一上来就全量采集

很多人第一反应是“那就全录呗”。我在一台跑编译任务的构建机上试过全量 execve 审计,结果是灾难性的:单机每秒 execve 峰值能到三四千次(make -j 展开的编译调用、脚本里的各种小工具调用),auditd 的日志一天写了接近 40 GB,磁盘告警直接把我叫醒,而且 auditd 自身的缓冲区被打满后开始丢事件,auditctl -s里lost计数一路涨,等于花了资源还没拿到完整数据。

所以粒度选择是这套方案里第一个要做的决策,我的经验是先回答三个问题:哪些主机必须全量?哪些主机只需要记录特权操作?日志保留多久?生产数据库和跳板机可以全量,构建机、CI runner 这种高频短命进程的机器,就该用白名单或者只在特权路径上开规则。这个判断做在前面,后面能省掉一大半的运维痛苦。

2. 一条命令在内核里到底经过了什么

要理解监控手段为什么长这个样子,得先把命令执行的路径走一遍。这个路径理解清楚了,你就知道每个工具是在哪个“卡口”上装摄像头。

2.1 从键盘敲下回车到进程诞生

用户敲字符,终端驱动把字符送到伪终端(pty)的主端,sshd 从主端读到数据交给从端,从端的对端是一个 shell 进程(bash/zsh/dash)。shell 读到一行文本后做词法分析、语法分析、别名和变量展开、通配符展开、重定向处理,然后判断这是内置命令还是外部命令。内置命令(cd、echo、export 这些)不产生新进程,直接在 shell 进程内执行;外部命令则走 fork 出一个子进程,子进程里调用 execve 把可执行文件加载进内存,从此这个进程的映像就换成了新程序。

关键点来了:execve 这个系统调用是所有外部命令执行的必经之路,绕过不了。不管是 shell 敲的、脚本调的、cron 拉起的、还是被注入的恶意进程,只要想跑一个新程序,就得过这道门。这就是为什么内核层的监控方案都盯着 execve,它是唯一一个语义明确、覆盖面完整的卡口。

而 shell 的内置命令不走 execve,这就是 shell 层钩子方案的独特价值——cd /etc/xxx这种操作在 execve 层面是看不到的,但它往往比跑一个程序更能说明意图。两套方案互补的原因就在这。

2.2 五个可观测点位的对比

我把 Linux 上能拿到命令执行信息的点位大致分了五层,从上到下越来越靠近内核,可信度递增,但语义信息递减。

层级典型实现能拿到什么主要短板
应用/终端层script、tlog 录制完整终端输入输出,含交互界面文件大,检索难,需解析
shell 层HISTFILE、PROMPT_COMMAND、trap DEBUG用户敲的原始命令行文本可被绕过、可被篡改
会话层pam_tty_audit、wtmp/btmp、last登录会话与 tty 对应关系只有会话,没有命令
系统调用层auditd、eBPF 探针execve 参数、进程链、返回值日志量大,需治理
进程记账层BSD process accounting(acct)已退出进程的汇总记录实时性差,参数信息少

这张表是我自己排的,实际用的时候你会发现它们之间不是替代关系。比如你要回答“是谁在什么时候删了文件”,最理想的拼装方式是:shell 层给你命令行文本,会话层给你 tty 和登录用户,系统调用层给你进程链和实际执行的可执行文件路径。三份数据用一个 session id 或者 tty 名关联起来。

提示:做任何方案之前先确认你的系统上$XDG_SESSION_ID、/proc/self/sessionid、wtmp 里的 ut_line 这几项是不是都能拿到,能不能互相关联。这是整个链路的粘合剂,粘不上后面全白干。

3. 方案一:shell 层钩子,最快能跑起来的做法

先说最容易落地的。这个方案的思路很朴素:既然用户敲的命令最先被 shell 拿到,那就在 shell 里埋个钩子,把每行命令记下来。

3.1 三个钩子点的选择与差异

bash 里能用来捕获命令的地方主要有三个,各有各的坑。

HISTFILE + HISTTIMEFORMAT是最原始的,也就是命令历史。默认历史不带时间戳,开启HISTTIMEFORMAT之后会写成#1700000000这样的时间标记。问题是历史文件是用户可写的,用户history -c一下就清了,而且并发多终端时会互相覆盖。不推荐作为审计依据,但作为辅助线索还行。

PROMPT_COMMAND是一个在每次显示提示符前执行的变量,可以塞一段函数进去。它执行时机是在上一条命令执行完毕、提示符渲染之前,此时history 1里就是刚才执行的那条命令。好处是它是 bash 内置机制,不需要特殊编译选项;坏处是多行命令、&&串联的命令在 history 里的形态不一定和实际执行的一致。

trap DEBUG是在每条简单命令执行前触发的,能拿到$BASH_COMMAND,它反映的是当前即将执行的这条简单命令,对管道和&&会分别触发多次。粒度更细,但噪声也更大,而且函数内部的每条命令也会触发,不做过滤会刷屏。

我实际用的是 PROMPT_COMMAND 为主、trap DEBUG 为辅的组合:PROMPT_COMMAND 负责记录用户完整输入的原始行(作为“人敲了什么”的证据),trap DEBUG 用来补内置命令和函数内部的细节。

3.2 一份可以直接抄的落地方案

先看记录函数。核心是不要用 echo 追加到文件(并发会交错),而是走logger交给系统日志,由 syslog 统一串行化写盘,天然解决了多进程竞争问题。

# 放在 /etc/profile.d/cmd_audit.sh 里,全局生效 # 只对交互式 shell 生效,避免脚本被记录 [ -z "$PS1" ] && return # 历史记录增强 export HISTTIMEFORMAT='%F %T ' export HISTSIZE=20000 export HISTFILESIZE=50000 shopt -s histappend shopt -s cmdhist __audit_last_cmd="" __audit_log_cmd() { local cur # 取 history 里最新一条,去掉行号前缀 cur=$(HISTTIMEFORMAT='' history 1 2>/dev/null | sed 's/^ *[0-9]* *//') [ -z "$cur" ] && return [ "$cur" = "$__audit_last_cmd" ] && return __audit_last_cmd="$cur" local tty_name sess pwd_now tty_name=$(tty 2>/dev/null || echo "no-tty") sess="${SSH_CONNECTION:-local}" pwd_now="$PWD" # 用 logger 投递到 syslog 的 local6 设施 logger -p local6.info -t cmd_audit \ "user=${USER} uid=${UID} tty=${tty_name} ssh=(${sess}) pwd=${pwd_now} cmd=${cur}" } # 只在交互式提示符前触发 case "$PROMPT_COMMAND" in *__audit_log_cmd*) ;; *) PROMPT_COMMAND="__audit_log_cmd;${PROMPT_COMMAND}" ;; esac

几点说明,都是我踩过之后加上去的。第一,[ -z "$PS1" ]这个判断必须有,否则所有非交互脚本都会加载这段逻辑,把echo、test这类内部调用全刷进日志。第二,__audit_last_cmd去重是必要的,因为有些场景(比如按回车空行、或者Ctrl+C中断)PROMPT_COMMAND 会被多次触发,不去重会产生大量重复记录。第三,history 1在设置了HISTTIMEFORMAT时会带上时间前缀,所以要临时清空这个变量再取,否则记下来的命令前面挂着一串时间戳。

然后是 syslog 侧。现在主流发行版都用 rsyslog,加一个规则文件即可:

# /etc/rsyslog.d/60-cmd-audit.conf local6.* /var/log/cmd_audit.log # 顺手再转发一份到中心日志,防止本地被删 local6.* @@10.0.0.1:514 & stop

& stop这行很重要,它的意思是命中这条规则后不再往下匹配,避免同一条记录又被写进/var/log/messages造成重复。日志轮转配一下:

# /etc/logrotate.d/cmd-audit /var/log/cmd_audit.log { daily rotate 180 compress delaycompress missingok notifempty create 0600 root adm postrotate /usr/bin/systemctl reload rsyslog >/dev/null 2>&1 || true endscript }

保留 180 天、压缩、权限 0600 root:adm,这三条是我认为的最低要求。日志本身也是敏感数据,里面可能包含密码参数(比如有人在命令行里直接写mysql -p密码),权限不收紧等于自己开了个后门。

3.3 这个方案的边界,得说清楚

shell 钩子的最大问题是不可信。用户有无数种方式绕过:起一个bash --norc --noprofile、直接unset PROMPT_COMMAND、用env -i /bin/sh、用 Python 的subprocess起进程、甚至exec /bin/sh换掉当前 shell。更不用说vi里执行:!command、find -exec、awk的system()这些旁路。

我在内网做过一次小测试,让几位同事尝试“在不留记录的情况下执行一条命令”,十分钟内就收集到了七八种可行方法。所以这个方案的定位要摆正:它是行为记录,不是安全审计证据。它的价值在于覆盖了内置命令、语义完整、检索方便,适合做日常运维的“操作日志”和事后回忆的线索。合规场景下它不能作为唯一手段。

另外还有个细节:日志里记录的命令文本长度要注意截断。有人会写一个几百行的 heredoc 直接贴在命令行里,如果不做长度限制,一条日志能写几兆,把 rsyslog 队列打满。我的做法是超过 4096 字符截断并标记truncated。

4. 方案二:auditd 内核审计,可信度最高的一档

如果你的需求里出现了“合规”“证据”“不可篡改”这些词,那就得上内核层的方案了。Linux 自带的 audit 子系统是这件事的标准答案,它工作在系统调用入口,在进程真正执行之前就把参数抓下来了,用户态完全无法绕过。

4.1 execve 规则怎么写才不炸

audit 的规则语法不算友好,但 execve 这条规则其实很固定:

# 监控 64 位程序执行 -a always,exit -F arch=b64 -S execve -F auid>=1000 -F auid!=4294967295 -k cmd_exec # 32 位兼容库上的程序也要覆盖 -a always,exit -F arch=b32 -S execve -F auid>=1000 -F auid!=4294967295 -k cmd_exec

逐段解释,因为每个字段都有人问过。

-a always,exit表示在系统调用退出时总是记录一条。用exit而不是entry的原因是这样能拿到返回值,区分执行成功还是失败(比如权限不足、文件不存在),这对排查很有用。

-F arch=b64是按 CPU 架构过滤。现代 x86_64 机器上,64 位程序走 b64,但装了 32 位兼容库的老程序(一些遗留脚本工具)走 b32,两条都写才完整。如果只写一条,你会在排查时发现某些命令“神秘消失”,实际上是走了另一个架构的系统调用号。

-S execve是指定系统调用名。

-F auid>=1000和-F auid!=4294967295是过滤非登录会话。auid是审计用户 ID,也就是登录时的原始用户 ID,即使后面sudo su变成了 root,auid 依然是原来的用户,这是它比 uid 更有价值的地方。4294967295是(uid_t)-1,表示“没有登录会话的进程”,比如系统启动阶段的服务进程。这两个条件组合起来的好处是只记录有真实登录来源的操作,能砍掉至少一半的噪声。

-k cmd_exec是给规则打个 key,方便后续ausearch -k cmd_exec检索。key 的命名要统一,我一般用模块_动作的格式,比如cmd_exec、file_write、priv_escalate。

注意:规则要持久化必须写进/etc/audit/rules.d/下的.rules文件,直接用auditctl -a添加的规则重启就没了。而且改动之前建议先auditctl -l > /tmp/audit.rules.bak备份,我见过有人手滑把整份规则清空导致审计断了三天没人发现。

4.2 一条日志长什么样,怎么读

执行一次ls -l /tmp之后,/var/log/audit/audit.log里会多出这么一组记录,一次 execve 通常产生三到四条,靠同一个msg=audit(时间戳:序号)关联:

type=SYSCALL msg=audit(1700000000.123:4567): arch=c000003e syscall=59 success=yes exit=0 a0=55d3... a1=55d3... a2=55d3... a3=8 items=2 ppid=1234 pid=5678 auid=1000 uid=1000 gid=1000 euid=1000 tty=pts0 ses=12 comm="ls" exe="/usr/bin/ls" key="cmd_exec" type=EXECVE msg=audit(1700000000.123:4567): argc=2 a0="ls" a1="-l" type=CWD msg=audit(1700000000.123:4567): cwd="/home/dev" type=PATH msg=audit(1700000000.123:4567): item=0 name="/usr/bin/ls" inode=... dev=fd:00 mode=0100755

几个字段值得单独拎出来说。auid是登录时的用户 ID,这是全链路追踪的主键;ses是会话 ID,同一个登录会话下所有命令的 ses 相同,把同一个 tty 上的操作串成一条线就靠它;tty=pts0是终端名,和who、last输出里的能对上;comm是进程名,注意它可以被prctl(PR_SET_NAME)改,所以不能全信;exe才是可执行文件的绝对路径,这个相对可靠;argc和a0、a1……是参数,但请注意audit 默认只记录前若干个参数,长命令的参数会被省略,而且当命令行很长时内核会拆分记录。

检索的时候,ausearch比 grep 好用得多:

# 查今天的 execve 记录,把 uid 转成用户名 ausearch -k cmd_exec -ts today -i | head -50 # 查某个会话 ID 下的所有命令 ausearch -k cmd_exec -a 4567 -i # 查某个用户在某台机器上的操作轨迹 ausearch -k cmd_exec -ua 1000 -ts recent -i

-i参数会把数字形式的 uid、gid、syscall 号翻译成人能看懂的字符串,不加的话全是数字,读起来很痛苦。aureport适合做汇总:

# 按可执行文件统计执行次数 aureport -x --summary # 查看最近失败的 execve(执行失败,比如权限不够) aureport -x --failed -ts yesterday

4.3 性能开销与容量,得算一笔账

很多人开 audit 之前不估算,开了之后被磁盘告警和 CPU 波动教育。我给一个我实际用过的估算方法。

先测峰值频率。在目标机器上跑一个短周期的采样:

# 10 秒内统计新建进程数,近似 execve 频率 timeout 10 sh -c 'while :; do cat /proc/loadavg > /dev/null; done' & sleep 1 grep -c '' /proc/self/status > /dev/null

更靠谱的写法是直接用已有审计数据反推,或者用一个简单的循环统计数据:

# 用 pidstat 观察每秒新进程数,连续采样 5 次 pidstat -w -p ALL 1 5 | awk '$0 ~ /UID/ || $4 > 0'

假设观测到的峰值是每秒 50 次 execve(这是普通业务机的量级),一次 execve 产生 3.5 条记录,平均每条 400 字节,那么:

日增 = 50 次/秒 × 86400 秒 × 3.5 条 × 400 字节 = 50 × 86400 × 1400 字节 ≈ 6.05 GB/天

压缩之后(audit 日志文本 compress 比大约 8:1 到 10:1)大约 600 MB 到 750 MB 每天。要留 90 天,就得准备 55 GB 到 68 GB。这个量级对大部分机器不算夸张,但构建机、容器宿主机这类每秒几百次 execve 的机器要乘以十,那就必须做过滤了。

性能方面,audit 的开销主要来自规则匹配和日志写入。规则越具体(带 uid、exe 过滤),匹配成本越低。我做过对比,全量 execve(不加 auid 过滤)的机器在高负载时 CPU 的 sys 占比会上升 2% 到 5%,加上 auid 和 exe 过滤之后基本可以忽略。

4.4 让 audit 只盯关键动作

全量记录的替代做法是“精准打击”。下面这几个规则是我在多数生产机上都会配的,覆盖面窄但信息密度高:

# 特权提权行为 -a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -k priv_exec # 关键配置文件被写 -w /etc/passwd -p wa -k passwd_write -w /etc/sudoers -p wa -k sudoers_write -w /etc/ssh/sshd_config -p wa -k sshd_conf # 敏感目录被删除或改名 -w /var/log -p wd -k log_tamper # 挂载、卸载操作 -a always,exit -F arch=b64 -S mount -S umount2 -k mount_op # 内核模块加载 -a always,exit -F arch=b64 -S init_module -S finit_module -k kernel_mod

-p wa里的w是写,a是属性变更(比如 chmod、chown);-p wd里的d是删除。这几个权限位组合使用能覆盖大部分篡改行为。相比全量 execve,这套规则的日志量通常只有十分之一甚至更低,但抓的都是要害。

5. 方案三:eBPF,实时性和灵活性的新选择

audit 靠谱,但它的日志格式对程序化处理不够友好,而且调整规则要改配置文件重载。近些年更受欢迎的是用 eBPF 在内核里挂钩子,抓 execve 事件,直接输出成结构化的数据。

5.1 先用 bpftrace 跑通整条链路

bpftrace 是做原型的利器,一行命令就能看到效果:

# 追踪所有 execve 调用,打印进程名、PID 和程序路径 bpftrace -e ' tracepoint:syscalls:sys_enter_execve { printf("%-16s %-8d %s\n", comm, pid, str(args->filename)); }'

跑起来之后在另一个终端随便执行几条命令,你能实时看到输出。tracepoint:syscalls:sys_enter_execve是内核里已经预置好的稳定探针点,不依赖内核符号,兼容性好。

另一个更实用的探针是sched_process_exec,它在进程映像替换完成的时刻触发,拿到的信息更完整:

bpftrace -e ' tracepoint:sched:sched_process_exec { printf("time=%llu user=%s pid=%d ppid=%d comm=%s file=%s\n", nsecs, username, pid, curtask->real_parent->pid, comm, str(args->filename)); }'

username是 bpftrace 内置变量,能把 uid 转成用户名。注意在容器里跑的时候,username拿的是宿主机的用户映射,可能和容器内的不一致,这个坑后面会讲。

如果要落盘成和 audit 类似的日志,可以在 bpftrace 里做格式化输出重定向:

bpftrace -e ' tracepoint:sched:sched_process_exec { printf("%s|%d|%d|%s|%s|%s\n", strftime("%Y-%m-%dT%H:%M:%S", nsecs), uid, pid, comm, str(args->filename), cgroup); }' >> /var/log/exec_trace.log

单看这一条命令不够工程化,实际部署一般会写成 systemd 服务,配上日志轮转,或者干脆用专门的事件采集组件把数据送到中心。

5.2 自己写一个最小的 execve 追踪器

bpftrace 适合验证和临时排查,长期跑还是用 libbpf + C 或者 BCC 更稳。核心逻辑其实很简单:挂一个 tracepoint,把结构体里的字段拷到用户态,用户态程序收到事件之后写日志。

// 关键部分示意,省略了许可证声明和 map 定义 SEC("tracepoint/sched/sched_process_exec") int handle_exec(struct trace_event_raw_sched_process_exec *ctx) { struct event_t ev = {}; ev.pid = bpf_get_current_pid_tgid() >> 32; ev.uid = bpf_get_current_uid_gid() & 0xffffffff; ev.ts = bpf_ktime_get_ns(); bpf_get_current_comm(&ev.comm, sizeof(ev.comm)); // 从 tracepoint 上下文里取文件名偏移 unsigned int fname_off = ctx->__data_loc_filename & 0xFFFF; bpf_probe_read_str(&ev.filename, sizeof(ev.filename), (void *)ctx + fname_off); bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &ev, sizeof(ev)); return 0; }

编译这段需要内核头文件支持(linux-headers包),挂载时要用bpf()系统调用或者 libbpf 的bpf_program__attach。如果你不想碰 C,退一步用 BCC 的 Python 接口也能快速验证:

from bcc import BPF prog = r""" TRACEPOINT_PROBE(sched, sched_process_exec) { bpf_trace_printk("exec: %s pid=%d\\n", args->filename, bpf_get_current_pid_tgid() >> 32); return 0; } """ b = BPF(text=prog) b.trace_print()

bpf_trace_printk只是调试用的,生产环境要用 perf buffer 或者 ring buffer,否则性能会很难看。这个区别新手经常忽略,结果跑起来发现机器负载飙升。

5.3 容器环境下的坑,比想象中多

这套方案在容器宿主机上会遇到两个必须解决的问题。

一是权限。eBPF 程序挂载需要CAP_BPF和CAP_PERFMON(新内核)或者CAP_SYS_ADMIN(老内核),在容器里跑采集器必须显式给这些能力,或者直接在宿主机上以特权方式运行。/sys/kernel/debug/tracing和/sys/fs/bpf也要挂进容器。

二是命名空间映射。eBPF 拿到的 pid 是宿主机的 pid,容器内的进程看到的 pid 是命名空间里的 pid,两者对不上。要拿到容器 ID,得读 cgroup 路径:

# 查看某个进程属于哪个 cgroup,从中提取容器 ID cat /proc/<pid>/cgroup | grep -o '[0-9a-f]\{64\}'

或者在新版本内核上用bpf_get_current_cgroup_id()直接拿 cgroup id,再和crictl或容器运行时索引里的 id 对应。这一步不做的话,你的日志里全是宿主机视角的 pid,排查的时候还得手工换算,效率极低。

6. 工具横向对比与组合建议

6.1 常见工具的能力矩阵

工具层级实时性能否看到参数部署成本适合场景
shell 钩子用户态实时完整原始命令行极低日常操作记录、内置命令
auditd内核 syscall准实时有,长命令会截断低合规审计、证据留存
bpftrace内核 tracepoint实时有中临时排查、原型验证
自研 eBPF 采集器内核 tracepoint实时有高规模化集中采集
process accounting内核进程退出后无参数低资源消耗统计
pam_tty_audit终端层实时按键级记录中会话完整回放
script / tlog终端层实时完整输入输出极低操作录像、教学演示

process accounting 这个老工具值得单独提一句,配置起来很简单:

# 开启进程记账 systemctl enable --now psacct # Debian 系 systemctl enable --now acct # RHEL 系 # 查看最近的命令汇总 lastcomm | head -20 # 按用户统计 sa -u

它的优点是开销极小、记录所有进程退出、留存时间长;缺点是不记录命令行参数,且是进程退出后才落盘,对于长时间运行的进程只能等它结束。它的定位是“资源使用审计”,不是“命令内容审计”,别指望用它回答“谁敲了什么”。

6.2 我在生产上的实际组合

说了这么多工具,具体怎么搭。我目前的配置分三档。

跳板机 / 特权入口机:全量。auditd 开全量 execve(带 auid 过滤),加 shell 钩子记录原始文本,加 pam_tty_audit 记录按键流,日志实时转发到中心节点。这三层任何一层被绕过,另外两层还能兜住。这类机器数量少,值得投入。

普通业务机:精准。auditd 只开关键规则(特权执行、敏感文件写、挂载、模块加载),shell 钩子按需开在内网运维账号上,进程记账常开作为兜底。日志量可控,保留 90 天。

构建机 / CI runner:几乎不开 execve 审计。这类机器进程创建频率太高,收益比太差。只保留进程记账和容器层的事件采集,靠应用侧的日志定位问题。

这个分层不是拍脑袋定的,是挨过几次磁盘告警和日志丢事件之后调整出来的。我的经验是:先按“日志量 ÷ 单机磁盘配额”算一遍,算出承受不起就降档,别硬上。

7. 踩坑实录与排查速查表

7.1 几个我真实遇到过的问题

日志里 auid 全是 4294967295。第一次配 auditd 的时候,发现所有记录都没有有效的 auid,全是-1。查了半天才明白,是因为登录方式的问题:某些场景下(比如直接用su而没有经过 PAM 的完整会话初始化),审计会话 ID 没有被正确设置。解决办法是确保 PAM 配置里有pam_loginuid.so,并且在 sshd 配置里不要关掉它。这个文件的位置在/etc/pam.d/sshd和/etc/pam.d/login,两处都要检查。

命令参数只记录了一半。audit 的 EXECVE 记录里参数数量有上限,当命令行特别长(比如一个巨长的curl带一堆 header)时,参数会被截断或者拆成多条type=EXECVE记录,其中包含a2[0]="..."这样的数组形式。这种情况要写解析脚本把多条合并,不能用简单的awk取第一行。

时间戳对不上。audit 日志里的时间戳是 Unix 秒,ausearch -i会转成本地时区。跨时区机器做统一分析的时候对不齐,我的处理方式是采集端统一按 UTC 存储,展示层再转换。吃过一次血的教训:事故复盘时把两条来自不同时区机器的日志按本地时间拼在一起,结论完全反了。

容器里跑 bpftrace 看不到容器内的进程。前面提过,pid 命名空间的问题。表现是明明容器里在跑命令,宿主机上的 bpftrace 只看到runc、containerd-shim这些。原因是要在宿主机的 pid 命名空间里运行 bpftrace,而不是在容器里运行。如果必须在容器里跑,就要挂载宿主机的/proc并且用 hostPID。

改了 audit 规则之后丢了三个月的日志。这件事最疼。当时改了/etc/audit/rules.d/下的文件,重载之后发现新规则里-D(删除所有规则)写在了前面,后面某条规则语法有误被跳过,结果相当于只执行了清空。auditd 不会因为单条规则失败就整体报错退出,它是静默跳过的。所以改规则之后必须用auditctl -l核对实际生效的规则列表,以及在测试机上先跑一遍。

日志被自己的轮转脚本删了。logrotate配置里漏了postrotate的 reload,导致轮转之后 auditd 还在往已经重命名的旧文件句柄写,磁盘空间不释放。这个用lsof | grep deleted能看出来,整改方式就是在 postrotate 里加systemctl reload auditd。

7.2 排查速查表

现象可能原因排查方法处理
没有 execve 记录规则未持久化或被清空auditctl -l查看生效规则修正 rules.d 文件并重载
auid 显示 -1PAM 会话未建立cat /proc/<pid>/loginuid检查 pam_loginuid 配置
日志量超预期规则过宽或高频进程aureport -x --summary看 top加 uid/exe 过滤
丢事件缓冲区满auditctl -s看 lost 计数调大 backlog,加过滤
磁盘不释放句柄未释放lsof +L1查 deleted 文件配 postrotate reload
bpftrace 无输出内核无对应 tracepointls /sys/kernel/debug/tracing/events/sched换探针或升级内核
容器内进程看不到pid 命名空间隔离宿主机侧运行采集器用 hostPID 或宿主部署
命令参数缺失参数超长被截断查多条 EXECVE 记录合并写解析脚本处理
时间对不上时区不一致timedatectl核对统一 UTC 存储

提示:auditctl -s这个命令要养成习惯,它输出的lost字段一旦非零,说明你丢失了审计事件,这时候的日志在合规场景下是不合格的。日常巡检把它加进监控项。

8. 会话级回放:把“命令”还原成“操作现场”

前面所有方案都是在“命令”这个粒度上做记录,但有些场景光有命令列表不够。比如要复现一次误操作,你想知道他在vim里到底改了什么、在top里看到了什么、交互式界面里点了哪里。这时候需要的是会话级录制。

script命令是最简单的入口:

# 开始录制当前会话,输出到文件 script -t 2> /var/log/sessions/timing.log /var/log/sessions/session.log # 回放,按原始速度还原 scriptreplay /var/log/sessions/timing.log /var/log/sessions/session.log

-t参数把时间信息单独写到 timing 文件,回放的时候才能还原真实的输入节奏。不用的 timing 也能回放,只是所有内容一瞬间刷出来,看不出操作顺序。

工程化的做法是用 tlog 这类专门工具,配合 SSSD 的会话记录功能,在用户登录时自动开启录制,退出时自动关闭并上传。核心配置是在 PAM 里挂一个pam_exec或者用 SSSD 的session_recording选项,把录制范围限定到特定用户组。

会话录制有两个必须提前想清楚的边界。第一是隐私。录的是全部终端输入输出,包括用户可能输入的密码(虽然终端有回显关闭,但有些程序不做)。所以录制策略必须明确告知,且只对特权账号开启。第二是存储。一个小时的交互式会话录像,文本加上控制字符,密级不同大小差异很大,从几十 KB 到几 MB 都有可能。按人均每天 2 小时算,一百人的团队一年能攒出相当可观的量,得做分级保留——比如最近 30 天全留,之后只留标记为异常会话的录像。

把会话录制和命令审计关联起来的关键是会话 ID。audit里的ses字段、wtmp里的会话记录、录制文件名里的时间戳,这三者要能对上。我的做法是在登录脚本里生成一个会话 ID(比如时间戳-用户名-随机串),写进环境变量,录制文件名和日志记录里都带上,事后用一个 ID 就能把三层数据串起来。

9. 几个容易被忽略的实操细节

最后补一些零散但很实用的点,都是踩过才知道的。

关于 sudo 的坑。sudo执行命令时,审计日志里的auid是原用户,euid是 0,这个组合能精确识别“哪个用户提权执行了什么”。但如果/etc/sudoers里配了!syslog,或者 sudo 版本较老,部分信息会缺失。另外sudo -i起的新 shell 会改变会话层级,ses字段可能会变,做关联分析时要用auid + 时间窗口而不是死抠ses唯一。

关于 history 的加固。如果要用 history 做辅助证据,加上这几个设置能提高一点可信度:chattr +a ~/.bash_history让文件只能追加不能删除,HISTIGNORE和HISTCONTROL不要设置成忽略敏感命令(很多人为了“安全”把含密码的命令忽略掉,结果审计时什么都查不到,这是反向操作)。

关于日志本身的保护。审计日志如果和业务日志放一起,被入侵者一把火烧了就是全丢。至少要做到异地转发。我通常的做法是本地磁盘留 7 天做快速检索,中心节点留 180 天做归档,转发走 TCP 加队列(rsyslog 的action.resumeRetryCount和磁盘队列),避免网络抖动时丢日志。

关于传统工具的价值。last、lastb、who、w这几个命令虽然老,但在排查时非常好用。last -i能看到登录来源 IP,lastb看失败的登录尝试,配合审计日志能快速圈定时间窗口。别因为有了 eBPF 就看不上这些基础工具。

关于误报治理。上线初期一定会遇到“这条记录为什么会有”的问题。我的处理方式是维护一份白名单,按exe路径加参数模式过滤已知的良性高频调用(比如监控 Agent 自己拉起的采集命令、包管理器的钩子脚本)。白名单要放在规则层而不是解析层,减少无效日志的产生,比事后过滤划算得多。

我个人的体会是,命令监控这件事的价值不在“技术有多炫”,而在“出事的时候能不能在三分钟内给出答案”。我见过太多团队花大力气上了全套工具,结果日志散在七八个地方,真出事了还是靠猜。方案可以做得很复杂,但检索路径必须足够短——一个会话 ID、一个时间窗口、一个用户名,就能把三层的记录拉齐,这才算真的落地了。另外一点,先想清楚“要回答哪些问题”再去选工具,比反过来要省一半力气。比如你只需要回答“谁改了这个文件”,那-w文件监控规则加 shell 钩子就够了,不必上全量 execve。工具是为问题服务的,这个顺序千万别搞反。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询