CoursesAdvanced Linux securityKernel & credential escalation

Kernel & credential escalation

Kernel exploits, cron, PATH, and secrets.

Advanced14 min · lesson 4 of 17

You have locked the place down. File permissions are tight, sudo (the tool that lets a user run a few chosen commands with elevated power) is scoped to a handful of commands, and the firewall only opens the ports you meant to open. An attacker who lands on the box as an ordinary user is stuck at their own privilege level. Now they have two roads up. They can attack the one piece of software that every other program is forced to trust, the kernel. Or they can hunt for a key that somebody left lying around on disk or in memory. This lesson walks both roads from the attacker's point of view, so you can stand on the other side and watch for the same footprints.

The One Guard Everyone Obeys

Every door in a building has its own lock. But there is one electrician who controls the power to all of them, and every lock obeys the power. The kernel (the core part of the operating system that talks directly to the hardware and decides which process is allowed to do what) is that electrician. Root (the account that can do anything on the box), your user, a background service, all of them hand their requests to the kernel and trust the answer it gives back. A bug in the kernel's own code turns that trust into a skeleton key.

The dangerous class here is local privilege escalation (a flaw that lets a program already running on the machine gain more power than it was handed). You have probably heard the famous ones. Dirty COW, short for dirty copy-on-write, was a 2016 kernel bug catalogued as CVE-2016-5195. A CVE (Common Vulnerabilities and Exposures) ID is the public catalog number stamped on each known bug, so anyone can look it up and, often, download a working exploit. That one lived in the kernel's copy-on-write memory handling and let an ordinary user overwrite files they were only allowed to read. Dirty Pipe (CVE-2022-0847, a 2022 bug in how the kernel handles pipe buffers) did much the same thing. Overwrite the right file and you are root. These exploits are public, they are reliable, and attackers weaponize them within days of disclosure. You cannot configure your way out of a bug in the code itself. The sysctl hardening (tuning the kernel's own runtime settings) from the earlier course raises the bar and shrinks what an exploit can reach, but the only real fix is a patched kernel.

~/secopslog — bash
$ # the running kernel; an attacker reads this to pick an exploit, you read it to prioritize patching uname -r # what kernel is actually installed on disk? apt list --installed 2>/dev/null | grep linux-image # does the box need a reboot to run the kernel it just installed? cat /var/run/reboot-required 2>/dev/null
5.15.0-89-generic linux-image-5.15.0-89-generic/jammy-updates,jammy-security,now 5.15.0-89.99 amd64 [installed,automatic] linux-image-5.15.0-91-generic/jammy-updates,jammy-security,now 5.15.0-91.101 amd64 [installed,automatic] linux-image-generic/jammy-updates,jammy-security,now 5.15.0.91.88 amd64 [installed,automatic] *** System restart required ***

An attacker's first move is to read the kernel version and pick a public exploit that matches it. You run the exact same command for the opposite reason. You want to find every host whose kernel carries a known local-root CVE and patch those first. There is a catch in that output that trips up a lot of teams, and it is sitting right there in the version numbers.

