← All cheat sheets

LINUX PERSISTENCE DETECTION

Plain-text reference · 16 KB. Read it, search it (Ctrl-F) or print it.

A triage reference of WHERE attackers keep a foothold on Linux, and how to
inspect each location read-only. It is for hunting and investigation (where
to look), NOT for planting persistence. Commands assume root, bash and a
systemd host; paths are given for Debian/Ubuntu and RHEL/Rocky/Alma.

Before you start: collect volatile data first (see the Linux Forensics sheet)
and work on a copy or a read-only mount when you can. File times can be
forged with touch; ctime (find -cmin/-ctime) cannot be set by touch, so
compare both. dpkg -S <file> / rpm -qf <file> tell you whether a package owns
a file; "not owned" is the first question for every hit below.

1. SYSTEMD SERVICES (ATT&CK T1543.002)#

Main unit paths, highest priority first (systemd.unit(5)):
  /etc/systemd/system/             admin units and <unit>.d/ drop-ins
  /run/systemd/system/             runtime units (gone after reboot)
  /usr/local/lib/systemd/system/   local installs (usually no package)
  /usr/lib/systemd/system/         package units
User units: ~/.config/systemd/user/, /etc/systemd/user/,
  /usr/lib/systemd/user/ -- they run in the user's session.
Inspect:
  systemd-analyze unit-paths              # every directory this host reads
  systemctl list-unit-files --state=enabled
  systemctl list-units --type=service --state=running
  systemctl cat sshd.service              # unit file plus all drop-ins
  systemd-delta --type=extended,overridden    # edits to packaged units only;
                                          # it never lists a NEW unit
  find /etc/systemd /usr/local/lib/systemd /run/systemd -type f -ctime -30 \
    2>/dev/null                           # changed in the last 30 days
  find /home/*/.config/systemd /root/.config/systemd -type f 2>/dev/null
  ls /var/lib/systemd/linger/             # users whose units start at boot
Flags: ExecStart/ExecStartPre in /tmp, /dev/shm, /var/tmp or a home dir;
sh -c with curl, wget or base64; a drop-in that replaces ExecStart of a
system service; Restart=always on an unknown unit; a user listed in linger.

2. SYSTEMD TIMERS AND GENERATORS (ATT&CK T1053.006)#

Timers live in the same unit paths as services (*.timer).
Generators (no ATT&CK sub-technique of their own) run early at every boot
and at each daemon-reload, before units load (systemd.generator(7)):
  /etc/systemd/system-generators/  /usr/local/lib/systemd/system-generators/
  /usr/lib/systemd/system-generators/  (and the matching user-generators/)
  /run/systemd/system-generators/  (tmpfs, highest priority, live host only)
Inspect:
  systemctl list-timers --all             # next/last run and the unit fired
  systemctl cat <name>.timer
  ls -la /etc/systemd/system-generators /run/systemd/system-generators \
    /usr/local/lib/systemd/system-generators 2>/dev/null
  ls /run/systemd/generator /run/systemd/generator.late 2>/dev/null  # output
Flags: a timer whose service runs from a writable path; any file in the /etc
or /usr/local generator dirs (rare on a normal host); generated units that no
package explains.

3. CRON, ANACRON AND AT (ATT&CK T1053.003, T1053.002)#

System:  /etc/crontab, /etc/cron.d/, /etc/cron.{hourly,daily,weekly,monthly}/
         /etc/anacrontab (RHEL; Debian if anacron is installed)
Users:   /var/spool/cron/crontabs/<user>   (Debian/Ubuntu)
         /var/spool/cron/<user>            (RHEL/Rocky/Alma)
at jobs: /var/spool/cron/atjobs/ (Debian), /var/spool/at/ (RHEL)
Access:  /etc/cron.allow, /etc/cron.deny, /etc/at.allow, /etc/at.deny
Inspect:
  cat /etc/crontab /etc/cron.d/* 2>/dev/null | grep -v '^#'
  ls -la /var/spool/cron/crontabs /var/spool/cron 2>/dev/null
  for u in $(cut -d: -f1 /etc/passwd); do crontab -l -u "$u" 2>/dev/null \
    | sed "s|^|$u: |"; done               # every user's crontab
  ls -la /etc/cron.hourly /etc/cron.daily  # scripts run by run-parts
  atq                                     # pending at jobs
  at -c <job>                             # full script of one job
Flags: curl/wget piped to sh; @reboot lines; jobs in /tmp or a home dir;
cron files not owned by root; a crontab for a service account.
More syntax: the Crontab sheet.

4. SHELL STARTUP FILES (ATT&CK T1546.004)#

Login shells:  /etc/profile, /etc/profile.d/*.sh, ~/.bash_profile,
               ~/.bash_login, ~/.profile (bash reads the first one found)
Interactive:   ~/.bashrc, /etc/bash.bashrc (Debian), /etc/bashrc (RHEL)
Others:        ~/.bash_logout, /etc/zsh/ or /etc/zshrc, ~/.zshrc,
               /etc/environment (read by pam_env, not by the shell)
New accounts:  /etc/skel/ is copied into every new home directory
Inspect:
  ls -la --time-style=full-iso /etc/profile.d/
  grep -nE 'curl|wget|base64|/dev/tcp|nohup|LD_PRELOAD|alias (sudo|ssh)=' \
    /etc/profile /etc/profile.d/*.sh /etc/bash.bashrc /etc/bashrc \
    /root/.bashrc /root/.profile /home/*/.bashrc /home/*/.profile 2>/dev/null
  tail -n 5 /home/*/.bashrc /root/.bashrc  # additions are usually appended
  grep -nP '[^\t -~]' /root/.bashrc /home/*/.bashrc  # non-ASCII, controls
Flags: an alias that wraps sudo or ssh (password capture); /dev/tcp
connections; background jobs started from a profile; changes in /etc/skel.

5. SSH KEYS AND SSHD SETTINGS (ATT&CK T1098.004)#

Keys:     ~/.ssh/authorized_keys and ~/.ssh/authorized_keys2 (upstream and
          Debian default; RHEL 9 reads authorized_keys only)
Login:    ~/.ssh/rc (or, if it is absent, /etc/ssh/sshrc) runs at every
          SSH login, before the user's shell (sshd(8), SSHRC)
Config:   /etc/ssh/sshd_config, /etc/ssh/sshd_config.d/*.conf
Inspect:
  find / -xdev \( -name authorized_keys -o -name authorized_keys2 \) \
    -type f -ls 2>/dev/null
  sshd -T | grep -iE 'authorizedkeys|permitrootlogin|permituserenv'
                                          # effective config, all includes
  ls -la /etc/ssh/sshrc /root/.ssh/rc /home/*/.ssh/rc 2>/dev/null
  grep -nE '(^|,)(command|environment|from)=' /root/.ssh/authorized_keys
  ssh-keygen -lf /root/.ssh/authorized_keys   # fingerprints to compare
