Handbook sections

Controlling cron and at access

Last reviewed

Scheduled jobs are an attacker's favourite way to stay. Lock down who may use cron and at with a default-deny allow-list so a foothold can't quietly become persistence.

The threat: scheduled jobs are turnkey persistence

Getting code onto a machine is only step one for an attacker; staying is the real prize. Scheduled jobs are the classic persistence mechanism: they are built into every Linux box, they survive reboots, and they run on their own with nobody logged in. The MITRE ATT&CK technique T1053.003 (Scheduled Task/Job: Cron) describes exactly this: drop a crontab entry or an at job and your payload re-launches every minute, every reboot, forever.

The danger is amplified by how permissive the defaults are. On many systems any user can write a crontab unless an allow-list says otherwise. If cron and at access is wide open, a compromised low-privilege account (a web service, a CI runner, a stale developer login) can schedule a beacon or a re-infection job nobody watches. A cron.deny-based model makes this worse: it blocks only named users, so any account you forgot to list is implicitly allowed.

Why harden it

Job scheduling is privilege that should be granted, not assumed. Flip the default from everyone may schedule to only an explicit, auditable set of accounts may schedule. That removes a turnkey persistence path from every compromised service account, shrinks the set of identities that can run code unattended, and makes scheduling an explicit, reviewable decision rather than a side effect of having a shell. Most ordinary users never need it, so the allow-list is usually short, and an empty-but-present cron.allow is a perfectly valid, very strong posture.

How does the allow/deny logic work?

The decision is allow-first, and the presence of cron.allow changes everything:

State of the files Who may use cron
cron.allow exists only users listed in it (cron.deny is ignored)
only cron.deny exists everyone except users listed in it (fail-open)
neither exists distro-dependent: root only (Debian) or everyone (others)

The hardened posture is therefore: create cron.allow (listing root and the few accounts that truly need it) and remove cron.deny. at uses the identical logic with at.allow / at.deny. Note that an allow-list gates user crontabs (crontab -e); the system crontab (/etc/crontab, /etc/cron.d, /etc/cron.daily...) runs regardless, which is why their file permissions matter just as much.

How do you set it up?

# cron: explicit allow-list, remove the fail-open deny-list
echo root > /etc/cron.allow
chown root:root /etc/cron.allow && chmod 600 /etc/cron.allow
rm -f /etc/cron.deny

# at: same model
echo root > /etc/at.allow
chown root:root /etc/at.allow && chmod 600 /etc/at.allow
rm -f /etc/at.deny

# lock the system cron surface (root-only)
chown root:root /etc/crontab /etc/cron.d /etc/cron.{hourly,daily,weekly,monthly}
chmod 600 /etc/crontab && chmod 700 /etc/cron.d /etc/cron.{hourly,daily,weekly,monthly}

What Pavois audits: the live filesystem state

Pavois's Cron/at access control domain enforces the default-deny model on the live system:

  • file-cron-allow-exists: /etc/cron.allow must exist (cron runs in allow-list mode, not the permissive default).
  • file-at-allow-exists: /etc/at.allow must exist, same model for at/batch.
  • file-cron-deny-absent: /etc/cron.deny must not exist, removing the fail-open deny-list.
  • file-at-deny-absent: /etc/at.deny must not exist, same reason on the at side.

Each control inspects the real filesystem with InSpec's file(...) { should exist / should_not exist }, so the verdict reflects what cron and at actually read at runtime. The companion ownership and permission checks on /etc/cron.allow, the cron.{d,daily,hourly} directories and crontab files live in Pavois's permissions and ownership domains, ensuring the access policy can't be tampered with by the users it restricts.

Verify

ls -l /etc/cron.allow /etc/at.allow      # present, root:root, 0600
ls /etc/cron.deny /etc/at.deny 2>&1      # should report 'No such file'
stat -c '%a %U' /etc/crontab /etc/cron.d # 600/700, root
# as a non-listed user, this must be refused:
sudo -u nobody crontab -e

Pitfalls

  • An empty cron.allow denies everyone, including admins, from user crontabs. That is intended; put root (and any real scheduler account) in it. System cron (/etc/cron.d, /etc/crontab) still runs.
  • cron.allow wins over cron.deny: listing a user in both allows them. Don't rely on deny once allow exists.
  • Removing cron.deny without creating cron.allow can, on some distros, fall back to everyone allowed. Create the allow-list first.
  • The allow-list does not inspect job content: it controls who may schedule, not what. Pair it with monitoring of the cron directories.

FAQ

cron.allow or cron.deny? cron.allow. It is default-deny (only listed users), whereas cron.deny is fail-open (anyone not named is allowed), so a forgotten account slips through.

Will an empty cron.allow stop system cron jobs? No. /etc/crontab and /etc/cron.d run regardless; the allow-list only gates per-user crontab. Lock the system files with permissions instead.

Why does Pavois check file existence, not a config value? Because cron/at decide access purely from whether these files exist and who they list; the live filesystem is the policy.