MODULEJAIL(8)

πŸ“¦ version 1.6.0, in ubuntu stonking

🌸 modulejail 🌸

okay bestie, gather round, because modulejail β€” that's section 8 of the manual, the system-administration girlies β€” is about to become your new favorite little gremlin πŸ’…


✨ NAME ✨

modulejail β€” shrinks down a Linux host's kernel-module attack surface πŸ¦‹


✨ SYNOPSIS ✨

modulejail [-p profile] [-o output] [--whitelist-file path]
[--no-syslog-logging] [--verbose-logging] [--dry-run]
[--quiet|--verbose] [-f] [--output-format format]
modulejail {-h|--help|-V|--version}

πŸ’– DESCRIPTION πŸ’–

so here's the tea πŸ“: modulejail shrinks the kernel-module attack surface of a Linux host by writing a modprobe.d(5) blacklist file that turns OFF every kernel module that isn't currently in use. literally closing the door on all the modules that are just standing there menacingly. periodt.

when you invoke it, the script snapshots the set of currently loaded modules from /proc/modules, then computes the complement against the full module tree under /lib/modules/$(uname -r). every module living in that complement β€” minus a built-in baseline of essential modules and minus an optional whitelist you supply as the sysadmin β€” gets emitted as an install mod <body> directive into /etc/modprobe.d/modulejail-blacklist.conf (or wherever you pointed -o). πŸ’«

and since v1.2: when /usr/bin/logger is executable on the host AND --no-syslog-logging is not set, that <body> is a lil shell invocation that calls logger(1) with tag modulejail, so blocked module load attempts actually produce a syslog entry. otherwise <body> is just /bin/true, matching the silent v1.1.4 behaviour. go peek at VIEWING BLOCKED ATTEMPTS below for more πŸ‘€

the script itself is a POSIX-friendly /bin/sh program with no runtime dependencies beyond awk, comm, find, sha256sum, and standard coreutils. no cap. it's built for Linux fleet operators who need to harden a whole bunch of servers against the wave of AI-assisted kernel privilege-escalation discoveries β€” because every additional loaded module is additional latent attack surface for the next disclosed CVE 😭πŸ”₯


πŸ’… OPTIONS πŸ’…

  • -p, --profile {minimal|conservative|desktop|none} β€” picks the built-in baseline profile of always-preserved modules (default: conservative). go read PROFILES below bestie.