Flags: keys for root or service accounts that no one can explain; a changed
AuthorizedKeysFile or AuthorizedKeysCommand; PermitUserEnvironment yes (lets
~/.ssh/environment and environment= set LD_PRELOAD); any sshrc or rc file.

6. ACCOUNTS AND SUDO (ATT&CK T1136.001, T1548.003)#

Files: /etc/passwd, /etc/shadow, /etc/group, /etc/sudoers, /etc/sudoers.d/
Inspect:
  awk -F: '$3 == 0 {print $1}' /etc/passwd        # only root expected
  awk -F: '$7 !~ /(nologin|false)$/ {print $1, $3, $7}' /etc/passwd
  getent group sudo wheel                 # admin groups (Debian / RHEL)
  grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null
  visudo -c                               # parses sudoers + every include
  ls -la --time-style=full-iso /etc/passwd /etc/shadow /etc/sudoers.d/
  passwd -S -a                            # Debian: status of all accounts
  passwd -S root                          # RHEL: one account at a time
Flags: a second UID 0 account; a system account (UID below 1000) with a
login shell; NOPASSWD ALL for a user; a sudoers.d file no package owns.

7. PAM MODULES (ATT&CK T1556.003)#

Config:  /etc/pam.d/ (common-* on Debian, system-auth/password-auth on RHEL)
Modules: /usr/lib/<arch>-linux-gnu/security/ (Debian), /usr/lib64/security/
Inspect:
  grep -rnE 'pam_exec|pam_python|pam_script' /etc/pam.d/
  grep -rnE '^[^#].*\.so' /etc/pam.d/ | grep -vE 'pam_[a-z0-9_]+\.so'
  for f in /usr/lib64/security/*.so /usr/lib/*-linux-gnu/security/*.so; do
    [ -e "$f" ] || continue; { dpkg -S "$f" || rpm -qf "$f"; } >/dev/null \
      2>&1 || echo "NOT OWNED: $f"; done
Flags: pam_exec.so running a script; a module that no package owns; a
module path outside the security directory; pam_permit.so in an auth stack
where it does not belong.

8. DYNAMIC LINKER HIJACKING (ATT&CK T1574.006)#

Files: /etc/ld.so.preload (normally absent), /etc/ld.so.conf.d/*.conf
Inspect:
  ls -la /etc/ld.so.preload && cat /etc/ld.so.preload
  cat /etc/ld.so.conf /etc/ld.so.conf.d/*.conf
  grep -l LD_PRELOAD /proc/[0-9]*/environ 2>/dev/null  # processes using it
  grep -rn LD_PRELOAD /etc/environment /etc/profile* /etc/systemd 2>/dev/null
  ldd /usr/bin/bash                       # compare with a clean host
