Linux on-disk forensics #
Logs
Linux logging is messy because there are two parallel systems: traditional text logs under /var/log/ (syslog, auth.log, messages - appended by rsyslog/syslog-ng) and the newer systemd journal (binary files under /var/log/journal/, queried with journalctl). Many distros run both, but Debian 12+, Fedora and Arch are journal-only by default - check whether a syslog daemon is installed before looking for the text files. Timestamp semantics differ: text logs record only wall-clock (and often local time without a TZ suffix, the single biggest Linux timeline trap); the journal carries both a wall-clock and a monotonic timestamp, letting you re-order events even if someone turned the system clock back.
| Path / source | What it tells you | How it's generated / key fact | Timestamps | Parsers / tools |
|---|---|---|---|---|
/ (Debian/Ubuntu), / (RHEL/Fedora) | SSH logins, sudo, su, PAM | Written by the syslog daemon (rsyslog / syslog-ng) from messages sshd, sudo, su, PAM, cron and logind send to the auth / authpriv facility. Only exists if a syslog daemon is installed - Debian 12+, Fedora and Arch are journal-only by default. Rotated by logrotate (commonly weekly, 4 kept). | Legacy prefix MMM DD HH:MM:SS: local time, no year, no TZ (RHEL, older releases). Debian 12+ / Ubuntu 24.04 rsyslog writes RFC 3339 with an offset (2025-01-01T10:00:00.123456+00:00). Confirm the format and the host's TZ. | grep/awk, UAC, log2timeline/plaso, Logstash/Elastic, Splunk UF |
/, / | Kernel / daemon messages | Same daemon - everything not split into a dedicated file: kernel (via journald or imklog), daemons, services. syslog on Debian/Ubuntu, messages on RHEL. | Same format caveats as auth.log. dmesg ring-buffer lines are seconds since boot - anchor them to the boot time (journalctl --list-boots, uptime -s). | grep, plaso, Chainsaw, Zircolite |
/ (last) | Login / logout / reboot history | Binary utmp records appended by login, sshd, the display manager and init on every login, logout, reboot and shutdown. Rotated monthly by logrotate on Debian/RHEL (1 kept). Newer systems are moving to wtmpdb (SQLite /var/lib/wtmpdb/wtmp.db) - check for it. Easily edited by root (utmpdump round-trip). | ut_tv = Unix seconds + microseconds, UTC. tv_sec is 32-bit for compatibility even on 64-bit glibc, so Y2038 applies. | last -Faiwx, utmpdump, wtmpdb last, UAC |
/ (lastb) | Failed logins | Same record format, appended by login / sshd on failed authentication. Root-only (600) - an attacker with root may clean it. | Same as wtmp - Unix seconds + µs, UTC. | lastb -Faiwx, utmpdump |
/ (lastlog) | Last login per user | Sparse file indexed by UID; pam_lastlog / login overwrites the user's record (time, tty, host) on every login. Being replaced by lastlog2 (/var/lib/lastlog/lastlog2.db). Useless for history, easy to tamper. | ll_time = Unix seconds UTC - most recent login only. | lastlog, lastlog2, plaso |
/ (who) | Currently logged in | Current sessions, written by login / sshd / logind at login and cleared at logout. On tmpfs, so empty after a reboot - collect it live. | Unix seconds UTC - current state only. | who, w, utmpdump |
journalctl (/) | Structured logs; filter by _SYSTEMD_UNIT=, _UID=, _BOOT_ID=; --since/--until | systemd-journald collects service stdout/stderr, syslog(3) calls, kernel and audit messages, and adds trusted fields from the kernel (_PID, _UID, _COMM, _EXE, _SYSTEMD_UNIT, _BOOT_ID) a process can't fake. Persistent in /var/log/journal/ if that folder exists (or Storage=persistent), otherwise volatile in /run/log/journal/ and lost at reboot. Default cap 10% of the filesystem, max 4 GB - on a busy host that can be days, not weeks. | Two timestamps per entry: __REALTIME_TIMESTAMP (µs since Unix epoch, UTC) and __MONOTONIC_TIMESTAMP (µs since boot, scoped to _BOOT_ID, unaffected by clock changes). Use -o json to see both. | journalctl -o json, jq, UAC, plaso, systemd-analyze |
audit.log (/) | auditd - syscall-level if rules are in place | auditd receives events from the kernel audit subsystem for the loaded rules (auditctl, /etc/audit/rules.d/) plus built-in events (logins, account changes, SELinux). Without rules there's little syscall detail. Defaults are tiny (max_log_file=8 MB, 5 files) - harden before an incident, not during. | msg=audit(<seconds>.<ms>:<serial>) - Unix seconds with milliseconds, UTC, plus a per-boot serial. Records of one event share the timestamp and serial. | ausearch, aureport, auditctl, Laurel (JSON transform), go-audit |
faillock dir, PAM tally | Lockout counts | pam_faillock writes a per-user file in /var/run/faillock/ (tmpfs on most systems, cleared at reboot) recording each failed attempt; pam_tally2 on older systems. | Unix epoch time per failed attempt. | faillock --user, pam_tally2 --user |
User / shell artifacts
| Artifact | What it proves | How it's generated / key fact | Timestamps | Parsers / tools |
|---|---|---|---|---|
Shell history~/, ~/, ~/ | Commands a user typed in an interactive shell, in order | The shell keeps history in memory and writes it to the file when the shell exits (bash appends on exit unless PROMPT_COMMAND='history -a' or shopt -s histappend changes that). A killed shell, unset HISTFILE, HISTSIZE=0, a link to /dev/null or a size-0 file all leave gaps. Non-interactive shells (cron, scripts, ssh host cmd, AI agents) don't write history at all. | bash writes no per-command time by default - only order. With HISTTIMEFORMAT exported, each entry gets a #<unix-epoch> line. zsh writes : <epoch>:<elapsed>;<cmd> only when EXTENDED_HISTORY is set (off by default, though some frameworks such as oh-my-zsh enable it). Otherwise the file's mtime ≈ when the last shell exited. | cat, UAC, osquery shell_history, Velociraptor Linux.Forensics.BashHistory |
Other app histories and dotfiles~/, ~/, ~/, ~/, ~/, ~/, ~/ | Files edited, queries run, interpreters used, hosts contacted | Each program writes its own history when it exits: vim records recently edited files, marks, search and command history; the database clients and Python REPL keep readline history; wget records HSTS hosts it connected to. Same gaps as shell history if the program was killed or the variable was redirected. | Mostly no embedded time - use the file's stat times (ext4 / XFS v5 also give a birth time). ~/.viminfo entries carry Unix-epoch times; ~/.wget-hsts stores created and max-age per host (Unix epoch). | Text editor, stat, UAC |
Credential and config files~/, ~/, ~/, ~/, ~/ | Which cloud, registry and git credentials were on the host (and could have been taken); which hosts the user has connected to | Written by the respective CLIs on login or configuration (aws configure, docker login, the git credential store, kubectl / cloud CLIs). known_hosts gets a line the first time the user's ssh client accepts a host key, hashed if HashKnownHosts yes, but you can still test candidate hosts with ssh-keygen -F. | No embedded times; stat times show when the file was last written. Pair with the provider's own logs for when the credential was used. | stat, ssh-keygen -F <host> -f known_hosts, UAC |
SSH authorized keys~/, / (AuthorizedKeysFile, AuthorizedKeysCommand) | Which keys can log in as this user - attacker-added keys are classic persistence | Edited by the user, by ssh-copy-id, by config management or cloud agents (e.g. cloud-init or the provider's guest agent writing keys from instance metadata), or by an attacker appending a line. sshd reads it on every key-based login, so a new line takes effect immediately. | No embedded times - use stat mtime / ctime (and birth time on ext4 / XFS v5). auth.log / the journal record Accepted publickey for <user> ... <key fingerprint> with the login time, so you can see which key logged in when. | ssh-keygen -lf authorized_keys (fingerprints), stat, osquery authorized_keys |
Shell startup files~/, ~/, ~/, ~/, /, /, / | Commands that run every time a user (or every user) starts a shell, a common persistence and alias-hijack spot | Created from /etc/skel when the account is made, or installed by packages (/etc/*); afterwards edited by the user, admins, installers, or an attacker. Login shells read /etc/profile and ~/.bash_profile / ~/.profile; interactive non-login shells read ~/.bashrc. | No embedded times. Compare the file's stat mtime with the package install time (dpkg -V / rpm -V also flag changed package files). | dpkg -V, rpm -Va, stat, diff against /etc/skel |
Filesystem timestamp reality check: Modern Linux defaults to relatime mount option - atime only updates if (a) atime < mtime/ctime, or (b) atime is >24h older than now. On noatime volumes (common for busy servers, flash) atime never updates. Always check cat /proc/mounts at collection; otherwise your "last access" findings are wrong. ext4 and XFSv5 give you crtime (birth) via stat - ext2/ext3 do not. Attackers using touch -r reference target clone the mtime+atime of reference onto target; the ctime still updates because the inode changed, which is how you catch it.
unix_shell_history, sudoers, authorized_keys; Velociraptor Linux artifacts; GRR; CatScale (WithSecure) bash script triage; linux_dfir_triage.AI agent artifacts
AI coding agents and assistants run commands, read files and edit code as the user, so their transcripts are often the best execution log on the host - the prompt, every command, and the output the agent saw. The same files collect any secrets the agent read. Paths below are for Linux; ~ = /home/<user> (and /root).
bash -c / sh -c), so they usually don't appear in ~/.bash_history / ~/.zsh_history. If EDR shows shells spawned by node, claude, codex, cursor or code, the agent transcript is where the command lines and their output are recorded.| Agent | Where | What it proves | Format, retention, notes |
|---|---|---|---|
| Terminal agents | |||
| Claude Code (Anthropic) | ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl - full transcripts~/.claude/history.jsonl - every prompt typed, with project path~/.claude/file-history/ - pre-edit copies of files it changed~/.claude/shell-snapshots/~/.claude/debug/~/.claude.json - per-project MCP servers, trusted folders, account~/.claude/settings.json - permission allow-lists, hooks; also <repo>/.claude/settings*.json | Prompts, model replies, every tool call (shell command lines, file reads and writes, web fetches) and the tool output, with working directory and git branch. File-history lets you diff what it changed. | JSONL, one event per line, ISO-8601 UTC timestamp on each. <encoded-cwd> is the project path with separators and dots turned into -. Transcripts older than cleanupPeriodDays (default 30) are deleted at startup - collect early. Optional OpenTelemetry export (CLAUDE_CODE_ENABLE_TELEMETRY) can mean a central copy in the SIEM. Enterprise policy: /etc/claude-code/managed-settings.json. |
| Codex CLI (OpenAI) | ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl - session transcripts~/.codex/history.jsonl - prompt history~/.codex/log/ - TUI / debug log~/.codex/config.toml - approval mode, sandbox, MCP servers | Prompts, model output, shell commands and results, patches applied, token use. | JSONL with timestamp / type / payload per line; the date folders bound session days. Newer builds add a SQLite index and may compress old rollouts (.jsonl.zst). approval_policy = "never", --yolo or --dangerously-bypass-approvals-and-sandbox = commands ran unreviewed. |
| Gemini CLI (Google) | ~/.gemini/tmp/<project-hash>/chats/ - auto-saved sessions~/.gemini/tmp/<project-hash>/logs/ - plus checkpoints/ and /chat save tags~/.gemini/history/<project-hash>/ - shadow git repo (checkpointing, off by default)~/.gemini/settings.json - and GEMINI.md context files | Conversation and tool calls per project; the shadow repo holds file states before edits. | JSON. The hash comes from the absolute project path - open every folder rather than computing it. --yolo / approvalMode = tool calls ran unprompted. |
| GitHub Copilot CLI | ~/.copilot/session-state/<session>/events.jsonl - plus workspace.yaml, plan.md, checkpoints/~/.copilot/session-store.db - SQLite index (newer builds)~/.copilot/logs/process-<ms-epoch>-<pid>.log~/.copilot/config.json | Full session event history, plans and file checkpoints; one debug log per process. | Older builds used history-session-state/. Log names carry a millisecond epoch and PID, a ready-made process start time to line up with EDR. COPILOT_HOME can relocate the folder. |
| Amazon Q Developer / Kiro CLI | $XDG_RUNTIME_DIR/qlog/ or /tmp/qlog/ - logs~/.aws/amazonq/, ~/.kiro/ - config | chat.log covers startup, SDK calls and networking; AWS warns it can hold file paths, code, command output and account IDs. | Temp-dir logs don't survive reboots or temp cleanup. Full prompt logging is an admin setting that writes to S3 - ask whether it's on. --trust-all-tools = no approval prompts. |
| Aider | In each repo: .aider.chat.history.md, .aider.input.history~/.aider.conf.yml | Full chat as Markdown plus input history; aider also commits its own edits to git. | Lives in the project, not the home dir - sweep for it, and check git log for aider: commits. |
| IDE agents | |||
| GitHub Copilot Chat / agent mode (VS Code) | ~/.config/Code/User/workspaceStorage/<hash>/chatSessions/ - chat sessions~/.config/Code/User/workspaceStorage/<hash>/chatEditingSessions/ - files it changed~/.config/Code/User/globalStorage/github.copilot-chat/~/.config/Code/logs/<YYYYMMDDTHHMMSS>/window<n>/exthost/GitHub.copilot-chat/ | Per-workspace chats including agent-mode tool calls and terminal commands; editing sessions show what it changed. | JSON (newer builds JSONL). Map <hash> to a project with the workspace.json next to it; renaming or moving a project orphans the old folder, which stays on disk. Log folder names are editor launch times (local time). |
| Cursor | ~/.config/Cursor/User/globalStorage/state.vscdb - table cursorDiskKV: composerData:<id> (session), bubbleId:<id>:<msg> (messages, tool calls, diffs)~/.config/Cursor/User/workspaceStorage/<hash>/state.vscdb - per-workspace chat list (ItemTable)~/.cursor/mcp.json - and <repo>/.cursor/ rules and project MCP~/.config/Cursor/logs/ | Agent conversations, the terminal commands it ran, the diffs it applied. | SQLite with JSON values. Composer IDs are UUIDs, not time-ordered - sort on the timestamps inside the JSON. The schema changes between versions, so list keys first (SELECT key FROM cursorDiskKV). The DB can be gigabytes. |
| Windsurf (Cascade) | ~/.codeium/windsurf/ - mcp_config.json, memories/, Cascade conversation data~/.config/Windsurf/User/~/.config/Windsurf/logs/ | Configured MCP servers, auto-generated memories about the user's projects, Cascade activity logs. | Conversation files are a proprietary binary format (reported as encoded protobuf) - expect to work from logs, memories and file timestamps. Verify against the build. |
| Cline / Roo Code (VS Code extensions) | ~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/<task-id>/ - Cline~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/<task-id>/ - Roo; same extensions also appear under the Cursor / Windsurf profiles | api_conversation_history.json = exactly what went to and from the model; ui_messages.json = what the user saw, incl. command approvals; task_metadata.json = files touched. | Task IDs are millisecond epochs - the folder name is the task start time. checkpoints/ is a shadow git repo of the workspace. |
| Continue | ~/.continue/sessions/ - chat sessions (JSON)~/.continue/config.yaml~/.continue/dev_data/ | Chat sessions and (if on) local usage data. | Config shows which model endpoints, including self-hosted ones, it talked to. |
| Desktop assistants and local models | |||
| Ollama (local models) | journalctl -u ollama - server log (systemd install)~/.ollama/history - ollama run prompts/usr/share/ollama/.ollama/models/ - models (service account home) | API requests with client IP, model pulls (where models came from), interactive prompts. | Listens on 11434; if bound to 0.0.0.0, the server log shows who else on the network used it. |
| LM Studio | ~/.lmstudio/conversations/ - saved chats (JSON)~/.lmstudio/ - models, local server settings | Saved chats; local API server configuration. | Like Ollama, an exposed local server is an abuse and data-exfil surface. |
CopilotInteraction audit records, prompt copies via eDiscovery), and S3 prompt logs for Amazon Q / Kiro. Ask for these in the first data request.What to look for in the transcripts:
- Commands it ran - timeline every shell tool call next to EDR process events.
- Secrets it read -
.env, cloud credential files, SSH keys, kube configs, browser profiles. What was read is in the transcript, so the transcript is now a copy of the secret. - Where data went - web fetches,
curl,git pushto unfamiliar remotes, new MCP servers pointing at external URLs. - Safety switches off -
--dangerously-skip-permissions,--yolo,--trust-all-tools, broad allow rules, auto-approve. Check the settings file's mtime against the incident window. - Instructions the user didn't write - prompt injection arrives in tool output (a web page, issue, README or MCP response). Read the tool results just before an odd action.
- Persistence - a planted
CLAUDE.md,AGENTS.md,GEMINI.md,.cursor/rules, hook or MCP server re-runs the attacker's instructions every session. - Agents driven by malware - the August 2025 Nx "s1ngularity" npm compromise ran
claude,geminiandqwith permission checks off to hunt for secrets. The parent is a package script, not a terminal, and there's no matching user prompt. Record agent and extension versions too: a poisoned Amazon Q VS Code release (1.84.0, July 2025) shipped a wipe prompt.
Inventory (then copy; take SQLite files with their -wal / -shm files, or close the app first - recent rows often live only in the WAL. Check that your KAPE / Velociraptor / UAC targets actually cover these folders):
for h in /home/* /root; do
for t in .claude .claude.json .codex .gemini .copilot .continue .cursor .codeium .kiro .aws/amazonq .ollama .lmstudio \
.config/Code/User .config/Cursor/User .config/Windsurf/User; do
[ -e "$h/$t" ] && ls -ld "$h/$t"
done
done 2>/dev/null
ls -ld /etc/claude-code /usr/share/ollama/.ollama /tmp/qlog 2>/dev/null
find / -xdev -name .aider.chat.history.md 2>/dev/null
YYYYMMDDTHHMMSS. SQLite stores (Cursor, state.vscdb) keep epoch-millisecond values inside JSON blobs - convert them and don't trust row order. Paths in this category change often: verify against the tool version on the host.Persistence
Linux persistence lives in three big buckets: scheduled execution (cron, systemd timers, at), process-start hooks (shell rc files, PAM, LD_PRELOAD, dynamic linker tricks), and kernel-level (loadable modules, eBPF programs). Below is the ASEP catalog you should sweep on every engagement. "Where to find it" matters more than "what tool to run" - attackers often customize to evade the obvious tools.
Scheduled execution
- cron - The classic Unix scheduler. System-wide tabs live at
/etc/crontaband in the/etc/cron.{hourly,daily,weekly,monthly}drop-dirs; files added to/etc/cron.d/are included. Per-user tabs live at/var/spool/cron/crontabs/<user>(Debian/Ubuntu) or/var/spool/cron/<user>(RHEL/Fedora). Format is 5-field schedule + user (system crontab only) + command. Attackers love it because any user can schedule in their own crontab without privs, and the entry just looks like a one-liner. Trap:crontab -lshows only the invoking user's tab - you must iterate every user.anacron(/etc/anacrontab) handles missed jobs on laptops; also check it. - at / batch jobs - One-shot scheduled commands via
at; queued in/var/spool/cron/atjobs/. Rarely used operationally, so a singleatjob on a server is highly suspicious. List withatq. - systemd units (services) - The modern Linux init system. A "unit" is a text file (
.service,.timer,.socket,.path,.mount, etc.) that systemd parses and acts on. Service units specify what to run (ExecStart=), under which user (User=), and when (WantedBy=multi-user.target= run on boot). Three directory tiers (in load order):/etc/systemd/system/(admin),/run/systemd/system/(runtime),/usr/lib/systemd/system/(distro). Per-user units in~/.config/systemd/user/run only when that user is logged in / lingering. Attackers drop a.servicefile named like a legit daemon, enable it (systemctl enablecreates a symlink in the target wants-dir), and reboot survives. Hunt:systemctl list-unit-files --state=enabled, diff against a known-good distro baseline;systemd-analyze security <unit>for sandboxing weaknesses. - systemd timers - The modern cron replacement. A
.timerunit says "every N minutes" or "at specific times" and activates a matching.service. More flexible than cron (random delays, monotonic schedules, calendar expressions) and easier to miss.systemctl list-timers --allis mandatory. - systemd generators - Executables in
/etc/systemd/system-generators/,/usr/lib/systemd/system-generators/, or/run/systemd/system-generators/run very early during boot, before most units, and can dynamically generate unit files. Niche attacker persistence that fires before many defenses are up. - systemd .path units - Trigger a service when a filesystem path changes. Attacker-friendly: "run my payload every time
/tmp/.X-flagis touched." - systemd user lingering -
loginctl enable-linger <user>keeps a user's systemd instance running even when they're not logged in. Combined with a per-user service, that's reboot-surviving code execution as a non-privileged user.
Legacy init (still present on older/RHEL systems)
- /etc/rc.local - A plain shell script run late in boot (if the
rc-local.servicestub is enabled). Lives on through modern systemd distros for backwards compatibility. - /etc/init.d/* + /etc/rc?.d/ - SysV init scripts and their symlinked runlevel directories. Mostly superseded, but still invoked on systemd distros via the
systemd-sysv-generator. New files in/etc/init.d/are a red flag on any system from the last 10 years. - /etc/inittab - The classic SysV init config. Legacy; still present on older distros / embedded.
Shell & login hooks (user-level, no privs)
- Shell init files -
~/.bashrc,~/.bash_profile,~/.profile,~/.zshrc,~/.zprofile,/etc/profile,/etc/bash.bashrc,/etc/profile.d/*.sh. Any of these runs when a user starts an interactive shell (bashrc = non-login, bash_profile/profile = login). Attackers append a line that executes a payload, downloads a C2 stub, or exports anLD_PRELOAD. If a user SSHs in once a day, that's your schedule. - MOTD / update-motd.d - Scripts in
/etc/update-motd.d/execute at every interactive login (SSH / console) to generate the message of the day. Writable by root; a new script here runs as root every login. Audit/etc/motdtoo (static) and/etc/pam.d/sshdfor thepam_motdmodule. - XDG autostart - Desktop-environment persistence:
~/.config/autostart/*.desktopand/etc/xdg/autostart/*.desktop. Only triggers when a GUI session starts, so applicable to workstations more than servers. - Default login shell swap - Changing a user's shell in
/etc/passwdto a wrapper that runs the payload and then execs bash.
Dynamic linker & library-level hooks
- /etc/ld.so.preload - Any shared object listed in this plain-text file is loaded into every dynamically-linked ELF process on the system before any other library. Classic rootkit hook (Jynx, Azazel, Symbiote all use it). The malicious .so hooks libc functions (
readdir,open,stat) to hide its own files, processes, and connections. A non-empty/etc/ld.so.preloadon a server is almost always malicious. - LD_PRELOAD environment variable - Same mechanism, scoped to a process and its children. Attackers set it in
~/.bashrcor/etc/environmentso it applies to every interactive user session. SUID binaries ignore it (good) but anything else honors it. - LD_LIBRARY_PATH injection - Prepending an attacker-controlled directory to
LD_LIBRARY_PATHlets malicious libs shadow real ones. Set via/etc/environment, user shell rc, or systemd unit'sEnvironment=. - Shared library trojaning - Replace or patch a system-wide library (
libc.so.6,libpam.so,libssl.so) with a backdoored version. Detect by signature check against distro packages:debsums -c(Debian/Ubuntu),rpm -Va(RHEL/Fedora), AIDE, Tripwire.
Kernel-level
- Loadable Kernel Modules (LKMs) -
.kofiles in/lib/modules/$(uname -r)/; loaded viamodprobe/insmod. Diamorphine, Reptile, LKM-Rootkit hook syscalls to hide processes, files, and network connections. Enumerate withlsmod,cat /proc/modules, inspect/proc/kallsymsfor suspicious symbols.dmesg | grep -i taint- any taint flag means a non-distro module got loaded./etc/modules-load.d/and/etc/modprobe.d/are config dirs that cause modules to auto-load at boot. - eBPF-based rootkits - BPFDoor, Pumakit, Boopkit, TripleCross, Symbiote (hybrid). Modern alternative to LKMs because eBPF programs are loadable at runtime by root, don't need a kernel-matching compile, and bypass many LKM-focused detections. Hunt via
bpftool prog list,bpftool map list,bpftool link list; inspect/sys/fs/bpf/for pinned programs;cat /sys/kernel/debug/tracing/kprobe_eventsfor attachment points. Tracee and Falco can detect anomalous eBPF loads at runtime. - Kernel core_pattern -
/proc/sys/kernel/core_patterndefines what to do with core dumps; setting it to a pipe (|/tmp/evil.sh %p) means your script runs as root every time any process crashes. Classic container-escape primitive too.
Authentication & access-granting primitives
- SSH authorized_keys - The simplest, most durable, most-often-missed attacker persistence on Linux. Any public key appended to
~/.ssh/authorized_keysgrants that user shell access. Attackers also drop into/root/.ssh/authorized_keys(if they have root) and sometimes/home/*/\.ssh/authorized_keysacross every user. Sweep every home directory. Check for ssh keys that reference an "options=" prefix (command=,no-pty) hiding what they do. - sshd_config backdoors -
/etc/ssh/sshd_configand the newer/etc/ssh/sshd_config.d/*. Attacker-interesting directives:AuthorizedKeysFilepointed at a world-readable path they control;ForceCommandthat runs their shim before dropping to a shell;Match Userblocks that relax rules for specific users;PermitRootLogin yes;PubkeyAuthentication yes+ a planted key;Bannerset to a payload path (rare but seen). Also check~/.ssh/config,~/.ssh/authorized_keysoptions likecommand=,environment=. - SSH host-key or client-config swap - Replacing
/etc/ssh/ssh_host_*to accept attacker clients, or planting a~/.ssh/configthat proxies through an attacker host. - PAM (Pluggable Authentication Modules) -
/etc/pam.d/*are text files describing the auth stack per service (sshd,login,sudo,passwd, …). Each line names a.somodule. Attackers either (a) add a line likeauth sufficient pam_backdoor.sothat always returns success, or (b) replace a legit module (pam_unix.so) with a backdoored version that logs cleartext credentials to disk (classic Skidmap, SkidMAP, variants of Tsunami).pam-rhino,Pambdare reference malicious modules. Enumerate loaded module paths withlddon each listed .so; checksum them against the distro package. - sudoers manipulation -
/etc/sudoersand/etc/sudoers.d/*. An entry likeattacker ALL=(ALL) NOPASSWD: ALLgrants pw-less root. More subtle:Defaults !tty_tickets,Defaults env_keep += "LD_PRELOAD", allowing specific binaries that have GTFO privesc paths. Always check for sudoers includes with recent mtime. - SUID / SGID binaries - Files with the setuid or setgid bit run with the owner's privileges regardless of invoker.
find / -perm -4000 -type f 2>/dev/null. Cross-reference every unusual SUID against GTFOBins - any match is a privesc gadget. Attackers also drop their own SUID-root shell copy somewhere obscure (/var/tmp/.cache/sh) as a durable local privesc path. - File capabilities - A finer-grained alternative to full SUID; capability bits attached via
setcap.getcap -r / 2>/dev/null. Dangerous ones to see on unexpected binaries:cap_sys_admin,cap_sys_ptrace,cap_dac_override,cap_net_raw,cap_setuid. Example:python3withcap_setuid+ep= instant root.
Package manager & config hooks
- apt hooks -
/etc/apt/apt.conf.d/*. Lines likeDPkg::Pre-Install-PkgsorAPT::Update::Post-Invokeexecute commands as root around every package install/update. Attacker-perfect because sysadmins update frequently. - dnf/yum plugins -
/usr/lib/python*/site-packages/dnf-plugins/*.py; equivalent code-execution opportunity on RHEL-family. - Debian dpkg triggers / RPM scriptlets - Package maintainer-scripts (
pre-install,post-install,pre-remove,post-remove) run as root. Backdoored packages are a supply-chain concern (XZ Utils CVE-2024-3094). - Udev rules -
/etc/udev/rules.d/can trigger scripts on hardware events. Example: a USB insert that execs a payload. Rare but seen in physical-access attacks. - NetworkManager / dispatcher scripts -
/etc/NetworkManager/dispatcher.d/*run on network state changes (as root). - logrotate hooks -
/etc/logrotate.d/*allowsprerotate/postrotateshell blocks; runs as root daily.
systemctl list-unit-files --state=enabled, systemctl list-timers --all, systemd-analyze security, iterate crontab -l -u <u> per user, cat /etc/crontab + /etc/cron.d/* + /etc/cron.{hourly,daily,weekly,monthly}/*, cat /etc/ld.so.preload, bpftool prog list, enumerate ~/.ssh/authorized_keys for every home, diff /etc/pam.d/* + /etc/ssh/sshd_config* against distro defaults, rpm -Va / debsums -c for binary tampering, Lynis hardening audit, UAC for packaged collection, linPEAS (ref: shows what an attacker would find), pspy (snoops cron/exec via procfs), osquery packs: startup_items, crontab, systemd_units, authorized_keys, pam_tokens.Rootkits & fileless
Modern Linux malware increasingly runs without touching disk - either as an eBPF program loaded into the kernel's virtual-machine subsystem (visible to bpftool but invisible to ls), or as a process backed by an in-memory file descriptor created with memfd_create, where /proc/<pid>/exe resolves to /memfd:... or shows (deleted). The classic LKM rootkits (Diamorphine, Reptile) are still around but now compete with eBPF-based alternatives (BPFDoor, Pumakit) that are harder to detect because eBPF is a legitimate observability feature of the kernel. Hunting here is a game of comparing enumerations: process from /proc walk vs. ps, loaded modules vs. /proc/modules vs. sysfs, and auditing which eBPF programs are loaded against a known-clean baseline.
- eBPF-era rootkits: BPFDoor, Symbiote (LD_PRELOAD), Pumakit, boopkit, TripleCross. Hunt via
bpftool prog list,cat /sys/kernel/debug/tracing/kprobe_events, unexpected tracepoints. - Fileless execution -
memfd_create: look for/proc/<pid>/exe → /memfd:…,fexecve, process whose/proc/<pid>/exeis(deleted)but still mapped (/proc/<pid>/maps). - io_uring - bypasses many hooks; watch for unusual
io_uring_setupin auditd. - Hidden processes - compare
/procenumeration tops; LKM rootkits (Diamorphine) hide via syscall hook.
prog list, map list, link list), /sys/kernel/debug/tracing/ inspection, Tracee (Aqua, eBPF runtime security), Falco, Sysdig Inspect, unhide (compares ps/proc), Lynis, Linux Forensics (Hal Pomeranz scripts), Volatility 3 Linux (linux.psaux, linux.malfind, linux.check_syscall, linux.check_modules, linux.bash), osquery kernel_modules, process_open_files. chkrootkit/rkhunter are junior-tier signature scanners.Containers
Containers are a namespace + cgroup construct, not a hypervisor boundary - they share the host kernel, so a container compromise can escalate to host compromise under the right conditions (privileged flag, host-pid, mounted docker.sock, kernel LPE). Forensics splits into three layers: the image (what shipped - scan with Trivy/Grype, verify provenance), the runtime (what actually executed - Falco/Tracee eBPF events, container stdout/stderr, docker events), and the orchestration plane (K8s audit log, admission events, ServiceAccount token usage). The containerd/overlay2 directory tree persists the writable layer of every container that ran, so you can often reconstruct a destroyed container's filesystem from /var/lib/docker/overlay2/ even after docker rm.
- Docker -
/var/lib/docker/(containers, image, overlay2, network).docker inspect,docker events --since,daemon.json. - Containerd -
/var/lib/containerd/, task shim sockets. - K8s -
kubectl get events --all-namespaces, audit logs on API server, admission controller trail, serviceaccount token mount (/var/run/secrets/kubernetes.io/serviceaccount/token). - Escapes to know by CVE: runC CVE-2024-21626 (Leaky Vessels), cgroups v2 release_agent abuse (older),
docker.sockmount, privileged +--pid=host,CAP_SYS_ADMIN+ user namespace tricks. - Image provenance -
skopeo inspect, SBOM, signature (cosign) when evaluating a suspect image.
get events -A, logs, describe), stern for multi-pod logs, k9s for live triage, Trivy/Grype for image scan, docker-explorer (Google) for offline analysis of docker filesystems, Cadvisor, Falco/Tracee runtime, kube-forensics, mkit (Microsoft Kubernetes attack toolkit, ref), Falcon Cloud Workload Protection for runtime k8s.Linux CVEs to check exposure against
- CVE-2022-0847 Dirty Pipe - pipe splice r/w on read-only files
- CVE-2023-0386 OverlayFS - fuse privesc
- CVE-2023-4911 Looney Tunables - glibc
GLIBC_TUNABLESlocal root - CVE-2024-1086 nf_tables UAF - kernel LPE
- CVE-2024-3094 XZ Utils backdoor (Jia Tan) - sshd supply chain; versions 5.6.0/5.6.1
Linux collection tooling
- UAC - Unix-like Artifacts Collector (Thiago Canozzo Lahr). Script-based triage, modular, outputs tar. The "KAPE of Linux"; Velociraptor's Linux artifacts and offline collector cover the same ground and scale across a fleet.
- AVML (Microsoft) - kernel-agnostic memory capture
- LiME - kernel-module memory capture (requires matching kernel-headers)
- Volatility3 Linux profiles (via dwarf2json / ISF)
- osquery live triage; Velociraptor Linux artifacts; GRR
- Loki/Thor YARA scanning; Sysmon for Linux (ETW-like)
bpftool and tracefs output; (4) take a disk image if needed.