the special value none zeroes out the built-in baseline entirely; the keep-set then becomes the union of currently loaded modules, the in-script WHITELIST, and any --whitelist-file additions. the >99% blacklist sanity guard is disabled under -p none (the loaded-keep invariant remains the boot-safety contract tho, that one never leaves). this is only recommended when --whitelist-file is supplied, so that essential modules that aren't currently loaded still get preserved. available since v1.3 ✨

  • -o, --output path β€” writes the generated blacklist to path (default: /etc/modprobe.d/modulejail-blacklist.conf). the target directory has to be writable; non-root invocations are supported when the chosen path lives outside /etc/modprobe.d/ 🫢
  • --whitelist-file path β€” reads site-local module names from path and appends them to the in-script WHITELIST (additive, bestie, it adds). the file format is described down in SITE-LOCAL WHITELIST FILE below: one module name per line, with # introducing a comment and blank lines ignored. every non-comment line must match [a-zA-Z0-9_-]+ exactly; malformed lines exit 65 (EX_DATAERR) with a line-numbered stderr diagnostic 😀. the file mode must not be group- or world-writable; if it is, modulejail exits 77 (EX_NOPERM) and prints a chmod go-w hint. when this flag isn't given, modulejail auto-detects /etc/modulejail/whitelist.conf and uses it if present (and prints an info: line on stderr noting the choice). pass --no-whitelist-file to skip that default for one run. available since v1.2.
  • --no-whitelist-file β€” skips the default whitelist file even if /etc/modulejail/whitelist.conf is right there. mutually exclusive with --whitelist-file path; combining them exits 64 (EX_USAGE) πŸ’€. use it during recovery, or when you want a guaranteed-empty external whitelist contribution for a single run. available since v1.2.
  • --no-syslog-logging β€” forces the v1.1.4-style install <mod> /bin/true install-line body, suppressing the default-on syslog emission that got added in v1.2. use this on hosts without /usr/bin/logger, in minimal or initramfs builds, or whenever a byte-identical regression contract across versions is required πŸ™ˆ. available since v1.2.
  • --dry-run β€” simulates the whole pipeline without writing any file at all. prints a one-line DRY-RUN: would blacklist summary to stdout (or a JSON/logfmt object when --output-format is set). prints the would-be 8-line header block plus the full set of would-be install directives to stderr (unless --quiet is also set). writes nothing under /etc/modprobe.d/ or the path passed to -o. exits 0 on simulated success. chef's kiss for previewing what would get blacklisted before you commit a profile change or a whitelist edit β€” just redirect stderr to a file to capture the preview πŸ‘‘. available since v1.3.
  • --quiet β€” suppresses all non-error stderr output: the default-whitelist info: line, the -p none info: line, the dry-run would-be header block, and the update-check notice: line. also suppresses the stdout summary line. severity-prefixed error: messages on stderr are never suppressed, so EX_USAGE, EX_NOPERM, and EX_SOFTWARE failures stay visible no matter what 🚨. mutually exclusive with --verbose; combining them exits 64 (EX_USAGE). available since v1.3.
  • --verbose β€” emits one decision line per module to stderr, shaped like keep: NAME (loaded|whitelist|baseline) or blacklist: NAME. reasons show up only on keep lines. precedence among keep-sources goes: loaded > whitelist > baseline. these lines carry no severity prefix (they're operator-requested telemetry, not script-status diagnostics, and they're living their truth πŸ’«). mutually exclusive with --quiet; combining them exits 64 (EX_USAGE). available since v1.3.
  • --output-format {json|logfmt} β€” emits a single-line machine-readable run summary on stdout instead of the human-readable summary line. valid formats are json and logfmt. see OUTPUT FORMATS below for the full 11-field schema v1 ✨. the machine-readable output is not silenced by --quiet β€” it is the parser surface, bestie. combinable with --dry-run (the JSON/logfmt dry_run field becomes true and output_path becomes the would-be path; no file is written). available since v1.3.
  • -f, --fail-on-module-load β€” makes blocked module loads return a non-zero exit code so that modprobe fails loudly for blacklisted modules. by default, blocked loads silently succeed (modprobe returns 0 even though the module was never loaded, which is very sneaky of it 😭). with this flag, the install-line body uses /bin/false instead of exit 0 or /bin/true, so modprobe <module> returns non-zero for any blacklisted module. use this when blocked loads should produce a visible error rather than silently succeeding.
  • --verbose-logging β€” enriches the per-blocked-load logger call with caller context, read straight from /proc/$PPID/: the parent PID (ppid), the audit loginuid (persists across su/sudo), the parent's short command name (pcomm), and argv[0] (pexe, truncated at the first NUL by command substitution; full cmdline is one ps -fp <ppid> away πŸ‘€). the default logger output is the bare blocked: <module> form. requires /usr/bin/logger to be executable (modulejail exits 66 (EX_NOINPUT) otherwise); mutually exclusive with --no-syslog-logging. available since v1.3.4.
  • --warn-only β€” permissive audit mode πŸ¦‹. instead of blocking, each install line logs a would-block: <module> event to syslog (tag modulejail) and then loads the module anyway via modprobe --ignore-install <module>. the --ignore-install flag does double duty here: it performs the real insmod AND stops modprobe from re-entering the same install directive (which would otherwise recurse, and we do not do infinite loops in this house πŸ’€). nothing is actually blocked, so the host keeps working while you observe, in the log, exactly what an enforcing run would block. this is the safe way to trial a profile on an unfamiliar system before committing, fr fr. requires /usr/bin/logger and an executable modprobe (searched at /usr/sbin/modprobe then /sbin/modprobe; modulejail exits 66 (EX_NOINPUT) if either is missing). mutually exclusive with -f / --fail-on-module-load and --no-syslog-logging (both exit 64 (EX_USAGE)); composes with --verbose-logging. available since v1.6 πŸ”₯
  • -V, --version β€” prints version, repository URL, and license, then exits 0.
  • -h, --help β€” prints a usage summary including exit codes and environment, then exits 0.

