Zkaisen头像

技术博客_PROMPT_COMMAND实时审计的幻觉

实时 ≠ 可靠:为什么 PROMPT_COMMAND 挡不住一行 unset

——命令审计系列之二:应用层采集的"实时幻觉"与它的正确定位

系列前作:《TTY 为空的命令,为什么最危险?》—— 讲了"能被伪造的字段只能做线索,不能被伪造的字段才能做证据"。这篇讲的是同一个内核的另一面:一个看起来解决了实时性的方案,其实只是把绕过成本从"关机"降到了"一行命令"。


一次"日志正常"的失陷

某公司上了命令审计,方案是 PROMPT_COMMAND + rsyslog 外送。上线后效果不错——运维敲的每一条命令都实时出现在 SIEM 里,团队挺满意。

半年后一次应急响应发现:某台机器被人拿下了,攻击者执行过几十条命令。但审计平台里,那台机器的日志在入侵前一天就停了,之后一直是空白。

为什么没告警?因为大家查的是"告警有没有命中",而不是"日志还在不来"。

事后取证发现两件事:

  1. 攻击者登录后第一件事是 unset PROMPT_COMMAND
  2. 环境变量配置文件没被动过——所以运维事后检查时看到配置"还在",以为一切正常

真正阴的地方在第 2 点:攻击者根本没必要改你的配置文件,他只要在自己那个 shell 里 unset 一下。配置文件还在,日志早就停了。

这篇文章讲的就是这件事:PROMPT_COMMAND 是什么样的机制、它确实解决了什么问题、以及它为什么在真正的对抗里不堪一击。


一、它是什么:卡在提示符前的那个缝隙

PROMPT_COMMAND 是 bash 的一个环境变量。每次 bash 显示命令提示符($)之前,会先执行它里面存的命令。

一次交互式 bash 的循环是这样的:

显示提示符 $  →  等你敲命令  →  执行命令
                                    ↓
                            【执行 PROMPT_COMMAND】
                                    ↓
                              显示提示符 $  → ...

它卡在**“上一条命令执行完"和"下一条提示符出现”**之间。

这个位置很巧妙——此时上一条命令已经进了 bash 内存里的 history 列表,用 history 1 就能取到。这就是它能做实时审计的全部原理。

在终端里试一下:

$ export PROMPT_COMMAND='echo "--- 上一条命令执行完了 ---"'
$ ls
file1.txt  file2.txt
--- 上一条命令执行完了 ---
$ whoami
root
--- 上一条命令执行完了 ---

每敲一条命令,它就触发一次。


二、经典写法,逐段拆解

流传最广的版本长这样:

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 ...取最后一条命令,sed 去掉序号和前导空格
[$RETRN_VAL]上一条命令的退出码

⚠️ 关键细节:RETRN_VAL=$? 必须放在最前面。

因为 $? 只保留最近一条命令的退出码。一旦你先执行了 logger 或任何别的命令,$? 就被覆盖成那个命令的退出码了。这是初学者最常写错的地方——写成了 ... [$?] 放在末尾,结果每一条记录的退出码永远是 0。

配合 rsyslog 外送:

# /etc/rsyslog.d/bash-audit.conf
local1.notice  @@log-server.example.com:514

@@ 是 TCP(单 @ 是 UDP)。日志产生后立刻外送。

bash 5.1 起,PROMPT_COMMAND 支持数组形式,可以挂多个命令而不互相覆盖:

PROMPT_COMMAND+=('my_audit_func')

这在多套工具共存时很有用,避免后配置的覆盖先配置的。


三、它确实解决了一个真问题

在说它的缺陷之前,先公允地讲讲它的价值。

相比 bash_history,PROMPT_COMMAND 解决了一个致命痛点:实时性。

bash_historyPROMPT_COMMAND
落盘时机bash 正常退出时每条命令执行完立刻
会话被 kill全部丢失已执行的都发出去了
攻击者 rm ~/.bash_history日志没了已外送的删不掉

