关键词: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 没有任何机会执行写盘,当前会话的全部历史丢失。
取证推论:
- 磁盘上的
.bash_history 永远"落后"于最近一次交互。 如果攻击者登入后 kill -9 自己的 shell 再重登,他在这个 kill 之前的命令不会落盘。
.bash_history 的 mtime 不等于"最后一条命令的时间"——它可能因为某次退出(哪怕是很久之前的正常退出后接了很多 kill -9 会话)而很旧。要判断"最后一次交互",更该看 ~/.bashrc 的 atime、/var/log/wtmp/lastlog 的登录记录、bash 的 .session 痕迹。
- 这解释了为什么"历史很干净"和"入侵者刚刚用过"可以并存。 一份
.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 指向非 |
|