🌈 PROFILES 🌈

  • minimal β€” core filesystems plus essential kernel modules only. the bare minimum, the skinny legend πŸ’…
  • conservative β€” minimal plus common server and VM drivers. this is the default; appropriate for virtualised or bare-metal Linux servers across the mainstream hardware matrix πŸ–₯️✨
  • desktop β€” conservative plus WiFi, Bluetooth, audio, and video drivers. for laptops and workstations, iykyk πŸŽ§πŸ“Ά

🧚 SYSADMIN WHITELIST 🧚

a site-local WHITELIST variable near the top of the script holds space-separated module names that are always preserved, beyond whichever baseline you selected. it ships empty. edit the script in place and go look for the === SYSADMIN WHITELIST === banner πŸ‘‘. those === anchors are designed for Ansible template insertion (lineinfile or blockinfile), which is honestly so iconic of them.

since v1.2, an external file is also supported via --whitelist-file; see SITE-LOCAL WHITELIST FILE below. the two mechanisms are additive: the in-script value remains the sysadmin's editable default, and the file appends to it 🫢


SITE-LOCAL WHITELIST FILE πŸŽ€

Okay bestie, so the --whitelist-file flag lets you read the modules-to-keep list from a file you choose. This is the move when modulejail got installed via .deb/.rpm/curl | sh, because if you go editing the script in place, all your cute site-local additions get yeeted on the next reinstall. We don't want that. πŸ’…

If you don't pass the flag, modulejail auto-detects /etc/modulejail/whitelist.conf and uses it when it's there, with the same strict mode and content gates as an explicitly-passed path. It prints an info: line on stderr so the choice is never silent β€” no sneaky behaviour here, bestie. And if you want out for one run, pass --no-whitelist-file.

File format:

# Comments start with # and run to end of line.
# Blank lines are ignored.
# Each non-comment line is one module name, matching [a-zA-Z0-9_-]+.
# Trailing whitespace and CR (CRLF endings) are stripped silently.
# Module names are accepted in both dash and underscore form;
# the pipeline normalises - to _ internally.

nft_compat
xt_owner
zfs

The file mode must NOT be group-writable or world-writable β€” it's the exact same hardening that sshd(8) applies to authorized_keys and that sudo(8) applies to sudoers. modulejail calls stat(1) with the GNU -c '%a' form, falling back to the BSD -f '%Lp' form for cross-distro portability. If either the group- or world-write bits are set, it refuses to run with exit code 77 (EX_NOPERM) and prints chmod go-w PATH as the remediation hint.


OUTPUT FORMATS ✨

When you pass --output-format json or --output-format logfmt, modulejail emits a single-line machine-readable run summary to stdout instead of the human-readable line. The schema is small, versioned, and additive-only across future schema versions. So it'll keep working, fr fr. 🫢

Schema version 1 fields

  • schema_version β€” integer (always 1)
  • tool β€” object {name, version}
  • kernel_version β€” string (host uname -r output)
  • profile β€” string (minimal|conservative|desktop|none)
  • dry_run β€” boolean (true when --dry-run was passed)
  • whitelist_file β€” string or null (null when no --whitelist-file)
  • modules_available β€” integer (total .ko* files under /lib/modules/KVER)
  • modules_loaded β€” integer (lines in /proc/modules at snapshot time)
  • modules_blacklisted β€” integer (entries written to the output file)
  • fingerprint β€” string (64-char lowercase hex, no "sha256:" prefix)
  • output_path β€” string (target path; under --dry-run, the would-be path)

JSON example

