Kernel exploits and credential discovery

Unpatched kernels, weak jobs and leaked secrets.

Advanced14 min · lesson 4 of 15

When SUID files, sudo rules and capabilities are clean, an intruder who is still a nobody on your host has three places left to look: a kernel with a known local-root bug, a job that root runs on a schedule but someone else can change, and credentials the host keeps for talking to other systems. You will measure a kernel's real exposure (the kernel that runs, not the one installed), audit a root cron job for paths other accounts can write, and find secrets exposed in files, process environments and unit settings. Parts recap linux-hard (patching, cron and secrets); the new part is auditing each as an intruder's option. No exploit is run; each weakness is built in front of you, found the way an audit would find it, and fixed.

Kernel exposure: the kernel that runs, not the one installed

Every process on the host, root included, asks the kernel for permission to do things and trusts the answer. A local privilege-escalation bug in the kernel lets an ordinary process change that answer, so it bypasses every file mode, sudo rule and capability you configured. Two famous examples: Dirty COW (CVE-2016-5195), a copy-on-write race that let a local user write to files they could only read, exploited in the wild in 2016; and Dirty Pipe (CVE-2022-0847), which let an unprivileged user write into the cached pages of read-only files, in kernels from 5.8 until the fixes in 5.16.11, 5.15.25 and 5.10.102. Both are history now, but public exploits appeared quickly, and no configuration setting closes a bug in the kernel's own code. Only a fixed kernel does, and only once it is the kernel that is running.

An intruder's first question is which kernel is running, because that picks the exploit. Ask the same question for the opposite reason, then compare it with what is installed.

deploy@web01 · Ubuntu 26.04 LTS
$ uname -r cat /proc/version_signature
7.0.0-34-generic Ubuntu 7.0.0-34.34-generic 7.0.14
$ dpkg-query -W 'linux-image-[0-9]*'
linux-image-7.0.0-31-generic 7.0.0-31.31 linux-image-7.0.0-34-generic 7.0.0-34.34
$ sudo needrestart -b -k
NEEDRESTART-VER: 3.11 NEEDRESTART-KCUR: 7.0.0-34-generic NEEDRESTART-KEXP: 7.0.0-34-generic NEEDRESTART-KSTA: 1
$ ls /run/reboot-required
ls: cannot access '/run/reboot-required': No such file or directory

uname -r is the running kernel. /proc/version_signature is an Ubuntu file that adds the upstream kernel it is based on (7.0.14), which is the version public advisories quote. dpkg-query lists two installed kernel images: the older one stays as a fallback in case a new kernel does not boot. needrestart -b -k reports the kernel in a form monitoring can parse: KCUR is the running kernel, KEXP the newest installed, and KSTA: 1 means no newer kernel is waiting. No /run/reboot-required file exists either (Ubuntu packages create it when an update needs a reboot). This host runs its newest kernel.

The Rocky Linux lab machine is the opposite case: a newer kernel package was installed after it last booted. (Before these commands the lab reinstalls the newest kernel-core, which puts the machine in the state a host is in right after an update delivered a kernel.) RHEL keeps a changelog in each kernel package, and Red Hat marks backported security fixes with their CVE identifier in braces, so the difference between the running kernel's changelog and the newest installed one's measures the gap.

deploy@rocky10 · Rocky Linux 10.2
$ uname -r rpm -q kernel-core
6.12.0-211.16.1.el10_2.0.1.aarch64 kernel-core-6.12.0-211.16.1.el10_2.0.1.aarch64 kernel-core-6.12.0-211.60.1.el10_2.aarch64
$ new=$(rpm -q --last kernel-core | head -1 | cut -d' ' -f1) comm -13 <(rpm -q --changelog kernel-core-$(uname -r) | grep -o 'CVE-[0-9]*-[0-9]*' | sort -u) \ <(rpm -q --changelog $new | grep -o 'CVE-[0-9]*-[0-9]*' | sort -u) | wc -l
375

The machine runs 211.16.1 while 211.60.1 is installed: the reboot gap that "Patching and update strategy" in Linux hardening detects with dnf needs-restarting -r. The last command collects the CVE identifiers from each changelog and counts those that appear only in the newer one: 375 fixes are on disk and not in effect. Until this machine reboots, a scanner that only checks installed packages calls it patched, and it is not.