Flags: any /etc/ld.so.preload on a server; a library in /tmp, /dev/shm,
/usr/local or a home dir; a new ld.so.conf.d entry. A rootkit that hooks
libc can hide the file from ls and cat -- check from a rescue system.

9. KERNEL MODULES (ATT&CK T1547.006)#

Load at boot: /etc/modules-load.d/, /usr/lib/modules-load.d/,
              /etc/modules (Debian, legacy but still read)
Options:      /etc/modprobe.d/ -- an "install <mod> <command>" line runs a
              command whenever that module loads (modprobe.d(5))
Inspect:
  lsmod                                   # loaded modules
  cat /proc/sys/kernel/tainted            # 4096 out-of-tree, 8192 unsigned
  grep -rn '' /etc/modules-load.d/ /etc/modules 2>/dev/null
  grep -rnE '^\s*install' /etc/modprobe.d/ /usr/lib/modprobe.d/ 2>/dev/null
  modinfo -F filename <module>            # where the .ko file lives
  find /lib/modules/$(uname -r) -name '*.ko*' -ctime -30
Flags: a module file outside /lib/modules/<release>/ (kernel/, updates/,
extra/); an unknown name in modules-load.d; an install line that runs a
script. A loaded rootkit can hide from lsmod: use memory forensics (see the
Volatility sheet).

10. UDEV RULES (ATT&CK T1546.017)#

Rules: /etc/udev/rules.d/, /run/udev/rules.d/, /usr/local/lib/udev/rules.d/,
       /usr/lib/udev/rules.d/ (udev(7))
Inspect:
  ls -la --time-style=full-iso /etc/udev/rules.d/ /run/udev/rules.d/
  grep -rnE 'RUN\+?=|PROGRAM=|IMPORT\{program\}|SYSTEMD_WANTS' \
    /etc/udev/rules.d/ /run/udev/rules.d/ /usr/local/lib/udev 2>/dev/null
  udevadm verify                          # syntax check (systemd 254+)
Flags: RUN+= starting a shell script or a binary outside /usr/lib/udev;
a rule on a device that always exists (e.g. KERNEL=="random"), so it runs
at every boot. udev kills what RUN starts once the event is handled, so a
rule that hands off to at, systemd-run, nohup or a SYSTEMD_WANTS unit is a
strong sign.

11. RC SCRIPTS AND SYSV INIT (ATT&CK T1037.004)#

Files: /etc/rc.local (Debian), /etc/rc.d/rc.local (RHEL; /etc/rc.local is
       a link to it) -- run at boot only when executable
       (systemd-rc-local-generator(8)); /etc/init.d/, /etc/rc?.d/
