Kernel exploits and credential discovery
Unpatched kernels, weak jobs and leaked secrets.
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.
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.
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.
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).
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.
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.
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.
Write the script and the job file as root (for example with sudoedit), then make the script executable.
#!/bin/sh# back up the web root every nightcd /var/www/html && tar czf /var/backups/www-$(date +%F).tgz .
# nightly backup of the web rootPATH=/opt/pk-scripts:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin30 2 * * * root backup.sh
Now audit it as if you had found it: list every root job, then look at the local one.
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.
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.
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.
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.
# app settings (placeholder keys from the AWS documentation)AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLEAWS_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.
Now audit. List file names, not values, so the audit itself does not copy secrets into your terminal history or a ticket.
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 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.
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.
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.
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=.
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?/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?tar would be lost as evidence, or keep running as root.pk-ops may have replaced executes their code as root, which is the thing you are trying to rule out.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.grep -rIE without -l) so reviewers can see which keys were found. What is the problem with that?-l stops reading a file after its first match, not a directory; without it grep reads every file in full, and nothing is skipped.-I skips binary files whether or not -l is given; the output format does not change which files are read.