A CVE missing from the changelog is not a CVE you are exposed to
The kernel changelog lists the fixes the vendor backported since its kernel was based on an upstream release. Searching the running Rocky kernel's changelog for Dirty Pipe finds nothing, and that is not a finding: RHEL 10's kernel is based on 6.12, years after the fix landed upstream in 5.16.11, so there was never anything to backport. The reverse mistake is as common. For a specific CVE, use the vendor's CVE page (the Ubuntu CVE tracker, the Red Hat CVE database), which states the fixed package version for each release.
deploy@rocky10 · Rocky Linux 10.2
$ rpm -q --changelog kernel-core-$(uname -r) | grep -c CVE-2022-0847
0

This is the same reboot gap linux-hard/patching measured, seen from the intruder's side. What closes it is the reboot policy that lesson sets up. Live patching narrows it: Canonical Livepatch (an Ubuntu Pro service) and Red Hat's kpatch apply fixes for selected critical and high (Red Hat's scale says Important) kernel vulnerabilities to the running kernel, and both vendors say this does not replace updating and rebooting. Settings that remove kernel code an unprivileged process can reach, such as unprivileged BPF and user namespaces, shrink the reachable surface (linux-hard/sysctlkern).

Root jobs that trust what others can write

A job root runs on a schedule runs whatever its command resolves to at that moment. It is only as safe as every path involved: the script, each directory above it, and, when the job calls a program by bare name, each directory the shell searches for that name before it reaches the real one. Write access to a directory is the right to delete and create names in it, whoever owns the files inside. So a root-owned, read-only script in a directory another group can write is replaceable by that group, and a writable directory early in a root job's PATH lets someone add a file named after a command the job calls.

Start with what PATH a root job actually gets. linux-hard/crontab showed where it comes from; measure it rather than assume it. On Ubuntu 26.04, /etc/crontab no longer sets one, so write a one-minute test job that records its own PATH, wait for it to run once, and delete it straight away (a root job every minute is not something to leave behind).

deploy@web01 · Ubuntu 26.04 LTS
$ grep PATH /etc/crontab
# You can also override PATH, but by default, newer versions inherit it from the environment #PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$ echo '* * * * * root echo "$PATH" > /var/tmp/pk-cronpath.txt' | sudo tee /etc/cron.d/pk-cronpath
* * * * * root echo "$PATH" > /var/tmp/pk-cronpath.txt
$ cat /var/tmp/pk-cronpath.txt sudo rm /etc/cron.d/pk-cronpath
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
$ grep PATH /etc/environment grep pam_env /etc/pam.d/cron
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin" # Read environment variables from pam_env's default files, /etc/environment # and /etc/security/pam_env.conf. session required pam_env.so session required pam_env.so envfile=/etc/default/locale

The job's PATH matches /etc/environment, because cron opens a PAM session for each job and pam_env loads that file. A root job without its own PATH= line therefore searches /usr/local/sbin and /usr/local/bin first, so those directories must stay root-owned and not group- or world-writable (they are root:root and mode 0755 on a default install). Rocky Linux 10 gives a different answer to the same test.

deploy@rocky10 · Rocky Linux 10.2
$ grep PATH /etc/crontab
PATH=/sbin:/bin:/usr/sbin:/usr/bin
$ grep pam_env /etc/pam.d/crond
$ echo '* * * * * root echo "$PATH" > /var/tmp/pk-cronpath.txt' | sudo tee /etc/cron.d/pk-cronpath
* * * * * root echo "$PATH" > /var/tmp/pk-cronpath.txt
$ cat /var/tmp/pk-cronpath.txt sudo rm /etc/cron.d/pk-cronpath
/usr/bin:/bin:/usr/sbin:/sbin

cronie's /etc/crontab does set PATH, but that line applies only to jobs in /etc/crontab itself. /etc/pam.d/crond does not load pam_env (the empty grep), so a /etc/cron.d job without its own PATH= gets cronie's built-in default, /usr/bin:/bin:/usr/sbin:/sbin, with no /usr/local directory at all. A systemd service is stricter: an ExecStart= command without a full path is resolved through a search path fixed when systemd was built, which systemd-path prints.

deploy@rocky10 · Rocky Linux 10.2
$ systemd-path search-binaries-default
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin

Ubuntu 26.04 prints the same list; per systemd.service(5) the sbin entries appear only where bin and sbin are still separate, as on both lab platforms.

Now audit a job. Build one the way it is often found on real hosts: a group of operators (pk-ops, with one member, pk-dev) allowed to maintain the scripts, a script directory they can write, a script, and a file in /etc/cron.d with its own PATH. It is scheduled for 02:30, so it never runs while you work.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo groupadd pk-ops sudo useradd -M -s /usr/sbin/nologin -G pk-ops pk-dev sudo install -d -m 0775 -o root -g pk-ops /opt/pk-scripts

Write the script and the job file as root (for example with sudoedit), then make the script executable.

/opt/pk-scripts/backup.sh
#!/bin/sh
# back up the web root every night
cd /var/www/html && tar czf /var/backups/www-$(date +%F).tgz .
/etc/cron.d/pk-backup
# nightly backup of the web root
PATH=/opt/pk-scripts:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
30 2 * * * root backup.sh
deploy@web01 · Ubuntu 26.04 LTS
$ sudo chmod 0755 /opt/pk-scripts/backup.sh ls -l /opt/pk-scripts/backup.sh /etc/cron.d/pk-backup
-rw-r--r-- 1 root root 130 Sep 27 11:06 /etc/cron.d/pk-backup -rwxr-xr-x 1 root root 108 Sep 27 11:06 /opt/pk-scripts/backup.sh

Now audit it as if you had found it: list every root job, then look at the local one.

deploy@web01 · Ubuntu 26.04 LTS
$ grep -hvE '^[[:space:]]*(#|$)' /etc/crontab /etc/cron.d/*
SHELL=/bin/sh 17 * * * * root cd / && run-parts --report /etc/cron.hourly 25 6 * * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; } 47 6 * * 7 root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; } 52 6 1 * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; } 30 3 * * 0 root test -e /run/systemd/system || SERVICE_MODE=1 /usr/libexec/e2fsprogs/e2scrub_all_cron 10 3 * * * root test -e /run/systemd/system || SERVICE_MODE=1 /sbin/e2scrub_all -A -r PATH=/opt/pk-scripts:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin 30 2 * * * root backup.sh

The first lines are Ubuntu's own: run-parts for the hourly, daily, weekly and monthly directories, and two e2scrub jobs from e2fsprogs, which do nothing on a systemd host because of their test -e /run/systemd/system guard. The last two lines are the local job. Its PATH puts /opt/pk-scripts first, it calls backup.sh by bare name, and the script calls tar and date by bare name too. Cron itself refuses files in /etc/cron.d that are group- or world-writable (cron(8)), so the job file is not the weak point. Check the path to the script and the directories on the job's PATH.

deploy@web01 · Ubuntu 26.04 LTS
$ namei -l /opt/pk-scripts/backup.sh
f: /opt/pk-scripts/backup.sh drwxr-xr-x root root / drwxr-xr-x root root opt drwxrwxr-x root pk-ops pk-scripts -rwxr-xr-x root root backup.sh
$ find /opt/pk-scripts /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin -maxdepth 0 -perm /022
/opt/pk-scripts
$ getent group pk-ops
pk-ops:x:1002:pk-dev

namei -l prints the owner and mode of every component of a path. backup.sh is -rwxr-xr-x root root and looks safe, but its directory is drwxrwxr-x root pk-ops: any member of pk-ops can delete backup.sh and create a new one, which root then runs at 02:30. find ... -maxdepth 0 -perm /022 tests each directory on the job's PATH for group or other write permission and names only /opt/pk-scripts, which also sits first on that PATH, so a file named tar or date created there would run as root in place of the real one. getent group shows who holds that access. Before you fix anything, look inside: anyone in pk-ops could already have replaced the script or added a tar next to it, and the fix updates the directory's change time.

deploy@web01 · Ubuntu 26.04 LTS
$ ls -la --time-style=full-iso /opt/pk-scripts stat -c "%n %U:%G %A birth=%w change=%z" /opt/pk-scripts/backup.sh
total 12 drwxrwxr-x 2 root pk-ops 4096 2026-09-27 11:06:01.954831689 +0000 . drwxr-xr-x 3 root root 4096 2026-09-27 11:06:01.948830525 +0000 .. -rwxr-xr-x 1 root root 108 2026-09-27 11:06:01.956832077 +0000 backup.sh /opt/pk-scripts/backup.sh root:root -rwxr-xr-x birth=2026-09-27 11:06:01.954831689 +0000 change=2026-09-27 11:06:02.021844685 +0000

The directory holds only backup.sh, and its birth and change times match the moment it was written. On a real host, any other file in a directory like this, or a script whose times do not match its last reviewed change, is an incident to capture before you touch it (linux-det/triage). Both weaknesses have the same fix: make every directory the job touches writable only by root, and call programs by absolute path with a PATH of root-owned directories.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo chown root:root /opt/pk-scripts sudo chmod 0755 /opt/pk-scripts sudo sed -i 's|^PATH=.*|PATH=/usr/sbin:/usr/bin|; s| root backup.sh$| root /opt/pk-scripts/backup.sh|' /etc/cron.d/pk-backup cat /etc/cron.d/pk-backup
# nightly backup of the web root PATH=/usr/sbin:/usr/bin 30 2 * * * root /opt/pk-scripts/backup.sh
$ find /opt/pk-scripts /usr/sbin /usr/bin -maxdepth 0 -perm /022 namei -l /opt/pk-scripts/backup.sh
f: /opt/pk-scripts/backup.sh drwxr-xr-x root root / drwxr-xr-x root root opt drwxr-xr-x root root pk-scripts -rwxr-xr-x root root backup.sh

The find now prints nothing and every component of the path is root root without group write. If the operators need to change the script, give them a reviewed deployment (a package, configuration management, or sudoedit on that one file) instead of a writable directory. Timers get the same review (systemctl cat shows the service's ExecStart= and User=). A change to /etc/cron.d or to a directory like this one is an escalation signal; linux-det/auditpipe writes the watch rules for it, and the next lesson baselines it.

Secrets the host hands to whoever lands on it

A host keeps credentials for the systems it talks to: database passwords, cloud keys, API tokens, SSH keys. For an intruder these are often worth more than root on this machine, because they lead to the next one. Search for them before anyone else does. To have something to find, create a service account pk-app with an application directory, and put its settings in a .env file the way many deployments do. The keys are the placeholder examples from the AWS documentation; the file is left readable by everyone (mode 0644), which is the mistake to find.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd --system --no-create-home --shell /usr/sbin/nologin pk-app sudo install -d -m 0755 /opt/pk-app
/opt/pk-app/.env
# app settings (placeholder keys from the AWS documentation)
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

Then start the application, here a sleep standing in for it, as a transient service running as pk-app with a placeholder database password in its environment.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemd-run --unit=pk-app -p User=pk-app -p Environment=DB_PASSWORD=example-not-a-real-password /usr/bin/sleep 900
Running as unit: pk-app.service; invocation ID: 84b4ea42aee84ccdb8e27ce16f50f799

Now audit. List file names, not values, so the audit itself does not copy secrets into your terminal history or a ticket.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo grep -rIlE 'AKIA[0-9A-Z]{16}|PASSWORD=' /etc /opt 2>/dev/null
/etc/overlayroot.conf /opt/pk-app/.env
$ ls -l /opt/pk-app/.env
-rw-r--r-- 1 root root 163 Sep 27 11:06 /opt/pk-app/.env

Two hits. /etc/overlayroot.conf is a false positive: a commented example that sets PASSWORD="foobar". /opt/pk-app/.env holds an AWS key pair (the lab uses the placeholder keys from the AWS documentation) and is mode 0644, so every local account can read it, including any service account an intruder lands in. Pattern searches find the obvious cases; they miss secrets with unusual names, so they are a floor, not a proof.

Environment variables are the other common store, and pk-app was started with DB_PASSWORD in its environment. /proc/PID/environ holds the environment a process started with, and the kernel lets only the process's own user, or root, read it.

deploy@web01 · Ubuntu 26.04 LTS
$ cat /proc/$(systemctl show -p MainPID --value pk-app)/environ
cat: /proc/265116/environ: Permission denied
$ sudo cat /proc/$(systemctl show -p MainPID --value pk-app)/environ | tr '\0' '\n' | cut -d= -f1
LANG PATH USER LOGNAME HOME INVOCATION_ID JOURNAL_STREAM SYSTEMD_EXEC_PID MEMORY_PRESSURE_WATCH MEMORY_PRESSURE_WRITE DB_PASSWORD

deploy without sudo is refused. With sudo, the variable names show DB_PASSWORD among the ones systemd sets for every service; its value is there in plain text too, which is why the command cuts it off. So anyone who becomes pk-app or root reads it. linux-hard/secrets showed the wider leak; here it is from the intruder's side, because systemd publishes a unit's Environment= setting to every local user.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl show -p User -p Environment pk-app
Environment=DB_PASSWORD=example-not-a-real-password User=pk-app
$ ls -l /run/systemd/transient/pk-app.service
-rw-r--r-- 1 root root 265 Sep 27 11:06 /run/systemd/transient/pk-app.service

No sudo was needed: systemctl show reads unit properties over D-Bus, and Environment= is one of them. The unit file itself, /run/systemd/transient/pk-app.service for a unit started with systemd-run, is world-readable as well. The systemd.exec(5) manual says it directly: environment variables are not suitable for passing secrets, because they are exposed to unprivileged clients and inherited by child processes. linux-hard/secrets replaces them with LoadCredential= and systemd-creds. For the file, restrict it to the account that needs it.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo chown root:pk-app /opt/pk-app/.env sudo chmod 0640 /opt/pk-app/.env ls -l /opt/pk-app/.env
-rw-r----- 1 root pk-app 163 Sep 27 11:06 /opt/pk-app/.env

0640 root:pk-app lets the service read the file and nobody else except root. Changing the mode does not un-expose the key: if the file was world-readable while untrusted code ran on the host, rotate the key. The same search belongs in shell history files (a password passed as mysql -p... or an export TOKEN=... line is stored there) and in private keys under home directories that have no passphrase.

Three roads up when SUID, sudo and capabilities are clean
The kernel
intruder reads uname -r
picks a public exploit
you compare running vs installed
needrestart -b -k, needs-restarting -r
Root-run jobs
intruder writes a path root runs
script, its directory, PATH
you check every path component
namei -l, find -perm /022
Stored secrets
intruder reads files and environments
keys lead to the next host
you scan names and modes
grep -l, systemctl show
The same command serves both sides; run it first, and on a schedule.

Try this

On your Ubuntu lab machine, start a throwaway unit with a placeholder secret: sudo systemd-run --unit=pk-try -p User=nobody -p Environment=API_TOKEN=placeholder-token /usr/bin/sleep 300. As yourself, without sudo, run systemctl show -p Environment pk-try and confirm the token is printed. Then run cat /proc/$(systemctl show -p MainPID --value pk-try)/environ and confirm it is refused with "Permission denied": the process is protected, the unit's settings are not. Stop it with sudo systemctl stop pk-try; the systemctl show then prints an empty Environment=. Optionally, on a RHEL-family host with a kernel update installed but not booted, run the changelog comparison above. Clean up this lesson's objects when you are done: sudo systemctl stop pk-app, sudo rm -r /opt/pk-app /opt/pk-scripts /etc/cron.d/pk-backup /var/tmp/pk-cronpath.txt, sudo userdel pk-app, sudo userdel pk-dev and sudo groupdel pk-ops.

Takeaway

Judge kernel exposure by the running kernel, and treat an installed-but-not-booted fix as absent. For every root job, check that each directory in its path and on its PATH is writable only by root, and never keep a secret in a world-readable file or in a unit's Environment=.

Quick check
01On a Rocky Linux 10 server, rpm -q --changelog kernel-core-$(uname -r) | grep -c CVE-2022-0847 prints 0. A colleague concludes the running kernel is exposed to Dirty Pipe. What do you tell them?
Incorrect — The changelog lists only what the vendor backported after basing its kernel on an upstream release; fixes already in that upstream release are never listed.
Correct — Absence from the changelog means nothing had to be backported. The vendor's CVE page states the fixed version for each release, which is the authoritative answer.
Incorrect — The changelog is useful: Red Hat tags backported fixes with their CVE identifiers, which is exactly how the lesson measured the reboot gap.
Incorrect — Dirty Pipe was an upstream kernel bug affecting 5.8 until its fixes, on any distribution that shipped those versions.
02Your audit finds that /opt/pk-scripts, which holds the script a root cron job runs, has been drwxrwxr-x root pk-ops for a year. What comes before chown root:root and chmod 755 on it?
Incorrect — Nothing in this setup redeploys the script, and a replaced script or an extra tar would be lost as evidence, or keep running as root.
Incorrect — Shrinking the group is a sensible later step, but it does not tell you whether someone already used the access; that question needs the directory's contents and times.
Incorrect — Running a script that someone in pk-ops may have replaced executes their code as root, which is the thing you are trying to rule out.
Correct — Anyone in pk-ops could already have swapped the script or added a file; capture the contents and birth and change times first, because chown and chmod update the directory's change time.
03A teammate wants the secret scan to print matching lines (grep -rIE without -l) so reviewers can see which keys were found. What is the problem with that?
Correct — The scan's job is to find where secrets are exposed; printing only file names avoids creating new copies, and the file's mode and owner decide the fix.
Incorrect — -l stops reading a file after its first match, not a directory; without it grep reads every file in full, and nothing is skipped.
Incorrect — -I skips binary files whether or not -l is given; the output format does not change which files are read.
Incorrect — Whether the value is live is checked at the file, by someone allowed to read it, and then the key is rotated; a report that repeats the key is another place it leaks.

Related