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");