Scheduled jobs: cron and timers
Inventory jobs and close writable-path traps.
A scheduled job runs unattended, often as root, and runs whatever its files say at that moment. That makes every job a standing privilege: whoever can change the job, its script, or a directory on its path, gets their code run with the job's rights. In this lesson you list every place a job can live on Ubuntu and RHEL, build and then fix the two classic weaknesses in a root job, restrict who may create crontabs (and see what that restriction does not do), and move the job to a timer that runs a sandboxed service under its own account. Writing crontabs and timers is covered in Linux essentials; this lesson is about controlling them.
Every place a job can live
You cannot protect jobs you have not found. On a default Ubuntu Server 26.04 install, start with the system crontab and the drop-in directory. Both use a format with a user field, so each line names the account the job runs as.
The four /etc/crontab lines run the scripts in /etc/cron.hourly, daily, weekly and monthly through run-parts, unless anacron is installed. e2scrub_all does nothing on a systemd host because of its test -e /run/systemd/system guard; its work is done by a timer. Then the directories run-parts executes, and the two schedulers a fresh install does not have:
Neither anacron nor at is installed, so the daily scripts run at 06:25 from /etc/crontab and there is no at queue to check. Per-user crontabs live in a spool directory that only root can list, and cron.allow and cron.deny do not exist, which on Debian and Ubuntu means every account may create a crontab (crontab(1)):
Finally the timers. --all includes timers that are loaded but not scheduled. System timers live in /usr/lib/systemd/system (packages) and /etc/systemd/system (local); timers for per-user managers live in /etc/systemd/user and each user's ~/.config/systemd/user.
A default install has 19 system timers and one global user timer from python3-launchpadlib. Save this whole inventory on a host you trust and compare it later; a new entry you did not create is a finding. On RHEL the per-user spool is /var/spool/cron and the daemon is crond (see the end of the lesson).
What cron refuses, and what it does not
Cron has some protection of its own. To see it, the lab set up a small, deliberately weak arrangement. An operators' group and one developer in it: sudo groupadd hard-cron-ops and sudo useradd -m -s /bin/bash -G hard-cron-ops hard-cron-dev. Some data in /srv/hard-cron, and a backup script owned root:hard-cron-ops with mode 0775, so the group may edit it. For the demonstration only, /usr/local/bin was given the same group and mode 0775 (the lab restores it afterwards). Then three files in /etc/cron.d: the backup job below, hard-cron-report.cron (a dot in its name) and hard-cron-gw (group hard-cron-ops, mode 0664); the last two each run logger -t with their own tag. All three ask to run every minute.
#!/bin/sh# Back up /srv/hard-cron (the lab's stand-in for application data).logger -t hard-cron-backup "start: user=$(id -un) PATH=$PATH"tar -czf /var/backups/hard-cron/data.tgz -C /srv hard-cronlogger -t hard-cron-backup done
# SecOpsLog lab: back up /srv/hard-cron. A real job would run nightly;# this one runs every minute so the lab can watch it.* * * * * root /usr/local/sbin/hard-cron-backup
Cron refused the group-writable file with INSECURE MODE and ran the backup job. It said nothing about hard-cron-report.cron: on Debian and Ubuntu, files in /etc/cron.d with a dot in the name are ignored silently (cron(8)), so that job simply never runs. Searching the journal by the three jobs' tags (-t) confirms it: only the backup job logged anything, and its start: line is the script reporting its own user and PATH. (A line logged by a short-lived job is not always attributed to cron.service, which is why the tag search is the reliable one.) The job runs as root with the PATH from /etc/environment, not cron's traditional /usr/bin:/bin, which matters in a moment. run-parts has the same name rule for the daily directories:
backup.sh would never run from /etc/cron.daily on Ubuntu, and nothing tells you. The group-writable report is listed, so run-parts checks the name, not who can write the script. RHEL's run-parts from cronie runs all three (below).
Writable scripts and the PATH a job really gets
Cron checks its own files. It does not check the script a job runs, or the directories on the job's PATH. Audit both: any file or directory a root job uses that is writable by a group or by everyone. -perm /022 matches group write or other write, and namei -l prints the owner and mode of every component of a path.
Three findings. The group-writable cron file is already refused by cron. The script is owned by root but writable by hard-cron-ops, whose one member is hard-cron-dev. And /usr/local/bin is writable by the same group. Why that directory matters is in the third command: on Ubuntu 26.04, cron starts with -P (do not set PATH) and its PAM session loads /etc/environment, so a root job without its own PATH= line searches /usr/local/sbin and /usr/local/bin before /usr/bin. The backup script calls tar by bare name; a file named tar in /usr/local/bin would run as root in its place. The first weakness is quicker to prove. A developer in the group adds one line to the script:
Within a minute root ran the developer's line. No exploit was needed, only write access to a file that root runs. Group write on scripts and tool directories is usually granted for a good reason (the operators deploy the scripts) and that reason needs a different answer: a package, configuration management, or a reviewed change, not write access to what root executes. The fix is ownership, mode, a PATH of root-owned directories in the job file, and absolute paths in the script.
# SecOpsLog lab: back up /srv/hard-cron. A real job would run nightly;# this one runs every minute so the lab can watch it.# Only root-owned system directories, in case the script calls a program by name.PATH=/usr/sbin:/usr/bin* * * * * root /usr/local/sbin/hard-cron-backup
The audit prints nothing, every path component is root root without group write, the developer's write is refused, and the next run logs the job's new PATH. The operational impact is that the operators lose direct edits; the rollback is chgrp and chmod g+w, which you should not need. Root ownership and restrictive modes for the cron files themselves are a CIS recommendation (the CIS RHEL 10 Level 1 server profile in scap-security-guide 0.1.82 sets /etc/crontab to 0600 and each cron directory to 0700); auditing what the jobs run, the PATH line and absolute paths are SecOpsLog advice.
Who may create crontabs
Per-user crontabs are the other way to schedule work, and every account can create one by default. The lab's developer has one, created before any restriction with crontab -e as hard-cron-dev; its one line is * * * * * logger -t hard-cron-devjob "hard-cron-dev job ran". /usr/bin/crontab is SGID crontab, which is how it writes into a spool directory users cannot open. /etc/cron.allow restricts the command to the accounts it lists; on Debian and Ubuntu it must be readable by the crontab group.
The developer is refused and deploy is allowed (it has no crontab yet, which is a different message). Now the part the name suggests but the file does not do:
The developer's existing crontab ran at the first minute after cron.allow was created. The file controls the crontab command, not the jobs already in the spool; cronie's crontab(1) says so explicitly, and Ubuntu's cron behaves the same way. Remove the crontabs of accounts you deny:
crontab -r -u refuses a user who is not allowed, even when root runs it, so delete the spool file directly (cron notices the change within a minute) and a minute later check the journal for that tag: --since -1min finds no entries. The CIS RHEL 10 profile requires /etc/cron.allow to exist with mode 0640, group root, and /etc/cron.deny not to exist. On Ubuntu that exact mode locks everyone out:
crontab runs with the crontab group, cannot read a root:root 0640 file, and denies everyone except root. On Ubuntu use root:crontab 0640. The impact of cron.allow is that each new user who genuinely needs cron has to be added; the rollback is deleting the file. Root is always allowed.
A timer that runs a sandboxed service
The strongest fix for a root job is to stop running it as root. A systemd timer starts a service, and the service can run as a dedicated account inside a sandbox, so even a modified script can only do what that account and sandbox allow. The lab creates a system account, hard-cron-bkp, that owns only the backup directory, rewrites the script with absolute paths, and replaces the cron file with two units. Every comment sits on its own line: systemd does not accept a comment after a value, and a setting written that way is ignored (the sandboxing lesson shows this and every directive below in detail).
#!/bin/sh# Back up /srv/hard-cron (the lab's stand-in for application data).# Every program is called by its absolute path, so PATH cannot redirect it./usr/bin/logger -t hard-cron-backup "start: user=$(/usr/bin/id -un) PATH=$PATH"/usr/bin/tar -czf /var/backups/hard-cron/data.tgz -C /srv hard-cron/usr/bin/logger -t hard-cron-backup done
[Unit]Description=Back up /srv/hard-cron[Service]Type=oneshot# A dedicated account: it can read the data and write only its backups.User=hard-cron-bkpGroup=hard-cron-bkp# The whole filesystem is read-only for this job, except the backup directory.ProtectSystem=strictReadWritePaths=/var/backups/hard-cron# No home directories, a private /tmp, and no way to gain privileges.ProtectHome=yesPrivateTmp=yesNoNewPrivileges=yesExecStart=/usr/local/sbin/hard-cron-backup
[Unit]Description=Back up /srv/hard-cron every night[Timer]OnCalendar=*-*-* 02:30:00# Start up to 15 minutes late, so many hosts do not start at the same moment.RandomizedDelaySec=15min# If the host was off at 02:30, run the backup at the next boot.Persistent=true[Install]WantedBy=timers.target
systemd-analyze verify found nothing wrong in the two new units; the two lines it printed are about a unit shipped by xfsprogs that it loaded along the way. Starting the service by hand is the test run: the script's own log lines, selected by their tag with -t, show that it ran as hard-cron-bkp, and it wrote a backup it owns; the timer shows its next run with the random delay applied. (journalctl -u hard-cron-backup.service adds systemd's own lines about the run, but it can miss lines sent by logger: the journal ties a message to a unit by looking up the process that sent it, and logger may already have exited. In the lab's runs the unit view sometimes lacked the done line.) 8.3 EXPOSED is well short of a locked-down unit, and the sandboxing lesson shows the further directives (system call filters, capability bounding, address families) that bring it down. The impact is that the job can no longer touch anything outside its directory, which is the point, so test it once by hand after every change. The rollback is sudo systemctl disable --now hard-cron-backup.timer and restoring the cron file. Every change to a job file or unit directory is also worth recording, with rules in the auditd lesson's form: for example -a always,exit -F arch=b64 -F dir=/etc/cron.d/ -F perm=wa -F key=cron, one line per directory listed at the start of this lesson, and -F path=/etc/crontab for the single file.
One limit of the pattern. The lab job works unprivileged because the account can read the lab data. A real system backup reads /etc, other services' data or databases, which takes root or CAP_DAC_READ_SEARCH (AmbientCapabilities= in the unit). That capability reads /etc/shadow and every private key on the host, so the backup account then becomes as valuable a target as root. The timer still buys write confinement (ProtectSystem=strict) and no privilege gain through SUID programs, but guard such an account, its script and its unit like root's.
On RHEL 10
RHEL uses cronie: the daemon is crond, the per-user spool is /var/spool/cron, and an empty /etc/cron.deny ships, which allows every account. Without either file, cronie allows only root (its crontab(1)). The daily, weekly and monthly scripts run through anacron, started from /etc/cron.hourly/0anacron, and cronie's run-parts skips only editor and package leftovers such as *~ and *.rpmnew:
So a backup.sh in /etc/cron.daily runs on RHEL but not on Ubuntu, and cronie's /etc/cron.d/0hourly sets a PATH without /usr/local.
Try this
On your Ubuntu lab machine, create a user crontab for a test account with a job that runs logger -t trycron ran every minute, then create /etc/cron.allow (root:crontab, 0640) that lists only your admin account. Confirm with journalctl -t trycron -f that the job keeps running, that sudo crontab -r -u refuses the test account, and that deleting its file from /var/spool/cron/crontabs stops it within a minute. Then put a script named check.sh in a scratch directory, make it executable, and run run-parts --test on the directory; rename it to check and run the test again.
Takeaway
Inventory every scheduler, and for each root job make sure nothing it runs or searches is writable by anyone but root. Better still, move it to a timer whose service runs as its own account in a sandbox, and remove the crontabs of accounts you deny rather than trusting cron.allow to stop them.