A patched kernel you haven't rebooted into is still the old kernel
uname -r shows the kernel you are running this second. The apt package list (apt being Debian and Ubuntu's package manager) shows the kernel installed on disk. Look again: the box is running 5.15.0-89 but 5.15.0-91 is already installed. Until you reboot, the machine keeps executing the old, vulnerable kernel, which is exactly what /var/run/reboot-required is warning you about. Live-patching tools (like Ubuntu Livepatch or kpatch) can close some gaps without a reboot, but they do not cover every CVE, so treat an unrebooted host as unpatched until you prove otherwise.

Jobs That Run as Root Without Looking

A note is stuck to the fridge, and the most powerful person in the house will carry out whatever it says, no questions asked. Whoever can edit the note controls that person. On a Linux box, the note is a scheduled job. Two systems schedule work: cron (the classic Unix job scheduler that runs commands at set times) and systemd timers (the modern equivalent built into systemd, the program that boots the machine and supervises its background services). A job that runs a script as root is only as safe as the answer to one question: can anyone below root change what that script does?

/etc/cron.d/backup
# /etc/cron.d/backup: nightly web-root backup
PATH=/opt/scripts:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin
*/5 * * * * root backup.sh

Read that job closely. Every five minutes, root runs a command called backup.sh. Not /opt/scripts/backup.sh with a full path, but backup.sh by bare name, and the job sets its own PATH (the ordered list of directories the shell searches to find a command you typed by name) with /opt/scripts at the very front. The same work often has a systemd timer behind it too, which you can list directly.

~/secopslog — bash
$ systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT ACTIVATES Fri 2026-07-17 10:09:00 UTC 4min 2s left Fri 2026-07-17 10:04:00 UTC 55s ago backup.timer backup.service Fri 2026-07-17 18:00:00 UTC 7h left Fri 2026-07-16 18:00:00 UTC 16h ago logrotate.timer logrotate.service Sat 2026-07-18 00:00:00 UTC 13h left Fri 2026-07-17 00:09:12 UTC 10h ago apt-daily.timer apt-daily.service Sun 2026-07-19 03:10:35 UTC 2 days left Sun 2026-07-12 03:10:20 UTC 5 days ago e2scrub_all.timer e2scrub_all.service 4 timers listed.
/etc/systemd/system/backup.service
[Unit]
Description=Nightly web-root backup
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
User=root

Both the cron entry and the timer point at the same script running as root. So the whole question of safety collapses into permissions. Who can write backup.sh, and who can write the directory it lives in? Check the script, and check every directory in that job's PATH.

~/secopslog — bash
$ # who owns the script and its directory, and are they writable below root? ls -l /opt/scripts/backup.sh ls -ld /opt/scripts /usr/local/sbin /usr/local/bin id
-rwxr-xr-x 1 root root 115 Jul 10 02:00 /opt/scripts/backup.sh drwxrwxr-x 2 root staff 4096 Jul 10 02:00 /opt/scripts drwxr-xr-x 2 root root 4096 Jan 15 2024 /usr/local/bin drwxr-xr-x 2 root root 4096 Jan 15 2024 /usr/local/sbin uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),50(staff)
A read-only script in a writable directory is still yours to replace
Notice that backup.sh is owned by root and not writable by you (rwxr-xr-x). It is tempting to call that safe and move on. It is not. The directory /opt/scripts is group-writable by staff (drwxrwxr-x), and your id shows you are in staff. Write permission on a directory means you can delete and recreate the files inside it, no matter who owns them, unless the sticky bit is set, and here it is not. You remove root's backup.sh and drop in your own. Within five minutes, cron runs it as root. Auditing only the files misses this completely, so always check the ownership and mode of the parent directories too.

Calling a Command by Its First Name

There is a second way in here, and it lives in that PATH line. Shout "Sam, grab the mail" across a crowded room and whoever answers to Sam does the job. If two people answer to Sam, the closer one wins. When a script runs tar instead of /bin/tar, the shell does the same thing: it walks each directory in PATH in order and runs the first file named tar that it finds. Look at what backup.sh actually calls.

/opt/scripts/backup.sh
#!/bin/sh
# back up the web root, keep one archive per day
cd /var/www/html
tar czf /backups/www-$(date +%F).tgz .

tar and date are both called by bare name. The job's PATH starts with /opt/scripts, which you can write to. Drop a file called tar into /opt/scripts, mark it executable, and the next run of this root job executes your tar instead of the real one. You never even had to touch backup.sh. This is why a writable directory anywhere in a root job's PATH is as dangerous as a writable script, and why hardened jobs use absolute paths and set a clean, root-only PATH.

Going Through the Drawers for Keys

Escalation on this box is not the only prize, and often it is not the real one. A burglar who cannot crack your safe will happily pocket the spare keys in the kitchen drawer, because those keys open other doors. The same is true here. A host holds credentials to other systems, and every one of them is a way off this machine and onto the next. So an attacker greps the filesystem for anything that smells like a password or a key.

