pack2theroot (CVE-2026-41651) + --auto accuracy work

Adds the third ported module — Pack2TheRoot, a userspace PackageKit
D-Bus TOCTOU LPE — and spends real effort hardening --auto so its
detect step gives an accurate, robust verdict before deploying.

pack2theroot (CVE-2026-41651):
- Ported from the public Vozec PoC
  (github.com/Vozec/CVE-2026-41651). Original disclosure by the
  Deutsche Telekom security team.
- Two back-to-back InstallFiles D-Bus calls (SIMULATE then NONE)
  overwrite the cached transaction flags between polkit auth and
  dispatch. GLib priority ordering makes the overwrite deterministic,
  not a timing race; postinst of the malicious .deb drops a SUID bash
  in /tmp.
- detect() reads PackageKit's VersionMajor/Minor/Micro directly over
  D-Bus and compares against the pinned fix release 1.3.5 (commit
  76cfb675). This is a high-confidence verdict, not precondition-only.
- Debian-family only (PoC builds its own .deb in pure C; ar/ustar/
  gzip-stored inline). Cleanup removes /tmp .debs + best-effort
  unlinks /tmp/.suid_bash + sudo -n dpkg -r the staging packages.
- Adds an optional GLib/GIO build dependency. The top-level Makefile
  autodetects via `pkg-config gio-2.0`; when absent the module
  compiles as a stub returning PRECOND_FAIL.
- Embedded auditd + sigma rules cover the file-side footprint
  (/tmp/.suid_bash, /tmp/.pk-*.deb, non-root dpkg/apt execve).

--auto accuracy improvements:
- Auto-enables --active before the scan. Per-module sentinel probes
  (page-cache /tmp files, fork-isolated namespace mounts) turn
  version-only checks into definitive verdicts, so silent distro
  backports don't fool the scan and --auto won't pick blind on
  TEST_ERROR.
- Per-module verdict printing — every module's result is shown
  (VULNERABLE / patched / precondition / indeterminate), not just
  VULNERABLE rows. Operator sees the full picture.
- Scan-end summary line: "N vulnerable, M patched/n.a., K
  precondition-fail, L indeterminate" with a separate callout when
  modules crashed.
- Distro fingerprint added to the auto banner (ID + VERSION_ID from
  /etc/os-release alongside kernel/arch).
- Fork-isolated detect() — each detector runs in a child process so
  a SIGILL/SIGSEGV in one module's probe is contained and the scan
  continues. Surfaced live while testing: entrybleed's prefetchnta
  KASLR sweep SIGILLs on emulated CPUs (linuxkit on darwin); without
  isolation the whole --auto died at module 7 of 31. With isolation
  the scan reports "detect() crashed (signal 4) — continuing" and
  finishes cleanly.

