From 86812b043d79cd858bb109a646d171dfc814b59f Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Sat, 23 May 2026 00:05:39 -0400 Subject: [PATCH] core/host: userspace version fingerprint (sudo, polkit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host fingerprint now captures sudo + polkit versions at startup so userspace-LPE modules can consult a single source of truth instead of each popen-ing the relevant binary themselves on every scan. Pack2theroot already queries PackageKit version via D-Bus in-module, so PackageKit stays there for now. core/host.h: - new fields: char sudo_version[64], char polkit_version[64]. Empty string when the tool isn't installed or version parse fails; modules should treat that as PRECOND_FAIL. - documented next to has_systemd / has_dbus_system in the struct. core/host.c: - new populate_userspace_versions(h) called from skeletonkey_host_get() after the other populators. - capture_first_line() helper runs a command via popen, grabs first stdout line, strips newline. Best-effort: failure leaves dst empty. - extract_version_after_prefix() pulls the version token after a fixed prefix string ('Sudo version', 'pkexec version'), handling the colon/space variants. - skeletonkey_host_print_banner() gained a third line when either version is non-empty: [*] userspace: sudo=1.9.17p2 polkit=- Module migration (graceful fallback pattern — modules still work without ctx->host populated): - sudo_samedit detect: if ctx->host->sudo_version is set, skip the popen and synthesize a 'Sudo version ' line for the existing parser. Falls back to the original find_sudo + popen path if the host fingerprint didn't capture a version. - sudoedit_editor detect: same pattern — host fingerprint sudo_version takes precedence over the local get_sudo_version popen. tests/test_detect.c additions (2 new cases, 33 → 35): - h_vuln_sudo fingerprint (sudo_version='1.8.31', kernel 5.15) — asserts sudo_samedit reports VULNERABLE via the host-provided version string. - h_fixed_sudo fingerprint (sudo_version='1.9.13p1', kernel 6.12) — asserts sudo_samedit reports OK on a patched sudo. This is the first test pair to cover the *vulnerable* path of a module rather than just precondition gates — proves the version-parsing logic itself, not only the short-circuits. Verification: 35/35 pass on Linux. macOS banner shows 'userspace: sudo=1.9.17p2 polkit=-' as the dev box has Homebrew sudo but no polkit. --- core/host.c | 62 +++++++++++++++++++ core/host.h | 10 +++ .../skeletonkey_modules.c | 54 +++++++++------- .../skeletonkey_modules.c | 8 ++- tests/test_detect.c | 48 ++++++++++++++ 5 files changed, 160 insertions(+), 22 deletions(-) diff --git a/core/host.c b/core/host.c index ecd4096..9ba8870 100644 --- a/core/host.c +++ b/core/host.c @@ -224,6 +224,63 @@ static void populate_services(struct skeletonkey_host *h) h->has_dbus_system = path_exists("/run/dbus/system_bus_socket"); } +/* Best-effort: run `cmd`, capture first stdout line, strip newline, + * copy up to (cap - 1) bytes into dst. Returns true iff popen + * succeeded, the command exited 0, and we got at least one line. + * Used for sudo/pkexec/packagekitd version parsing at startup. */ +static bool capture_first_line(const char *cmd, char *dst, size_t cap) +{ + dst[0] = '\0'; + FILE *p = popen(cmd, "r"); + if (!p) return false; + char buf[256]; + bool got = (fgets(buf, sizeof buf, p) != NULL); + int rc = pclose(p); + if (!got || rc != 0) return false; + size_t L = strlen(buf); + while (L > 0 && (buf[L-1] == '\n' || buf[L-1] == '\r')) + buf[--L] = '\0'; + if (L >= cap) L = cap - 1; + memcpy(dst, buf, L); + dst[L] = '\0'; + return true; +} + +/* Extract the version-string token from a line of the form + * ": [rest]" or " [rest]". The + * version token is everything from the first non-space after + * `prefix` up to the next whitespace. Empty result when prefix not + * found. */ +static void extract_version_after_prefix(const char *line, + const char *prefix, + char *dst, size_t cap) +{ + dst[0] = '\0'; + const char *p = strstr(line, prefix); + if (!p) return; + p += strlen(prefix); + while (*p == ' ' || *p == ':' || *p == '\t') p++; + size_t i = 0; + while (*p && *p != ' ' && *p != '\t' && i + 1 < cap) + dst[i++] = *p++; + dst[i] = '\0'; +} + +static void populate_userspace_versions(struct skeletonkey_host *h) +{ + h->sudo_version[0] = '\0'; + h->polkit_version[0] = '\0'; + + char line[256]; + if (capture_first_line("sudo -V 2>/dev/null", line, sizeof line)) + extract_version_after_prefix(line, "Sudo version", + h->sudo_version, sizeof h->sudo_version); + + if (capture_first_line("pkexec --version 2>/dev/null", line, sizeof line)) + extract_version_after_prefix(line, "pkexec version", + h->polkit_version, sizeof h->polkit_version); +} + /* ── public entrypoints ───────────────────────────────────────────── */ const struct skeletonkey_host *skeletonkey_host_get(void) @@ -237,6 +294,7 @@ const struct skeletonkey_host *skeletonkey_host_get(void) populate_platform_family(&g_host); populate_caps(&g_host); populate_services(&g_host); + populate_userspace_versions(&g_host); g_host.probe_source = "skeletonkey core/host.c"; g_host_ready = true; return &g_host; @@ -280,4 +338,8 @@ void skeletonkey_host_print_banner(const struct skeletonkey_host *h, bool json) h->kernel_lockdown_active ? "on" : "off", h->selinux_enforcing ? "on" : "off", h->yama_ptrace_restricted ? "yes" : "no"); + if (h->sudo_version[0] || h->polkit_version[0]) + fprintf(stderr, "[*] userspace: sudo=%s polkit=%s\n", + h->sudo_version[0] ? h->sudo_version : "-", + h->polkit_version[0] ? h->polkit_version : "-"); } diff --git a/core/host.h b/core/host.h index 9b58ca9..3649fd8 100644 --- a/core/host.h +++ b/core/host.h @@ -70,6 +70,16 @@ struct skeletonkey_host { bool has_systemd; /* /run/systemd/system exists */ bool has_dbus_system; /* /run/dbus/system_bus_socket exists */ + /* ── userspace component versions ───────────────────────── + * Parsed once at startup via popen() of the relevant binary's + * --version output. Empty string ("") means "tool not installed + * or version parse failed" — modules should treat that as + * PRECOND_FAIL (no exploit target). The exact format mirrors + * what the tool prints (`Sudo version 1.9.5p2`, `pkexec version + * 0.105`, …); modules do their own range parsing. */ + char sudo_version[64]; /* "1.9.13p1" or "" */ + char polkit_version[64]; /* "0.105" or "126" or "" */ + /* Informational: the SKELETONKEY component that populated this * snapshot (for log/JSON output). */ const char *probe_source; diff --git a/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c b/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c index d5a296e..bbb05e6 100644 --- a/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c +++ b/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c @@ -151,30 +151,42 @@ static const char *find_sudoedit(void) static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ctx) { - const char *sudo_path = find_sudo(); - if (!sudo_path) { - if (!ctx->json) { - fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n"); - } - return SKELETONKEY_PRECOND_FAIL; - } - if (!ctx->json) { - fprintf(stderr, "[i] sudo_samedit: found setuid sudo at %s\n", sudo_path); - } - - char cmd[512]; - snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path); - FILE *p = popen(cmd, "r"); - if (!p) return SKELETONKEY_TEST_ERROR; - + /* Prefer the centrally-fingerprinted sudo version (populated once + * at startup by core/host.c) — saves a popen per scan and gives + * unit tests a clean mock point. Fall back to the local popen if + * ctx->host is missing the version (e.g. degenerate test ctx, or + * a future refactor that disables userspace probing). */ char line[256] = {0}; - char *r = fgets(line, sizeof line, p); - pclose(p); - if (!r) { + if (ctx->host && ctx->host->sudo_version[0]) { + snprintf(line, sizeof line, "Sudo version %s", + ctx->host->sudo_version); if (!ctx->json) { - fprintf(stderr, "[?] sudo_samedit: could not read `sudo --version` output\n"); + fprintf(stderr, "[i] sudo_samedit: host fingerprint reports " + "sudo version %s\n", ctx->host->sudo_version); + } + } else { + const char *sudo_path = find_sudo(); + if (!sudo_path) { + if (!ctx->json) { + fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n"); + } + return SKELETONKEY_PRECOND_FAIL; + } + if (!ctx->json) { + fprintf(stderr, "[i] sudo_samedit: found setuid sudo at %s\n", sudo_path); + } + char cmd[512]; + snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path); + FILE *p = popen(cmd, "r"); + if (!p) return SKELETONKEY_TEST_ERROR; + char *r = fgets(line, sizeof line, p); + pclose(p); + if (!r) { + if (!ctx->json) { + fprintf(stderr, "[?] sudo_samedit: could not read `sudo --version` output\n"); + } + return SKELETONKEY_TEST_ERROR; } - return SKELETONKEY_TEST_ERROR; } /* Trim newline for nicer logging. */ diff --git a/modules/sudoedit_editor_cve_2023_22809/skeletonkey_modules.c b/modules/sudoedit_editor_cve_2023_22809/skeletonkey_modules.c index f26b0a8..65eeefe 100644 --- a/modules/sudoedit_editor_cve_2023_22809/skeletonkey_modules.c +++ b/modules/sudoedit_editor_cve_2023_22809/skeletonkey_modules.c @@ -210,7 +210,13 @@ static skeletonkey_result_t sudoedit_editor_detect(const struct skeletonkey_ctx fprintf(stderr, "[i] sudoedit_editor: sudoedit at %s\n", sudoedit_path); char ver[128] = {0}; - if (!get_sudo_version(sudo_path, ver, sizeof ver)) { + /* Prefer the centrally-fingerprinted sudo version (populated once + * at startup by core/host.c) — saves a popen per scan and gives + * unit tests a clean mock point. Fall back to the local popen if + * ctx->host is missing the version. */ + if (ctx->host && ctx->host->sudo_version[0]) { + snprintf(ver, sizeof ver, "%s", ctx->host->sudo_version); + } else if (!get_sudo_version(sudo_path, ver, sizeof ver)) { if (!ctx->json) fprintf(stderr, "[?] sudoedit_editor: could not parse `sudo --version`\n"); return SKELETONKEY_TEST_ERROR; diff --git a/tests/test_detect.c b/tests/test_detect.c index dd7a3e1..b31ad94 100644 --- a/tests/test_detect.c +++ b/tests/test_detect.c @@ -55,6 +55,8 @@ extern const struct skeletonkey_module copy_fail_gcm_module; extern const struct skeletonkey_module dirty_frag_esp_module; extern const struct skeletonkey_module dirty_frag_esp6_module; extern const struct skeletonkey_module dirty_frag_rxrpc_module; +extern const struct skeletonkey_module sudo_samedit_module; +extern const struct skeletonkey_module sudoedit_editor_module; static int g_pass = 0; static int g_fail = 0; @@ -136,6 +138,37 @@ static const struct skeletonkey_host h_fedora_no_debian = { .has_systemd = true, }; +/* Modern fingerprint with a known-vulnerable sudo (1.8.31 sits in + * both the samedit [1.8.2, 1.9.5p1] and sudoedit_editor + * [1.8.0, 1.9.12p2) vulnerable ranges). Used to assert the sudo + * modules accept the host-fingerprint version string and reach the + * VULNERABLE-by-version path. */ +static const struct skeletonkey_host h_vuln_sudo = { + .kernel = { .major = 5, .minor = 15, .patch = 0, + .release = "5.15.0-vulnsudo" }, + .arch = "x86_64", + .nodename = "test", + .distro_id = "debian", + .is_linux = true, + .is_debian_family = true, + .unprivileged_userns_allowed = true, + .sudo_version = "1.8.31", +}; + +/* Modern fingerprint with a fixed sudo (1.9.13p1 is above both + * sudo_samedit and sudoedit_editor vulnerable ranges). */ +static const struct skeletonkey_host h_fixed_sudo = { + .kernel = { .major = 6, .minor = 12, .patch = 0, + .release = "6.12.0-fixedsudo" }, + .arch = "x86_64", + .nodename = "test", + .distro_id = "debian", + .is_linux = true, + .is_debian_family = true, + .unprivileged_userns_allowed = true, + .sudo_version = "1.9.13p1", +}; + /* Ubuntu 24.04, userns allowed, D-Bus running, Debian family * (because Ubuntu has /etc/debian_version). Used as the "fragnesia * preconditions OK" baseline — fragnesia should NOT short-circuit @@ -365,6 +398,21 @@ static void run_all(void) run_one("dirty_frag_rxrpc: userns_allowed=false → PRECOND_FAIL", &dirty_frag_rxrpc_module, &h_kernel_5_14_no_userns, SKELETONKEY_PRECOND_FAIL); + + /* ── userspace version fingerprinting (sudo) ───────────────── + * Both sudo modules now consult ctx->host->sudo_version + * populated once at startup. */ + + /* sudo_samedit: vulnerable sudo 1.8.31 (range [1.8.2, 1.9.5p1]) + * → VULNERABLE by version */ + run_one("sudo_samedit: sudo_version=1.8.31 → VULNERABLE", + &sudo_samedit_module, &h_vuln_sudo, + SKELETONKEY_VULNERABLE); + + /* sudo_samedit: fixed sudo 1.9.13p1 (above 1.9.5p1) → OK */ + run_one("sudo_samedit: sudo_version=1.9.13p1 → OK", + &sudo_samedit_module, &h_fixed_sudo, + SKELETONKEY_OK); #else fprintf(stderr, "[i] non-Linux platform: detect() bodies are stubbed; " "tests skipped (would tautologically pass).\n");