如何审计服务器上用户执行过的命令
Shell history 不是审计证据。本文比较 sudo 日志、sudo 会话录制、Shell hook 与 auditd execve 规则,并说明如何将日志传出服务器。
实际记录用户在服务器上执行命令的内容
要审计用户在服务器上执行过哪些命令,您需要一份用户无法修改的记录。Shell 历史记录不是这种记录。它只是一个便捷文件,由写入它的账户拥有;任何能够在该 Shell 中输入内容的人,都可以关闭或删除它。
有 4 层机制可以保留真实记录,但每层都有代价。sudo 会将每条命令写入 syslog。sudo I/O logging 会记录某个账户的完整会话。使用 PROMPT_COMMAND 等 Shell hook,可以记录交互式 bash 用户输入的内容。内核审计子系统会记录 execve 系统调用本身,因此只有这一层能够看到每个进程。本指南将逐层介绍这些机制,说明每层的覆盖边界,最后讨论决定这些记录是否有价值的关键:在被审计者能够访问这些记录之前,先将记录传出服务器。
开始前请注意。审计子系统由内核实现,因此无法在共享宿主机内核的容器中测试这些功能。请在内核由您控制的 KVM VPS 上执行这些命令。
为什么 shell history 不是审计记录
~/.bash_history 由于 4 个常见原因不能作为证据,而且这些原因都不需要攻击者具备高超技巧。
它属于用户。 文件权限为 600,所有者是该账户,因此 rm ~/.bash_history 完全不需要任何权限。用编辑器打开文件并删除其中关键的 20 行,同样不需要额外权限。
它在 shell 退出时写入。 会话以 kill -9 $$ 结束,或连接中断时,不会写入任何内容。在 exit 之前执行 history -c 也会产生相同效果,而且看起来就像什么都没有发生。
只需一个单词即可关闭。 unset HISTFILE 会阻止该会话写入文件。set +o history 会立即停止记录。HISTCONTROL=ignorespace 会隐藏所有以空格开头输入的命令。所有这些设置都位于 man bash 中,因为它本来就由用户控制。
它记录的是输入内容,而不是实际执行的内容。 使用别名或 shell 函数时,文件中的文本并不是内核实际执行的程序。
除非在写入条目时设置了 HISTTIMEFORMAT,否则记录中也没有时间戳,因为 bash 只有在该变量已设置时才会写入 #1755043200 标记行。
在共享登录账户中,它还无法告诉您执行者是谁。3 个人使用同一个 deploy 账户时,会在同一个 uid 下生成一个交错的记录文件。当 2 个人共享一个 uid 时,任何日志层都无法将操作归因于具体人员。这也是应当为每个人使用一个非特权账户,而不是共享登录账户的实际原因。
Shell history 真正的用途是帮助您重新输入昨天的命令,它在这方面很有用。可以将其作为线索。绝不能将其作为证据。
sudo 记录什么,以及记录在哪里停止
sudo 为它执行的每条命令向 authpriv syslog facility 写入一行记录。
sudo grep 'sudo:' /var/log/auth.log | tail -5
journalctl -t sudo -n 5每行都会记录用户、终端、工作目录、目标用户和命令:
sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/apt update如果 /var/log/auth.log 不存在,说明该镜像未安装 rsyslog;相同记录只存在于 journal 中。依赖 journal 前,先确认它不是易失的:
journalctl --list-boots如果只列出当前启动,说明 /var/log/journal 不存在,因此 journal 位于 /run 中,每次重启后所有记录都会丢失。将其设为持久化:
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald下面说明记录边界。sudo 会记录它被要求执行的命令,但不会记录该命令随后执行的操作。因此,记录链会在这里中断:
sudo -i日志只会为该 shell 生成一条记录。在这个 root shell 中输入的每条命令对 sudo 都不可见,因为 sudo 已不在执行路径中。sudo su -、sudo bash 和 sudo vim /etc/shadow 后接 :!bash,结果都一样。允许执行带有 shell escape 的任意程序(例如 vim 或 find)的 sudoers 规则,实际上就是授予未记录的 root 权限。在信任某个账户的日志记录前,应先确认它实际能够访问哪些内容:
sudo -l -U alice记录一个账户的完整会话
首先确认您使用的是哪种 sudo,因为 Rust 重写版本不提供此功能:
sudo --version | head -1如果输出中包含 sudo-rs,请跳过本节并使用审计子系统。Ubuntu 针对 25.10 和 26.04 版本的官方文档将 I/O 日志记录和 sudoreplay 列为不支持的功能;截至 2026 年 8 月,这一情况仍未改变。这一点很重要,因为 sudo-rs 是这些版本中的默认 sudo,升级可能会移除您原以为可用的控制项。在规划基于 sudo 的日志记录前,建议阅读sudo-rs 行为变更完整列表。
对于仍随 Ubuntu 24.04 LTS 发布的原始 sudo,可以为一个账户启用 I/O 日志记录:
sudo visudo -f /etc/sudoers.d/iologDefaults:deploy log_output
Defaults!/usr/bin/sudoreplay !log_output请使用 visudo,不要使用编辑器,因为如果文件无法解析,它会拒绝保存。损坏的 sudoers 文件会导致所有人都无法使用 sudo。然后重放会话:
sudo sudoreplay -l user=deploy
sudo sudoreplay 000001sudoreplay -l 会列出会话及其 ID;如果 log_output 从未应用于该用户,则不输出任何内容。代价是:终端中传输的每个字节都会存储在 /var/log/sudo-io 下,因此输出详细的会话会占用大量空间。第二行 sudoers 配置会阻止重放操作再次被记录。真正的风险在于机密信息,因为 I/O 日志会保存输入和输出的所有内容,包括在会话内的提示符中输入的密码,因此必须按照密码存储库的安全级别进行保护。覆盖范围也很有限。它只能记录通过 sudo 运行的命令。用户登录后完全以自身账户操作时,不会被记录。
Shell 钩子,以及它们被绕过的确切方式
常见的“记录每条命令”方案,是将 PROMPT_COMMAND 钩子写入 /etc/profile.d/:
# /etc/profile.d/00-cmdlog.sh
PROMPT_COMMAND='logger -p local6.info -t cmdlog "$(whoami) $$ $(history 1 | sed "s/^ *[0-9]* *//")"'bash 会在显示每个提示符前运行 PROMPT_COMMAND,因此命令行在输入时就会写入 syslog,而不是等到 shell 退出时才记录;logger 则通过系统日志守护进程写入日志,因此不会受到用户自身文件权限的影响。打开一个新的登录 shell,然后使用 sudo tail -f /var/log/syslog 检查;如果系统镜像没有 rsyslog,则使用 journalctl -t cmdlog -f。
随后它会停止工作,具体有以下 5 种方式,每种都可以在 1 分钟内复现。
- 非交互式 shell 不会显示提示符。
ssh you@server 'id'执行命令后返回,不会记录任何内容,因为PROMPT_COMMAND从未执行。 - 它是一个变量。
unset PROMPT_COMMAND可以在本次会话的剩余时间内禁用它,而且不需要任何权限。 - 登录 shell 会读取该文件。
bash --noprofile --norc根本不会加载/etc/profile.d/。 - 它仅适用于 bash。
zsh、sh、python3 -c 'import os; os.system("id")'和:!id在vim中运行的程序,都不会被 bash 提示符钩子捕获。 - 它记录的是输入的命令行,因此别名或函数仍会隐藏实际执行的命令。
可以将 shell 钩子作为便捷功能使用。对于配合操作的用户,它可以回答“我上周二运行了什么”。不要让检查清单将其当作控制措施。
The kernel audit subsystem sees every execve
The Linux audit subsystem, driven by the auditd daemon, is the only layer here that a user cannot step around, because the record is made inside the kernel at the moment the syscall runs. If a process executes a program, there is an event. The shell, the language and the presence of a terminal make no difference.
sudo apt update && sudo apt install -y auditd audispd-plugins
sudo systemctl enable --now auditd
sudo auditctl -sauditctl -s prints the daemon state. enabled 1 with a non-zero pid means it is running, and lost 0 means no records have been dropped yet. Remember that lost counter, it comes back later.
auid is the field that makes audit worth the trouble. PAM sets a login uid when a session starts, and the kernel carries it on every child process from then on. Check yours:
cat /proc/self/loginuidAn interactive SSH session prints your uid, because /etc/pam.d/sshd includes pam_loginuid.so. A value of 4294967295 means the loginuid was never set, which is normal for a process started by a system daemon at boot. The important part is that sudo -i does not change it: a root shell opened by alice still carries auid 1000, so every command inside it is attributable to alice. That is exactly the gap sudo leaves open. Changing a loginuid once set needs CAP_AUDIT_CONTROL, which ordinary users do not have, and sudo auditctl --loginuid-immutable closes it for root as well until the next reboot.
Check that /etc/pam.d/sshd, /etc/pam.d/login and /etc/pam.d/cron each include pam_loginuid.so, or events will arrive with nobody attached to them. That is the same file list you touch when hardening SSH access on a VPS, so do the two jobs together.
auditd 的起始规则集
规则位于 /etc/audit/rules.d/*.rules。augenrules 会按文件名顺序将这些规则拼接成一个列表,顺序会决定行为,因为内核会在找到第一个匹配规则后停止处理。添加规则前,先读取该目录中已有的内容,因为后续文件中的 -D 会清除之前加载的全部规则。
ls /etc/audit/rules.d/
cat /etc/audit/rules.d/audit.rules然后写入 /etc/audit/rules.d/50-exec.rules:
## Suppressions first: the kernel takes the first matching rule.
-a never,exit -F arch=b64 -S execve -F exe=/usr/bin/dpkg
-a never,exit -F arch=b64 -S execve -F exe=/usr/bin/dpkg-deb
-a never,exit -F arch=b64 -S execve -F exe=/usr/bin/dpkg-query
-a never,exit -F arch=b64 -S execve -F exe=/usr/bin/dpkg-split
-a never,exit -F arch=b64 -S execve -F exe=/usr/bin/dpkg-trigger
## Every program started by a logged-in human.
-a always,exit -F arch=b64 -S execve,execveat -F auid>=1000 -F auid!=unset -k exec
-a always,exit -F arch=b32 -S execve,execveat -F auid>=1000 -F auid!=unset -k exec
## Changes to who may become root.
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
## Changes to the audit configuration itself.
-w /etc/audit/ -p wa -k auditconfig加载规则并确认:
sudo augenrules --load
sudo auditctl -lauditctl -l 将规则打印回来,表示规则已生效。No rules 表示加载失败,journalctl -u auditd -n 20 会指出解析器拒绝的文件和行号。较旧版本的 audit userspace 不理解 unset 关键字。如果加载器报告该字段存在问题,请改用 -F auid!=4294967295,它表示相同的值,只是写成完整形式。
现在读取事件:
sudo ausearch -k exec -ts recent -i | tail -40
sudo ausearch -ul 1000 -ts today -i
sudo aureport -k --summary -i-i 会将 uid 和系统调用编号转换为名称,实际使用时不能省略。-ts recent 覆盖最近十分钟。每次执行都会生成一组记录:SYSCALL 记录包含 uid、auid、退出状态和 key,EXECVE 记录包含完整的参数列表,此外还有 CWD 和 PATH 记录提供上下文信息。
这里有一个容易误解的限制。audit 记录系统调用,而 shell 内建命令本身不会发起系统调用。cd /root 不会运行任何程序。在 bash 提示符中输入 echo evil >> /etc/passwd 也不会运行任何程序,因为 echo 和重定向都在已经运行的 shell 进程内完成。因此,execve 规则可以看到程序,-w 规则可以看到写入操作。单独使用其中一种规则都不够。
最后锁定配置:
## /etc/audit/rules.d/99-finalize.rules
-e 2-e 2 会使规则集保持不可变,直到下一次重启。加载后,auditctl -s 会报告 enabled 2;任何添加或删除规则的尝试都会因 Operation not permitted 而失败,包括 root 执行的操作。将此文件放在最后,并且每次修改规则时都要重启系统。这种取舍正是其意义所在:任何人都能悄悄关闭的规则集不能作为证据。
没人读取的审计日志只是合规材料
auditd 的故障并不是遗漏事件,而是记录了太多内容,导致始终没人查看。最后,日志的存在只是为了满足检查清单,而不是为了回答问题。
在调整配置前,先在自己的主机上完成以下计算:
sudo aureport -k --summary -i
sudo du -sh /var/log/audit单个 sudo apt upgrade 会运行数千个短生命周期进程,而每个进程都会携带您的 auid。因此,一次软件包更新产生的记录量可能超过一周的人工输入量。这就是上面的抑制规则要指定 dpkg 及其辅助程序的原因。应按可执行文件进行抑制,不要按用户进行抑制:针对 /usr/bin/dpkg 的排除规则可以用一句话说明其范围,而针对某个账户的排除规则,其范围恰好与您要捕获的对象一样宽。
每条规则中的 -k key 决定了一个月后能否检索日志。ausearch -k sudoers 是一个有明确答案的问题。没有过滤条件的 ausearch 只会产生一堵文本墙,让您逐渐停止阅读。如果您的日志收集器需要 JSON 而不是原生格式,laurel 是一个 auditd 插件,可将每个事件重写为一个 JSON 对象,并解码其中的参数。它与其他插件一样注册在 /etc/audit/plugins.d/ 中,auditd 会在 sudo pkill -HUP auditd 上加载插件变更。
auditd 的实际开销
每个匹配的系统调用都会生成一条记录,由内核格式化后交给用户空间处理。开销主要体现在两个方面。这两项都应在您自己的工作负载上测量,而不是根据他人公布的数值估算。
- CPU 和延迟。频繁 fork 的机器、构建主机或 CI runner 会为每次 exec 生成一条记录。内核 backlog 填满后,
--backlog_wait_time会暂停生成事件的进程,直到有可用空间。因此,audit 的影响可能表现为构建变慢,而不是 CPU 百分比升高。在实际负载下,监控sudo auditctl -s中的backlog和lost。lost持续升高表示记录被丢弃。日志出现无提示的缺口比完全没有日志更糟,因为您仍可能信任这份日志。 - 磁盘。读取
/etc/audit/auditd.conf,并明确决定磁盘已满时应采取什么措施,因为软件自带的默认值只是预设选择。max_log_file、num_logs和max_log_file_action控制日志轮转。space_left_action、admin_space_left_action和disk_full_action控制紧急情况的处理方式。其中某些操作(包括halt和single)会使机器停止运行,而不是丢弃记录。
/etc/audit/rules.d/audit.rules 中的 -f 行表示内核级别的同一决策:-f 1 会将 audit 故障报告给 syslog,-f 2 会使内核 panic。只有在您确实宁可丢失服务器也不愿丢失一条记录时,才选择 2。对于运行着他人依赖的服务的 VPS,应改用日志轮转,并将存储问题移出这台服务器。
将日志实时发送到服务器外
事故报告反复证明了这一点。留在遭入侵主机上的日志可以被入侵者修改。root 可以重写 /var/log/auth.log、删除 /var/log/audit/audit.log,还可以停止守护进程。-e 2 可防止卸载规则,但无法保护 rm。上层的每一层机制,只有在副本先离开这台机器后,才能生成证据。
Audit 自带的传输机制是来自 audispd-plugins 的 audisp-remote 插件。在 /etc/audit/plugins.d/au-remote.conf 中启用它:
active = yes
direction = out
path = /usr/sbin/audisp-remote
type = always
format = string重新加载前,先根据 command -v audisp-remote 检查 path,因为路径错误时,除了在 journal 中留下一行记录外,不会产生任何结果。在 /etc/audit/audisp-remote.conf 中设置 remote_server 和 port,并在收集器自己的 auditd.conf 中设置 tcp_listen_port = 60。使用 sudo pkill -HUP auditd 重新加载。在许多镜像中,systemctl restart auditd 会被拒绝,因为单元文件设置了 RefuseManualStop=yes,因此通过该信号传输更可靠。
另一种方式是将 audit 加入现有的 syslog 流。/etc/audit/plugins.d/syslog.conf 随 active = no 一起提供。将其设置为 yes,重新加载后,audit 事件就会与 sudo 日志及其他日志一起发送。然后通过 TLS(传输层安全)使用 rsyslog 转发全部日志;这需要安装 rsyslog-gnutls 软件包:
# /etc/rsyslog.d/60-forward.conf
*.* action(type="omfwd"
target="logs.example.net" port="6514" protocol="tcp"
StreamDriver="gtls" StreamDriverMode="1"
StreamDriverAuthMode="x509/name"
StreamDriverPermittedPeers="logs.example.net"
action.resumeRetryCount="-1"
queue.type="linkedList" queue.filename="fwd" queue.saveOnShutdown="on")队列设置是关键部分。action.resumeRetryCount="-1" 会无限重试;启用 queue.saveOnShutdown="on" 的磁盘辅助队列后,收集器不可达期间,记录会保存在本地,收集器恢复后再发送。如果缺少这两项,收集器重启会在证据中留下空缺,而且没有任何信息告诉你空缺已经存在。使用 sudo systemctl restart rsyslog 应用配置,然后在信任整个方案前,确认记录确实已到达收集器。
还需要完成一个闭环:收集器必须是一台受审计人员无法登录的机器。如果同一个管理员组在日志服务器上拥有 root 权限,你只是复制了文件,并没有保护文件。应使用独立凭据、独立密钥,最好还使用独立的服务商账户。这与在需要之前就建立集中管理多台 Linux 服务器的方式是同一个道理;当你正在处理遭入侵的 VPS时,这也是第一小时能否发挥作用的关键。
确认普通用户无法改写记录
不要想当然地接受这一结论。使用普通账户且不通过 sudo 进行验证:
echo test >> /var/log/auth.log
cat /var/log/audit/audit.log
auditctl -D
id -nG应依次得到以下结果:Permission denied,因为 auth.log 的所有者是 syslog,所属组为 adm,权限模式为 640;再次得到 Permission denied,因为审计日志的权限模式为 600,且所有者是 root;命令拒绝执行,因为更改审计规则需要 CAP_AUDIT_CONTROL;以及一个既不包含 adm、也不包含 systemd-journal 的组列表。
最后一项检查最容易被忽略。加入 adm 后可以读取 /var/log/auth.log,加入 systemd-journal 后可以读取整个 journal。这两种组成员身份都不授予写入权限,因此都无法篡改记录。但二者都会允许用户读取此服务器上的每一条身份验证记录。是否授予这种访问权限应当有明确决策,而不是直接复制论坛答案中的 usermod -aG 行。
最后,确认以下两项设置在重启后仍然有效:
sudo auditctl -s
systemctl is-enabled auditdenabled 2 表示规则集会一直锁定到下一次启动。第二条命令中的 enabled 表示 auditd 会在该次启动后再次运行。只持续到下一次内核更新的规则集,也不能算作审计跟踪。
FAQ
如何查看某个用户执行过的所有命令?
使用 id -u alice 找到该用户的 uid,然后按登录 uid 搜索 audit 日志:sudo ausearch -ul 1000 -ts today -i。添加 -k exec 可将范围限制为 execve 规则。登录 uid 在用户登录时设置,并会在 su 和 sudo -i 中保持不变,因此可以捕获该账户打开的 root shell 中执行的命令。此方法只适用于规则加载后执行的命令,因为 audit 不会保留未配置记录的事件历史。如果想先了解数据分布,可使用 sudo aureport -k --summary -i 查看各规则的计数。
用户可以删除 bash 历史记录来隐藏执行过的命令吗?
可以,而且不需要任何特权。~/.bash_history 归该用户所有,权限为 600,因此用户可以编辑、截断或删除它。用户还可以使用 unset HISTFILE 停止写入历史记录,使用 set +o history 停止记录当前会话,或者在设置 HISTCONTROL=ignorespace 后为命令添加前导空格,以隐藏单条命令。Bash 会在 shell 退出时写入该文件,因此使用 kill -9 $$ 终止的会话不会记录任何内容。应将 shell 历史记录视为线索,而不是证据。
sudo 会记录 sudo -i 内发生的操作吗?
不会。sudo 只记录它收到的命令,因此 sudo -i 只会为 shell 生成一行记录,之后的内容不会记录。用户在该 root shell 中输入的每条命令对 sudo 都不可见,因为后续操作不再经过 sudo。sudo su -、sudo bash 以及任何允许使用 shell escape 的程序也有相同问题。可以通过两种方式弥补这一缺口:为 execve 配置 audit 规则,记录每个程序并附带原始登录 uid;以及配置 sudoers 规则,避免直接授予 shell。
auditd 会降低服务器性能吗?
这完全取决于工作负载启动的进程数量,因此应进行测量,不要直接套用某个数值。主要处理请求的服务器很少执行 exec,通常不会察觉性能变化。构建主机或 CI runner 会持续执行 exec,可能明显受到影响,因为内核 audit backlog 填满后,生成事件的进程会暂停,直到有空间可用。请在实际负载下运行 sudo auditctl -s,并监控 backlog 和 lost。任何大于零的 lost 都表示有记录被丢弃,这是最严重的结果,因为日志现在存在无法观察的缺口。
audit 日志应存储在哪里?
应存储在另一台机器上,延迟应控制在数秒内。任何取得被审计主机 root 权限的人都可以删除 /var/log/audit/audit.log 并改写 /var/log/auth.log,因此本地副本只能回答无人试图隐藏的事件。使用 audisp-remote plugin 将日志转发到中央 auditd,或者启用 audit syslog plugin,再通过 TLS 使用 rsyslog 转发完整的 syslog 流。为收集器配置独立凭据,并确保被审计账户无权访问该收集器。