~/secopslog — bash
$ # what an attacker greps for the moment they land, and what you must keep off the host grep -rniE 'password|secret|api[_-]?key|token' /etc /opt 2>/dev/null | head
/etc/mysql/debian.cnf:5:password = k3pR9x2wQ7mVbN4t /etc/mysql/debian.cnf:9:password = k3pR9x2wQ7mVbN4t /opt/app/shared/.env:3:DB_PASSWORD=Sup3rS3cr3t! /opt/app/shared/.env:7:JWT_SECRET=a3f9c1d8e2b47f60 /opt/app/config/database.yml:14: password: prod_db_9Xk2vT
$ # private keys and cloud credentials sitting in home directories find /home /root -name 'id_*' ! -name '*.pub' 2>/dev/null cat /home/deploy/.aws/credentials 2>/dev/null
/home/deploy/.ssh/id_ed25519 /home/deploy/.ssh/id_rsa [default] aws_access_key_id = AKIAIOSFODNN7EXAMPLE aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
$ # gunicorn (a Python web-app server) got its secrets as environment variables; # /proc exposes them in plain text to that process's own user and to root tr '\0' '\n' < /proc/$(pgrep -n gunicorn)/environ | grep -iE 'pass|secret|token|key|url'
DATABASE_URL=postgres://app:[email protected]:5432/prod JWT_SIGNING_KEY=a3f9c1d8e2b47f60 AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
$ # secrets typed on the command line get logged straight to shell history grep -iE 'export .*(KEY|TOKEN)|mysql .*-p|curl .*-u ' ~/.bash_history
export GITHUB_TOKEN=ghp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8 mysql -u root -pR00tDbP@ss prod curl -u admin:hunter2 https://internal-api.example/health

Read those results the way an attacker does. The SSH (Secure Shell, the standard way to log into a remote machine) private keys in /home/deploy/.ssh open whatever servers trust them. The AWS (Amazon Web Services) keys in ~/.aws can read or spin up cloud resources far beyond this box. The environment dump is the sharp one. Secrets handed to a service as environment variables sit in plain text inside that process's /proc/PID/environ (a virtual file the kernel exposes for every running process), readable by root and by the user the service runs as. Compromise the service account and you inherit every secret in its environment, including the JWT (JSON Web Token, a signed token that proves who a request is from) signing key that lets you mint your own valid sessions. One foothold, and the host hands over the keys to everything it was talking to.

Three roads up when the config is locked
The kernel
Local-root CVE
Dirty COW, Dirty Pipe
Attacker
read uname -r, run a public exploit
You
patch and reboot on a short clock
Root-run jobs
cron / systemd timer
runs a script as root
Attacker
write the script, its dir, or hijack PATH
You
audit ownership of scripts and their dirs
Credentials
keys, tokens, env
~/.ssh, ~/.aws, /proc/PID/environ
Attacker
grep, then reuse on the next host
You
short-lived tokens, secrets manager
One signal, two readers: what the attacker does with it, and what you check.
Quick check
01A root cron job runs /opt/scripts/backup.sh. You check the file: it is owned by root and shows -rwxr-xr-x, so you cannot write to it. Which finding would still let you run code as root?
Incorrect — A changed hash is worth investigating, but on its own it grants no write access and is not an escalation path.
Correct — Write permission on the directory lets you delete and recreate backup.sh, even though the file itself is root-owned and read-only.
Incorrect — Comments are documentation. They change nothing about permissions or what gets executed.
Incorrect — Frequency only decides how fast an exploit fires, not whether an escalation path exists at all.
02On a host, apt list --installed shows linux-image-5.15.0-91 is installed, but uname -r prints 5.15.0-89 and /var/run/reboot-required exists. What is the true security state?
Incorrect — the fix is installed but not running, so the box still executes the old, vulnerable kernel.
Incorrect — the lesson notes live-patching does not cover every CVE, so you cannot assume this one is closed.
Correct — uname -r shows the kernel executing right now, and that is the old one until the machine restarts.
Incorrect — local-root kernel bugs are exploited precisely by unprivileged local users to become root.
03A root cron job runs backup.sh, which calls tar and date by bare name with no full path. The job sets PATH=/opt/scripts:/usr/local/sbin:..., and /opt/scripts is writable by you, while the backup.sh file itself is root-owned and not writable. How do you get code running as root?
Incorrect — you never touch the script, because the commands it calls by name are resolved through a writable PATH directory.
Incorrect — that file is root-owned too; the path up here is the writable PATH directory, not the cron file.
Incorrect — setting a capability needs root you do not have yet, and the real tar already runs as root inside this job anyway.
Correct — bare-name lookup walks PATH in order, so a writable directory at the front lets you shadow a trusted command.

Two checks belong in your weekly routine, and both come straight out of this lesson. Run systemctl list-timers --all and read /etc/crontab together with /etc/cron.d to get the full list of what runs as root on a clock, then check the ownership and mode of every script and every directory in each job's PATH. And compare uname -r against the version of the installed linux-image package on each host. When apt reports a fixed kernel but uname still shows the old one, you have found a privilege escalation that is already sitting in your fleet, booted and waiting for someone to notice.

Try this

Work through “Going Through the Drawers for Keys” 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: a patched kernel you haven't rebooted into is still the old kernel. 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