{"schema_version":1,"tool":{"name":"modulejail","version":"1.3.0"},
 "kernel_version":"6.8.0-31-generic","profile":"conservative",
 "dry_run":false,"whitelist_file":"/etc/modulejail/whitelist.conf",
 "modules_available":4912,"modules_loaded":47,
 "modules_blacklisted":4810,"fingerprint":"a3f5b9c2e1d4...",
 "output_path":"/etc/modprobe.d/modulejail-blacklist.conf"}

logfmt example

schema_version=1 tool_name=modulejail tool_version=1.3.0 kernel_version=6.8.0-31-generic profile=conservative dry_run=false whitelist_file=/etc/modulejail/whitelist.conf modules_available=4912 modules_loaded=47 modules_blacklisted=4810 fingerprint=a3f5b9c2e1d4... output_path=/etc/modprobe.d/modulejail-blacklist.conf

Fleet automation idiom

Without --quiet, modulejail may emit human-readable info: lines to stderr (like the default-whitelist notice, or the -p none sanity-guard notice) right alongside the machine-readable stdout record. Fleet automation that captures stderr into a unified log (2>&1) will receive both the structured output and these human-readable lines. To suppress all non-error stderr output while keeping the structured stdout record intact, combine --output-format with --quiet:

modulejail --output-format json --quiet | jq .
modulejail --output-format logfmt --quiet

--quiet never suppresses the machine-readable stdout record β€” only the human-targeted stderr diagnostics. πŸ’…

Fingerprint format

