Shell 痕迹与命令历史

~/.bash_history 是 Linux 取证里最容易被找到、也最容易被误读的工件。它看起来人畜无害——就是一串敲过的命令。

最后更新 2026-10-09版本 v1.0维护 DigiForensics查看历史 0

关键词:bash_history、HISTTIMEFORMAT、HISTCONTROL、HISTFILE、HISTSIZE、zsh_history、伪造检测 难度:进阶 前置知识:Linux 用户目录结构、Shell 环境变量基础、日志分析基础

一、概述

~/.bash_history 是 Linux 取证里最容易被找到、也最容易被误读的工件。它看起来人畜无害——就是一串敲过的命令。

但三个机制特性决定了它记什么、漏什么、能被伪造到什么程度:

  • 默认不记时间戳。 只有 ~/.bashrc 或 /etc/bash.bashrc 里设了 HISTTIMEFORMAT,历史文件里才会出现 #<epoch> 行。没有这些 # 行,就不能把命令绑定到任何时间点。
  • 路径、长度、写入内容都可以被用户自己改。 HISTFILE 改路径、HISTCONTROL=ignorespace 让空格开头的命令不记录、history -c 随时清空。「没找到 .bash_history」完全不能推出「没有历史」。
  • 只在 shell 正常退出时写盘。 kill -9、断电、崩溃都会丢掉当前会话的全部历史。磁盘上的历史文件永远落后于内存。

三条合起来会产生一个致命误判:看到历史里都是无害命令,就认定入侵者没用过这台机器。

实际情况可能是:他用了 ignorespace、把 HISTFILE 指到别处、或者 kill -9 掉自己的 shell 后登出。

这类证据属于分析环节的「行为类证据」,位置是「登录之后」这一段的起点。

上游依赖账户与家目录的确认。HISTFILE 可被改写意味着不能在默认路径找不到就收工,家目录清点方式见取证工件地图;账户身份由SSH 痕迹与密钥取证提供。

下游支撑四件事。命令内容解释了这个账户「实际做了什么」;命令里的时间戳是时间线重建的输入之一,但只在配置了 HISTTIMEFORMAT 时才可信;命令内容常直接指向落地的文件(wget 的 URL、crontab 的写入、chmod 的 SUID),这些是排查下一步的入口;与认证日志的交叉验证是反伪造的核心手段。

第一步得先回答「这份历史到底有没有时间戳」,这个判断决定了它能进时间线还是只能当静态文本用。

痕迹的形态与位置:

形态 位置 关键结构
bash 历史 ~/.bash_history、/root/.bash_history 纯文本行;带时间戳时格式为命令行后跟一行 #<epoch>,该时间戳标记的是「这条命令何时被读入历史列表」,不是执行时刻
zsh 历史 ~/.zsh_history 格式完全不同,默认以 : <epoch>:<elapsed>;<command> 开头
bash 配置 ~/.bashrc、~/.bash_profile、~/.profile、/etc/bash.bashrc 决定 HISTTIMEFORMAT、HISTCONTROL、HISTFILE、HISTSIZE 四个变量
派生 shell 历史 ~/.bash_sessions/(部分发行版) 记录每个登录会话的历史片段
应用自有历史 各应用目录下的独立历史文件 格式与位置各不相同,不遵循 bash 约定,需要逐个确认
认证与提权日志 /var/log/auth.log(Debian 系)/ secure(RHEL 系) 独立时钟,是反伪造的对照源
登录记账 /var/log/wtmp 给出「谁在什么时候登录过」,用于给无时间戳的历史定锚

关键判定:bash 历史默认无时间戳这件事是常态而非异常,报告里不能因为「没有时间戳」就认为该文件不可用,它仍然能证明命令内容的存在。

答得出来的是:某个账户执行过哪些命令、命令的顺序、命令中出现的文件路径与网络地址、以及在有 HISTTIMEFORMAT 的机器上命令与时间的粗略对应。