module_safety_rank additions:
- pack2theroot: 95 (userspace D-Bus TOCTOU; dpkg + /tmp SUID footprint
  — clean but heavier than pwnkit's gconv-modules-only path).
- dirtydecrypt / fragnesia: 86 (page-cache writes; one step below the
  verified copy_fail/dirty_frag family at 88 to prefer verified
  modules when both apply).

Docs:
- README badge / tagline / tier table /  block / example output /
  v0.5.0 status — all updated to "28 verified + 3 ported".
- CVES.md counts line, the ported-modules note (now calling out
  pack2theroot's high-confidence detect vs. precondition-only for
  the page-cache pair), inventory row, operations table row.
- ROADMAP Phase 7+: pack2theroot moved out of carry-overs into the
  "landed (ported, pending VM verification)" group; added a new
  "--auto accuracy work" subsection documenting the dispatcher
  hardening landed in this commit.
- docs/index.html: scanning-count example bumped to 31, status line
  updated to mention 3 ported modules.

Build verification: full `make clean && make` in `docker gcc:latest`
with libglib2.0-dev installed: links into a 31-module skeletonkey
ELF (413KB), `--list` shows all modules including pack2theroot,
`--detect-rules --format=auditd` emits the new pack2theroot section,
`--auto --i-know --no-shell` exercises the new banner + active
probes + verdict table + fork isolation + scan summary end-to-end.
Only build warning is the pre-existing
`-Wunterminated-string-initialization` in dirty_pipe (not introduced
here).
This commit is contained in:
2026-05-22 22:42:07 -04:00
parent ac557b67d0
commit 9a4cc91619
13 changed files with 1152 additions and 52 deletions
+148 -11
View File
@@ -21,6 +21,7 @@
#include <time.h>
#include <sys/utsname.h>
#include <sys/wait.h>
#include <getopt.h>
#include <stdbool.h>
@@ -670,10 +671,13 @@ static int module_safety_rank(const char *n)
if (!strcmp(n, "cgroup_release_agent")) return 98; /* structural, no offsets */
if (!strcmp(n, "overlayfs_setuid")) return 97; /* structural setuid */
if (!strcmp(n, "overlayfs")) return 96; /* userns + xattr */
if (!strcmp(n, "pack2theroot")) return 95; /* userspace D-Bus TOCTOU; dpkg + /tmp SUID footprint */
if (!strcmp(n, "dirty_pipe")) return 90; /* page-cache write */
if (!strcmp(n, "dirty_cow")) return 89;
if (!strncmp(n, "copy_fail", 9) ||
!strncmp(n, "dirty_frag", 10)) return 88;
!strncmp(n, "dirty_frag", 10)) return 88; /* verified page-cache writes */
if (!strcmp(n, "dirtydecrypt") ||
!strcmp(n, "fragnesia")) return 86; /* ported page-cache writes, NOT VM-verified */
if (!strcmp(n, "ptrace_traceme")) return 85; /* userspace cred race */
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
@@ -682,6 +686,68 @@ static int module_safety_rank(const char *n)
return 50; /* kernel primitives — middle of pack */
}
/* Run a module's detect() in a forked child so a SIGILL/SIGSEGV/etc.
* in one detector cannot tear down the dispatcher. The verdict travels
* back via the child's exit status (skeletonkey_result_t values fit in
* 0..5). On a crash, returns SKELETONKEY_TEST_ERROR; *crashed_signal
* is set to the terminating signal (0 if exited normally).
*
* This matters because --auto auto-enables active probes, which can
* exercise CPU instructions (entrybleed's prefetchnta sweep) or
* kernel paths (XFRM ESP-in-TCP setup) that may misbehave under
* emulation or hardened containers. Without isolation, one bad probe
* stops the whole scan and the operator never sees the rest of the
* verdict table. */
static skeletonkey_result_t run_detect_isolated(
const struct skeletonkey_module *m,
const struct skeletonkey_ctx *ctx,
int *crashed_signal)
{
*crashed_signal = 0;
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return SKELETONKEY_TEST_ERROR;
}
if (pid == 0) {
skeletonkey_result_t r = m->detect(ctx);
fflush(NULL);
_exit((int)r);
}
int st;
if (waitpid(pid, &st, 0) < 0) return SKELETONKEY_TEST_ERROR;
if (WIFEXITED(st)) return (skeletonkey_result_t)WEXITSTATUS(st);
if (WIFSIGNALED(st)) *crashed_signal = WTERMSIG(st);
return SKELETONKEY_TEST_ERROR;
}
/* Best-effort host distro fingerprint via /etc/os-release. Populates
* id_out and ver_out with up to 63 chars each; falls back to "?" when
* /etc/os-release is missing or unparseable. */
static void read_os_release(char *id_out, size_t id_cap,
char *ver_out, size_t ver_cap)
{
snprintf(id_out, id_cap, "?");
snprintf(ver_out, ver_cap, "?");
FILE *f = fopen("/etc/os-release", "r");
if (!f) return;
char line[256];
while (fgets(line, sizeof line, f)) {
const char *key = NULL; char *dst = NULL; size_t cap = 0;
if (strncmp(line, "ID=", 3) == 0) {
key = line + 3; dst = id_out; cap = id_cap;
} else if (strncmp(line, "VERSION_ID=", 11) == 0) {
key = line + 11; dst = ver_out; cap = ver_cap;
} else continue;
const char *v = key;
if (*v == '"' || *v == '\'') v++;
size_t L = strcspn(v, "\"'\n");
if (L >= cap) L = cap - 1;
memcpy(dst, v, L); dst[L] = '\0';
}
fclose(f);
}
static int cmd_auto(struct skeletonkey_ctx *ctx)
{
if (!ctx->authorized) {
@@ -695,28 +761,98 @@ static int cmd_auto(struct skeletonkey_ctx *ctx)
return 0;
}
/* Active probes give --auto a more accurate verdict on modules that
* implement them (dirty_pipe, the copy_fail family, dirtydecrypt,
* fragnesia, overlayfs). Each per-module probe is documented safe:
* /tmp sentinel files + fork-isolated namespace mounts. No real
* system state is corrupted by the scan. Without this, --auto can
* miss vulnerabilities that a version-only check would flag as
* indeterminate (TEST_ERROR), or accept distro silent backports
* that the version check is fooled by. */
bool prev_active = ctx->active_probe;
ctx->active_probe = true;
struct utsname u; uname(&u);
fprintf(stderr, "[*] auto: host=%s kernel=%s arch=%s\n", u.nodename, u.release, u.machine);
char distro_id[64], distro_ver[64];
read_os_release(distro_id, sizeof distro_id, distro_ver, sizeof distro_ver);
fprintf(stderr, "[*] auto: host=%s distro=%s/%s kernel=%s arch=%s\n",
u.nodename, distro_id, distro_ver, u.release, u.machine);
fprintf(stderr, "[*] auto: active probes enabled — brief /tmp file "
"touches and fork-isolated namespace probes\n");
fprintf(stderr, "[*] auto: scanning %zu modules for vulnerabilities...\n",
skeletonkey_module_count());
struct cand { const struct skeletonkey_module *m; int rank; } cands[64];
int nc = 0;
int n_vuln = 0, n_ok = 0, n_precond = 0, n_test = 0, n_crash = 0, n_other = 0;
size_t n = skeletonkey_module_count();
for (size_t i = 0; i < n && nc < 64; i++) {
for (size_t i = 0; i < n; i++) {
const struct skeletonkey_module *m = skeletonkey_module_at(i);
if (!m->detect || !m->exploit) continue;
skeletonkey_result_t r = m->detect(ctx);
if (r == SKELETONKEY_VULNERABLE) {
cands[nc].m = m;
cands[nc].rank = module_safety_rank(m->name);
fprintf(stderr, "[+] auto: %-22s VULNERABLE (safety rank %d)\n",
m->name, cands[nc].rank);
nc++;
int sig = 0;
skeletonkey_result_t r = run_detect_isolated(m, ctx, &sig);
if (sig != 0) {
fprintf(stderr, "[?] auto: %-22s detect() crashed "
"(signal %d) — continuing\n", m->name, sig);
n_crash++;
continue;
}
switch (r) {
case SKELETONKEY_VULNERABLE:
if (nc < 64) {
cands[nc].m = m;
cands[nc].rank = module_safety_rank(m->name);
fprintf(stderr, "[+] auto: %-22s VULNERABLE (safety rank %d)\n",
m->name, cands[nc].rank);
nc++;
} else {
fprintf(stderr, "[+] auto: %-22s VULNERABLE (overflow; not "
"considered for pick)\n", m->name);
}
n_vuln++;
break;
case SKELETONKEY_OK:
fprintf(stderr, "[ ] auto: %-22s patched or not applicable\n",
m->name);
n_ok++;
break;
case SKELETONKEY_PRECOND_FAIL:
fprintf(stderr, "[ ] auto: %-22s precondition not met\n", m->name);
n_precond++;
break;
case SKELETONKEY_TEST_ERROR:
fprintf(stderr, "[?] auto: %-22s indeterminate "
"(detector could not decide)\n", m->name);
n_test++;
break;
default:
fprintf(stderr, "[?] auto: %-22s %s\n", m->name, result_str(r));
n_other++;
break;
}
}
/* Restore caller's --active setting before we call exploit(). The
* exploit() of each module may use ctx->active_probe with different
* semantics than detect(); we owned this flag only for the scan. */
ctx->active_probe = prev_active;
fprintf(stderr, "\n[*] auto: scan summary — %d vulnerable, %d patched/"
"n.a., %d precondition-fail, %d indeterminate%s\n",
n_vuln, n_ok, n_precond, n_test,
n_other ? " (+other)" : "");
if (n_crash > 0)
fprintf(stderr, "[!] auto: %d module(s) crashed during detect "
"— dispatcher recovered via fork isolation\n", n_crash);
if (nc == 0) {
fprintf(stderr, "\n[-] auto: no vulnerable modules. Host appears patched.\n");
if (n_test > 0) {
fprintf(stderr, "[i] auto: %d module(s) returned indeterminate. "
"Try `skeletonkey --exploit <name> --i-know` if "
"you know the host is vulnerable.\n", n_test);
}
fprintf(stderr, "[-] auto: no confirmed-vulnerable modules. Host "
"appears patched.\n");
return 0;
}
@@ -791,6 +927,7 @@ int main(int argc, char **argv)
skeletonkey_register_vmwgfx();
skeletonkey_register_dirtydecrypt();
skeletonkey_register_fragnesia();
skeletonkey_register_pack2theroot();
enum mode mode = MODE_SCAN;
struct skeletonkey_ctx ctx = {0};