The fingerprint field in JSON and logfmt output is raw 64-character lowercase hex, without the sha256: prefix that appears in the generated file header (# fingerprint: sha256:HEX). So machine consumers parsing via jq(1) or awk don't need to do string slicing to extract the hash β€” and the file header keeps the algorithm tag for human auditability. Everybody wins. πŸ‘‘

Schema stability contract

schema_version=1 is stable. Future schema versions are strictly additive: new fields may be added; renames and removals require schema_version=2. This mirrors the reproducible-builds.org stability convention: field semantics locked once published. Periodt.

Known limitation 😭

Operator-supplied paths containing a literal double-quote character ("), a backslash (\), or a newline are not JSON-escaped in the v1 schema. Real-world paths essentially never contain these characters, but operators using exotic paths may receive malformed JSON and a jq(1) parse error. A future schema_version may add full path escaping.


VIEWING BLOCKED ATTEMPTS πŸ•΅οΈβ€β™€οΈ

Since v1.2, when /usr/bin/logger is executable on the host running modulejail and --no-syslog-logging is not set, the generated install lines emit a syslog entry tagged modulejail every single time a blocked module is targeted by modprobe(8). View the entries via:

journalctl -t modulejail --since '1 hour ago'      # systemd / journald
grep modulejail /var/log/syslog                       # classic syslog

The generated blacklist file's header annotation records which install-line form is in use:

# install-line: /bin/sh + logger (syslog tag: modulejail)
# install-line: /bin/true (silent, --no-syslog-logging or logger absent)
Heads up bestie: if /usr/bin/logger is absent and --no-syslog-logging was NOT set, modulejail silently falls back to the /bin/true form. No stderr warning is emitted; the header annotation is the only visible cue. Use --no-syslog-logging explicitly when the v1.1.4 install-line body is a hard requirement.

INVOCATION πŸ“

A header line starting with # invocation: is added to the blacklist, which registers the invocation that was used to produce the blacklist β€” and it can be copied and pasted straight into the terminal for reproducible results. Iconic.


HEADER FIELDS</header>

The generated blacklist file carries an 8-line #-prefixed header documenting the tool version, repository, generation profile, host kernel, fingerprint, install-line form, invocation, and a do-not-edit-by-hand disclaimer. So much lore in eight little lines. 🌸

# modulejail VERSION

Tool name and version that produced the file.

# https://modulejail.com

Project home page (source repository is at https://github.com/jnuyens/modulejail).

# profile: PROFILE

The profile name passed to -p (or conservative when the default was used).

# kernel: KVER

The host kernel version string from uname -r at the time modulejail ran. This is the same value as the kernel_version field in the JSON and logfmt output (see OUTPUT FORMATS above).

# fingerprint: sha256:HEX

A sha256 hash over canonical inputs: sorted loaded-module set, sorted baseline, sorted whitelist, profile name, and kernel version. The sha256: prefix identifies the algorithm for human readers; the JSON and logfmt fingerprint field strips this prefix and carries raw hex only.

# install-line: FORM

Records which install-line body is in use: /bin/sh + logger (syslog tag: modulejail) or /bin/true (silent, --no-syslog-logging or logger absent).

# invocation: ARGV-STRING

The full command line that produced the file, in POSIX-canonical single-quote encoding. See INVOCATION above.

# Do not edit by hand -- regenerate with modulejail(8).

Disclaimer. Edits are overwritten on the next run. 😬

The # kernel: line is regression-locked: test suite fixture header-kernel-line-locked.sh asserts that this line exists and carries a non-empty kernel-version token in every generated file. See OUTPUT FORMATS for the machine-readable schema that parallels these header fields.


SCOPE OF THE BLACKLIST πŸ¦‹

A modprobe.d(5) blacklist intercepts modprobe(8)-mediated module loads: udev hotplug events, dependency resolution, autoloaded modules via the alias system. It does not intercept, by design:

  • insmod(8) β€” insmod bypasses modprobe(8) entirely; it never reads /etc/modprobe.d/. A root user can always insert a .ko directly.
  • modprobe --ignore-install β€” modprobe(8)'s explicit escape hatch from install-line indirection. Treating it as suspicious is a higher-layer concern (auditd, eBPF) outside the modprobe.d contract.

modulejail is a default-safe policy on modprobe(8). It removes the auto-loading attack surface that unprivileged code or remote attackers can reach, and it does not pretend to block a root user with intent. No cap. πŸ’€


SAFETY MODEL πŸ›‘οΈ

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.

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 loaded, all filesystems mounted. Running it on a partial or in-flux system risks blacklisting a module that is occasionally needed. πŸ™ˆ

The built-in baseline ensures that core filesystems, storage controllers, and essential networking modules are never blacklisted regardless of the running profile.


IDEMPOTENCY πŸ’–

Two consecutive runs on an unchanged host produce byte-identical output files. The generated blacklist header carries a sha256 fingerprint over canonical inputs (sorted loaded-module set, sorted baseline, sorted whitelist, profile name, and kernel version) rather than a wall-clock timestamp. Identical inputs therefore produce an identical fingerprint, and an identical output file. So satisfying. ✨

Fleet operators can use the fingerprint to correlate hardening state across machines: two hosts with the same fingerprint had identical loaded sets, baseline, whitelist, profile, and kernel version when modulejail ran. Slay.

πŸ’₯ EXIT CODES πŸ’₯

okay so bestie, exit codes here follow the sysexits(3) conventions β€” that's the grown-up Unix way of saying "the number we quit with tells you exactly what went wrong." no guessing, no vibes-based debugging. ✨

  • 0 β€” Success. we ate. πŸ’…
  • 64 β€” Command-line argument error: bad flag, missing value, or unknown profile. basically you typed something we don't recognise. 🫠
  • 65 β€” Invalid data in --whitelist-file (EX_DATAERR): malformed module name on at least one non-comment, non-blank line. the whitelist file is giving typos. 😭
  • 66 β€” Required kernel input missing (/proc/modules or /lib/modules/<kernel>). can't find the stuff we need, periodt.
  • 70 β€” Sanity guard tripped: the computed blacklist is empty, or it covers more than 99% of the module universe. Aborting to avoid an unbootable host. 🚨 this one is the big one β€” it's the seatbelt. respect the seatbelt.
  • 71 β€” OS-level error: can't create the temporary work directory, or we find reported errors while walking /lib/modules.
  • 73 β€” Output path cannot be created: symlinked target, existing directory, trailing slash, or mktemp failure in the target directory. πŸ™ˆ
  • 77 β€” Target directory not writable. Try sudo, or use -o other-path.

🌿 ENVIRONMENT 🌿

the env vars, bestie. most of these are test-only plumbing β€” real operators leave them unset, iykyk.

  • MODULEJAIL_NO_UPDATE_CHECK β€” Set to any non-empty value to skip the post-run check for a newer release on GitHub. The check has a 10-second hard timeout and is silent on every failure mode; this variable disables it entirely for offline fleets, restricted networks, or pipeline-style automation where any unexpected output is noise. ✨
  • MODULEJAIL_LOGGER_PATH β€” Path to the logger(1) binary used for the v1.2 syslog install-line detection (default: /usr/bin/logger). Test-only plumbing that lets acceptance tests exercise the absent-fallback path without a chroot or namespace; end-user operators leave this unset.
  • MODULEJAIL_MODULES_ROOT β€” Root of the kernel modules tree to walk (default: /lib/modules). Test-only plumbing parallel to MODULEJAIL_PROC_MODULES and MODULEJAIL_KVER; lets host-local cases on non-Linux dev boxes point modulejail at a synthetic tree under $TMPDIR. End-user operators leave this unset.
  • MODULEJAIL_PROC_MODULES β€” Path to read the loaded-module list from (default: /proc/modules). Test-only plumbing.
  • MODULEJAIL_KVER β€” Kernel version string used to locate the module tree (default: uname -r). Test-only plumbing.

πŸ—‚οΈ FILES πŸ—‚οΈ

the receipts of where everything lives πŸ’–

  • /etc/modprobe.d/modulejail-blacklist.conf β€” Default output path. Overridable with -o.
  • /proc/modules β€” Read at run time as the keep-list. πŸ‘‘
  • /lib/modules/$(uname -r) β€” Walked to enumerate the full module universe (including DKMS and weak-updates modules).

🌈 EXAMPLES 🌈

okay this is the fun part, let's actually do stuff πŸŽ€

Hardening a server with the default profile and default output path:

sudo modulejail

Workstation hardening, preserving WiFi, Bluetooth, audio, and video drivers:

sudo modulejail -p desktop

Writing to a custom path (does not require root if the directory is writable):

modulejail -o /tmp/site-blacklist.conf

Fleet-automation context: disable the update check to keep output deterministic.

MODULEJAIL_NO_UPDATE_CHECK=1 sudo modulejail

Site-local whitelist via an operator-owned file (preferred when modulejail is installed via package manager):

# /etc/modulejail/whitelist.conf: chmod 0644 (or stricter)
#
# nft_compat
# xt_owner
# zfs

sudo modulejail --whitelist-file /etc/modulejail/whitelist.conf

View syslog entries for blocked module load attempts (since v1.2):

journalctl -t modulejail --since '1 hour ago'
grep modulejail /var/log/syslog

Suppress syslog emission to restore the v1.1.4 install-line body byte-for-byte (useful on hosts without logger, or for byte-identical regression contracts):

sudo modulejail --no-syslog-logging

πŸ¦‹ REVERTING πŸ¦‹

changed your mind? totally fine, no judgement πŸ’œ

Remove the generated file and reboot:

sudo rm /etc/modprobe.d/modulejail-blacklist.conf
sudo reboot

To bring back a specific module without rebooting (the restriction re-applies after the next reboot unless the file has been removed):

sudo modprobe <module_name>

πŸ‘€ SEE ALSO πŸ‘€

modprobe(8), modprobe.d(5), sysexits(3), lsmod(8)


✨ AUTHOR ✨

Jasper Nuyens <jnuyens@linuxbe.com>


Copyright Β© 2026 Jasper Nuyens. License GPL-3.0-only.

This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law.


🌸 HOMEPAGE 🌸

https://modulejail.com

Source repository, issue tracker, signed release tarballs and binary artifacts: https://github.com/jnuyens/modulejail


annnnd that's a wrap bestie 🫢 you just read the whole MODULEJAIL(8) page β€” this version slaps as modulejail 1.6.0, dated 2026-08-19. go forth, harden that kernel, and stay iconic πŸ’…πŸ‘‘

πŸ“¦ where this page comes from