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:
+62
@@ -224,6 +224,63 @@ static void populate_services(struct skeletonkey_host *h)
|
|||||||
h->has_dbus_system = path_exists("/run/dbus/system_bus_socket");
|
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 ───────────────────────────────────────────── */
|
/* ── public entrypoints ───────────────────────────────────────────── */
|
||||||
|
|
||||||
const struct skeletonkey_host *skeletonkey_host_get(void)
|
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_platform_family(&g_host);
|
||||||
populate_caps(&g_host);
|
populate_caps(&g_host);
|
||||||
populate_services(&g_host);
|
populate_services(&g_host);
|
||||||
|
populate_userspace_versions(&g_host);
|
||||||
g_host.probe_source = "skeletonkey core/host.c";
|
g_host.probe_source = "skeletonkey core/host.c";
|
||||||
g_host_ready = true;
|
g_host_ready = true;
|
||||||
return &g_host;
|
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->kernel_lockdown_active ? "on" : "off",
|
||||||
h->selinux_enforcing ? "on" : "off",
|
h->selinux_enforcing ? "on" : "off",
|
||||||
h->yama_ptrace_restricted ? "yes" : "no");
|
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
@@ -70,6 +70,16 @@ struct skeletonkey_host {
|
|||||||
bool has_systemd; /* /run/systemd/system exists */
|
bool has_systemd; /* /run/systemd/system exists */
|
||||||
bool has_dbus_system; /* /run/dbus/system_bus_socket 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
|
/* Informational: the SKELETONKEY component that populated this
|
||||||
* snapshot (for log/JSON output). */
|
* snapshot (for log/JSON output). */
|
||||||
const char *probe_source;
|
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)
|
static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
const char *sudo_path = find_sudo();
|
/* Prefer the centrally-fingerprinted sudo version (populated once
|
||||||
if (!sudo_path) {
|
* at startup by core/host.c) — saves a popen per scan and gives
|
||||||
if (!ctx->json) {
|
* unit tests a clean mock point. Fall back to the local popen if
|
||||||
fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n");
|
* ctx->host is missing the version (e.g. degenerate test ctx, or
|
||||||
}
|
* a future refactor that disables userspace probing). */
|
||||||
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 line[256] = {0};
|
char line[256] = {0};
|
||||||
char *r = fgets(line, sizeof line, p);
|
if (ctx->host && ctx->host->sudo_version[0]) {
|
||||||
pclose(p);
|
snprintf(line, sizeof line, "Sudo version %s",
|
||||||
if (!r) {
|
ctx->host->sudo_version);
|
||||||
if (!ctx->json) {
|
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. */
|
/* 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);
|
fprintf(stderr, "[i] sudoedit_editor: sudoedit at %s\n", sudoedit_path);
|
||||||
|
|
||||||
char ver[128] = {0};
|
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)
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[?] sudoedit_editor: could not parse `sudo --version`\n");
|
fprintf(stderr, "[?] sudoedit_editor: could not parse `sudo --version`\n");
|
||||||
return SKELETONKEY_TEST_ERROR;
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
|||||||
@@ -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_esp_module;
|
||||||
extern const struct skeletonkey_module dirty_frag_esp6_module;
|
extern const struct skeletonkey_module dirty_frag_esp6_module;
|
||||||
extern const struct skeletonkey_module dirty_frag_rxrpc_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_pass = 0;
|
||||||
static int g_fail = 0;
|
static int g_fail = 0;
|
||||||
@@ -136,6 +138,37 @@ static const struct skeletonkey_host h_fedora_no_debian = {
|
|||||||
.has_systemd = true,
|
.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
|
/* Ubuntu 24.04, userns allowed, D-Bus running, Debian family
|
||||||
* (because Ubuntu has /etc/debian_version). Used as the "fragnesia
|
* (because Ubuntu has /etc/debian_version). Used as the "fragnesia
|
||||||
* preconditions OK" baseline — fragnesia should NOT short-circuit
|
* 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",
|
run_one("dirty_frag_rxrpc: userns_allowed=false → PRECOND_FAIL",
|
||||||
&dirty_frag_rxrpc_module, &h_kernel_5_14_no_userns,
|
&dirty_frag_rxrpc_module, &h_kernel_5_14_no_userns,
|
||||||
SKELETONKEY_PRECOND_FAIL);
|
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
|
#else
|
||||||
fprintf(stderr, "[i] non-Linux platform: detect() bodies are stubbed; "
|
fprintf(stderr, "[i] non-Linux platform: detect() bodies are stubbed; "
|
||||||
"tests skipped (would tautologically pass).\n");
|
"tests skipped (would tautologically pass).\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user