Description
crontab and at read an allow list and a deny list:
/etc/cron.allowand/etc/cron.denyforcrontab;/etc/at.allowand/etc/at.denyforat.
When the allow file exists, only the users it lists may schedule jobs, and the deny file is
ignored. The allow file must exist, belong to root, with group root or the group the tool
runs as (crontab for cron, daemon for at), and mode 0640 or stricter. A deny file, if
present, must have the same ownership and mode.
The checks run only for the tools that are installed (facts cron and at). Ubuntu installs
cron everywhere, and at only on 20.04 (pulled in by the ubuntu-server metapackage). crontab and at read these files through their setgid group, so a
group other than root must be crontab or daemon for them to work.
Rationale
An allow list is safer than a deny list: a new account cannot schedule jobs until someone decides it should. Scheduled jobs are a common persistence technique, and restricting who can create them reduces that option for a compromised account.
Impact
Users who are not listed in the allow file can no longer create or change crontabs or at jobs.
Add the accounts that need them (service accounts included) before applying. The fix creates
missing allow files with root as the only entry.
Audit
- Run
stat -Lc '%n %U:%G %a' /etc/cron.allow /etc/at.allow. Each allow file must exist, belong toroot, with grouprootorcrontab(cron) /daemon(at), and mode640or stricter. /etc/cron.denyand/etc/at.deny, if they exist, must meet the same rules.
Automated: every check must pass. Scanners report each check by its id.
cron-allow
/etc/cron.allow exists with safe ownership and mode.
script: |
f=/etc/cron.allow
[ -f "$f" ] || { echo "$f missing"; exit 1; }
set -- $(stat -Lc '%U %G %a' "$f")
echo "$f $1:$2 $3"
[ "$1" = root ] && { [ "$2" = root ] || [ "$2" = crontab ]; } && [ $((0$3 & 0137)) -eq 0 ]
cron-deny
/etc/cron.deny is absent or has safe ownership and mode.
script: |
f=/etc/cron.deny
[ -e "$f" ] || { echo "$f absent"; exit 0; }
set -- $(stat -Lc '%U %G %a' "$f")
echo "$f $1:$2 $3"
[ "$1" = root ] && { [ "$2" = root ] || [ "$2" = crontab ]; } && [ $((0$3 & 0137)) -eq 0 ]
at-allow
/etc/at.allow exists with safe ownership and mode.
script: |
f=/etc/at.allow
[ -f "$f" ] || { echo "$f missing"; exit 1; }
set -- $(stat -Lc '%U %G %a' "$f")
echo "$f $1:$2 $3"
[ "$1" = root ] && { [ "$2" = root ] || [ "$2" = daemon ]; } && [ $((0$3 & 0137)) -eq 0 ]
at-deny
/etc/at.deny is absent or has safe ownership and mode.
script: |
f=/etc/at.deny
[ -e "$f" ] || { echo "$f absent"; exit 0; }
set -- $(stat -Lc '%U %G %a' "$f")
echo "$f $1:$2 $3"
[ "$1" = root ] && { [ "$2" = root ] || [ "$2" = daemon ]; } && [ $((0$3 & 0137)) -eq 0 ]
Remediation
- cron:
printf 'root\n' > /etc/cron.allow, add the other allowed users one per line, thenchown root:crontab /etc/cron.allowandchmod 640 /etc/cron.allow. Apply the same owner and mode to/etc/cron.denyif it exists. - at: same steps for
/etc/at.allowand/etc/at.denywith groupdaemon.
| Restart | none |
| Disruption | low |
Rollback
Delete /etc/cron.allow and /etc/at.allow (the deny files then apply again).
cron
f=/etc/cron.allow
if [ ! -e "$f" ]; then
cb_backup "$f"
printf 'root\n' >"$f"
chown root:crontab "$f"
chmod 0640 "$f"
fi
for f in /etc/cron.allow /etc/cron.deny; do
[ -e "$f" ] || continue
set -- $(stat -Lc '%U %G %a' "$f")
[ "$1" = root ] || chown root "$f"
[ "$2" = root ] || [ "$2" = crontab ] || chgrp crontab "$f"
[ $((0$3 & 0137)) -eq 0 ] || chmod u-x,g-wx,o-rwx "$f"
done
at
f=/etc/at.allow
if [ ! -e "$f" ]; then
cb_backup "$f"
printf 'root\n' >"$f"
chown root:daemon "$f"
chmod 0640 "$f"
fi
for f in /etc/at.allow /etc/at.deny; do
[ -e "$f" ] || continue
set -- $(stat -Lc '%U %G %a' "$f")
[ "$1" = root ] || chown root "$f"
[ "$2" = root ] || [ "$2" = daemon ] || chgrp daemon "$f"
[ $((0$3 & 0137)) -eq 0 ] || chmod u-x,g-wx,o-rwx "$f"
done
Handwritten code reads parameters from CB_<NAME> environment variables. Each release turns these fixes into remediate.sh and an Ansible role per profile: dry run by default, a fix runs only when its check fails.
References
- crontab(1), Ubuntu manual pages Canonical, vendor-doc, accessed 2026-10-19
- at(1), Ubuntu manual pages Canonical, vendor-doc, accessed 2026-10-19
Mappings
| Framework | ID | Relation |
|---|---|---|
| NIST SP 800-53 Rev. 55.1.1 | AC-6 | related |
| NIST SP 800-53 Rev. 55.1.1 | CM-7 | related |
How this control relates to the other framework's: equivalent, stricter, looser, partial or related.
Threats mitigated
People and lifecycle
| Authors |
|
| Verified | 2026-10-19 by Sn0wAlice on 20.04, Proxmox VM, Ubuntu 20.04 cloud image, kernel 5.4.0-216-generic. Stock image fails (no cron.allow, no at.allow; at installed by ubuntu-server); fix creates both with root only, audit passes after reboot. |
| Verified | 2026-10-19 by Sn0wAlice on 22.04, Proxmox VM, Ubuntu 22.04 cloud image, kernel 5.15.0-198-generic. Stock image fails (no cron.allow); fix creates it; with at installed at.allow is created too; audit passes after reboot. |
| Verified | 2026-10-19 by Sn0wAlice on 24.04, Proxmox VM, Ubuntu 24.04 cloud image, kernel 6.8.0-147-generic. Stock image fails (no cron.allow); fix creates it; with at installed at.allow is created too; audit passes after reboot. |
| Verified | 2026-10-19 by Sn0wAlice on 26.04, Proxmox VM, Ubuntu 26.04 cloud image, kernel 7.0.0-38-generic. Stock image fails (no cron.allow); fix creates it; with at installed at.allow is created too; audit passes after reboot. |
| Introduced in | v0.1.0 |
| Created, updated | 2026-10-19, 2026-10-19 |
| Translations | fr |