bash_history 有个很荒谬的失效场景:攻击者 kill -9 掉自己的 shell,或者机器突然断电——这次会话敲的所有命令一条都不留。

而 PROMPT_COMMAND 在命令执行完的瞬间就写 syslog 外送了,攻击者事后清 ~/.bash_history 没用。

这就是"实时"的价值,也是它让人产生安全感的地方。

但请注意:它解决的是"日志留不留得下",不是"日志看不看得见"。

后面三节讲的就是后者。


四、致命缺陷一:只在交互式 bash 里触发

PROMPT_COMMAND 的触发前提是——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,且不留痕迹

这是最致命的一点,也是开头那个案例的根因。

unset PROMPT_COMMAND        # 没了
export PROMPT_COMMAND=""     # 也没了

成本是一行命令,且没有任何技术门槛。

更麻烦的是这个特性:

unset 这个动作本身,不会被记录下来。

因为记录日志的机制就是 PROMPT_COMMAND,你把它 unset 掉的那一刻,这个 unset 命令自己不会被记录(它执行完之后,已经没有 PROMPT_COMMAND 去执行了)。

审计机制被关闭的过程,审计机制自己记录不到。 这是所有应用层自审计方案的固有悖论。

对比一下内核层:auditctl -D 清规则会被 auditd 记下一条 AUDIT_CONFIG_CHANGE——因为记录者(内核)和执行者不在同一个层级。这是分层带来的根本差异。


六、致命缺陷三:比 unset 更阴的"改写"

真正老练的做法不是删掉,而是换成空操作:

export PROMPT_COMMAND='true'

或者:

export PROMPT_COMMAND='echo ""'

为什么这比 unset 更阴?

  • 运维日常巡检时 echo $PROMPT_COMMAND —— 有值,看起来正常
  • 配置文件没被动过 —— 看起来正常
  • 日志早就不产生了

unset 是让变量消失,'true' 是让变量还在但什么都不做。前者容易发现,后者几乎不可能在例行检查中发现。

这一类手法逼出了一个设计原则:

永远不要检查"配置在不在",要检查"日志还在不来"。

配置是静态的、可被伪装的状态;日志流是动态的、无法伪装的行为。这就是为什么反复强调心跳断流检测——它是唯一能抓住这类手法的手段。


七、readonly 也挡不住

有人会说:那我用 readonly 锁住它?

readonly PROMPT_COMMAND='...审计命令...'

readonly 之后,unset 和重新赋值都会报错:

$ unset PROMPT_COMMAND
bash: unset: PROMPT_COMMAND: cannot unset: readonly variable

看起来锁住了?没有。两个绕过:

绕过一:开一个新 shell

$ exec bash        # readonly 属性不会继承到新进程
$ unset PROMPT_COMMAND   # 成功

readonly 是当前 shell 的属性,环境变量导出的是"值",不是"只读属性"。新起的 bash 进程只继承了值,只读标记丢失。

绕过二:直接跑一个不带配置的 shell

$ bash --norc --noprofile     # 不加载任何配置文件

--norc 不读 ~/.bashrc,--noprofile 不读 /etc/profile。你的全局配置根本没机会加载。

结论:readonly 只防手滑,不防攻击者。 它防的是"运维不小心改了",不是"攻击者故意绕"。


八、trap DEBUG:更实时的兄弟,同样脆弱

讲 PROMPT_COMMAND 就必须提它的兄弟——trap ... DEBUG。

trap 'logger -p local1.notice "$(whoami) [$(tty)] $BASH_COMMAND"' DEBUG

区别在触发时机:

触发时机能拿到
PROMPT_COMMAND命令执行后history 1、退出码
trap DEBUG命令执行前$BASH_COMMAND(原始命令)

trap DEBUG 有个 PROMPT_COMMAND 做不到的优势:命令还没执行就先记下来了。

这在两种场景下是决定性的:

  1. 命令把机器搞挂了 —— rm -rf /、kill -9 自己、触发内核 panic。PROMPT_COMMAND 永远等不到"执行完",日志就没了;trap DEBUG 在执行前已经记下了
  2. 网络被断掉 —— 攻击者第一步先切断外送通道。PROMPT_COMMAND 的日志发不出去,trap DEBUG 也没用……(这点两者一样)

