Debian GNU/Linux · Services

Allow only listed users to schedule jobs with crontab and at

When it fails: Any local user can schedule jobs with crontab or at. Scheduled jobs let an intruder persist and run code unattended, even after logging out.

CB-DEB-SVC-019mediumactiveautomatedautomated fixprofile core

As released in v0.1.1 beta · pre-release. Current state.

Description

crontab and at read an allow list and a deny list:

  • /etc/cron.allow and /etc/cron.deny for crontab;
  • /etc/at.allow and /etc/at.deny for at.

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); Debian cloud images install neither. 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

  1. Run stat -Lc '%n %U:%G %a' /etc/cron.allow /etc/at.allow. Each allow file must exist, belong to root, with group root or crontab (cron) / daemon (at), and mode 640 or stricter.
  2. /etc/cron.deny and /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

  1. cron: printf 'root\n' > /etc/cron.allow, add the other allowed users one per line, then chown root:crontab /etc/cron.allow and chmod 640 /etc/cron.allow. Apply the same owner and mode to /etc/cron.deny if it exists.
  2. at: same steps for /etc/at.allow and /etc/at.deny with group daemon.
Restartnone
Disruptionlow

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

Mappings

FrameworkIDRelation
NIST SP 800-53 Rev. 55.1.1AC-6related
NIST SP 800-53 Rev. 55.1.1CM-7related

How this control relates to the other framework's: equivalent, stricter, looser, partial or related.

Threats mitigated

People and lifecycle

Authors
Verified2026-10-18 by Sn0wAlice on 12, Proxmox VM, Debian 12 genericcloud image, kernel 6.1.0-54-cloud-amd64. Not applicable on fresh image; with cron and at installed the allow files are missing, fix creates them root:crontab and root:daemon 0640, audit passes after reboot; a non-listed user is refused by crontab.
Verified2026-10-18 by Sn0wAlice on 13, Proxmox VM, Debian 13 genericcloud image, kernel 6.12.113+deb13-cloud-amd64. Not applicable on fresh image; with cron and at installed the allow files are missing, fix creates them root:crontab and root:daemon 0640, audit passes after reboot; a non-listed user is refused by crontab.
Introduced inv0.1.0
Created, updated2026-10-18, 2026-10-18
Translationsfr