Scheduled-task hardening
cron & timer privilege boundaries and PATH hijack.
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.
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.
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.
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.
# a safe, minimal PATH: only standard, root-owned binary directories, in orderPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binSHELL=/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 ...
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.
[Unit]Description=Nightly backup of /var/www[Service]Type=oneshot # run once and exit, not a service that stays runningUser=backup # least privilege: a dedicated non-root accountNoNewPrivileges=true # this job can never gain more privilege than it starts withProtectSystem=strict # the entire filesystem is read-only to this job...ReadWritePaths=/srv/backups # ...except the one directory it must write toPrivateTmp=true # its own throwaway /tmp, invisible to every other processExecStart=/usr/local/bin/backup.sh
[Unit]Description=Run the nightly backup at 03:00[Timer]OnCalendar=*-*-* 03:00:00 # every day at 03:00 local timePersistent=true # if the box was off at 03:00, run once at next bootUnit=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.
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.
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.
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.