CoursesLinux hardeningScheduled-task hardening

Scheduled-task hardening

cron & timer privilege boundaries and PATH hijack.

Advanced12 min · lesson 5 of 16

A scheduled task is a standing order. You write a job down once (back up the database at 3am, rotate the logs every Sunday), and the machine carries it out on its own, forever, with nobody watching. Handy. Also dangerous, because a lot of those standing orders run as root (the all-powerful administrator account), and root does exactly what the order says, no questions asked. If an attacker can change what the order says, or trick it into running the wrong program, your machine will hand them root on a fixed schedule.

So hardening scheduled tasks is two jobs. First, make the jobs you already have impossible to turn against you. Second, know precisely which jobs exist, so the one you didn't write stands out the moment it appears. The essentials course showed you how to write a cron entry. This is about keeping the ones you have safe, and spotting the one that shouldn't be there.

Find Every Job Before You Trust Any Job

You can't secure a set of things you can't list. And scheduled work on a Linux box hides in more places than most people remember. There's cron (the classic Unix service that runs commands on a clock), which reads jobs from several directories. There are per-user crontabs tucked away in a spool directory. And there are systemd timers, a newer mechanism built into systemd (the program that starts and supervises services on nearly every modern Linux). An attacker only needs one of these to be unwatched. You need to enumerate all three.

