#!/usr/bin/env bash # ============================================================================= # Copy Fail (CVE-2026-31431) Vulnerability Checker # ============================================================================= # # This script checks whether the running Linux system is vulnerable to the # "Copy Fail" local privilege escalation vulnerability disclosed on # 2026-04-29 and tracked as CVE-2026-31431. # # Background: # Copy Fail is a logic bug in the Linux kernel's `authencesn` AEAD # cryptographic template combined with the AF_ALG (algif_aead) socket # interface. An unprivileged local user can trigger a deterministic, # controlled 4-byte write into the page cache of any readable file on the # system, which is enough to overwrite a setuid binary and gain root. # # The bug was introduced in 2017 when an in-place AEAD optimization was # added to algif_aead.c, so essentially every mainstream distribution # shipped since 2017 is affected unless explicitly patched or mitigated. # # What this script does (read-only, non-destructive): # 1. Collects basic system info (distro, kernel version). # 2. Checks whether the running kernel version is at or above the known # patched version for the detected distribution. # 3. Checks the runtime status of the `algif_aead` kernel module # (loaded / available / blacklisted). # 4. Checks whether the AF_ALG socket family is reachable from userspace. # 5. Prints a final verdict: VULNERABLE / MITIGATED / PATCHED / UNKNOWN. # # This script does NOT exploit the vulnerability and makes no persistent change # (nothing is written to disk). One runtime caveat: Check 3 performs a single # bind() on an AF_ALG 'aead' socket exactly as an attacker would; on an # UNMITIGATED box that bind autoloads the algif_aead module (the same module the # exploit needs) into the running kernel. No AEAD operation is performed and no # accept()/sendmsg() follows. On a mitigated/blacklisted box the autoload is a # no-op. To avoid even that, blacklist algif_aead first (see copyfail-setup.sh). # # Usage: # chmod +x check-copy-fail.sh # ./check-copy-fail.sh # human-readable output # ./check-copy-fail.sh --json # machine-readable JSON summary # # Exit codes: # 0 Patched or fully mitigated # 1 Vulnerable # 2 Unknown / could not determine # 3 Not a Linux system / unsupported environment # ============================================================================= set -u # ----- Output helpers -------------------------------------------------------- # Colors are only emitted when stdout is a TTY, so logs and CI captures stay # clean of escape sequences. if [ -t 1 ]; then C_RED=$'\033[0;31m' C_GREEN=$'\033[0;32m' C_YELLOW=$'\033[0;33m' C_BLUE=$'\033[0;34m' C_BOLD=$'\033[1m' C_RESET=$'\033[0m' else C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_BOLD=""; C_RESET="" fi JSON_MODE=0 if [ "${1:-}" = "--json" ]; then JSON_MODE=1 fi log_info() { [ "$JSON_MODE" -eq 0 ] && printf '%s[INFO]%s %s\n' "$C_BLUE" "$C_RESET" "$1"; } log_ok() { [ "$JSON_MODE" -eq 0 ] && printf '%s[OK]%s %s\n' "$C_GREEN" "$C_RESET" "$1"; } log_warn() { [ "$JSON_MODE" -eq 0 ] && printf '%s[WARN]%s %s\n' "$C_YELLOW" "$C_RESET" "$1"; } log_bad() { [ "$JSON_MODE" -eq 0 ] && printf '%s[VULN]%s %s\n' "$C_RED" "$C_RESET" "$1"; } log_head() { [ "$JSON_MODE" -eq 0 ] && printf '\n%s== %s ==%s\n' "$C_BOLD" "$1" "$C_RESET"; } # ----- Platform sanity check ------------------------------------------------- # This vulnerability is Linux kernel specific. Bail out early on anything else # (macOS, BSD, WSL1, etc.) so we don't print misleading results. if [ "$(uname -s)" != "Linux" ]; then log_warn "This system is not Linux (uname -s = $(uname -s)). CVE-2026-31431 does not apply." [ "$JSON_MODE" -eq 1 ] && printf '{"verdict":"not_applicable","os":"%s"}\n' "$(uname -s)" exit 3 fi # ----- Collect distribution metadata ----------------------------------------- # /etc/os-release is the standard, machine-parseable identity file on every # modern Linux distro (systemd-defined). We source a copy in a subshell-free # way to avoid clobbering shell variables. DISTRO_ID="unknown" DISTRO_VERSION_ID="unknown" DISTRO_PRETTY="unknown" if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release DISTRO_ID="${ID:-unknown}" DISTRO_VERSION_ID="${VERSION_ID:-unknown}" DISTRO_PRETTY="${PRETTY_NAME:-unknown}" fi KERNEL_RELEASE="$(uname -r)" KERNEL_VERSION_NUMERIC="$(printf '%s' "$KERNEL_RELEASE" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')" log_head "System information" log_info "Distribution : $DISTRO_PRETTY ($DISTRO_ID $DISTRO_VERSION_ID)" log_info "Kernel : $KERNEL_RELEASE" # ----- Version comparison helper -------------------------------------------- # Compare two dotted version strings (e.g. "5.14.0-611.49.2" vs # "5.14.0-611.49.1"). Returns 0 if $1 >= $2, 1 otherwise. # # We rely on `sort -V` which implements GNU's natural version ordering and is # available on every distro this script targets (coreutils >= 7.0). version_ge() { local a="$1" b="$2" [ "$a" = "$b" ] && return 0 local first first=$(printf '%s\n%s\n' "$a" "$b" | sort -V | head -n1) [ "$first" = "$b" ] } # ----- Patched kernel database ---------------------------------------------- # Map of "distro_id:version_id" -> minimum patched kernel string. # These are the official fixed versions published by each vendor's security # advisory for CVE-2026-31431. Update this table as new advisories ship. # # A blank value means "no fixed version available yet from this vendor". get_patched_version() { case "${DISTRO_ID}:${DISTRO_VERSION_ID}" in almalinux:8*|rhel:8*|rocky:8*|centos:8*) echo "4.18.0-553.121.1.el8_10" ;; almalinux:9*|rhel:9*|rocky:9*|centos:9*) echo "5.14.0-611.49.2.el9_7" ;; almalinux:10*|rhel:10*|rocky:10*|centos:10*) echo "6.12.0-124.52.2.el10_1" ;; ubuntu:26.04|ubuntu:26.10) # Ubuntu Resolute (26.04) and later are not affected upstream. echo "0.0.0" ;; ubuntu:*) # Ubuntu 18.04 .. 25.10: vendor patches are rolling out via apt. # No single fixed version covers every supported HWE/AWS/GCP # kernel flavor, so we leave this blank and fall back to the # module / AF_ALG runtime checks below. echo "" ;; debian:*) # Debian patches are published per-suite via DSA. Leave blank # and rely on runtime mitigation checks. echo "" ;; *) echo "" ;; esac } PATCHED_VERSION="$(get_patched_version)" # ----- Check 1: kernel version vs patched version --------------------------- # The patched-version DB lists vendor *ABI* kernels (e.g. 5.14.0-611.49.2.el9_7), # whose dotted ordering is only meaningful against a kernel from the SAME vendor # line. A third-party kernel — ELRepo kernel-ml (`…​.elrepo`), Oracle UEK # (`…uek`), or any hand-rolled build — carries an unrelated version, so a numeric # "newer" comparison is bogus: a mainline 6.17.x would read as ">=" the RHEL # 5.14.0 fix and be mis-reported as PATCHED. Guard: only trust the DB when the # running kernel's base X.Y.Z matches the patched entry's base; otherwise treat # Check 1 as inconclusive and rely on the runtime module / AF_ALG checks below. log_head "Check 1 / 3 — Kernel version" KERNEL_STATUS="unknown" if [ "$PATCHED_VERSION" = "0.0.0" ]; then # Sentinel from the DB: this release is not affected upstream, independent of # the exact kernel build, so no base-version comparison is needed. log_ok "Distribution ${DISTRO_ID} ${DISTRO_VERSION_ID} is not affected by CVE-2026-31431." KERNEL_STATUS="patched" elif [ -n "$PATCHED_VERSION" ]; then PATCHED_BASE="$(printf '%s' "$PATCHED_VERSION" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')" if [ "$KERNEL_VERSION_NUMERIC" != "$PATCHED_BASE" ]; then case "$KERNEL_RELEASE" in *elrepo*) log_warn "Running an ELRepo kernel ($KERNEL_RELEASE); the ${DISTRO_ID} vendor fix ($PATCHED_VERSION) is a different kernel line and does not apply." ;; *uek*) log_warn "Running an Oracle UEK kernel ($KERNEL_RELEASE); the ${DISTRO_ID} vendor fix ($PATCHED_VERSION) is a different kernel line and does not apply." ;; *) log_warn "Running kernel base ($KERNEL_VERSION_NUMERIC) differs from the vendor line ($PATCHED_BASE) — a custom/third-party kernel; the recorded fix ($PATCHED_VERSION) does not apply." ;; esac log_warn "Cannot decide by version; relying on the runtime mitigation checks below." # KERNEL_STATUS stays "unknown" → verdict falls through to module/AF_ALG. elif version_ge "$KERNEL_RELEASE" "$PATCHED_VERSION"; then log_ok "Running kernel ($KERNEL_RELEASE) is at or above the patched version ($PATCHED_VERSION)." KERNEL_STATUS="patched" else log_bad "Running kernel ($KERNEL_RELEASE) is older than the patched version ($PATCHED_VERSION)." KERNEL_STATUS="vulnerable" fi else log_warn "No vendor-fixed kernel version is recorded for ${DISTRO_ID} ${DISTRO_VERSION_ID}." log_warn "Falling back to runtime mitigation checks below." fi # ----- Check 2: algif_aead kernel module status ----------------------------- # The exploit needs the algif_aead module either already loaded OR autoloadable # on demand. If the module is blacklisted AND not currently loaded, the # AF_ALG attack surface is effectively closed even on an unpatched kernel. log_head "Check 2 / 3 — algif_aead kernel module" MODULE_LOADED=0 MODULE_AVAILABLE=0 MODULE_BLACKLISTED=0 if lsmod 2>/dev/null | awk '{print $1}' | grep -qx "algif_aead"; then MODULE_LOADED=1 fi if command -v modinfo >/dev/null 2>&1; then if modinfo algif_aead >/dev/null 2>&1; then MODULE_AVAILABLE=1 fi fi # A module is considered blacklisted if any modprobe config file sets either # `blacklist algif_aead` or `install algif_aead /bin/(false|true)`. if grep -RhsE '^[[:space:]]*(blacklist[[:space:]]+algif_aead|install[[:space:]]+algif_aead[[:space:]]+/bin/(false|true))' \ /etc/modprobe.d /usr/lib/modprobe.d /run/modprobe.d 2>/dev/null | grep -q .; then MODULE_BLACKLISTED=1 fi if [ "$MODULE_LOADED" -eq 1 ]; then log_bad "algif_aead is currently loaded into the running kernel." elif [ "$MODULE_BLACKLISTED" -eq 1 ]; then log_ok "algif_aead is blacklisted and not currently loaded." elif [ "$MODULE_AVAILABLE" -eq 1 ]; then log_warn "algif_aead is not loaded but can be autoloaded on demand (no blacklist found)." else log_ok "algif_aead is neither loaded nor available on this kernel build." fi # ----- Check 3: AF_ALG / algif_aead reachability ---------------------------- # Two questions, answered from an unprivileged context exactly as an attacker # would see them: # (a) Can we open an AF_ALG socket at all? (the crypto socket *family*) # (b) Can we bind it to the *aead* transform the exploit needs? # (b) is the decisive one. The exploit does # bind(AF_ALG, {.salg_type="aead", .salg_name="gcm(aes)"}). # The default copyfail-setup.sh mitigation blacklists only algif_aead, so that # bind fails (ENOENT) even though the af_alg family socket may still open for # legitimate users (IPsec af_alg / OpenSSL afalg / libkcapi). Probing the # family alone (as before) would mis-flag such a host as VULNERABLE, so we test # the bind directly. We bind ONLY to set up the transform — no accept(), no # sendmsg() — so no AEAD operation is ever performed. Caveat: on an *unmitigated* # box this bind autoloads algif_aead (the same module an attacker would trigger); # on a blacklisted box the autoload is a no-op and the bind fails. log_head "Check 3 / 3 — AF_ALG / algif_aead reachability" AF_ALG_STATUS="unknown" # the AF_ALG socket family AEAD_STATUS="unknown" # the specific algif_aead 'aead' interface (decisive) # The probe is single-quoted so bash never interprets the Python. AF_ALG = 38 is # a Linux constant not exported by every Python build, so it is hardcoded. # CPython's socket module supports AF_ALG bind((type, name)) since 3.6. AF_ALG_PY='import socket try: s = socket.socket(38, socket.SOCK_SEQPACKET, 0) except PermissionError: print("family=blocked aead=blocked"); raise SystemExit except OSError: print("family=blocked aead=blocked"); raise SystemExit try: s.bind(("aead", "gcm(aes)")) print("family=reachable aead=reachable") except OSError: print("family=reachable aead=blocked") except Exception: print("family=reachable aead=unknown") finally: s.close()' if command -v python3 >/dev/null 2>&1; then AF_ALG_PROBE_OUTPUT="$(python3 -c "$AF_ALG_PY" 2>&1)" case "$AF_ALG_PROBE_OUTPUT" in *family=reachable*) AF_ALG_STATUS="reachable" ;; *family=blocked*) AF_ALG_STATUS="blocked" ;; esac case "$AF_ALG_PROBE_OUTPUT" in *aead=reachable*) AEAD_STATUS="reachable" ;; *aead=blocked*) AEAD_STATUS="blocked" ;; esac if [ "$AEAD_STATUS" = "reachable" ]; then log_bad "The algif_aead AEAD interface is reachable (bind aead/gcm(aes) succeeded)." elif [ "$AEAD_STATUS" = "blocked" ] && [ "$AF_ALG_STATUS" = "blocked" ]; then log_ok "AF_ALG socket creation is blocked — the whole crypto socket family is closed." elif [ "$AEAD_STATUS" = "blocked" ]; then log_ok "AF_ALG opens, but binding the aead interface is blocked (algif_aead unavailable)." else log_warn "Could not determine AF_ALG/algif_aead status: $AF_ALG_PROBE_OUTPUT" fi else log_warn "python3 is not installed; skipping live AF_ALG/algif_aead probe." fi # ----- Final verdict --------------------------------------------------------- # Decision matrix (most specific signal wins). The live 'aead' bind probe is # the ground truth, so it outranks the coarse module/blacklist signals: # * Patched kernel -> patched # * algif_aead loaded, OR aead interface reachable -> vulnerable # * aead interface live-probed as blocked -> mitigated # * (no live probe) blacklisted AND not loaded -> likely_mitigated # * unpatched/unknown kernel with surface still open -> vulnerable # * (no live probe) module present + autoloadable, # no blacklist, kernel not known-patched -> vulnerable log_head "Verdict" VERDICT="unknown" EXIT_CODE=2 if [ "$KERNEL_STATUS" = "patched" ]; then VERDICT="patched" EXIT_CODE=0 log_ok "This system appears to be PATCHED against CVE-2026-31431." elif [ "$MODULE_LOADED" -eq 1 ] || [ "$AEAD_STATUS" = "reachable" ]; then VERDICT="vulnerable" EXIT_CODE=1 log_bad "This system appears to be VULNERABLE to CVE-2026-31431." log_bad "The algif_aead AEAD interface is reachable from an unprivileged context." log_bad "Apply the vendor kernel update, or blacklist algif_aead (see copyfail-setup.sh)." elif [ "$AEAD_STATUS" = "blocked" ]; then VERDICT="mitigated" EXIT_CODE=0 log_ok "This system appears to be MITIGATED (the algif_aead AEAD interface is unreachable)." elif [ "$MODULE_BLACKLISTED" -eq 1 ] && [ "$MODULE_LOADED" -eq 0 ]; then VERDICT="likely_mitigated" EXIT_CODE=0 log_ok "This system appears to be MITIGATED via algif_aead blacklist (no live probe was possible)." elif [ "$KERNEL_STATUS" = "vulnerable" ] || [ "$AF_ALG_STATUS" = "reachable" ]; then VERDICT="vulnerable" EXIT_CODE=1 log_bad "This system appears to be VULNERABLE to CVE-2026-31431." log_bad "Unpatched kernel and the AF_ALG attack surface is not blocked." log_bad "Apply the vendor kernel update, or blacklist algif_aead (see copyfail-setup.sh)." elif [ "$MODULE_AVAILABLE" -eq 1 ] && [ "$MODULE_BLACKLISTED" -eq 0 ]; then # No live probe was possible (e.g. python3 absent) and the kernel is not # known-patched, but algif_aead is present and autoloadable with no # blacklist — an attacker's bind(AF_ALG, aead) would autoload it on demand. VERDICT="vulnerable" EXIT_CODE=1 log_bad "This system appears to be VULNERABLE to CVE-2026-31431." log_bad "algif_aead is available and autoloadable with no blacklist (no live probe was possible)." log_bad "Apply the vendor kernel update, or blacklist algif_aead (see copyfail-setup.sh)." else VERDICT="unknown" EXIT_CODE=2 log_warn "Could not reach a definitive verdict. Treat the system as potentially vulnerable." fi # ----- JSON output (optional) ------------------------------------------------ if [ "$JSON_MODE" -eq 1 ]; then printf '{' printf '"verdict":"%s",' "$VERDICT" printf '"distro_id":"%s",' "$DISTRO_ID" printf '"distro_version_id":"%s",' "$DISTRO_VERSION_ID" printf '"kernel":"%s",' "$KERNEL_RELEASE" printf '"patched_version":"%s",' "$PATCHED_VERSION" printf '"kernel_status":"%s",' "$KERNEL_STATUS" printf '"module_loaded":%s,' "$MODULE_LOADED" printf '"module_available":%s,' "$MODULE_AVAILABLE" printf '"module_blacklisted":%s,' "$MODULE_BLACKLISTED" printf '"af_alg_status":"%s",' "$AF_ALG_STATUS" printf '"aead_status":"%s"' "$AEAD_STATUS" printf '}\n' fi exit "$EXIT_CODE"