Skip to content

ExecutionUser activity

Shell History on macOS: zsh, bash and Terminal Sessions

macOS shell history in .zsh_history, .bash_history and Terminal's per-window session files: which commands a user typed and roughly when.

Location
~/.zsh_history
Proves
Which commands a user typed in an interactive shell, in what order, and in which Terminal window
Timestamps
None by default; Unix epoch seconds only if EXTENDED_HISTORY (zsh) or HISTTIMEFORMAT (bash) is set
Access
File owner or root; plain files in the home folder
Retention
zsh default SAVEHIST=1000 lines; Terminal session files deleted after about two weeks
Collection
UAC, Aftermath, mac_apt

What it is

Interactive shells write the commands a user typed to a plain-text history file in the home folder. Apple made zsh the default login and interactive shell for new accounts starting with macOS Catalina, so ~/.zsh_history is the main file on current systems. Accounts created on Mojave or earlier and later upgraded can still use bash (~/.bash_history), and users can switch shells with chsh at any time.

Apple's Terminal adds a second layer. The Apple-provided startup scripts /etc/zshrc_Apple_Terminal and /etc/bashrc_Apple_Terminal implement "Resume": each Terminal window or tab gets a TERM_SESSION_ID, and its commands are also saved into a per-session history file in ~/.zsh_sessions or ~/.bash_sessions. That makes it possible to attribute commands to a specific Terminal window, and sometimes recovers commands trimmed from the main file.

Where it lives

FilePathNotes
zsh history~/.zsh_historySet by HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history in /etc/zshrc; zsh default since 10.15 Catalina for new accounts
zsh Terminal sessions~/.zsh_sessions/<TERM_SESSION_ID>.history, .historynew, .sessionCreated by /etc/zshrc_Apple_Terminal (folder mode 700)
bash history~/.bash_historyDefault for accounts on 10.14 and earlier; macOS ships bash 3.2
bash Terminal sessions~/.bash_sessions/<TERM_SESSION_ID>.history, .historynew, .sessionCreated by /etc/bashrc_Apple_Terminal
Root's history/private/var/root/.zsh_history (or .bash_history)Commands typed in a root login shell such as sudo -i
Startup files/etc/zshrc, /etc/zprofile, ~/.zshrc, ~/.zprofile, ~/.zshenv, ~/.zloginCheck for a changed HISTFILE, HISTSIZE, SAVEHIST or disabled history

If ZDOTDIR is set, zsh looks for its files in that directory instead of $HOME. Other shells (fish, ksh, tcsh) keep their own history files. Home folders sit on the Data volume (/System/Volumes/Data/Users/<name>). The dotfiles themselves are not in a TCC-protected location, but a collector should still run with Full Disk Access.

What it proves

  • The commands typed in interactive shells, in the order they were saved.
  • Download-and-execute patterns (curl ... | sh, chmod +x, xattr -d com.apple.quarantine), persistence setup (launchctl load, editing LaunchAgents), reconnaissance, archive creation and exfiltration commands.
  • With Terminal session files: which commands belong to which Terminal window, and when a session was last saved.

It does not prove:

  • That a command succeeded, or what it printed.
  • When a command ran, unless extended timestamps are enabled.
  • Anything about non-interactive execution: scripts, osascript, launchd jobs and remote tools that spawn sh -c do not write history. Use the Unified Logs and process telemetry for those.

Key fields

Default zsh format: one command per line, no timestamps.

zsh with setopt EXTENDED_HISTORY:

: 1790000000:4;curl -fsSL https://example.test/a.sh -o /tmp/a.sh
PartMeaning
: Marker (zsh writes a no-op : command)
1790000000Command start time, Unix epoch seconds
4Elapsed time in seconds
after ;The command line

bash with HISTTIMEFORMAT set writes a comment line #<epoch> before each command. Terminal .session files contain a line that echoes Restored session: followed by date -r <epoch>, where the epoch is the time the session state was saved.