但 trap DEBUG 同样脆弱:

trap - DEBUG       # 一行取消,同样不留痕迹

而且它的噪声更大——每一条简单命令(包括 cd、ls)甚至管道里的每一段都会触发。

两者是同一个层级的两个变体,都不是解决方案。 它们的关系是"互补兜底",不是"谁替代谁"。


九、工程落地的六个坑

真要上的话,这些坑必须提前知道。

坑一:HISTCONTROL 让命令凭空消失

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。真正的恶意命令淹没在噪声里。必须在入库后做降噪和归一化(参考系列第一篇)。

坑六:敏感信息泄露

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_COMMANDsyslog/rsyslog → 日志平台

两条完全独立的通道。 攻击者要同时干掉两条才能让日志彻底干净——这显著提高了他的操作成本和暴露概率。

具体怎么用:

告警规则:主机 A 的 HIDS Agent 心跳正常、有进程事件上报,
         但 syslog 通道 30 分钟没有命令日志 → 可疑

反过来也成立:syslog 有命令日志,但 HIDS 那边没有对应进程事件——说明有进程绕过了内核采集(或者 Agent 被针对性绕过)。

交叉验证的价值不在于任一单源有多可靠,而在于它们失效的方式不同。


十二、怎么发现它停了

三招,从简单到硬核:

第一招:断流检测(最实用)

每台主机应该有稳定的日志心跳。一旦某台主机突然安静了,这本身就是最高优先级告警——正常业务不会突然一条日志都不产生。

-- 找出最近 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 实际有日志上报的主机数。差集就是"没在监控下的资产"。

第三招:检测绕过动作本身

把攻击者必然要做的动作写成高危规则:

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)。


十三、生产级配置模板

如果确实要上,用这个(比网上流传的版本严谨一些):

# /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 外送:

# /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',变量还在、配置没动,但日志早停了。

所以生产上它是降级兜底和独立通道交叉验证,主力必须是内核层。判断标准就一句:能被你不信任的人用一条命令关掉的方案,不能作为主力证据源。"


总结:三句话

  1. 实时性 ≠ 可靠性。 PROMPT_COMMAND 解决了"日志留不留得下",但解决不了"日志看不看得见"。这两件事在对抗场景里是分离的。

  2. 自审计的固有悖论:审计机制被关闭的过程,审计机制自己记录不到。 解法只有分层——让记录者和执行者不在同一个层级。内核之所以可靠,正是因为攻击者改不了自己在内核眼里的样子。

  3. 能被一条命令关掉的方案,只能做线索,不能做证据。

回扣系列前作那句结论:能被伪造的字段只能做线索,不能被伪造的字段才能做证据。
这一篇补上了它的另一半:能被一条命令关掉的采集源,同样只能做线索。

两句话合起来,就是应用层采集的完整定位——不是不能用,是知道它能证明什么、不能证明什么。


附录:一分钟自查清单

  • 主力采集源是不是内核层(eBPF/auditd)?这一条不满足,后面全不用查
  • PROMPT_COMMAND 有没有配?配了的话,日志是走独立通道(syslog)还是跟 HIDS 共用?
  • 有没有配 HISTCONTROL=ignorespace 检测?(攻击者常用的前置动作)
  • 有没有"主机日志断流"告警?(比任何配置检查都重要)
  • 有没有 unset PROMPT_COMMAND / trap - DEBUG 的检测规则?
  • 有没有做命令脱敏?(-p密码、Token 会被原样记进日志)
  • 有没有资产对账(台账主机数 vs 实际上报主机数)?
  • 在授权靶场实测:unset PROMPT_COMMAND 之后,你的告警多久能发现?

最后一条最实在:别相信配置,去打一遍。在授权环境里 unset 掉它,然后掐表——从日志停止到告警产生,用了多久?这个数字才是你这套体系的真实水平。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/fencecat/article/details/169269389

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--