答不出来的,以及几个必须先纠正的误读:

  • 命令的准确执行时刻。 即使有 #<epoch>,它标记的是命令何时被读入历史列表,不是执行时刻;交互式 shell 里两者可能相差很远。
  • 「历史里没有」等于「没执行」。 上面三条机制(ignorespace、HISTFILE、非正常退出)每一条都足以造成缺失。
  • 会话期间的全部命令。 磁盘上的历史文件永远落后于内存,最后一次会话的内容在写入前就可能已经随会话消失。
  • 「历史看起来正常」的证明力。 这是最贵的一个错误:历史里都是无害命令,不能推出入侵者没用过机器。 把它当正面证据,代价是漏掉整条攻击链。
  • 同一时刻其它用户或其它终端做了什么。历史是按账户分文件的,跨账户的时序推断必须靠认证日志。
  • 命令是否真的生效了。历史记录的是输入,执行结果与副作用要靠文件系统变化来验证。

要读懂这些,前提是熟悉 Linux 用户目录结构,知道家目录下有哪些隐藏配置文件;理解 Shell 环境变量的继承与覆盖关系,否则无法判断 HISTFILE 的实际取值来自哪一层;能读懂 syslog 格式的日志行作为交叉验证的对照源,见系统日志深度分析;掌握时间戳的基本语义与纪元换算——#<epoch> 是 Unix 时间戳,与其它时间基准的转换见时间戳换算;会使用只读挂载后的镜像检索隐藏文件,能在指定目录下按名查找而非依赖目录列表。

二、核心原理

2.1 bash 历史的五个控制变量(机制核心)

bash 的历史行为由这五个变量决定。取证时要从"系统配置 + 用户配置"里反推当时生效的值。

变量 默认值 作用 取证意义
HISTFILE ~/.bash_history 历史文件路径 可被改成任意路径 → 找不到默认文件不代表无历史
HISTSIZE 1000(多数发行版) 内存中保留的命令条数 超出的旧命令退出时不会写入文件
HISTFILESIZE HISTSIZE 写入文件的条数上限 与 HISTSIZE 不同时,文件可能比内存历史短
HISTCONTROL ignoredups 记录策略(见下) ignorespace = 空格开头的命令不记录
HISTTIMEFORMAT 未设置 时间戳显示格式 不设就不写时间戳(考点一)
HISTIGNORE 未设置 按模式排除命令 可排除某些命令,可能是反取证配置

考点一:默认无时间戳,#<epoch> 行从哪来

bash 默认只写命令本身,不写时间。所以一个"正常"的 .bash_history 是一列裸命令:

ls -la /var/www
whoami
uname -a

只有当 HISTTIMEFORMAT 被设置后,bash 才会在每条命令前写一行 #<Unix epoch> 注释:

#1710700800
ls -la /var/www
#1710700860
whoami
#1710700920
uname -a

机制细节:这些 # 行是 bash 自己加的注释行。# 后面是 Unix epoch 秒,可直接换算。HISTTIMEFORMAT 里的 %F(日期)和 %T(时间)只是决定交互提示符怎么显示,真正让 # 行落盘的条件是 HISTTIMEFORMAT 这个变量被设置过(哪怕值是空的)。发行版通常在 /etc/bash.bashrc 或 /etc/skel/.bashrc 里用条件语句设置:

HISTTIMEFORMAT='%F %T '

所以"有没有时间戳"直接取决于该系统/用户的 bashrc 里这一行在不在。

取证判定:

观察 结论
历史文件无 # 行 该用户/系统未启用时间戳 → 不能给命令绑定时间。这是"局限",不是"无事发生"
历史文件有 # 行 时间戳可用,#<epoch> 逐条对应其后的命令
部分条目有 #、部分没有 时间戳功能是中途被启用的(用户改了 bashrc 后重开 shell)→ 有 # 的才可定时间

⚠️ #<epoch> 只对紧随其后的第一条命令有效。 解析时不能把一个 # 行"套"给后面多条命令。bash 的格式是严格一一对应。

考点二:HISTCONTROL=ignorespace —— 空格开头的命令不记录

HISTCONTROL 是一个冒号分隔的多值变量,常见取值:

取值 含义
ignoredups 忽略与上一条完全相同的连续命令(多数发行版默认)
ignorespace 忽略以空格开头的命令 ★
ignoreboth 同时忽略重复和空格开头
erasedups 把重复命令从历史中移除(而非只不显示)

