A single POSIX shell script that shrinks a Linux host's kernel-module attack
surface by writing a modprobe.d blacklist for every kernel module not
currently in use, minus a built-in baseline and an optional sysadmin
whitelist. No daemons, no initramfs changes, no AI inside the tool. One
script, one run, one blacklist file.
AI-assisted security scanning is about to do to the Linux kernel what large-scale fuzzing did to userspace code a decade ago, only faster and at a much larger scale. Many years of latent privilege-escalation bugs in kernel modules are about to surface in quick succession over the coming weeks and months. Long term, this is a major win for kernel security: every disclosure closes a door that an attacker could otherwise have walked through unseen. Short term, it is a nightmare for sysadmins. Every public release brings another race against patch cycles, vendor backports, and reboots across thousands of hosts.
ModuleJail does not try to fix kernel bugs, and it cannot. It does the one thing a sysadmin can do today, on any host, in seconds: shrink the attack surface so that the next disclosed bug is more likely to land on a module the host is not even loading. A typical Linux host ships with several thousand kernel modules and uses a few hundred. ModuleJail blacklists the rest. The next CVE in the unused 90% becomes a non-event on that host, and the fleet operator buys time to schedule the patch on their own terms instead of emergency-paging at 03:00.
This is intentionally a boring tool. No AI inside it, no daemon, no
continuous monitoring, no risk scoring, no CVE database lookups. Just one
shell script, run once on a steady-state host, that writes
/etc/modprobe.d/modulejail-blacklist.conf to blacklist the thousands of
unused modules, specific to your system.
curl -fsSL https://raw.githubusercontent.com/jnuyens/modulejail/v1.1.4/modulejail | sudo shWARNING: convenient, not safe. This pipes unverified bytes from the network to a root shell. The safer alternative below is the recommended path.
The script writes its blacklist to /etc/modprobe.d/modulejail-blacklist.conf
by default. To use a different path:
curl -fsSL https://raw.githubusercontent.com/jnuyens/modulejail/v1.1.4/modulejail | sudo sh -s -- -o /etc/modprobe.d/site-blacklist.confDownload, inspect, then run:
curl -fsSL https://raw.githubusercontent.com/jnuyens/modulejail/v1.1.4/modulejail -o /tmp/modulejail
less /tmp/modulejail
sudo sh /tmp/modulejailThis is the recommended path for any production deployment. The script is plain POSIX shell and inspection takes under ten minutes.
For Debian/Ubuntu and RHEL/Fedora/Rocky hosts, prebuilt packages are attached to the GitHub release page:
# Debian / Ubuntu:
curl -fsSLO https://github.com/jnuyens/modulejail/releases/download/v1.1.4/modulejail_1.1.4_all.deb
sudo dpkg -i modulejail_1.1.4_all.deb
# RHEL / Fedora / Rocky:
curl -fsSLO https://github.com/jnuyens/modulejail/releases/download/v1.1.4/modulejail-1.1.4-1.noarch.rpm
sudo rpm -i modulejail-1.1.4-1.noarch.rpmBoth packages install /usr/bin/modulejail, the modulejail(8) manpage
under /usr/share/man/man8/, and the README and LICENSE under
/usr/share/doc/modulejail/. They depend on coreutils, findutils, and
awk/gawk (all standard) and recommend curl or wget so the optional
post-run update check can reach GitHub.
After install, man 8 modulejail shows the full reference: options,
profiles, safety model, idempotency, exit codes, environment, and examples.
To rebuild the packages locally from a checkout:
./packaging/build.sh # builds whatever this host's tooling supports
./packaging/build.sh --deb # .deb only (requires dpkg-deb)
./packaging/build.sh --rpm # .rpm only (requires rpmbuild)Output goes to packaging/dist/. The script skips gracefully on hosts
without the matching tooling.
ModuleJail snapshots the set of currently loaded modules (/proc/modules) and
computes the complement against the full module tree
(/lib/modules/$(uname -r)). Every module in the complement, minus a built-in
baseline of essential modules and an optional sysadmin-supplied whitelist, is
emitted as an install <mod> /bin/true directive in a modprobe.d-compatible
blacklist file.
The tool is aimed at Linux fleet operators who need to harden many servers against the wave of AI-assisted kernel privilege-escalation discoveries. Every additional loaded module is additional latent attack surface for the next disclosed CVE. ModuleJail's model is simple: if it is not loaded today on a steady-state host, blacklist it.
The script is portable across Debian/Ubuntu, RHEL/Rocky, Arch, Alpine, and
SUSE families. It has no runtime dependencies beyond awk, comm, find,
sha256sum, and standard coreutils, all present in every base Linux install
including busybox.
The invariant is: whatever is currently loaded is assumed necessary for the
host to function, and is preserved. ModuleJail does not guess; it reads
/proc/modules at run time and treats that exact set as the keep-list.
This means the operator's responsibility is to run ModuleJail when the host is in a known-good, steady-state configuration: after all services are started, all kernel drivers are loaded, all filesystems are mounted. Running it on a partial or in-flux system risks blacklisting a module that is occasionally needed.
The generated file is placed under /etc/modprobe.d/. To revert, remove the
file and reboot (see the Reverting section). The built-in baseline ensures
that core filesystems, storage controllers, and essential networking modules
are never blacklisted regardless of the running profile.
- No initramfs handling. Modules baked into initramfs are out of scope. The loaded-module surface is the target; baked-in modules are not the relevant attack vector.
- No revert tooling. The revert path is "remove the generated file and reboot." Sysadmin discipline replaces tool guardrails.
- No daemon or continuous monitoring. One-shot script by design.
- No AI inside the tool. AI is the threat-model backdrop, not a feature.
- No per-distro packaging in v1. The curl one-liner and a cloned repo are the distribution channels.
- No module risk scoring. The model is "unused implies blacklist," not "vulnerable implies blacklist."
- No kernel rebuild. Runtime blacklist only.
ModuleJail ships three built-in baseline profiles. The selected profile determines which modules are always preserved regardless of loaded state.
# Profile selection via -p (default: conservative)
sudo sh modulejail -p conservative
sudo sh modulejail -p minimal
sudo sh modulejail -p desktopProfile descriptions (from --help):
minimal Core filesystems + essential kernel modules only
conservative Minimal + common server/VM drivers (default)
desktop Conservative + WiFi, Bluetooth, audio, video drivers
conservative is the right choice for virtualised or bare-metal server
Linux. desktop is for laptops and workstations where WiFi, Bluetooth,
audio, and video drivers must be preserved. minimal is for environments
where you have full control over which drivers are loaded and want the
smallest possible baseline.
A site-local WHITELIST variable near the top of the script holds
space-separated module names that are always preserved, beyond the selected
baseline. It ships empty.
To use it, open the script and find the === SYSADMIN WHITELIST === section:
# === SYSADMIN WHITELIST ===
# Site-local additions to the keep-set, in addition to the selected baseline
# profile. Modules listed here will never appear in the generated blacklist.
#
# Format: space-separated module names in canonical underscore form
# (the pipeline normalizes - to _, so either form works).
# Default: empty.
#
# Example (uncomment and adapt):
# WHITELIST='nft_compat xt_owner'
WHITELIST=''
# === END SYSADMIN WHITELIST ===Edit WHITELIST='' to add your site-specific modules. The === banner
anchors are designed for Ansible template insertion (lineinfile or
blockinfile).
Exit codes follow sysexits.h conventions (see man 3 sysexits). Fleet
automation tools can case $? cleanly.
| Code | Meaning |
|---|---|
| 0 | success |
| 64 | command-line argument error (bad flag, missing value, unknown profile) |
| 66 | required kernel input missing (/proc/modules or /lib/modules/<kernel>) |
| 70 | sanity guard tripped (empty blacklist or >99% of modules blacklisted) |
| 71 | OS-level error (mktemp work dir, or find errors on /lib/modules) |
| 73 | output path cannot be created (symlink/directory/trailing-slash, or mktemp failure) |
| 77 | target directory not writable (try sudo, or use -o <other-path>) |
Two consecutive runs on an unchanged host produce byte-identical output files. The generated blacklist header carries a sha256 run fingerprint, not a wall-clock timestamp, computed over the canonical inputs: sorted loaded-module set, sorted baseline set, sorted whitelist, profile name, and kernel version. Because the fingerprint is a deterministic function of inputs, identical inputs produce an identical fingerprint and thus an identical output file.
# fingerprint: sha256:e284ee9741eb544adf1af6c0fffc162dedd6029191673237a8155cd497908686
Fleet operators can use the fingerprint to correlate "what was on the host at hardening time" across machines: two hosts with the same fingerprint had identical loaded sets, baseline, whitelist, profile, and kernel version when ModuleJail ran. No wall-clock drift, no spurious diffs in configuration management systems.
After a successful run, ModuleJail performs a best-effort lookup against the
GitHub tags API to see whether a newer release is available. The check has a
10-second hard timeout and is silent on every failure mode (no network, no
curl or wget installed, parse failure, current version equal to or newer
than the latest tag). It only prints a stderr notice when the upstream
release is strictly newer than the running version.
To disable the check entirely (for offline fleets, restricted networks, or pipeline-style automation where any unexpected output is noise), set:
export MODULEJAIL_NO_UPDATE_CHECK=1The check fires only on a successful run. Error paths (bad arguments,
missing /proc/modules, sanity-guard trip, etc.) exit before reaching it.
ModuleJail has been verified across two confidence tiers.
| Distro | Kernel | Result |
|---|---|---|
| Ubuntu 24.04.4 LTS (Noble Numbat) | 6.8.0-110-generic | PASS (6363 of 6474 modules blacklisted) |
| Debian GNU/Linux 13.4 (trixie) | 6.12.74+deb13+1-amd64 | PASS (4091 of 4227 modules blacklisted) |
| Rocky Linux 9.7 (Blue Onyx) | 5.14.0-503.35.1.el9_5.x86_64 | PASS (2253 of 2338 modules blacklisted) |
Note for Rocky/RHEL hosts: on hosts with strict SELinux enforcement,
non-root execution may encounter a find permission denial on
/lib/modules/, causing exit code 71 (EX_OSERR). This is expected,
documented behaviour. Use sudo, or relax the relevant SELinux policy, if
this occurs.
| Distro | Base image | Shell | Result |
|---|---|---|---|
| Arch Linux (latest) | archlinux:latest |
/bin/sh (bash) |
PASS (10/10 assertions) |
| Alpine Linux (latest) | alpine:latest |
busybox ash | PASS (10/10 assertions) |
| openSUSE Tumbleweed | opensuse/tumbleweed:latest |
/bin/sh |
PASS (10/10 assertions) |
Fixture containers run against a synthetic
/lib/modules/6.99.0-fixture/ tree with representative .ko, .ko.gz,
.ko.xz, and .ko.zst files to exercise all four suffix variants.
The MODULEJAIL_PROC_MODULES and MODULEJAIL_KVER environment variables
are test-only plumbing (analogous to TMPDIR or GIT_DIR) used by the
fixture harness to point the script at synthetic /proc/modules and module
tree paths. End-user operators leave these unset.
Remove the generated file under /etc/modprobe.d/ and reboot, or modprobe
the specific modules you need back without rebooting.
# Full revert (requires reboot):
sudo rm /etc/modprobe.d/modulejail-blacklist.conf
sudo reboot
# Selective reload without reboot (bring back a specific module):
sudo modprobe <module_name>The generated file uses install <module> /bin/true directives, which block
autoloading. Explicitly loading with modprobe overrides the blacklist for
the current boot session only; the restriction re-applies after the next
reboot unless the file has been removed.
The test matrix lives in tests/. Both harnesses are POSIX shell scripts
runnable by anyone with the prerequisites:
# Container fixture suite (Arch/Alpine/openSUSE):
# Requires: docker or podman; exits 77 if neither is found (graceful skip).
./tests/run-fixtures.sh
# Real-SSH-host acceptance suite:
# Requires: SSH key access to the hosts configured in the harness.
./tests/run-ssh-hosts.sh./tests/run-fixtures.sh exits 77 on any host without a container runtime;
that is the documented graceful degradation (autoconf/TAP skip convention).
Run it on a Linux host with Docker or Podman.
Both harnesses are shellcheck-clean (shellcheck --shell=sh).
Copyright (C) 2026 Jasper Nuyens jnuyens@linuxbe.com
GPL-3.0-only. See the LICENSE file for the full text.
