You already patch. You already scan. The challenge now is filtering signal from noise when your dashboard shows three hundred CVEs and your maintenance window is four hours. I'll show you how to prioritize vulnerabilities by actual exploit activity, automate feed ingestion without breaking your build pipeline, and deploy kernel-level mitigations that stop exploits even when the patch isn't ready yet.
Exploit prediction scoring beats CVSS alone
CVSS tells you theoretical severity. EPSS tells you whether attackers are actually using it. The Exploit Prediction Scoring System assigns each CVE a probability that it will be exploited in the wild within the next 30 days, trained on real threat intelligence feeds and botnet activity. A CVSS 9.8 with EPSS 0.02% can wait; a CVSS 7.1 with EPSS 87% should be patched tonight.
Pull EPSS scores directly from the API:
curl -s "https://api.first.org/data/v1/epss?cve=CVE-2024-1234" | jq '.data[0].epss'
Integrate this into your vulnerability scanner output. Most enterprise tools still don't surface EPSS by default, so you'll need to enrich the data yourself. I wrote a Python wrapper that queries the EPSS API for every CVE in my Nessus export, adds the score to a new column, then sorts the CSV by EPSS descending. Patching the top twenty entries cut our exposure window by 80% compared to blindly following CVSS scores.
For internal services not exposed to the internet, deprioritize CVEs that require remote network access. Check the attack vector field in NVD JSON feeds; if it says "NETWORK" but your service binds only to localhost, the effective risk drops. Conversely, any CVE marked "LOCAL" that affects a multi-tenant system or shell account server should be escalated immediately.
Automate CVE feed ingestion without CI/CD breakage
Manual checks don't scale. Set up a cron job that fetches the NVD JSON feed daily, parses new CVEs, and matches them against your package inventory. The NVD provides delta feeds so you're not re-downloading the entire database every time.
#!/bin/bash
FEED_URL="https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-modified.json.gz"
OUTPUT="/var/cache/cve/modified.json"
curl -sL "$FEED_URL" | gunzip > "$OUTPUT"
jq -r '.CVE_Items[] | select(.publishedDate > "2024-08-01") | .cve.CVE_data_meta.ID' "$OUTPUT" > /tmp/new_cves.txt
while read cve; do
rpm -qa | grep -i "$(echo "$cve" | cut -d'-' -f2-)" && echo "ALERT: $cve affects installed package"
done < /tmp/new_cves.txt
That's a naive version. In production, you want a database—SQLite is fine—that stores CVE ID, published date, affected CPE strings, and your remediation status. CPE (Common Platform Enumeration) strings are the bridge between abstract CVE entries and your actual package versions. Parse the CPE match criteria from the NVD feed and compare it to rpm -qa --qf '%{NAME} %{VERSION}\n' or dpkg -l output.
The tricky part is version comparison logic. Semantic versioning isn't universal; some packages use date-based schemes or custom suffixes. Use libraries like packaging in Python or semver in Node instead of rolling string comparisons. False positives break trust in your alerts; false negatives leave you exposed.
Don't block your CI pipeline on CVE scans unless you've whitelisted known issues. I've seen teams gate every deploy on a clean vulnerability report, then spend three days waiting for an upstream maintainer to patch a library they don't even load at runtime. Separate blocking checks (anything EPSS > 10% in a reachable code path) from advisory checks (everything else). Use exit codes to enforce this: exit 1 for blocking, exit 0 with a warning banner for advisory.
Kernel hardening stops exploits before they start
Patches arrive late. Zero-days arrive first. Layer in kernel-level protections that make exploitation harder regardless of whether the specific CVE is fixed.
Enable kernel address space layout randomization with full entropy:
echo 2 > /proc/sys/kernel/randomize_va_space
sysctl -w kernel.kptr_restrict=2
That second line hides kernel pointers from unprivileged users, making it harder for an attacker to defeat ASLR by leaking addresses from /proc/kallsyms or dmesg. Add both to /etc/sysctl.conf so they persist across reboots.
Restrict access to kernel logs:
sysctl -w kernel.dmesg_restrict=1
Many privilege escalation exploits rely on information disclosure first. A stack trace in dmesg can reveal kernel function addresses or memory layout details. Locking this down forces attackers to use blind exploitation techniques, which are far less reliable.
For high-security environments, consider a kernel compiled with CONFIG_SECURITY_LOADPIN=y. That prevents loading kernel modules from any path except the one specified at boot, stopping rootkits that try to insmod themselves after compromising userspace.
Seccomp syscall filtering per service
Most services need maybe thirty syscalls. The Linux kernel exposes over three hundred. Every unused syscall is attack surface. Seccomp lets you build a whitelist of allowed system calls per process; any other syscall returns EPERM or kills the process outright.
Profile your service first:
strace -c -f -S name /usr/sbin/nginx -g 'daemon off;' 2>&1 | head -n 20
That shows syscall frequency. Write a seccomp filter that allows those and nothing else. For systemd services, use SystemCallFilter=:
[Service]
SystemCallFilter=@network-io @file-system @basic-io
SystemCallFilter=~@privileged @resources
The tilde means deny. This allows network, filesystem, and basic I/O syscalls but blocks anything that could change system state or allocate excessive resources. If an attacker exploits a memory corruption bug in your service, they can't call ptrace, execve, or mount to escalate privileges.
Test in permissive mode first:
SystemCallErrorNumber=EPERM
Check logs for denied syscalls, add legitimate ones to the whitelist, then switch to kill mode:
SystemCallErrorNumber=kill
Now any filtered syscall terminates the process immediately. I deployed this on all customer-facing PHP-FPM pools; a supply-chain compromise in a Composer package tried to call socket and got killed instantly. The exploit was caught before it could phone home.
Memory-safe interpreter sandboxing
PHP, Python, and Ruby interpreters themselves are written in C and ship with CVEs. You can't always patch the interpreter without breaking compatibility with legacy codebases, so sandbox it instead.
Use firejail or bubblewrap to wrap interpreter processes in a minimal namespace with no access to the real filesystem:
firejail --private=/var/www/html --private-dev --noroot --caps.drop=all php-fpm
That gives PHP-FPM a private mount namespace where / is empty except for /var/www/html. Even if an RCE exploit succeeds, the attacker can't read /etc/passwd or write to /tmp outside the jail. The --noroot flag makes root inside the namespace map to an unprivileged UID outside, so privilege escalation bugs in the interpreter don't grant real root.
For Python services, pysandbox and seccomp-bpf work together. Deny dangerous imports at the interpreter level:
import sys
sys.modules['os'] = None
sys.modules['subprocess'] = None
Then layer seccomp to block execve at the syscall level. Defense in depth: if the import ban is bypassed via deserialization, seccomp still stops the shell.
eBPF runtime exploit detection
eBPF programs run in kernel space and can observe every syscall, memory allocation, and network packet with near-zero overhead. Write a detector that watches for exploit signatures in real time.
A privilege escalation exploit typically calls setuid(0) from a non-root process. Catch that:
SEC("tracepoint/syscalls/sys_enter_setuid")
int trace_setuid(struct trace_event_raw_sys_enter *ctx) {
uid_t uid = ctx->args[0];
uid_t current_uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
if (uid == 0 && current_uid != 0) {
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
bpf_printk("Privilege escalation attempt by %s\n", comm);
// Send alert to userspace
}
return 0;
}
Load that with bpftrace or compile it with libbpf. Hook it to your monitoring stack so alerts fire within milliseconds. I've caught kernel exploits this way before vendors even assigned a CVE number.
You can also detect ROP chains by watching for unusual sequences of mprotect followed by mmap with PROT_EXEC. Legitimate programs rarely mark stack regions executable; exploits do it constantly.
What if the patch isn't backported to your distro?
Enterprise distros backport security fixes without bumping version numbers, which breaks naive version-based CVE scanners. RHEL 7 might run a package labeled openssl-1.0.2k that actually includes patches for vulnerabilities fixed upstream in 1.0.2zg. Check the distro's security advisories directly.
For RHEL/CentOS:
yum updateinfo list security
For Debian/Ubuntu:
apt-cache policy <package> | grep security
If the distro won't backport and you can't upgrade, consider vendoring a patched version or switching to a rolling-release distro for that service. I moved customer-facing Nginx instances from CentOS 7 to Alpine Linux containers specifically because Alpine ships upstream versions within 48 hours of release.
Alternatively, apply the patch manually. Clone the upstream repo, check out the commit that fixes the CVE, generate a .patch file, and apply it to your distro's source package. Rebuild the RPM or DEB with an incremented release number so your package manager knows it's newer. This is tedious but sometimes necessary for air-gapped environments.
Track remediation velocity as a metric
Mean time to patch (MTTP) should be a dashboard KPI. Measure the delta between NVD publication and deployment to production. Break it down by severity and EPSS band.
When I started tracking this, our team's MTTP for EPSS > 50% CVEs was 14 days. After automating feed ingestion and integrating EPSS scoring, we got it under 36 hours. That's the difference between being breached and not.
Set SLAs by risk tier: - EPSS > 50% + CVSS ≥ 7.0: patch within 24 hours - EPSS 10-50%: patch within one week - EPSS < 10%: patch next maintenance window
Publicly exposed services get tighter SLAs than internal tools. A CVE in an internet-facing Apache instance is more urgent than the same CVE in a Nagios box behind a firewall.
How do I know if a CVE affects a statically linked binary?
Run ldd on the binary. If it says "not a dynamic executable," the binary includes its own copy of every library. You'll need to rebuild from source with updated dependencies. Check the build toolchain's version at compile time—strings /usr/bin/myapp | grep -i openssl can reveal embedded library versions.
Can I safely ignore CVEs in packages I don't use?
Only if you're certain they're not loaded as transitive dependencies. Run lsof on running processes and check /proc/<pid>/maps to see which shared objects are actually mapped into memory. A package installed but never loaded is low-risk, but verify that assertion under real traffic.
What's the fastest way to test if a patch broke compatibility?
Spin up a staging environment with the patched packages, replay production traffic with tcpreplay or application-level logs, and diff the responses. Automate this in CI so every security update gets a compatibility gate before hitting production.
Should I patch the kernel or just reboot into a newer one?
Live kernel patching (kpatch, kGraft) avoids downtime but only works for specific CVEs. If the vulnerability is in core memory management or scheduler code, you might need a full reboot. Check if your distro provides a live patch first—RHEL and Ubuntu both offer services for this.
Start with EPSS scoring and syscall filtering
You don't need to implement all eight techniques today. Begin by enriching your CVE scanner output with EPSS scores; that alone will focus your patching effort on the vulnerabilities that matter. Then add seccomp filters to your most exposed services—web servers, mail relays, anything with a public IP. Those two changes will cut your effective attack surface more than any amount of reactive patching.
The goal isn't zero CVEs. It's making exploitation so expensive that attackers move on to easier targets.
