Phase 7: nf_tables CVE-2024-1086 + active probe for dirty_pipe
dirty_pipe detect: active sentinel probe (Phase 1.5-ish improvement)
- New dirty_pipe_active_probe(): creates a /tmp probe file with known
sentinel bytes, fires the Dirty Pipe primitive against it, re-reads
via the page cache, returns true if the poisoning landed.
- detect() gated on ctx->active_probe: --scan does version-only check
(fast, no side effects); --scan --active fires the empirical probe
and overrides version inference with the empirical verdict. Catches
silent distro backports that don't bump uname() version.
- Three verdicts now distinguishable:
(a) version says patched, no active probe → 'patched (version-only)'
(b) version says vulnerable, --active fires + probe lands → CONFIRMED
(c) version says vulnerable, --active fires + probe blocked → 'likely
patched via distro backport'
- Probe is safe: only /tmp, no /etc/passwd.
nf_tables CVE-2024-1086 (detect-only, new module):
- Famous Notselwyn UAF in nft_verdict_init. Affects 5.14 ≤ K, fixed
mainline 6.8 with backports landing in 5.4.269 / 5.10.210 / 5.15.149
/ 6.1.74 / 6.6.13 / 6.7.2.
- detect() checks: kernel version range, AND unprivileged user_ns clone
availability (the exploit's reachability gate — kernel-vulnerable
but userns-locked-down hosts report PRECOND_FAIL, signalling that
the kernel still needs patching but unprivileged path is closed).
- Ships auditd + sigma detection rules: unshare(CLONE_NEWUSER) chained
with setresuid(0,0,0) on a previously-non-root process is the
exploit's canonical telltale.
- Full Notselwyn-style exploit (cross-cache UAF → arbitrary R/W → cred
overwrite or modprobe_path hijack) is the next commit.
9 modules total now. CVES.md and ROADMAP.md updated.
This commit is contained in:
@@ -212,10 +212,48 @@ static const struct kernel_range dirty_pipe_range = {
|
||||
sizeof(dirty_pipe_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* Active sentinel probe: write a known byte into a /tmp probe file
|
||||
* via the Dirty Pipe primitive, then re-read to verify the page cache
|
||||
* was actually poisoned. This catches the case where /proc/version
|
||||
* looks vulnerable (e.g. Debian 5.10.0-30 — apparent version 5.10.30)
|
||||
* but the distro silently backported the fix without bumping the
|
||||
* upstream version number visible to uname().
|
||||
*
|
||||
* Side effects: creates and removes a single file under /tmp. No
|
||||
* /etc/passwd writes; safe to run from --scan --active. */
|
||||
static int dirty_pipe_active_probe(void)
|
||||
{
|
||||
char probe_path[] = "/tmp/iamroot-dirty-pipe-probe-XXXXXX";
|
||||
int fd = mkstemp(probe_path);
|
||||
if (fd < 0) return -1;
|
||||
const char seed[16] = "ABCDABCDABCDABCD";
|
||||
if (write(fd, seed, sizeof seed) != sizeof seed) { close(fd); unlink(probe_path); return -1; }
|
||||
fsync(fd);
|
||||
close(fd);
|
||||
|
||||
/* Try writing 'X' at offset 4 — well inside the first page, not
|
||||
* page-aligned (offset 4 → page-relative offset 4, not 0). */
|
||||
int rc = dirty_pipe_write(probe_path, 4, "X", 1);
|
||||
if (rc < 0) {
|
||||
unlink(probe_path);
|
||||
return 0; /* primitive could not even fire — patched or blocked */
|
||||
}
|
||||
|
||||
/* Re-open and read; if the primitive works, byte 4 reads as 'X'.
|
||||
* Use O_RDONLY + read, which goes through the page cache (which
|
||||
* we just poisoned if the bug is live). */
|
||||
fd = open(probe_path, O_RDONLY);
|
||||
if (fd < 0) { unlink(probe_path); return -1; }
|
||||
char readback[16] = {0};
|
||||
ssize_t got = read(fd, readback, sizeof readback);
|
||||
close(fd);
|
||||
unlink(probe_path);
|
||||
if (got < 5) return -1;
|
||||
return readback[4] == 'X' ? 1 : 0;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_pipe_detect(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] dirty_pipe: could not parse kernel version\n");
|
||||
@@ -231,19 +269,51 @@ static iamroot_result_t dirty_pipe_detect(const struct iamroot_ctx *ctx)
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&dirty_pipe_range, &v);
|
||||
if (patched) {
|
||||
bool patched_by_version = kernel_range_is_patched(&dirty_pipe_range, &v);
|
||||
|
||||
/* Active probe overrides version-only verdict when requested.
|
||||
* The version check is necessary-but-not-sufficient: distros
|
||||
* silently backport fixes without bumping the upstream version
|
||||
* visible to uname(). The active probe fires the actual primitive
|
||||
* and confirms whether it lands. */
|
||||
if (ctx->active_probe) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_pipe: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[*] dirty_pipe: running active sentinel probe (safe; /tmp only)\n");
|
||||
}
|
||||
int probe = dirty_pipe_active_probe();
|
||||
if (probe == 1) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_pipe: ACTIVE PROBE CONFIRMED — primitive lands "
|
||||
"(version %s)\n", v.release);
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
}
|
||||
if (probe == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_pipe: active probe sentinel did NOT land — "
|
||||
"primitive blocked (likely patched%s)\n",
|
||||
patched_by_version ? "" : ", or distro silently backported");
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
/* probe < 0: probe machinery failed (mkstemp/open/read) — fall
|
||||
* back to version-only verdict and report TEST_ERROR caveat */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[?] dirty_pipe: active probe machinery failed; "
|
||||
"falling back to version check\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched_by_version) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_pipe: kernel %s is patched (version-only check; "
|
||||
"use --active to confirm empirically)\n", v.release);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_pipe: kernel %s appears VULNERABLE\n"
|
||||
" (caveat: distro may have backported below threshold —\n"
|
||||
" confirm by checking /proc/version for fix references or\n"
|
||||
" by running the active exploit primitive once the Phase 1.5\n"
|
||||
" helpers land in core/)\n",
|
||||
fprintf(stderr, "[!] dirty_pipe: kernel %s appears VULNERABLE (version-only check)\n"
|
||||
" Confirm empirically: re-run with --scan --active\n",
|
||||
v.release);
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* nf_tables_cve_2024_1086 — IAMROOT module
|
||||
*
|
||||
* Netfilter nf_tables UAF when NFT_GOTO/NFT_JUMP verdicts coexist
|
||||
* with NFT_DROP/NFT_QUEUE. Triggers a double-free → cross-cache UAF
|
||||
* exploitable to arbitrary kernel R/W. Discovered and exploited in
|
||||
* January 2024; widely known as "Pumpkin's pipapo UAF" or just
|
||||
* "CVE-2024-1086".
|
||||
*
|
||||
* STATUS: 🔵 DETECT-ONLY (2026-05-16). Full exploit is a public PoC
|
||||
* by Notselwyn — porting it into the iamroot_module form is a
|
||||
* follow-up commit.
|
||||
*
|
||||
* Affected kernel ranges:
|
||||
* Bug introduced in commit f1a2e44 (5.14) "netfilter: nf_tables:
|
||||
* introduce nf_chain..."
|
||||
* Fixed mainline 6.8-rc1 in commit f342de4 ("netfilter: nf_tables:
|
||||
* reject QUEUE/DROP verdict parameters")
|
||||
* Stable backports landed in 6.7.2, 6.6.13, 6.1.74, 5.15.149,
|
||||
* 5.10.210, 5.4.269
|
||||
* So vulnerable if:
|
||||
* - 5.14 <= K < 5.15 (no backport) — vulnerable
|
||||
* - 5.15.x: K <= 5.15.148 — vulnerable
|
||||
* - 5.10.x: K <= 5.10.209 — vulnerable
|
||||
* - 5.4.x: K <= 5.4.268 — vulnerable
|
||||
* - 6.0/6.1.x: K <= 6.1.73 — vulnerable
|
||||
* - 6.2-6.5: no backport tags — assume vulnerable
|
||||
* - 6.6.x: K <= 6.6.12 — vulnerable
|
||||
* - 6.7.x: K <= 6.7.1 — vulnerable
|
||||
* - 6.8+: patched
|
||||
*
|
||||
* Exploitation preconditions (which detect should also check):
|
||||
* - CONFIG_USER_NS=y AND sysctl unprivileged_userns_clone=1 (or
|
||||
* kernel.unprivileged_userns_clone default=1) so an unprivileged
|
||||
* user can create a userns and become CAP_NET_ADMIN inside it
|
||||
* - nf_tables module loaded or autoload-able (CONFIG_NF_TABLES=y/m)
|
||||
*
|
||||
* If user_ns is locked down (modern Ubuntu's
|
||||
* apparmor_restrict_unprivileged_userns), the trigger is unreachable
|
||||
* for unprivileged users even on a kernel-vulnerable host.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sched.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* Stable-branch backport thresholds — host is patched if on these
|
||||
* branches at or above the threshold patch, or on mainline >= 6.8. */
|
||||
static const struct kernel_patched_from nf_tables_patched_branches[] = {
|
||||
{5, 4, 269}, /* 5.4.x */
|
||||
{5, 10, 210}, /* 5.10.x */
|
||||
{5, 15, 149}, /* 5.15.x */
|
||||
{6, 1, 74}, /* 6.1.x */
|
||||
{6, 6, 13}, /* 6.6.x */
|
||||
{6, 7, 2}, /* 6.7.x */
|
||||
{6, 8, 0}, /* mainline fix */
|
||||
};
|
||||
|
||||
static const struct kernel_range nf_tables_range = {
|
||||
.patched_from = nf_tables_patched_branches,
|
||||
.n_patched_from = sizeof(nf_tables_patched_branches) /
|
||||
sizeof(nf_tables_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* Best-effort check: can an unprivileged process clone a user
|
||||
* namespace? This is the gating capability for the exploit's
|
||||
* CAP_NET_ADMIN-in-userns trigger. Fork+unshare+exit to avoid
|
||||
* polluting our own namespace state. */
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
/* try */
|
||||
if (unshare(CLONE_NEWUSER) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
/* Check whether the nf_tables module is loaded OR can be auto-loaded.
|
||||
* /proc/modules tells us about loaded modules. For modules that aren't
|
||||
* loaded but are buildable, we rely on the kernel autoload via
|
||||
* setsockopt(SOL_NETLINK, NETLINK_NF_TABLES). Conservative: if not
|
||||
* loaded, assume autoload-able and report no info. */
|
||||
static bool nf_tables_loaded(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/modules", "r");
|
||||
if (!f) return false;
|
||||
char line[512];
|
||||
bool found = false;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
/* /proc/modules format: "<name> <size> <use_count> <by> <state> <addr>" */
|
||||
if (strncmp(line, "nf_tables ", 10) == 0) { found = true; break; }
|
||||
}
|
||||
fclose(f);
|
||||
return found;
|
||||
}
|
||||
|
||||
static iamroot_result_t nf_tables_detect(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] nf_tables: could not parse kernel version\n");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced in 5.14. Anything below predates it. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 14)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nf_tables: kernel %s predates the bug "
|
||||
"(introduced in 5.14)\n", v.release);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&nf_tables_range, &v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nf_tables: kernel %s is patched\n", v.release);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
/* Vulnerable by version. Now check preconditions that affect
|
||||
* unprivileged reachability. */
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool nft_loaded = nf_tables_loaded();
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nf_tables: kernel %s is in the vulnerable range\n",
|
||||
v.release);
|
||||
fprintf(stderr, "[i] nf_tables: unprivileged user_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" :
|
||||
"could not test");
|
||||
fprintf(stderr, "[i] nf_tables: nf_tables module currently loaded: %s\n",
|
||||
nft_loaded ? "yes" : "no (will autoload on first nft use)");
|
||||
}
|
||||
|
||||
/* If user_ns is denied, the unprivileged-exploit path is closed.
|
||||
* (A root attacker would still trigger the bug, but root LPE-of-root
|
||||
* is not interesting.) */
|
||||
if (userns_ok == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nf_tables: kernel vulnerable but user_ns clone "
|
||||
"denied → unprivileged exploit unreachable\n");
|
||||
fprintf(stderr, "[i] nf_tables: still patch the kernel — a root "
|
||||
"attacker can still trigger the bug\n");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] nf_tables: VULNERABLE — kernel in range AND user_ns "
|
||||
"clone allowed\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
}
|
||||
|
||||
static iamroot_result_t nf_tables_exploit(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr,
|
||||
"[-] nf_tables: exploit not yet implemented in IAMROOT.\n"
|
||||
" Status: 🔵 DETECT-ONLY (see CVES.md).\n"
|
||||
" Reference: Notselwyn's CVE-2024-1086 public PoC. The exploit\n"
|
||||
" uses double-free → cross-cache UAF → arbitrary kernel R/W →\n"
|
||||
" overwrite modprobe_path or current task's cred. Porting that\n"
|
||||
" into iamroot_module form (with the userns + nft_set + nft_pipapo\n"
|
||||
" setup boilerplate) is the next nf_tables commit.\n");
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
/* ----- Embedded detection rules ----- */
|
||||
|
||||
static const char nf_tables_auditd[] =
|
||||
"# nf_tables UAF (CVE-2024-1086) — auditd detection rules\n"
|
||||
"# Flag unshare(CLONE_NEWUSER|CLONE_NEWNET) followed by nft socket setup.\n"
|
||||
"# This is the canonical exploit shape; legitimate userns + nft use\n"
|
||||
"# (e.g. firewalld, docker rootless) will also trip — tune per env.\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k iamroot-nf-tables-userns\n"
|
||||
"-a always,exit -F arch=b32 -S unshare -k iamroot-nf-tables-userns\n"
|
||||
"# Also watch for the canonical post-exploit primitives: modprobe_path\n"
|
||||
"# overwrite OR setresuid(0,0,0) on a previously-non-root process.\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -k iamroot-nf-tables-priv\n";
|
||||
|
||||
static const char nf_tables_sigma[] =
|
||||
"title: Possible CVE-2024-1086 nf_tables UAF exploitation\n"
|
||||
"id: a72b5e91-iamroot-nf-tables\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical exploit shape: unprivileged user creating a\n"
|
||||
" user namespace, then issuing nft commands within it. False positives:\n"
|
||||
" legitimate use of nft inside containers, podman/docker rootless,\n"
|
||||
" firewalld. Combine with process-tree analysis: a previously-unpriv\n"
|
||||
" process that suddenly has effective uid 0 is the smoking gun.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns_clone:\n"
|
||||
" type: 'SYSCALL'\n"
|
||||
" syscall: 'unshare'\n"
|
||||
" a0: 0x10000000\n"
|
||||
" uid_change:\n"
|
||||
" type: 'SYSCALL'\n"
|
||||
" syscall: 'setresuid'\n"
|
||||
" auid|expression: '!= 0'\n"
|
||||
" condition: userns_clone and uid_change\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.1086]\n";
|
||||
|
||||
const struct iamroot_module nf_tables_module = {
|
||||
.name = "nf_tables",
|
||||
.cve = "CVE-2024-1086",
|
||||
.summary = "nf_tables nft_verdict_init UAF (cross-cache) → arbitrary kernel R/W",
|
||||
.family = "nf_tables",
|
||||
.kernel_range = "5.14 ≤ K, fixed mainline 6.8; backports: 6.7.2 / 6.6.13 / 6.1.74 / 5.15.149 / 5.10.210 / 5.4.269",
|
||||
.detect = nf_tables_detect,
|
||||
.exploit = nf_tables_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR set unprivileged_userns_clone=0 */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = nf_tables_auditd,
|
||||
.detect_sigma = nf_tables_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
void iamroot_register_nf_tables(void)
|
||||
{
|
||||
iamroot_register(&nf_tables_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* nf_tables_cve_2024_1086 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef NF_TABLES_IAMROOT_MODULES_H
|
||||
#define NF_TABLES_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module nf_tables_module;
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user