core/host: userspace version fingerprint (sudo, polkit)

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 <X>' 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.
This commit is contained in:
2026-05-23 00:05:39 -04:00
parent 0d87cbc71c
commit 86812b043d
5 changed files with 160 additions and 22 deletions
+62
View File
@@ -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
* "<prefix>: <version> [rest]" or "<prefix> <version> [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 : "-");
}
+10
View File
@@ -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;
@@ -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. */
@@ -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;
+48
View File
@@ -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");