Skip to content

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

What 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

ShellDefault fileNotes
bash~/.bash_historyPath set by HISTFILE; if HISTFILE is unset or null, nothing is saved at exit
bash (root)/root/.bash_historyWhere commands land after sudo -i, sudo su - or su -
zshNo default; commonly ~/.zsh_history or ~/.zhistoryOnly 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

FormatRecord layoutMeaning
bash, plaincommand lineOne entry per line, no time
bash, HISTTIMEFORMAT set#1727512345 then command lineComment character plus digits marks a timestamp for the next entry; it also delimits multi-line entries
zsh EXTENDED_HISTORY: <start>:<elapsed>;commandStart time in epoch seconds, elapsed seconds, then the command
fish- cmd: ... / when: ... / optional paths: listwhen 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 HISTSIZE entries (default 500) are appended (with histappend) or overwrite the file, which is then trimmed to HISTFILESIZE lines. Old activity falls off the top.
  • zsh: SAVEHIST caps the file. APPEND_HISTORY is on by default; INC_APPEND_HISTORY or SHARE_HISTORY write each command immediately instead of at exit.
  • fish: history clear and history delete remove entries; history merge pulls 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_history and zsh_extended_history text plugins are in the linux preset (select them as text/bash_history and text/zsh_extended_history); fish_history is a separate parser. Only entries with timestamps become events.
  • Volatility 3: linux.bash.Bash recovers 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's ctime and compare the file mtime with 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/ignoreboth or zsh HIST_IGNORE_SPACE is set, and fish drops such lines by design. That is a default, not proof of intent.
  • Partial #epoch coverage in a bash file means HISTTIMEFORMAT was enabled at some point; untimed lines are older or came from a shell without it.
  • After sudo -i or su -, continue in /root/.bash_history and 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.

See also