实时 ≠ 可靠:为什么 PROMPT_COMMAND 挡不住一行 unset
------命令审计系列之二:应用层采集的"实时幻觉"与它的正确定位
系列前作:《TTY 为空的命令,为什么最危险?》------ 讲了"能被伪造的字段只能做线索,不能被伪造的字段才能做证据"。这篇讲的是同一个内核的另一面:一个看起来解决了实时性的方案,其实只是把绕过成本从"关机"降到了"一行命令"。
一次"日志正常"的失陷
某公司上了命令审计,方案是 PROMPT_COMMAND + rsyslog 外送。上线后效果不错------运维敲的每一条命令都实时出现在 SIEM 里,团队挺满意。
半年后一次应急响应发现:某台机器被人拿下了,攻击者执行过几十条命令。但审计平台里,那台机器的日志在入侵前一天就停了,之后一直是空白。
为什么没告警?因为大家查的是"告警有没有命中",而不是"日志还在不来"。
事后取证发现两件事:
- 攻击者登录后第一件事是
unset PROMPT_COMMAND - 环境变量配置文件没被动过------所以运维事后检查时看到配置"还在",以为一切正常
真正阴的地方在第 2 点:攻击者根本没必要改你的配置文件,他只要在自己那个 shell 里 unset 一下。配置文件还在,日志早就停了。
这篇文章讲的就是这件事:PROMPT_COMMAND 是什么样的机制、它确实解决了什么问题、以及它为什么在真正的对抗里不堪一击。
一、它是什么:卡在提示符前的那个缝隙
PROMPT_COMMAND 是 bash 的一个环境变量。每次 bash 显示命令提示符($)之前,会先执行它里面存的命令。
一次交互式 bash 的循环是这样的:
显示提示符 $ → 等你敲命令 → 执行命令
↓
【执行 PROMPT_COMMAND】
↓
显示提示符 $ → ...
它卡在**"上一条命令执行完"和"下一条提示符出现"**之间。
这个位置很巧妙------此时上一条命令已经进了 bash 内存里的 history 列表,用 history 1 就能取到。这就是它能做实时审计的全部原理。
在终端里试一下:
bash
$ export PROMPT_COMMAND='echo "--- 上一条命令执行完了 ---"'
$ ls
file1.txt file2.txt
--- 上一条命令执行完了 ---
$ whoami
root
--- 上一条命令执行完了 ---
每敲一条命令,它就触发一次。
二、经典写法,逐段拆解
流传最广的版本长这样:
bash
export PROMPT_COMMAND='RETRN_VAL=$?; logger -p local1.notice "$(whoami) [$$] $(tty) [$(pwd)] $(history 1 | sed "s/^[ ]*[0-9]*[ ]*//") [$RETRN_VAL]"'
拆开看每一段在干什么:
| 片段 | 作用 |
|---|---|
RETRN_VAL=$? |
先存下上一条命令的退出码 |
logger -p local1.notice |
写 syslog,facility=local1,级别=notice |
$(whoami) |
当前用户名 |
$$ |
当前 bash 的 PID(注意:不是刚执行完那条命令的 PID) |
$(tty) |
终端------对,它能拿到 TTY(后面第十节细说) |
$(pwd) |
当前工作目录 |
| `history 1 | sed ...` |
[$RETRN_VAL] |
上一条命令的退出码 |
⚠️ 关键细节:RETRN_VAL=$? 必须放在最前面。
因为 $? 只保留最近一条命令 的退出码。一旦你先执行了 logger 或任何别的命令,$? 就被覆盖成那个命令的退出码了。这是初学者最常写错的地方------写成了 ... [$?] 放在末尾,结果每一条记录的退出码永远是 0。
配合 rsyslog 外送:
bash
# /etc/rsyslog.d/bash-audit.conf
local1.notice @@log-server.example.com:514
@@ 是 TCP(单 @ 是 UDP)。日志产生后立刻外送。
bash 5.1 起 ,
PROMPT_COMMAND支持数组形式,可以挂多个命令而不互相覆盖:
bashPROMPT_COMMAND+=('my_audit_func')这在多套工具共存时很有用,避免后配置的覆盖先配置的。
三、它确实解决了一个真问题
在说它的缺陷之前,先公允地讲讲它的价值。
相比 bash_history,PROMPT_COMMAND 解决了一个致命痛点:实时性。
| bash_history | PROMPT_COMMAND | |
|---|---|---|
| 落盘时机 | bash 正常退出时 | 每条命令执行完立刻 |
| 会话被 kill | 全部丢失 | 已执行的都发出去了 |
攻击者 rm ~/.bash_history |
日志没了 | 已外送的删不掉 |
bash_history 有个很荒谬的失效场景:攻击者 kill -9 掉自己的 shell,或者机器突然断电------这次会话敲的所有命令一条都不留。
而 PROMPT_COMMAND 在命令执行完的瞬间就写 syslog 外送了,攻击者事后清 ~/.bash_history 没用。
这就是"实时"的价值,也是它让人产生安全感的地方。
但请注意:它解决的是"日志留不留得下 ",不是"日志看不看得见"。
后面三节讲的就是后者。
四、致命缺陷一:只在交互式 bash 里触发
PROMPT_COMMAND 的触发前提是------bash 要显示提示符。
非交互式场景下,bash 根本不显示提示符,它就压根不执行:
bash
# 脚本里执行
$ cat test.sh
#!/bin/bash
whoami
id
$ bash test.sh # ← PROMPT_COMMAND 不触发
# -c 方式执行
$ bash -c "whoami" # ← 不触发
# Web 进程调用
php-fpm → sh -c "id" # ← 不触发
这跟 bash_history 是完全一样的盲区。
回到系列前作那个场景:攻击者通过 Web 漏洞打进来,载荷由 php-fpm 直接 system() 执行,从头到尾没有交互式 bash------PROMPT_COMMAND 一条都记不到。
结论:它依然是应用层采集,看不见不经过 bash 的程序执行。
五、致命缺陷二:一行 unset,且不留痕迹
这是最致命的一点,也是开头那个案例的根因。
bash
unset PROMPT_COMMAND # 没了
export PROMPT_COMMAND="" # 也没了
成本是一行命令,且没有任何技术门槛。
更麻烦的是这个特性:
unset这个动作本身,不会被记录下来。
因为记录日志的机制就是 PROMPT_COMMAND,你把它 unset 掉的那一刻,这个 unset 命令自己不会被记录(它执行完之后,已经没有 PROMPT_COMMAND 去执行了)。
审计机制被关闭的过程,审计机制自己记录不到。 这是所有应用层自审计方案的固有悖论。
对比一下内核层:auditctl -D 清规则会被 auditd 记下一条 AUDIT_CONFIG_CHANGE------因为记录者(内核)和执行者不在同一个层级。这是分层带来的根本差异。
六、致命缺陷三:比 unset 更阴的"改写"
真正老练的做法不是删掉,而是换成空操作:
bash
export PROMPT_COMMAND='true'
或者:
bash
export PROMPT_COMMAND='echo ""'
为什么这比 unset 更阴?
- 运维日常巡检时
echo $PROMPT_COMMAND------ 有值,看起来正常 - 配置文件没被动过 ------ 看起来正常
- 日志早就不产生了
unset 是让变量消失,'true' 是让变量还在但什么都不做。前者容易发现,后者几乎不可能在例行检查中发现。
这一类手法逼出了一个设计原则:
永远不要检查"配置在不在",要检查"日志还在不来"。
配置是静态的、可被伪装的状态;日志流是动态的、无法伪装的行为。这就是为什么反复强调心跳断流检测------它是唯一能抓住这类手法的手段。
七、readonly 也挡不住
有人会说:那我用 readonly 锁住它?
bash
readonly PROMPT_COMMAND='...审计命令...'
readonly 之后,unset 和重新赋值都会报错:
bash
$ unset PROMPT_COMMAND
bash: unset: PROMPT_COMMAND: cannot unset: readonly variable
看起来锁住了?没有。两个绕过:
绕过一:开一个新 shell
bash
$ exec bash # readonly 属性不会继承到新进程
$ unset PROMPT_COMMAND # 成功
readonly 是当前 shell 的属性,环境变量导出的是"值",不是"只读属性"。新起的 bash 进程只继承了值,只读标记丢失。
绕过二:直接跑一个不带配置的 shell
bash
$ bash --norc --noprofile # 不加载任何配置文件
--norc 不读 ~/.bashrc,--noprofile 不读 /etc/profile。你的全局配置根本没机会加载。
结论:readonly 只防手滑,不防攻击者。 它防的是"运维不小心改了",不是"攻击者故意绕"。
八、trap DEBUG:更实时的兄弟,同样脆弱
讲 PROMPT_COMMAND 就必须提它的兄弟------trap ... DEBUG。
bash
trap 'logger -p local1.notice "$(whoami) [$(tty)] $BASH_COMMAND"' DEBUG
区别在触发时机:
| 触发时机 | 能拿到 | |
|---|---|---|
PROMPT_COMMAND |
命令执行后 | history 1、退出码 |
trap DEBUG |
命令执行前 | $BASH_COMMAND(原始命令) |
trap DEBUG 有个 PROMPT_COMMAND 做不到的优势:命令还没执行就先记下来了。
这在两种场景下是决定性的:
- 命令把机器搞挂了 ------
rm -rf /、kill -9自己、触发内核 panic。PROMPT_COMMAND 永远等不到"执行完",日志就没了;trap DEBUG 在执行前已经记下了 - 网络被断掉 ------ 攻击者第一步先切断外送通道。PROMPT_COMMAND 的日志发不出去,trap DEBUG 也没用......(这点两者一样)
但 trap DEBUG 同样脆弱:
bash
trap - DEBUG # 一行取消,同样不留痕迹
而且它的噪声更大 ------每一条简单命令(包括 cd、ls)甚至管道里的每一段都会触发。
两者是同一个层级的两个变体,都不是解决方案。 它们的关系是"互补兜底",不是"谁替代谁"。
九、工程落地的六个坑
真要上的话,这些坑必须提前知道。
坑一:HISTCONTROL 让命令凭空消失
bash
export HISTCONTROL=ignorespace # 空格开头不进 history
$ whoami # ← 前面有空格
这条 whoami 不会进 history,所以 history 1 取不到它,PROMPT_COMMAND 记录的会是上一条别的命令。
同理 HISTIGNORE 也会让命令不进 history。
有意思的是,攻击者常用的
export HISTCONTROL=ignorespace本身就是个危险信号,值得单独做一条检测规则。
坑二:history 有容量上限
HISTSIZE 控制内存条数、HISTFILESIZE 控制文件条数。超了会滚动丢弃。对 history 1 影响不大(只取最后一条),但如果配了 shopt -s histappend 之类要留意。
坑三:只对加载了配置的 shell 生效
要覆盖所有用户,得写进 /etc/profile 或 /etc/bashrc。但用户可以:
bash --norc绕过- 在自己的
~/.bashrc里覆盖掉全局设置
全局配置挡不住有 shell 权限的人。
坑四:性能开销
每条命令都派生 logger 子进程。高频操作(for 循环里跑几万次)时代价可观。生产上建议改成写本地文件 + 异步外送,而不是每条都走 syslog。
坑五:信噪比极低
它记录用户敲的所有东西 ------大量的 ls、cd、vim。真正的恶意命令淹没在噪声里。必须在入库后做降噪和归一化(参考系列第一篇)。
坑六:敏感信息泄露
bash
mysql -u root -p123456
curl -H "Authorization: Bearer eyJhbGci..."
命令里的明文密码会被原样记进日志。审计日志本身成了新的泄露源。入库前必须做敏感信息脱敏。
十、字段上限:它能补上 TTY,但补不上进程链
这里要修正一下系列第一篇里的表述。
第一篇的表格里,我把 PROMPT_COMMAND 的字段丰富度标为"低"。更精确的说法是:它能手工富化一部分字段,但富化有明确天花板。
能补上的:
- ✅
tty------$(tty)能拿到,因为它在交互式 shell 里执行 - ✅
exit_code------$? - ✅
cwd------$(pwd) - ✅
username------$(whoami)
这一点比 bash_history 强------bash_history 只有一行纯文本,TTY 那些字段全给不出来。
补不上的(天花板所在):
- ❌
pid------$$是当前 bash 的 PID,不是刚执行完那条命令的 PID - ❌
ppid/process_chain------$PPID是当前 bash 的父进程(sshd 或终端),不是命令的真实进程树 - ❌
euid/auid------ 拿不到 sudo 后的有效身份,也拿不到最初登录 UID
这个"补不上"才是要命的。
回到第一篇那个核心判定:php-fpm → bash 这条进程链是定罪证据。PROMPT_COMMAND 拿不到进程链 ------它能告诉你"有人敲了这条命令",但它告诉不了你"是谁的程序在敲"。
而攻击者恰恰就藏在这个信息差里。
十一、正确定位:降级兜底 + 独立通道
既然这么脆弱,是不是就该弃用?不是。它的正确定位有两个:
定位一:降级兜底
老内核机器(比如 CentOS 7 的 3.10)装不了 eBPF,auditd 性能又扛不住------这时候 PROMPT_COMMAND 是唯一能拿到实时命令的手段。
有,总比没有强。关键在于知道它的边界在哪,别把兜底方案当成主力方案。
定位二:独立通道的交叉验证(更有价值)
这是它真正被低估的价值。
| 采集源 | 通道 |
|---|---|
| HIDS(eBPF/auditd) | Agent → 日志平台 |
| PROMPT_COMMAND | syslog/rsyslog → 日志平台 |
两条完全独立的通道。 攻击者要同时干掉两条才能让日志彻底干净------这显著提高了他的操作成本和暴露概率。
具体怎么用:
告警规则:主机 A 的 HIDS Agent 心跳正常、有进程事件上报,
但 syslog 通道 30 分钟没有命令日志 → 可疑
反过来也成立:syslog 有命令日志,但 HIDS 那边没有对应进程事件------说明有进程绕过了内核采集(或者 Agent 被针对性绕过)。
交叉验证的价值不在于任一单源有多可靠,而在于它们失效的方式不同。
十二、怎么发现它停了
三招,从简单到硬核:
第一招:断流检测(最实用)
每台主机应该有稳定的日志心跳。一旦某台主机突然安静了,这本身就是最高优先级告警------正常业务不会突然一条日志都不产生。
sql
-- 找出最近 1 小时无命令日志上报的主机
SELECT asset_id, hostname, MAX(event_time) AS last_log
FROM hids_bash_command_log
WHERE dt >= '2026-10-27'
GROUP BY asset_id, hostname
HAVING last_log < NOW() - INTERVAL 1 HOUR;
再强调一次:检查"日志还来不来",不是"配置在不在"。
第二招:资产对账
定期比对资产台账里的主机数 vs 实际有日志上报的主机数。差集就是"没在监控下的资产"。
第三招:检测绕过动作本身
把攻击者必然要做的动作写成高危规则:
sql
SELECT host_ip, username, cmd_full, event_time
FROM hids_bash_command_log
WHERE cmd_full LIKE '%unset PROMPT_COMMAND%'
OR cmd_full LIKE '%PROMPT_COMMAND=%'
OR cmd_full LIKE '%trap - DEBUG%'
OR cmd_full LIKE '%HISTCONTROL=ignorespace%'
OR cmd_full LIKE '%--norc%';
这类规则正常运维几乎永不触发------误报率极低、置信度极高。一旦命中,直接打最高优先级。
对应 ATT&CK:T1562.001(Disable or Modify Tools)、T1070(Indicator Removal)。
十三、生产级配置模板
如果确实要上,用这个(比网上流传的版本严谨一些):
bash
# /etc/profile.d/bash-audit.sh
# 1. 确保 history 可用
export HISTCONTROL= # 禁用 ignorespace,防止命令不进 history
export HISTSIZE=10000
shopt -s histappend
# 2. 审计函数
__bash_audit() {
local rc=$? # 第一个动作:存退出码
local cmd
cmd=$(history 1 | sed 's/^[[:space:]]*[0-9]*[[:space:]]*//')
# 跳过空命令(连续回车会触发)
[[ -z "$cmd" ]] && return 0
# 脱敏:把疑似密码替换掉
cmd=$(echo "$cmd" | sed -E 's/(-p|--password[= ])[^[:space:]]+/\1******/g')
logger -p local1.notice \
"AUDIT|user=$(whoami)|uid=$UID|tty=$(tty 2>/dev/null || echo none)|pwd=$PWD|rc=$rc|cmd=$cmd"
}
# 3. 挂载(数组形式,bash 5.1+,避免覆盖别人挂的)
PROMPT_COMMAND+=('__bash_audit')
配套 rsyslog 外送:
bash
# /etc/rsyslog.d/90-bash-audit.conf
local1.notice @@log-server.example.com:514
# 关键:本地也留一份,且用不易被清的方式
local1.notice /var/log/bash-audit.log
但请务必理解:这套配置防的是"意外丢失",不是"主动绕过"。 真正的安全边界在内核层。
十四、四层采集体系里的位置
| 层级 | 实时 | 字段 | 绕过成本 | |
|---|---|---|---|---|
bash_history |
应用 | ❌ | 极低 | 关机/清文件 |
PROMPT_COMMAND |
应用 | ✅ | 中(可富化 TTY,缺进程链) | 一行 unset |
trap DEBUG |
应用 | ✅✅ | 中 | 一行 trap - DEBUG |
auditd |
内核 | ✅ | 高 | 需清规则(会留痕) |
eBPF |
内核 | ✅ | 高(可编程) | 难 |
生产推荐:eBPF/auditd 为主,PROMPT_COMMAND 作为独立通道兜底和交叉验证。
判断标准很简单:如果一个方案能被"你不信任的人"用一条命令关掉,它就不能作为主力证据源。
十五、PROMPT_COMMAND一句话总括
"
PROMPT_COMMAND是 bash 的环境变量,每次显示提示符前执行,卡在上一条命令执行完和下一条提示符出现的缝隙里,所以能用history 1取到刚执行的命令,实现实时审计。它解决了bash_history最大的痛点------非正常退出时日志全丢。但它有三个致命缺陷 :一是只在交互式 bash 触发,抓不到 Web 进程调用这类不经过 bash 的执行;二是
unset PROMPT_COMMAND一行就能关掉,而且关闭这个动作它自己记录不到 ,因为审计机制和执行者在同一层级;三是攻击者可以改成export PROMPT_COMMAND='true',变量还在、配置没动,但日志早停了。所以生产上它是降级兜底和独立通道交叉验证 ,主力必须是内核层。判断标准就一句:能被你不信任的人用一条命令关掉的方案,不能作为主力证据源。"
总结:三句话
-
实时性 ≠ 可靠性。 PROMPT_COMMAND 解决了"日志留不留得下",但解决不了"日志看不看得见"。这两件事在对抗场景里是分离的。
-
自审计的固有悖论:审计机制被关闭的过程,审计机制自己记录不到。 解法只有分层------让记录者和执行者不在同一个层级。内核之所以可靠,正是因为攻击者改不了自己在内核眼里的样子。
-
能被一条命令关掉的方案,只能做线索,不能做证据。
回扣系列前作那句结论:能被伪造的字段只能做线索,不能被伪造的字段才能做证据。
这一篇补上了它的另一半:能被一条命令关掉的采集源,同样只能做线索。
两句话合起来,就是应用层采集的完整定位------不是不能用,是知道它能证明什么、不能证明什么。
附录:一分钟自查清单
- 主力采集源是不是内核层(eBPF/auditd)?这一条不满足,后面全不用查
- PROMPT_COMMAND 有没有配?配了的话,日志是走独立通道(syslog)还是跟 HIDS 共用?
- 有没有配
HISTCONTROL=ignorespace检测?(攻击者常用的前置动作) - 有没有"主机日志断流"告警?(比任何配置检查都重要)
- 有没有
unset PROMPT_COMMAND/trap - DEBUG的检测规则? - 有没有做命令脱敏?(
-p密码、Token 会被原样记进日志) - 有没有资产对账(台账主机数 vs 实际上报主机数)?
- 在授权靶场实测:
unset PROMPT_COMMAND之后,你的告警多久能发现?
最后一条最实在 :别相信配置,去打一遍。在授权环境里 unset 掉它,然后掐表------从日志停止到告警产生,用了多久?这个数字才是你这套体系的真实水平。