Cron Security: Hardening Scheduled Jobs on Linux
Contents
Securing cron means running each job as the least-privileged account that can do the work, making sure nothing a privileged job runs, reads, or writes can be changed by a less-privileged account, keeping secrets out of crontab lines, and watching cron's files, because attackers use cron to persist. The cron daemon checks the crontab file and nothing else: not the scripts it starts, the directories they live in, or the PATH it searches. Those are what you have to check yourself.
This guide was checked on October 25, 2026 against cronie 1.7.2 (Fedora, RHEL and derivatives, Arch), Debian's cron 3.0pl1-197 (Debian 13; Ubuntu packages the same cron), BusyBox 1.37.0 (Alpine), audit 4.2.1, and systemd 262.
What Cron Checks Before It Loads a Crontab#
| cronie 1.7.2 | Debian cron 3.0pl1 | BusyBox crond 1.37.0 | |
|---|---|---|---|
| User crontab | Owned by root or that user; not executable; not group- or other-writable; one hard link | Owned by root or that user; mode exactly 0600; not a symlink; one hard link |
Owned by root; mode not checked |
/etc/crontab, /etc/cron.d/* |
Owned by root; not executable; not group- or other-writable | Owned by root, and so is a symlink to it; not group- or other-writable; one hard link | Not read |
| A file that fails | Skipped and logged as BAD FILE MODE, WRONG FILE OWNER, NOT REGULAR, or BAD LINK COUNT |
Skipped and logged as INSECURE MODE, WRONG FILE OWNER, or NUMBER OF HARD LINKS > 1 |
Skipped without a log line |
Sources: cronie database.c, Debian database.c, BusyBox crond.c.
On cronie, make sure CRONDARGS in /etc/sysconfig/crond (cronie.systemd) doesn't include -p, which "allows Cron to accept any user set crontables" and turns these checks off (cron(8)).
That is where the checks end. Debian's source notes that it doesn't check parent directories and assumes "root will handle responsible" (database.c). run-parts, which runs /etc/cron.daily and its siblings, requires only that each file be a regular, executable file (run-parts.c). A root-owned crontab line that runs /opt/app/bin/cleanup is only as safe as that file and every directory above it.
Run Each Job as a Dedicated, Unprivileged Account#
A job running as root turns every bug in it, and every file it trusts, into a root compromise. Give each job its own system account with access to only what it touches, and schedule it from a file in /etc/cron.d, whose lines name the account to run as:
sudo useradd --system --create-home --home-dir /var/lib/report-export \
--shell /usr/sbin/nologin report-export
printf '%s\n' '15 3 * * * report-export /opt/report-export/bin/run' |
sudo install -o root -g root -m 0644 /dev/stdin /etc/cron.d/report-export--system skips the home directory unless you add --create-home (useradd(8)). Cron starts jobs there, and cronie won't run a job whose home directory is missing (security.c). The nologin shell blocks logins, not cron jobs: cronie and Debian run jobs with /bin/sh unless the crontab sets SHELL, and BusyBox with crond's own shell (cronie, Debian, BusyBox). On Alpine, which has no /etc/cron.d, use /etc/crontabs/report-export. Under systemd, the job is a timer whose service sets User=; see systemd timers.
Job scripts can refuse to run as root, as the template in shell script best practices for cron does. Keep service accounts away from crontab, too, because systemd's sandboxing doesn't follow work handed to cron: NoNewPrivileges= "has no effect on processes potentially invoked on request of them through tools such as at, crontab, systemd-run" (systemd.exec(5)).
Control Who Can Schedule Jobs#
/etc/cron.allow and /etc/cron.deny decide who may run the crontab command. If cron.allow exists, only users listed in it can; otherwise, everyone not listed in cron.deny can. When neither file exists, cronie allows only root (crontab(1)) and Debian allows everyone (misc.c). Root is always allowed. For an allowlist:
printf '%s\n' deploy | sudo install -o root -g root -m 0644 /dev/stdin /etc/cron.allowKeep it readable: Debian's cron denies every user if cron.allow or cron.deny isn't readable by everyone or by the crontab group (crontab(1)). BusyBox's crontab reads neither file (crontab.c); on Alpine, ordinary users need the setuid helper from the busybox-suid package to run it (bbsuid.c).
These files have limits:
- They gate the command, not the jobs. Restricting
crontab"has no effect on an existing crontab of a user" (crontab(1)). Remove a denied user's crontab withsudo crontab -u alice -r. - They don't cover
at.athas its own/etc/at.allowand/etc/at.deny; if neither exists, only root may use it (at.allow(5)). - They don't cover systemd user timers. systemd's default polkit policy lets any user enable lingering for their own account (policy), which starts their user manager, and their timers, at boot (loginctl(1)). Lingering accounts appear as files in
/var/lib/systemd/linger/(logind-user.c).
PAM, by contrast, applies when jobs run. Both daemons check the account's PAM account stack before each job, except cronie for root's system-crontab jobs (Debian, cronie), so an expired account's jobs don't start (pam_unix).
Nothing a Root Job Uses Should Be Writable by Another Account#
If root's job runs a script that deploy can edit, deploy can run anything as root. The same goes for any directory above that script, a file it sources, or a missing script in a directory someone else can write to. A procedure in ATT&CK's cron technique, campaign C0064, did this: the intruder edited a script a root crontab ran, then restored the file's modification time so it looked untouched.
Writes count too. If root appends to /var/log/app/sync.log and appuser owns /var/log/app, appuser can replace the log with a symlink to any file. The kernel's protected_symlinks setting blocks this only in sticky, world-writable directories such as /tmp, not when "the directory owner matches the symlink's owner" (kernel docs).
This script, for cronie, Debian, and Alpine systems, lists the paths in an account's cron entries, and their parent directories, that another account could change. For root it also reads /etc/anacrontab and the periodic directories:
#!/bin/sh
# cron-writable-paths.sh [account]: paths an account's cron jobs use that another
# account could change. Run as root. Read-only.
account=${1:-root}
id "$account" > /dev/null || exit 1
# Absolute paths in commands that crontab file $2 runs as $account.
# $1: user, system (/etc/crontab, /etc/cron.d), or anacron.
paths_in() {
[ -f "$2" ] || return 0
awk -v kind="$1" -v who="$account" -v q="'" '
/^[[:space:]]*(#|$)/ { next } # comment or blank
/^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/ { next } # variable
{
f = ($1 ~ /^-?@/) ? 2 : 6 # @daily-style lines have one time field
if (kind == "anacron") f = 4 # period, delay, job id, command
if (kind == "system") { if ($f != who) next; f++ }
for (i = f; i <= NF; i++) {
w = $i; gsub(q, "", w); gsub(/["`]/, "", w); gsub(/[;|&<>()=]/, " ", w)
n = split(w, part, " ")
for (j = 1; j <= n; j++) if (part[j] ~ /^\/[^\/]/) print part[j]
}
}' "$2"
}
# Show the path and its parents (and a symlink's target) if owned by someone other
# than root or $account, or writable by group or others.
check() {
for p in "$1" "$(readlink -f "$1" 2>/dev/null)"; do
while [ -n "$p" ]; do
find "$p" -maxdepth 0 \( -type f -o -type d \) \
\( ! -user root ! -user "$account" -o -perm /022 \) -exec ls -ld {} \; 2>/dev/null
parent=$(dirname "$p")
[ "$parent" = "$p" ] && break
p=$parent
done
done
}
{
for f in /var/spool/cron/"$account" /var/spool/cron/crontabs/"$account" /etc/crontabs/"$account"; do
paths_in user "$f"
done
for f in /etc/crontab /etc/cron.d/*; do paths_in system "$f"; done
if [ "$account" = root ]; then
paths_in anacron /etc/anacrontab
for f in /etc/cron.hourly/* /etc/cron.daily/* /etc/cron.weekly/* /etc/cron.monthly/* /etc/periodic/*/*; do
[ -e "$f" ] && printf '%s\n' "$f"
done
fi
} | sort -u | while IFS= read -r path; do check "$path"; done | sort -uNo output means nothing was found. A line such as drwxrwxr-x 2 deploy deploy 4096 Oct 20 11:02 /opt/app/bin means deploy controls what root runs. The script sees only literal absolute paths, not paths built from variables or what the scripts themselves run. For those, namei -l /opt/app/bin/cleanup prints the owner and mode of every component (namei(1)). Fix a finding by making the path root-owned and not group- or other-writable, or, usually better, by running the job as the account that owns those files.
PATH Lookups and Wildcards in Privileged Jobs#
Relative Command Names#
A command written without a slash is looked up in each PATH directory in turn (POSIX), so a writable directory early in root's PATH lets its owner shadow tar or run-parts. ATT&CK tracks this as T1574.007. Debian's /etc/crontab puts /usr/local/sbin:/usr/local/bin first (crontab.main), and an empty element, as in :: or a trailing colon, means the current directory. Check every directory on the PATH a root job uses:
p='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' # the job's PATH
echo "$p" | tr ':' '\n' | xargs -n 1 namei -lEvery component should be root-owned with no group or other w. In root's jobs, use absolute command paths and set PATH to root-owned directories.
Globs in Directories Other Accounts Write To#
When * expands, a file name that begins with - becomes an option to the command, the argument injection described in CWE-88. Some options run programs: GNU tar 1.35 can execute a command at each checkpoint (manual). A root job running tar * in an upload directory lets anyone who can create files there choose tar's options:
# Risky: runs as root and expands * in a directory other accounts can write to
0 3 * * * root cd /srv/uploads && tar -czf /var/backups/uploads.tgz *
# Safer: no glob, so no file name can become an option
0 3 * * * root tar -C /srv/uploads -czf /var/backups/uploads.tgz .Where a glob is unavoidable, write ./*, or put -- before it, which ends option parsing for utilities that follow POSIX Guideline 10. Better still, run the job as the directory's owner. For temporary files, use mktemp rather than fixed names in /tmp, or give a systemd service its own /tmp with PrivateTmp=.
Keep Secrets Out of Crontab Lines#
A secret typed into a crontab line, such as mysqldump -pS3cret or curl -H "Authorization: Bearer ...", is copied to several places:
| Where it goes | Who can read it |
|---|---|
| The running job's command line | Every user, through ps; cronie and Debian start each job as sh -c 'the command', and /proc/pid/cmdline is readable by all unless /proc is mounted with hidepid |
| cron's log of each job | cronie and Debian log CMD (the command) by default; the system journal is readable by members of systemd-journal, adm, and wheel (journalctl(1)) |
| Cron mail, for crontab variables | Whoever receives it: cronie and Debian add an X-Cron-Env header for every variable (cronie, Debian) |
| Crontab backups and configuration repos | Anyone with access to them |
BusyBox crond also logs each command at its default log level (crond.c). Find likely secrets already sitting in crontabs:
sudo grep -r -n -i -E '(pass(word|wd)?|token|secret|api[_-]?key)[[:space:]]*=|--password|bearer |://[^/@[:space:]]+:[^/@[:space:]]+@' \
/etc/crontab /etc/cron.d /var/spool/cron /etc/crontabs 2>/dev/null-r lets grep, as root, read the spool directories, which your own shell couldn't glob. It misses tokens without a telltale name. On Debian it also searches pending at jobs, which keep the environment they were submitted with (at(1)).
Where the Secret Goes Instead#
Put it in a file owned by the job's account with mode 0600 and have the job's wrapper load it, as the wrapper in cron environment variables does. Hand it to programs through a file or the environment, not arguments: a process's environment passes a ptrace access check before anyone can read it (proc_pid_environ(5)), while its command line is public. Avoid set -x, which writes each command to standard error after expansion (POSIX), values included, and cron mails standard error. Set umask 077 or 027 in the script, too: neither cronie nor Debian's cron sets one for jobs, and neither project's systemd unit sets UMask=, so jobs start with systemd's default 0022 (systemd.exec(5)) unless a PAM module changes it.
On a systemd timer, use credentials, not Environment=, which is exposed to unprivileged clients over D-Bus (systemd.exec(5)). LoadCredential= (systemd 247+) copies a file into a directory only the unit's user and root can read, named in $CREDENTIALS_DIRECTORY; the source can stay root:root 0600, since only the service manager reads it (systemd.exec(5)):
[Service]
Type=oneshot
User=report-export
UMask=0077
LoadCredential=db-password:/etc/report-export/db-password
ExecStart=/opt/report-export/bin/runThe job reads "$CREDENTIALS_DIRECTORY/db-password". LoadCredentialEncrypted= takes a file made with systemd-creds encrypt, so the secret isn't stored in plaintext.
Monitoring Check-Ins Carry No Secrets#
A check-in needs only a monitor key, the schedule, and perhaps a run ID or short failure message, never job data or credentials. With CronRadar, the one secret is the application API key. There's no CronRadar SDK or auto-discovery for cron, so each job sends its own HTTP check-in, as in the dead man's switch wrapper. Keep the key in a file only the job's account can read and pass it with --config, not --user: curl hides a --user argument from process listings, but it is "still visible for a moment" and such data "should be retrieved from a file instead" (curl):
# In the job's wrapper, after the work succeeds. The config file is mode 600,
# owned by the job's account, and holds one line: user = "ck_app_12345_abcdef:"
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--config /etc/report-export/cronradar.curl --get \
--data-urlencode "schedule=15 3 * * *" \
https://cron.life/api/v1/ping/report-export || trueThe key travels in the Basic auth header, not the URL, which keeps it out of proxy logs (API reference). CronRadar reads the schedule in UTC.
Cron as a Persistence Mechanism: Audit and Detect#
ATT&CK lists cron abuse as T1053.003 under Execution, Persistence, and Privilege Escalation (ATT&CK v20), next to at and systemd timers.
Inventory Every Schedule#
The read-only script in the crontab guide prints every crontab, anacron, the periodic directories, and system timers. Add what it doesn't cover:
sudo atq # every user's at jobs; sudo at -c 12 prints job 12
ls /var/lib/systemd/linger/ # accounts whose user timers run without a login
sudo systemctl --user [email protected] list-timers --all # alice's timers, if her manager runsRun by root, atq lists everybody's jobs (at(1)); [email protected] connects to that user's service manager (systemctl(1)). Save the output with content hashes of the files, and compare on a schedule:
sudo find /etc/crontab /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly \
/etc/cron.monthly /etc/anacrontab /var/spool/cron /etc/crontabs -type f \
-exec sha256sum {} + 2>/dev/null | sort -k 2 > "cron-$(hostname)-$(date +%F).sha256"
diff cron-web1-2026-10-18.sha256 cron-web1-2026-10-25.sha256Compare hashes, not timestamps, which C0064 reset, and add the scripts root's jobs run. Keep baselines on another host, where root on this one can't rewrite them. AIDE (0.19.5, checked October 25, 2026) does the same for any paths you configure. When the intended crontabs live in a repository, crontab version control shows how to detect drift against it.
Record Every Change With auditd#
auditd records who changed a file and with which program, including edits that bypass crontab. In audit 4.2.1, auditctl calls the -w path -p wa watch form "deprecated due to poor system performance" (auditctl(8)). Write syscall rules with path= for a file and dir= for a directory tree (audit.rules(7)), in b32 and b64 pairs like the project's sample rules. This writes rules for the paths that exist on the host:
for p in /etc/crontab /etc/anacrontab /etc/cron.allow /etc/cron.deny /etc/at.allow /etc/at.deny \
/etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly \
/var/spool/cron /var/spool/at /etc/crontabs /etc/systemd/system /var/lib/systemd/linger; do
if [ -d "$p" ]; then field="dir=$p/"; elif [ -e "$p" ]; then field="path=$p"; else continue; fi
for arch in b32 b64; do
echo "-a always,exit -F arch=$arch -F $field -F perm=wa -F key=cron"
done
done | sudo tee /etc/audit/rules.d/50-cron.rules > /dev/null
sudo augenrules --load
sudo auditctl -l -k cronperm=wa records writes and attribute changes, and augenrules merges /etc/audit/rules.d/*.rules and loads them (augenrules(8)). Review events with sudo ausearch -k cron -i -ts today (ausearch(8)). Root can unload rules unless the configuration is locked with -e 2, which takes a reboot to undo, so forward audit logs off the host. Users' timers in ~/.config/systemd/user/ aren't covered; the linger directory shows who can run them unattended.
Read Cron's Own Logs#
On cronie and Debian, the crontab command logs under the syslog identifier crontab as (invoking user) ACTION (target user), with LIST, DELETE, BEGIN EDIT, REPLACE, or END EDIT, and AUTH (crontab command not allowed) for a denied user (cronie crontab.c, Debian crontab.c). The daemon logs RELOAD when a crontab it already loaded changes, and the check failures from the table above:
journalctl -t crontab --since "7 days ago" # (root) REPLACE (alice)
journalctl -u crond --since "7 days ago" | grep -E 'RELOAD|BAD |WRONG|INSECURE|LINK|REGULAR|ORPHAN'Use -u cron on Debian and Ubuntu and -u cronie on Arch. A file written straight into /etc/cron.d or the spool leaves no crontab line, which is why the audit rules matter. On cronie, an entry in a system crontab or root's crontab that begins with - isn't logged when it runs (crontab(5), entry.c), so look for such lines.
Frequently Asked Questions#
What is cron.allow?#
/etc/cron.allow lists the users, one per line, allowed to run the crontab command. When it exists, everyone else is refused and /etc/cron.deny is ignored; root is always allowed. It doesn't stop crontabs already installed, at jobs, or systemd user timers, and BusyBox ignores it.
Should cron jobs run as root?#
Only when the work needs root. Run everything else as a dedicated system account. When a job must run as root, its script, the files it uses, and every directory above them and on its PATH should be root-owned and not writable by group or others.
How do attackers use cron for persistence?#
They add an entry to a crontab, drop a file into /etc/cron.d or a periodic directory, or edit a script an existing root job runs, so the schedule looks unchanged. MITRE ATT&CK tracks this as T1053.003. auditd rules and a hash baseline of cron's files catch the first two; for the third, keep root's scripts unwritable by others and hash them too.
How do I audit all cron jobs on a server?#
List user crontabs in the spool directory, /etc/crontab, /etc/cron.d, the periodic directories, /etc/anacrontab, atq as root, system timers, and user timers of accounts in /var/lib/systemd/linger. Check what root's jobs run for paths others can modify, and diff the results on a schedule against a baseline kept off the host.
Where should a cron job keep its secrets?#
In a file owned by the job's account with mode 0600, loaded by the job's wrapper, or in a systemd credential for a timer. Not in the crontab line, which shows up in ps, cron's log, and backups, and not in a crontab variable, which cronie and Debian copy into cron mail headers. Pass it to programs through files or the environment, not arguments.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.