Inspect:
  ls -la /etc/rc.local /etc/rc.d/rc.local 2>/dev/null
  systemctl status rc-local.service       # did it run at the last boot?
  ls -la --time-style=full-iso /etc/init.d/
  for f in /etc/init.d/*; do [ -e "$f" ] || continue; dpkg -S "$f" \
    >/dev/null 2>&1 \
    || rpm -qf "$f" >/dev/null 2>&1 || echo "NOT OWNED: $f"; done
Flags: an executable rc.local with network commands; init scripts no
package owns. systemd-sysv-generator turns each one into a unit at boot
(/run/systemd/generator.late/<name>.service); it is deprecated upstream.

12. XDG AUTOSTART (DESKTOPS) (ATT&CK T1547.013)#

Files: ~/.config/autostart/*.desktop, /etc/xdg/autostart/*.desktop
       (freedesktop Desktop Application Autostart spec)
Inspect:
  ls -la /etc/xdg/autostart/ /home/*/.config/autostart/ 2>/dev/null
  grep -HnE '^(Exec|TryExec|Hidden|X-GNOME-Autostart-enabled)=' \
    /etc/xdg/autostart/*.desktop /home/*/.config/autostart/*.desktop
Flags: Exec= pointing to a script in a home dir or /tmp; a user entry with
the same name as a system one (it overrides it). Hidden=true disables an
entry, so a user file can also switch off a security agent's autostart.

13. PACKAGE MANAGER HOOKS (ATT&CK T1546.016)#

APT:  /etc/apt/apt.conf.d/ -- DPkg::Pre-Invoke, DPkg::Post-Invoke,
      APT::Update::Pre-Invoke / Post-Invoke run commands (apt.conf(5))
dpkg: /var/lib/dpkg/info/*.postinst, *.prerm (maintainer scripts)
DNF:  /etc/dnf/plugins/, post-transaction-actions.d/*.action
RPM:  scripts stored in the rpm database
Inspect:
  grep -rnE 'Invoke|Pre-Install-Pkgs' /etc/apt/apt.conf.d/ 2>/dev/null
  ls -lt /var/lib/dpkg/info/*.postinst | head   # newest first
  rpm -q --scripts <package>              # pre/post install scripts
  ls -la /etc/dnf/plugins/ /etc/yum.repos.d/ /etc/apt/sources.list.d/
Flags: a hook that calls curl, wget or a script in /tmp; a repository that
nobody added; a .deb or .rpm installed outside change windows (check
/var/log/dpkg.log or dnf history).

14. LOGIN MESSAGE SCRIPTS (DEBIAN/UBUNTU)#

Files: /etc/update-motd.d/* -- Debian's pam_motd runs them as root with
run-parts at each SSH or console login ("noupdate" skips it). RHEL's
pam_motd only prints /etc/motd and /etc/motd.d/. No ATT&CK sub-technique.
Inspect:
  ls -la --time-style=full-iso /etc/update-motd.d/
  grep -n 'pam_motd' /etc/pam.d/sshd /etc/pam.d/login
  grep -nvE '^\s*(#|$)' /etc/update-motd.d/*
Flags: a script that does anything but print text, especially downloads,
user creation or background jobs.

15. PYTHON STARTUP HOOKS (ATT&CK T1546.018)#

Files: *.pth files in site-packages run any line starting with "import"
       (deprecated in Python 3.15 but still run); sitecustomize.py and
       usercustomize.py are imported at every start (Python site docs)
Inspect:
  python3 -c 'import site, sys; print(site.getsitepackages(), sys.path)'
  find / -xdev -name '*.pth' -path '*-packages*' -exec grep -Hn '^import' \
    {} + 2>/dev/null
  find / -xdev \( -name sitecustomize.py -o -name usercustomize.py \) \
    2>/dev/null
Flags: a .pth import line that runs exec, base64 or a network call; a
sitecustomize.py that no package owns.

16. SETUID AND FILE CAPABILITIES (ATT&CK T1548.001)#

Inspect:
  find / -xdev -perm -4000 -type f 2>/dev/null   # setuid
  find / -xdev -perm -2000 -type f 2>/dev/null   # setgid
  getcap -r / 2>/dev/null                        # file capabilities
  find /tmp /var/tmp /dev/shm /home -perm /6000 -type f 2>/dev/null
Flags: setuid copies of bash, sh, python or find; cap_setuid or
cap_dac_override on an interpreter; any setuid file in a writable dir.

17. CHANGED SYSTEM FILES (ATT&CK T1554)#

Inspect (compare installed files with the package database):
  dpkg --verify                           # Debian: "??5??????" = new hash
  debsums -c                              # Debian: changed files (debsums)
  rpm -Va                         # RHEL: S size, 5 hash, T time, P caps
  rpm -Va | grep -v ' c /'                # skip expected config edits
Flags: a hash change (5) on /usr/bin, /usr/sbin or library files such as
sshd, sudo, login, ls, ps or ss. Changed config files (c) are often
normal; read them anyway, always for /etc/ssh, /etc/sudoers and
/etc/pam.d. The database itself can be tampered with, so
compare against a known-good host or install media when it matters.

FAST TRIAGE PASS#

1. Units: the find -ctime line (new units), list-unit-files --state=enabled,
   list-timers --all, systemd-delta (edited packaged units), plus every
   ~/.config/systemd and /var/lib/systemd/linger.
2. Cron: /etc/cron*, the user spool and atq.
3. Keys and accounts: every authorized_keys, UID 0, sudoers NOPASSWD.
4. Loader and kernel: /etc/ld.so.preload, lsmod, modprobe.d install lines.
5. Hooks: PAM, udev RUN, apt/dnf hooks, update-motd.d, .pth files.
6. Integrity: dpkg --verify or rpm -Va, then setuid and capabilities.
7. Order hits by ctime and match them with the incident window and with
   auth logs (see the Linux Forensics and Auditd sheets).

Collect at scale: osquery tables crontab, systemd_units, authorized_keys,
kernel_modules, suid_bin; Velociraptor artifacts Linux.Sys.Crontab,
Linux.Sys.Services, Linux.Ssh.AuthorizedKeys, Linux.Sys.SUID (see the
Osquery and Velociraptor sheets).

Paths and options checked against the systemd, udev, OpenSSH, bash, glibc,
kmod, Linux-PAM, sudo, apt, dpkg and rpm manual pages, the Python site docs
and the freedesktop autostart spec; technique IDs per MITRE ATT&CK
Enterprise v20. Commands tested on Debian 13 and Rocky Linux 9.

Defensive reference on CyberRamen. Offensive / red-team sheets live on OffensiveRamen.com.