取证意义(也是本知识库反复强调的考点):一个交互式 shell 的提示符默认本身就带尾随空格(user@host:~$ )。攻击者只需在敲命令前多加一个空格,就能让命令完全不出现在任何历史文件里,而对操作者来说几乎无感。

这直接导致一条反直觉的结论:.bash_history 里"缺少某条命令"完全不能证明该命令没被执行过。 反过来,历史里出现了某条本该需要空格才不被记录的敏感命令(如 HISTCONTROL=ignorespace 环境下的下载/执行链),反而说明该配置当时可能未生效——这是一个可交叉验证的判据。

如何知道 ignorespace 是否生效:查 /etc/bash.bashrc、/etc/profile、/etc/profile.d/*.sh、~/.bashrc、~/.bash_profile 里的 HISTCONTROL 赋值。注意 HISTCONTROL 是在 shell 启动时读的,之后在会话内改它不影响已有历史。

考点三:HISTFILE 可改路径 —— "找不到 .bash_history"不代表没有

HISTFILE 决定历史写到哪个文件。发行版默认 ~/.bash_history,但用户可改成任意可写路径:

export HISTFILE=/dev/null        # 历史全部丢弃(等价于关掉历史)
export HISTFILE=~/.bash_hist     # 改到别的文件名
export HISTFILE=/tmp/.h           # 藏在 tmp
export HISTFILE=/var/tmp/hist    # 更隐蔽

取证含义:

  • 默认路径没有文件 = 三种可能:用户从未交互式登录、历史被清、HISTFILE 被改。必须全盘搜一遍再下结论。
  • 搜历史文件的正确方式是按内容特征全盘搜,而不是只 ls ~/.bash_history:
# 全盘搜 bash 历史特征的候选文件(含隐藏、非默认路径)
find / -xdev -type f \( -name '*hist*' -o -name '.*history*' \) 2>/dev/null
grep -rlE '^(cd|ls|vi|sudo|curl|wget|chmod)\b' /home /root /tmp /var/tmp 2>/dev/null

⚠️ 注意 HISTSIZE/HISTFILESIZE 的连带影响:即使 HISTFILE 指向正常位置,若 HISTSIZE=0 或 HISTFILESIZE=0,历史同样为空。判定"无历史"时把这两个变量一并查。

考点四:bash 只在正常退出时写盘 —— kill -9 丢失当前会话

这是四个考点里最容易被忽略、后果最严重的一个。

bash 的历史机制分两层:

交互式输入命令  ──→  内存历史列表(HISTSIZE 控制条数)
                        │
                        └── 仅在 shell 退出时 ──→ 一次性写入 HISTFILE
  • 正常退出(exit、Ctrl-D、SSH 断开后 shell 收到 SIGHUP 而退出):bash 在退出时把内存历史一次性 append 写盘。
  • kill -9(SIGKILL,不可捕获)、断电/崩溃、会话被 OOM Killer 杀掉:bash 没有任何机会执行写盘,当前会话的全部历史丢失。

取证推论:

  1. 磁盘上的 .bash_history 永远"落后"于最近一次交互。 如果攻击者登入后 kill -9 自己的 shell 再重登,他在这个 kill 之前的命令不会落盘。
  2. .bash_history 的 mtime 不等于"最后一条命令的时间"——它可能因为某次退出(哪怕是很久之前的正常退出后接了很多 kill -9 会话)而很旧。要判断"最后一次交互",更该看 ~/.bashrc 的 atime、/var/log/wtmp/lastlog 的登录记录、bash 的 .session 痕迹。
  3. 这解释了为什么"历史很干净"和"入侵者刚刚用过"可以并存。 一份 .bash_history 的缺失或无害内容,在机制上完全不能排除同期的未落盘操作。

补充机制:HISTCONTROL=ignoredups 意味着连续重复的同一条命令在文件里只留一条——所以文件里的命令条数 ≠ 实际敲过的次数。另有部分发行版的 bash 会在 $HISTFILE 所在目录维护 .bash_sessions(记录会话与历史改写情况);它不是 bash 的通用核心机制,各版本差异较大,发现时按"辅助线索"对待即可。

2.2 zsh 历史:格式完全不同,且默认带时间戳

zsh(Oh My Zsh 在开发者群体中极常见)历史在 ~/.zsh_history,格式与 bash 完全不同:

: 1710700800:0;ls -la /var/www
: 1710700860:0;whoami
: 1710700920:3;cd /tmp

字段含义:

字段 含义
冒号 + epoch 秒 命令开始执行的时间(由 EXTENDED_HISTORY 启用,不需 HISTTIMEFORMAT)★
第二个数字(0/3) 该命令的运行时长(秒)——3 表示这条命令跑了约 3 秒
分号后 命令原文

关键差异:

  • zsh 默认就有时间戳,bash 要 HISTTIMEFORMAT 才会写。所以机器上存在带时间戳的 .zsh_history,本身就是「这台机器的时间线精度更高」的正面信号。
  • 历史能按时间精确还原。这就是 zsh 痕迹比 bash 更值钱的原因。
  • ⚠️ 第二个数字恒为 0 不代表命令瞬间完成。 启用 INC_APPEND_HISTORY 或 SHARE_HISTORY 时,zsh 在命令提交时就写入历史,此时命令尚未结束、时长无从得知,一律记 0;只有 INC_APPEND_HISTORY_TIME(等命令结束后再写)才记录真实时长。所以时长字段只能作「该命令是否超过 1 秒」的粗判据,不能当精确执行时间用。
  • 解析规则:取「行首第一个冒号到分号之间」的第一个整数作为 epoch,其余数字字段一律忽略。这样即使格式扩展(多冒号)也不会解析错位。

取证判定:如果一台机器上同时有 .bash_history(无时间戳)和 .zsh_history(有),说明用户既用过 bash 也用过 zsh;分析时必须两个都看,只看 bash 会漏掉 zsh 里的时间线。

2.3 各应用自有的历史文件

这些不是 shell 历史,但记录了"用过什么、连过什么",且各自有独立路径和格式:

文件 所属 记录内容 取证价值
~/.python_history Python 交互解释器 交互式输入的 Python 语句 脚本化操作的痕迹;无时间戳
~/.mysql_history MySQL 客户端 逐条 SQL 可含明文口令(CREATE USER ... IDENTIFIED BY);无时间戳
~/.psql_history PostgreSQL psql SQL + 历史命令 同上
~/.rediscli_history redis-cli Redis 命令 可能含 CONFIG SET、FLUSHALL
~/.sqlite_history sqlite3 SQL 少用但存在
~/.viminfo vim 最近打开文件列表、光标位置、寄存器 极有价值:记录编辑过的文件路径
~/.lesshst less 历史搜索模式 辅助证据
~/.node_repl_history Node.js REPL JS 语句 现代环境
~/.irb_history Ruby IRB Ruby 语句 少见
~/.gnu_history / ~/.history 某些 shell 的历史 命令 少见但存在

⚠️ 这批文件绝大多数默认不记时间戳。 和 bash 历史一样,只能当"内容证据",不能直接定时间。要定时间,得和文件系统时间戳、auth.log、journal 交叉。

2.4 历史伪造:能改什么、怎么发现

"能伪造"是历史证据的固有弱点。

但伪造会留下可检出的痕迹。取证的关键不是"信不信历史",而是"这些历史自洽吗"。

攻击者能做的(用于理解局限,本篇不给出实现):

  • 直接编辑 .bash_history 文件(增删改命令)
  • history -c 清空内存 + 文件
  • 设置 HISTFILE=/dev/null 彻底关掉
  • 结束会话时 kill -9 当前 shell,让部分操作不落盘
  • 事后覆写时间戳(bash 存的是可改的 epoch 文本)

可检出的反伪造信号(这是本节的重点):

信号 判据
命令与 auth.log 的 Accepted 时刻矛盾 历史里有 wget 某 URL,但 auth.log 里该用户的登录/会话时间不覆盖该文件的 crtime
顺序不自然 下载 → 执行 → 删除 的逻辑链断裂;或出现"先删后建"
大量 export HISTFILE / history -c / unset HISTFILE 出现在历史里 攻击者自己留下了"清历史"的痕迹
时间戳被人为整齐化 大量 #<epoch> 呈规律间隔(如全在整点、全间隔相同)= 脚本批量生成,非人工输入
epoch 超出合理范围 #<epoch> 换算后是未来时间 / 1970 / 明显不可能的年份
文件 mtime 与最后一条 #epoch 严重不符 最后一条命令的时间远早于文件 mtime 很久 → 有人在会话后大量编辑
同一用户历史里同时出现两套 HISTFILE 设置 说明中途切换过存储位置

核心方法论:永远不要单独用历史定性。 历史文件是"最容易被伪造的工件之一",它的价值在于与 auth.log、wtmp、文件系统时间戳、包管理器日志交叉印证。当三者矛盾时,以独立于历史的日志为准,并把矛盾本身作为发现写进报告。


三、操作步骤

3.1 第 1 步:确定用户列表与家目录

# 真实可登录用户(家目录存在 + 有 bash_history/zsh_history)
for d in /home/* /root; do
  [ -d "$d" ] || continue
  ls -la "$d"/.bash_history "$d"/.zsh_history 2>/dev/null
done

# 从 passwd 取家目录 + shell(判定 nologin 账户不会有 shell 历史)
awk -F: '$7 !~ /(nologin|false|sync)$/ {print $1"  home="$6"  shell="$7}' /mnt/df/etc/passwd

发行版可能把家目录放在 /home、/export/home(SUSE)、/Users(macOS 风格)等。以 /etc/passwd 的第 6 字段为准,不要假设 /home。

3.2 第 2 步:提取并解析 bash 历史(含时间戳判定)

# 提取
cp --preserve=all /mnt/df/home/deploy/.bash_history /evidence/bash_history_deploy

# 判定是否带时间戳(看有无 #<epoch> 行)
grep -cE '^#[0-9]{9,}$' /evidence/bash_history_deploy
# 0   → 无时间戳(不能定时间)
# >0  → 有时间戳

# 若有,按时间戳把 epoch 换算为可读时间(逐行解析)
python3 - <<'PY'
import re, datetime
cur = None
out = []
for line in open('/evidence/bash_history_deploy', encoding='utf-8', errors='replace'):
    m = re.match(r'^#(\d{9,})$', line.rstrip('\
'))
    if m:
        cur = datetime.datetime.fromtimestamp(int(m.group(1)),
              datetime.timezone.utc).isoformat()
    else:
        out.append((cur, line.rstrip('\
')))
for t, c in out:
    if t: print(f'{t}  {c}')
    else: print(f'[无时间戳]      {c}')
PY

解析要点:#<epoch> 只对紧随其后的第一条命令生效,一一对应,不能跨多条套用。epoch 先用 UTC 换算,再按机器本地时区调整(见第 08 篇)。

3.3 第 3 步:判定 bash 是否配置了时间戳(找"为什么没有时间戳")

# 查 HISTTIMEFORMAT 是否在任何 bashrc/profile 里被设置
grep -rnE 'HISTTIMEFORMAT' /mnt/df/etc/bash.bashrc /mnt/df/etc/profile \
  /mnt/df/etc/profile.d/ /mnt/df/home/*/.bashrc /mnt/df/root/.bashrc 2>/dev/null

# 查是否被明确关掉或清空
grep -rnE 'HISTTIMEFORMAT=(""|'"''"')' /mnt/df/etc /mnt/df/home 2>/dev/null

# 查其余四个控制变量
grep -rnE '^\s*(export\s+)?(HISTSIZE|HISTFILESIZE|HISTCONTROL|HISTFILE|HISTIGNORE)=' \
  /mnt/df/etc/bash.bashrc /mnt/df/etc/profile /mnt/df/etc/profile.d/ \
  /mnt/df/home/*/.bashrc /mnt/df/home/*/.bash_profile /mnt/df/root/.bashrc 2>/dev/null
配置发现 取证含义
有 HISTTIMEFORMAT='%F %T ' 该用户/系统会写时间戳;历史无 # 行 = 被人删过 # 行或历史被伪造
无 HISTTIMEFORMAT 历史本就不带时间戳;无 # 行属正常,不是篡改
有 HISTCONTROL=ignorespace 空格开头命令不记录;历史"缺命令"不能证明未执行
有 HISTSIZE=0 / HISTFILESIZE=0 历史本就是空的;缺失非篡改
HISTFILE 指向非