Where scheduled work lives (your audit surface)
System cron (root-owned)
/etc/crontab
the master table, has a user field
/etc/cron.d/
drop-in job files, one per package
cron.hourly | daily | weekly
run-parts script directories
Per-user cron
/var/spool/cron/crontabs/
one file per user, edited via crontab -e
cron.allow / cron.deny
who is even allowed to schedule
systemd timers
/etc/systemd/system/*.timer
system timers, each pairs with a .service
~/.config/systemd/user/
per-user timers, easy to miss
systemctl list-timers --all
the one command that shows them all
A job you didn't write in any of these places is a persistence red flag. Enumerate all three, save the result as a baseline, and alert on anything that shows up later.
~/secopslog — bash
$ # the file-based cron surface: master table + drop-in dir, all root-owned ls -la /etc/crontab /etc/cron.d/
-rw-r--r-- 1 root root 1136 Mar 23 2024 /etc/crontab /etc/cron.d/: total 20 drwxr-xr-x 2 root root 4096 Apr 2 09:11 . drwxr-xr-x 108 root root 4096 Jul 15 08:40 .. -rw-r--r-- 1 root root 102 Mar 23 2024 .placeholder -rw-r--r-- 1 root root 201 Feb 14 2024 e2scrub_all -rw-r--r-- 1 root root 190 Jul 10 22:04 sysstat
$ # every systemd timer on the box, active or not, with last and next run systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT ACTIVATES Fri 2026-07-17 20:00:00 UTC 8h left Thu 2026-07-16 20:00:14 UTC 15h ago apt-daily.timer apt-daily.service Sat 2026-07-18 00:00:00 UTC 12h left Fri 2026-07-17 00:00:11 UTC 11h ago logrotate.timer logrotate.service Sat 2026-07-18 00:00:00 UTC 12h left Fri 2026-07-17 00:00:12 UTC 11h ago man-db.timer man-db.service Sat 2026-07-18 03:00:00 UTC 15h left Fri 2026-07-17 03:00:02 UTC 8h ago backup.timer backup.service Mon 2026-07-20 00:00:00 UTC 2 days left Mon 2026-07-13 00:00:22 UTC 4 days ago fstrim.timer fstrim.service 5 timers listed.

Read those two outputs like an inventory sheet. Every cron file is owned by root and readable, which is what you want. The timer list ties each schedule to the service it starts, so you can see backup.timer fires backup.service. Do this on a host you believe is clean and keep the output. That saved list is your baseline, and the rest of this lesson is about hardening what's on it and noticing what gets added to it.

The Writable-Script Trap

Think of a root cron job as a chef who cooks whatever a recipe card tells them, no matter who last wrote on the card. The job runs /usr/local/bin/backup.sh every night as root. If a lower-privileged user can edit that script, they don't need to break into root. They wait. Cron reads their edited recipe and cooks it with root's hands. Same story if they can't touch the file but can write to the directory it lives in, because write on a directory means you can delete the real script and drop your own in its place. So the rule is strict: every file a privileged job runs, and every directory along the way, must be owned by root and writable by nobody else.

~/secopslog — bash
$ # check the script cron runs, and the directory it sits in ls -l /usr/local/bin/backup.sh ls -ld /usr/local/bin # then sweep the whole tree for anything group- or world-writable # -perm /022 matches any file/dir with the group-write (020) or other-write (002) bit set sudo find /usr/local/sbin /usr/local/bin /opt -perm /022 \! -type l -printf '%M %u:%g %p\n'
-rwxrwxr-x 1 root deploy 812 Jul 10 09:14 /usr/local/bin/backup.sh drwxr-xr-x 2 root root 20480 Jul 10 09:14 /usr/local/bin -rwxrwxr-x root:deploy /usr/local/bin/backup.sh

There's your finding. The directory is fine (owned by root, not writable by others), but the script is mode 775 and group deploy. Anyone in the deploy group can rewrite backup.sh, and tonight at 3am root will run their version. A deployment tool probably left it that way. Attackers love this exact mistake, because it needs no exploit, only patience. The fix is ownership and a tight mode, then verify.

~/secopslog — bash
$ sudo chown root:root /usr/local/bin/backup.sh sudo chmod 755 /usr/local/bin/backup.sh ls -l /usr/local/bin/backup.sh
-rwxr-xr-x 1 root root 812 Jul 10 09:14 /usr/local/bin/backup.sh

PATH Hijacking And The Bare-Name Problem

Here's a subtler trap that catches careful people. When a script calls a command by its bare name, like running backup instead of /usr/local/bin/backup, the shell has to figure out which program that name means. It does so by walking PATH (the ordered list of directories your shell searches to turn a bare command name into an actual program), left to right, and running the first match it finds. Now suppose an early directory in that list is one an attacker can write to. They drop a malicious file called backup there. The shell finds theirs first, and cron runs it as root. It's like telling a courier 'pick up the parcel from the shop' when there are three shops on the street and the nearest one is a trap.

Two defenses, use both. Set a short, absolute PATH at the top of the crontab so a job can never resolve a command out of some writable corner of the disk. And inside your scripts, call binaries by full path (/usr/bin/python3, not python3) so there's no search to hijack in the first place.

/etc/crontab
# a safe, minimal PATH: only standard, root-owned binary directories, in order
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/bash
# m h dom mon dow user command (note the full path to the script)
0 3 * * * root /usr/local/bin/backup.sh
# inside backup.sh, call tools absolutely too:
# /usr/bin/rsync ... not rsync ...
# /usr/bin/find ... not find ...
run-parts silently skips filenames with dots
Scripts in /etc/cron.daily, /etc/cron.hourly and friends are launched by run-parts (a small helper that runs every script in a directory in turn), and by default run-parts ignores any filename that isn't made only of letters, digits, underscores, and hyphens. Drop a hardened backup.sh into /etc/cron.daily and it never runs, with no error logged anywhere. Name the file backup with no extension, or schedule it as a systemd timer instead.

Least Privilege With systemd Timers

Give a job the smallest key that opens the one door it needs, not the master key to the whole building. In practice that means running each job as the least-privileged user that can actually do the work, not root out of habit. A backup that reads /var/www and writes to a backup volume needs neither root to read nor root to write. Give it its own low-privilege account and the blast radius of a hijacked script shrinks from 'the whole machine' to 'the files that one account can touch'. For anything you care about hardening, prefer a systemd timer over a cron line. A timer starts a normal service unit, so the job inherits every confinement control systemd offers, its runs land in the journal (systemd's built-in, structured log), and the schedule and the work live in two separate files you can review on their own.

/etc/systemd/system/backup.service
[Unit]
Description=Nightly backup of /var/www
[Service]
Type=oneshot # run once and exit, not a service that stays running
User=backup # least privilege: a dedicated non-root account
NoNewPrivileges=true # this job can never gain more privilege than it starts with
ProtectSystem=strict # the entire filesystem is read-only to this job...
ReadWritePaths=/srv/backups # ...except the one directory it must write to
PrivateTmp=true # its own throwaway /tmp, invisible to every other process
ExecStart=/usr/local/bin/backup.sh
/etc/systemd/system/backup.timer
[Unit]
Description=Run the nightly backup at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00 # every day at 03:00 local time
Persistent=true # if the box was off at 03:00, run once at next boot
Unit=backup.service
[Install]
WantedBy=timers.target

Those directives are the whole point of choosing a timer. NoNewPrivileges=true seals off the classic escalation where a program flips itself to root through a setuid binary (a program file carrying a special permission bit that makes it run as its owner, often root, no matter who starts it). ProtectSystem=strict makes the operating system's own files read-only to this job, and ReadWritePaths pokes a single hole for the one directory it legitimately writes. PrivateTmp=true hands the job a private /tmp so it can't be tricked through a file some other process planted there. You get all of that per job, in a plain text file. Reload systemd so it picks up the edited units, then check the schedule, how exposed systemd rates the job, and the last run in the journal.

~/secopslog — bash
$ # you edited two unit files, so reload systemd, then check the schedule, the hardening, and the last run sudo systemctl daemon-reload systemctl list-timers backup.timer systemd-analyze security backup.service | tail -n 4 journalctl -u backup.service -n 5 --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES Sat 2026-07-18 03:00:00 UTC 15h left Fri 2026-07-17 03:00:02 UTC 8h ago backup.timer backup.service ✓ ProtectSystem= Service has strict read-only access to the OS file hierarchy ✓ User=/DynamicUser= Service runs under a static non-root user identity ✓ NoNewPrivileges= Service cannot acquire new privileges → Overall exposure level for backup.service: 6.6 MEDIUM 😐 Jul 17 03:00:02 web01 systemd[1]: Starting backup.service - Nightly backup of /var/www... Jul 17 03:00:02 web01 backup.sh[24187]: syncing /var/www -> /srv/backups/2026-07-17 Jul 17 03:00:49 web01 backup.sh[24187]: done: 1.4 GiB in 47s Jul 17 03:00:49 web01 systemd[1]: backup.service: Deactivated successfully. Jul 17 03:00:49 web01 systemd[1]: Finished backup.service - Nightly backup of /var/www.

Two things to read here. The journal shows the last run finishing cleanly, 1.4 GiB in 47 seconds, which is what you want to confirm after any change to the unit. The security score is the more interesting one. 6.6 is not a gold star, and that's the honest read: those few directives pull backup.service well below the roughly 9.6 an unhardened root service scores, but systemd-analyze counts everything you left unset too. Scroll up past the green checks and you'll find red ones for SystemCallFilter (limit which kernel calls the job may make), RestrictAddressFamilies (stop it opening network sockets it never needs), and CapabilityBoundingSet (drop root powers it will never use). Each is one more line in the same file. Add them as you go.

Baseline The Set, Alert On Additions

On a stable host the set of scheduled jobs barely changes for months. That stability is a gift, because it means a brand-new job is loud. Attackers reach for scheduled tasks to survive a reboot: plant a cron file or a timer, and their payload comes back to life on its own long after they've left. So watch the cron surface for writes. auditd (the Linux audit system, which records security-relevant events like file creation and edits straight from the kernel) can put a tripwire on those directories and tell you exactly who touched them.

~/secopslog — bash
$ # put tripwires on the places scheduled jobs are defined (-p wa = watch writes + attribute changes) sudo auditctl -w /etc/cron.d/ -p wa -k cron_change sudo auditctl -w /etc/crontab -p wa -k cron_change sudo auditctl -w /etc/systemd/system/ -p wa -k timer_change # later, review what tripped the cron tripwire today sudo ausearch -k cron_change -i --start today | tail -n 8
---- time->Fri Jul 17 14:22:31 2026 type=PROCTITLE proctitle=cp payload /etc/cron.d/collectd-stats type=PATH item=1 name=/etc/cron.d/collectd-stats nametype=CREATE type=CWD cwd=/home/deploy type=SYSCALL arch=x86_64 syscall=openat success=yes exit=3 uid=root gid=root euid=root auid=deploy ses=42 comm=cp exe=/usr/bin/cp key=cron_change

Read that record closely, because it tells the whole story. Someone dropped a new file into /etc/cron.d named collectd-stats, a name picked to blend in with real monitoring. The write ran as root (uid=root), which by itself tells you nothing about who was behind it. Now look at auid=deploy. That's the audit login id, the account that first signed in for this session, and the kernel keeps it pinned to the real person even after they run sudo and become root. So root's hands wrote the file, but the session traces back to the deploy account, working out of /home/deploy. The filename lies. The audit trail doesn't. If you'd rather query the current state than read audit logs, osquery (a tool that exposes your machine's live state as tables you read with SQL, the same query language databases use) covers the same ground, as plain rows you can compare against your baseline.

~/secopslog — bash
$ osqueryi --line "SELECT command, path FROM crontab;"
command = /usr/local/bin/backup.sh path = /var/spool/cron/crontabs/root command = test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily ) path = /etc/crontab
A job you didn't write is persistence until proven otherwise
A fresh cron entry or .timer that points at /tmp, a home directory, a base64 blob, or a binary with a plausible-but-wrong name (collectd-stats, systemd-worker, kworkerd) is one of the most common ways an attacker survives a reboot. Don't read it and move on. Find out who created it and when (auid and timestamp), and treat it as live until you can account for it.
Quick check
01A root-run timer executes /usr/local/bin/collect.sh. ls -l shows the script as -rwxr-xr-x root root, but the directory is drwxrwxr-x root ops /usr/local/bin. Are you safe?
Incorrect — the file's own mode isn't the whole story when the directory around it is writable.
Correct — directory write means control over the file, no matter how locked-down the file's own mode is (the directory has no sticky bit to stop deletion).
Incorrect — NoNewPrivileges blocks setuid-style escalation, it does not stop the job from running an attacker-supplied script.
Incorrect — oneshot units are fully confinable; the real problem here is the group-writable directory.
02A root cron job runs a script that calls 'backup' by its bare name instead of '/usr/local/bin/backup'. How can an attacker turn this into root code execution?
Incorrect — a bare name does not change file permissions; the weakness is name resolution, not the file's mode.
Correct — the shell walks PATH left to right and runs the first match, so a malicious 'backup' in an earlier writable directory wins.
Incorrect — timing the boot is not the issue; the PATH search order is what gets hijacked.
Incorrect — resolution follows PATH, not automatically the current directory; the risk is a writable PATH entry.
03auditd logs a new file /etc/cron.d/collectd-stats created with 'uid=root' but 'auid=deploy'. What does auid=deploy tell you?
Correct — auid survives the switch to root, so it names the human behind the action even when uid reads root.
Incorrect — uid=root shows root wrote it; auid names the login behind the session, not file ownership.
Incorrect — auid records who created the file now, not which user the job will later execute as.
Incorrect — the record is doing exactly its job; auid deliberately ties the event to the original login.

Do one thing today. On a host you believe is clean, run systemctl list-timers --all and dump every crontab (system, cron.d, and each user's), then save that output somewhere you'll notice it. That file is your ground truth. The day the live list and the saved list stop matching is the day you have something specific to go investigate, with a name, a path, and an auid already waiting for you.

Try this

Work through “Baseline The Set, Alert On Additions” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: run-parts silently skips filenames with dots. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related