ExecutionUser activityAnti-forensics
Shell History: Linux bash, zsh and fish Command Logs
Per-user command history written by bash, zsh and fish on Linux: what each file records, when timestamps exist and how history evasion shows up.
- Location
- ~/.bash_history
- Proves
- Which commands were typed in an interactive shell running as a given account, and sometimes when
- Timestamps
- Unix epoch seconds (UTC) when recorded: bash only with HISTTIMEFORMAT, zsh with EXTENDED_HISTORY, fish always
- Access
- Any user for own files; root for other users' homes
- Retention
- Until truncated by HISTFILESIZE/SAVEHIST or deleted
- Collection
- UAC, Velociraptor, cp -a, tar
Tools
Compare all toolsWhat it is
Interactive shells keep a list of the command lines a user typed and save it to a per-user file so it can be recalled in later sessions. Bash, zsh and fish each use their own file and format. The files are plain text (fish uses a YAML-like layout), owned and writable by the user, and written on the shell's own schedule rather than as each command runs.
For an investigator this is often the most direct record of hands-on-keyboard activity after an account compromise. It is also incomplete by design: whatever the shell never flushed, or the user chose to suppress, is simply not there.
Where it lives
| Shell | Default file | Notes |
|---|---|---|
| bash | ~/.bash_history | Path set by HISTFILE; if HISTFILE is unset or null, nothing is saved at exit |
| bash (root) | /root/.bash_history | Where commands land after sudo -i, sudo su - or su - |
| zsh | No default; commonly ~/.zsh_history or ~/.zhistory | Only written if HISTFILE is set (and SAVEHIST non-zero) in a startup file |
| fish | ~/.local/share/fish/fish_history | $XDG_DATA_HOME/fish/ if set; fish_history variable changes the session name to <name>_history, an empty value disables saving |
The paths are the same on Debian/Ubuntu, RHEL/Fedora and other families. What differs is the default configuration: for example the Debian and Ubuntu skeleton ~/.bashrc sets HISTCONTROL=ignoreboth and enables histappend, while other distributions may set history variables in /etc/profile, /etc/bashrc or /etc/profile.d/. Always read the actual startup files on the image (see shell startup files) before interpreting gaps.
Service accounts have homes too: check /var/www, /var/lib/postgresql, /var/lib/mysql, /opt/* and any home listed in /etc/passwd for stray history files.
What it proves
- Which command lines were entered in an interactive shell running under that account.
- The relative order in which a shell wrote those lines (not always execution order across parallel sessions).
- With timestamps: when each command was entered (zsh and fish also let you bound session activity).
- Tool usage, file paths, remote hosts, URLs and credentials-in-arguments that point to further artifacts.
It does not prove:
- Who was at the keyboard (shared, stolen or
su-switched accounts break attribution). - That a command succeeded, or that it ran at all if it was typed and aborted.
- Anything about non-interactive execution: scripts, cron jobs, web shells and
ssh host 'cmd'normally leave nothing here.
Key fields
| Format | Record layout | Meaning |
|---|---|---|
| bash, plain | command line | One entry per line, no time |
bash, HISTTIMEFORMAT set | #1727512345 then command line | Comment character plus digits marks a timestamp for the next entry; it also delimits multi-line entries |
zsh EXTENDED_HISTORY | : <start>:<elapsed>;command | Start time in epoch seconds, elapsed seconds, then the command |
| fish | - cmd: ... / when: ... / optional paths: list | when is epoch seconds; paths lists arguments fish recognised as existing paths |
Zsh stores non-ASCII bytes in a "metafied" encoding, so some characters look corrupted in a plain text viewer.
Timestamps
All three formats use Unix epoch seconds, which are UTC. The file's own mtime shows the last time a shell wrote it, and ctime shows the last metadata change (useful when a file was truncated or replaced by a symlink).
# bash with HISTTIMEFORMAT
grep -E '^#[0-9]{9,}$' .bash_history | head -1 | cut -c2- | xargs -I{} date -u -d @{} # first timed entry
# zsh extended history
awk -F'[:;]' '/^: [0-9]+:/ {cmd="date -u -d @" ($2+0) " +%FT%TZ"; cmd | getline t; close(cmd); print t, $0}' .zsh_history
Retention
- bash: at exit, the last
HISTSIZEentries (default 500) are appended (withhistappend) or overwrite the file, which is then trimmed toHISTFILESIZElines. Old activity falls off the top. - zsh:
SAVEHISTcaps the file.APPEND_HISTORYis on by default;INC_APPEND_HISTORYorSHARE_HISTORYwrite each command immediately instead of at exit. - fish:
history clearandhistory deleteremove entries;history mergepulls in other sessions' changes. - Sessions killed with
SIGKILL, crashed or still open at acquisition time may never reach disk. A running bash keeps them in process memory.
Collection
Collect from every home directory and /root, including rotated or backup copies (.bash_history~, .bash_history.*). UAC's files/shell/* artifacts (part of ir_triage) cover bash, zsh, fish and other shells, and also parse startup files to find a non-default HISTFILE.
sudo ./uac -p ir_triage /mnt/usb/case42
# dead box, read-only mount
find /mnt/evidence/root /mnt/evidence/home -maxdepth 4 \
\( -name '.*history*' -o -path '*/fish/fish_history' \) -print0 | tar --null -T - -czf history.tgz
In Velociraptor, Linux.Sys.BashHistory greps /{root,home/*}/.*_history across endpoints. If the suspect shell may still be running, capture memory first (LiME or AVML): acquiring the disk does not flush in-memory history, but a later logout will overwrite what you saw.
Parsing
- Plaso:
bash_historyandzsh_extended_historytext plugins are in thelinuxpreset (select them astext/bash_historyandtext/zsh_extended_history);fish_historyis a separate parser. Only entries with timestamps become events. - Volatility 3:
linux.bash.Bashrecovers history from running bash processes, including commands never written to disk. - Plain tools are enough for review:
log2timeline.py --parsers 'text/bash_history,text/zsh_extended_history,fish_history' \
--storage-file hist.plaso /mnt/evidence/home
psort.py -o dynamic --output_time_zone UTC -w hist.csv hist.plaso
Investigator tips
- Treat an empty, zero-byte or
/dev/null-symlinked history on an active account as a lead. Check the symlink'sctimeand compare the filemtimewith logins in wtmp and lastlog. - Look for evasion strings in other shells' histories, startup files and memory:
unset HISTFILE,HISTFILE=/dev/null,HISTSIZE=0,set +o history,history -c,history -d,kill -9 $$. - Missing commands typed with a leading space are expected where
ignorespace/ignorebothor zshHIST_IGNORE_SPACEis set, and fish drops such lines by design. That is a default, not proof of intent. - Partial
#epochcoverage in a bash file meansHISTTIMEFORMATwas enabled at some point; untimed lines are older or came from a shell without it. - After
sudo -iorsu -, continue in/root/.bash_historyand correlate with sudo logs. - Where auditd logs
EXECVE, it gives independent, timestamped execution records to confirm history entries (see auditd). - Editors, pagers and REPLs keep their own histories that attackers rarely clean: see viminfo and lesshst.