Timestamps

  • Default configuration: no per-command time at all. Only the file system times of the history file help: its modification time is roughly when a shell last exited and saved history (see APFS timestamps).
  • zsh EXTENDED_HISTORY and bash HISTTIMEFORMAT store Unix epoch seconds, UTC. Neither is enabled in Apple's default /etc/zshrc or /etc/bashrc.
  • Terminal session files: the epoch in .session, plus the modification times of each .history file, give a per-window "last saved" time.
# Convert zsh extended history to readable UTC
sed -nE 's/^: ([0-9]+):([0-9]+);(.*)$/\1 \2 \3/p' .zsh_history |
while read -r ts dur cmd; do printf '%s  %ss  %s\n' "$(date -u -r "$ts" '+%F %T')" "$dur" "$cmd"; done

Retention

  • Apple's /etc/zshrc sets HISTSIZE=2000 (in memory) and SAVEHIST=1000 (lines kept in the file). Older lines are trimmed as new ones are saved.
  • zsh appends history when the shell exits (APPEND_HISTORY is on by default). A window killed hard, or a crash, can lose that session's commands. Options such as INC_APPEND_HISTORY or SHARE_HISTORY write each command immediately.
  • Terminal's session logic deletes files in ~/.zsh_sessions / ~/.bash_sessions older than two weeks, checking at most once a day (_expiration_check_timestamp).
  • Users can disable or erase history: unset HISTFILE, HISTSIZE=0, SHELL_SESSIONS_DISABLE=1 (turns off only the Terminal session files, not history), a leading space with HIST_IGNORE_SPACE, history -c in bash, or simply deleting the file.

Collection

# Per user, preserving timestamps (run with Full Disk Access)
for h in /Users/*; do
  ditto "$h/.zsh_history" "/cases/host01/$(basename "$h")/zsh_history" 2>/dev/null
  ditto "$h/.zsh_sessions" "/cases/host01/$(basename "$h")/zsh_sessions" 2>/dev/null
  ditto "$h/.bash_history" "/cases/host01/$(basename "$h")/bash_history" 2>/dev/null
  ditto "$h/.bash_sessions" "/cases/host01/$(basename "$h")/bash_sessions" 2>/dev/null
done
sudo ditto /private/var/root /cases/host01/root_home
  • UAC: zsh.yaml and bash.yaml collect history files, session folders, startup files and any custom HISTFILE found in them.
  • Aftermath: collects bash and zsh history, .sh_history, and shell startup files including fish's config.fish; no csh, ksh or fish history (root plus Full Disk Access).
  • mac_apt: the TERMSESSIONS plugin reads bash and zsh session folders and history for every user.

Collect live before a user logs out if possible: open shells hold unsaved commands in memory only.

Parsing

History files are text, so cat, grep and sed do most of the work. Things to watch:

  • zsh writes the file in its internal "metafied" encoding: bytes in the meta range are stored as 0x83 followed by the byte XOR 0x20. Commands with accented or non-Latin characters look garbled until you reverse that (drop 0x83, XOR the next byte with 0x20).
  • mac_apt TERMSESSIONS merges session .history and .historynew files per TERM_SESSION_ID.
  • Quick triage for common attacker commands:
grep -nE 'curl|wget|base64|osascript|xattr|launchctl|chmod \+x|nc |python3? -c|/tmp/|/Users/Shared/' .zsh_history

Investigator tips

  • Compare .zsh_history with each ~/.zsh_sessions/*.history: session files are trimmed independently and can hold commands missing from the main file.
  • An empty or zero-byte history file with a recent modification time, or a symlink to /dev/null, is a classic anti-forensics sign. Check startup files for unset HISTFILE or SAVEHIST=0.
  • Root shells write to /private/var/root; pair them with sudo logs to see who elevated.
  • Commands that touch LaunchAgents or LaunchDaemons should be cross-checked against launchd plists and FSEvents for the matching file creation.
  • Local APFS snapshots can hold older, longer versions of the history file.
  • /etc/zshrc only loads the session logic when TERM_PROGRAM is Apple_Terminal, so iTerm2, VS Code terminals and similar emulators write only to the main history file, never to ~/.zsh_sessions.

See also